1530 lines
50 KiB
Python
1530 lines
50 KiB
Python
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from typing import Any, Iterable, Mapping
|
|
|
|
from sqlalchemy import func, select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from govoplan_core.security.credential_envelopes import (
|
|
CredentialAccessContext,
|
|
CredentialEnvelope,
|
|
CredentialEnvelopeError,
|
|
create_credential_envelope,
|
|
credential_envelope_summary,
|
|
credential_visible_to_context,
|
|
get_credential_envelope,
|
|
list_credential_envelopes,
|
|
resolve_credential_envelope,
|
|
retire_credential_envelope,
|
|
update_credential_envelope,
|
|
)
|
|
from govoplan_core.security.secrets import decrypt_secret
|
|
from govoplan_mail.backend.config import (
|
|
ImapConfig,
|
|
ImapServerConfig,
|
|
Pop3Config,
|
|
Pop3ServerConfig,
|
|
SmtpConfig,
|
|
SmtpServerConfig,
|
|
)
|
|
from govoplan_mail.backend.db.models import (
|
|
MailServerCredentialBinding,
|
|
MailServerEndpoint,
|
|
MailServerProfile,
|
|
new_uuid,
|
|
)
|
|
|
|
|
|
MAIL_SERVER_PROTOCOLS = frozenset({"smtp", "imap", "pop3"})
|
|
|
|
|
|
class MailServerHierarchyError(RuntimeError):
|
|
pass
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class MailHierarchyContext:
|
|
tenant_id: str | None
|
|
user_id: str | None = None
|
|
group_ids: frozenset[str] = frozenset()
|
|
target_scope_type: str = "tenant"
|
|
target_scope_id: str | None = None
|
|
administrative: bool = False
|
|
|
|
def credential_context(self, *, server_id: str | None = None) -> CredentialAccessContext:
|
|
return CredentialAccessContext(
|
|
tenant_id=self.tenant_id,
|
|
user_id=self.user_id,
|
|
group_ids=self.group_ids,
|
|
target_scope_type=self.target_scope_type,
|
|
target_scope_id=self.target_scope_id,
|
|
module_id="mail",
|
|
server_ref=mail_server_ref(server_id) if server_id else None,
|
|
administrative=self.administrative,
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class ResolvedMailTransport:
|
|
profile: MailServerProfile
|
|
server: MailServerEndpoint | None
|
|
credential: CredentialEnvelope | None
|
|
config: SmtpConfig | ImapConfig | Pop3Config
|
|
transport_revision: str
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class SelectedMailTransport:
|
|
profile: MailServerProfile
|
|
server: MailServerEndpoint | None
|
|
credential: CredentialEnvelope | None
|
|
available: bool
|
|
transport_revision: str
|
|
|
|
|
|
def mail_server_ref(server_id: str | None) -> str | None:
|
|
clean_id = _optional_text(server_id)
|
|
return f"mail:{clean_id}" if clean_id else None
|
|
|
|
|
|
def profile_default_scope(profile: MailServerProfile) -> tuple[str, str | None]:
|
|
scope_type = str(profile.scope_type or "tenant").strip().casefold()
|
|
if scope_type == "system":
|
|
return "system", None
|
|
return scope_type, profile.scope_id or profile.tenant_id
|
|
|
|
|
|
def profile_hierarchy_payload(
|
|
session: Session,
|
|
*,
|
|
profile: MailServerProfile,
|
|
context: MailHierarchyContext,
|
|
include_inactive: bool = False,
|
|
) -> list[dict[str, Any]]:
|
|
servers = list_mail_server_endpoints(
|
|
session,
|
|
profile=profile,
|
|
context=context,
|
|
include_inactive=include_inactive,
|
|
)
|
|
if not servers:
|
|
return _legacy_server_payloads(profile)
|
|
return [
|
|
mail_server_endpoint_payload(
|
|
session,
|
|
server=server,
|
|
context=context,
|
|
include_inactive_credentials=include_inactive,
|
|
)
|
|
for server in servers
|
|
]
|
|
|
|
|
|
def mail_server_endpoint_payload(
|
|
session: Session,
|
|
*,
|
|
server: MailServerEndpoint,
|
|
context: MailHierarchyContext,
|
|
include_inactive_credentials: bool = False,
|
|
) -> dict[str, Any]:
|
|
return {
|
|
"id": server.id,
|
|
"profile_id": server.profile_id,
|
|
"tenant_id": server.tenant_id,
|
|
"protocol": server.protocol,
|
|
"name": server.name,
|
|
"config": _server_config_payload(server.config),
|
|
"scope_type": server.scope_type,
|
|
"scope_id": server.scope_id,
|
|
"inherit_to_lower_scopes": bool(server.inherit_to_lower_scopes),
|
|
"is_default": bool(server.is_default),
|
|
"is_active": bool(server.is_active),
|
|
"transport_revision": server.transport_revision,
|
|
"credentials": list_server_credential_summaries(
|
|
session,
|
|
server=server,
|
|
context=context,
|
|
include_inactive=include_inactive_credentials,
|
|
),
|
|
"created_at": server.created_at,
|
|
"updated_at": server.updated_at,
|
|
}
|
|
|
|
|
|
def list_mail_server_endpoints(
|
|
session: Session,
|
|
*,
|
|
profile: MailServerProfile,
|
|
context: MailHierarchyContext,
|
|
include_inactive: bool = False,
|
|
) -> list[MailServerEndpoint]:
|
|
statement = select(MailServerEndpoint).where(MailServerEndpoint.profile_id == profile.id)
|
|
if not include_inactive:
|
|
statement = statement.where(MailServerEndpoint.is_active.is_(True))
|
|
rows = session.execute(
|
|
statement.order_by(
|
|
MailServerEndpoint.protocol,
|
|
MailServerEndpoint.is_default.desc(),
|
|
MailServerEndpoint.name,
|
|
MailServerEndpoint.id,
|
|
)
|
|
).scalars()
|
|
return [row for row in rows if mail_server_visible_to_context(row, context)]
|
|
|
|
|
|
def get_mail_server_endpoint(
|
|
session: Session,
|
|
*,
|
|
profile: MailServerProfile,
|
|
server_id: str,
|
|
context: MailHierarchyContext,
|
|
protocol: str | None = None,
|
|
require_active: bool = True,
|
|
for_update: bool = False,
|
|
) -> MailServerEndpoint:
|
|
statement = select(MailServerEndpoint).where(
|
|
MailServerEndpoint.id == _required_text(server_id, "Mail server id is required"),
|
|
MailServerEndpoint.profile_id == profile.id,
|
|
)
|
|
if protocol:
|
|
statement = statement.where(MailServerEndpoint.protocol == _normalize_protocol(protocol))
|
|
if for_update:
|
|
statement = statement.with_for_update().execution_options(populate_existing=True)
|
|
row = session.execute(statement).scalar_one_or_none()
|
|
if row is None or not mail_server_visible_to_context(row, context):
|
|
raise MailServerHierarchyError("Mail server not found")
|
|
if require_active and not row.is_active:
|
|
raise MailServerHierarchyError("Mail server is inactive")
|
|
return row
|
|
|
|
|
|
def get_default_mail_server_endpoint(
|
|
session: Session,
|
|
*,
|
|
profile: MailServerProfile,
|
|
protocol: str,
|
|
context: MailHierarchyContext,
|
|
require_active: bool = True,
|
|
) -> MailServerEndpoint | None:
|
|
clean_protocol = _normalize_protocol(protocol)
|
|
statement = select(MailServerEndpoint).where(
|
|
MailServerEndpoint.profile_id == profile.id,
|
|
MailServerEndpoint.protocol == clean_protocol,
|
|
)
|
|
if require_active:
|
|
statement = statement.where(MailServerEndpoint.is_active.is_(True))
|
|
rows = session.execute(
|
|
statement.order_by(
|
|
MailServerEndpoint.is_default.desc(),
|
|
MailServerEndpoint.name,
|
|
MailServerEndpoint.id,
|
|
)
|
|
).scalars()
|
|
for row in rows:
|
|
if mail_server_visible_to_context(row, context):
|
|
return row
|
|
return None
|
|
|
|
|
|
def create_mail_server_endpoint(
|
|
session: Session,
|
|
*,
|
|
profile: MailServerProfile,
|
|
protocol: str,
|
|
name: str,
|
|
config: Mapping[str, Any],
|
|
user_id: str | None,
|
|
scope_type: str | None = None,
|
|
scope_id: str | None = None,
|
|
inherit_to_lower_scopes: bool | None = None,
|
|
is_default: bool = False,
|
|
is_active: bool = True,
|
|
) -> MailServerEndpoint:
|
|
clean_protocol = _normalize_protocol(protocol)
|
|
clean_scope_type, clean_scope_id = _server_scope(
|
|
profile,
|
|
scope_type=scope_type,
|
|
scope_id=scope_id,
|
|
)
|
|
clean_config = _validated_server_config(clean_protocol, config)
|
|
if is_default:
|
|
_clear_default_server(session, profile_id=profile.id, protocol=clean_protocol)
|
|
row = MailServerEndpoint(
|
|
profile_id=profile.id,
|
|
tenant_id=profile.tenant_id,
|
|
protocol=clean_protocol,
|
|
name=_required_text(name, "Mail server name is required"),
|
|
config=clean_config,
|
|
scope_type=clean_scope_type,
|
|
scope_id=clean_scope_id,
|
|
inherit_to_lower_scopes=(
|
|
bool(profile.inherit_to_lower_scopes)
|
|
if inherit_to_lower_scopes is None
|
|
else bool(inherit_to_lower_scopes)
|
|
),
|
|
is_default=bool(is_default),
|
|
is_active=bool(is_active),
|
|
created_by_user_id=_optional_text(user_id),
|
|
updated_by_user_id=_optional_text(user_id),
|
|
)
|
|
session.add(row)
|
|
session.flush()
|
|
if not is_default and not _has_default_server(
|
|
session,
|
|
profile_id=profile.id,
|
|
protocol=clean_protocol,
|
|
excluding_id=row.id,
|
|
):
|
|
row.is_default = True
|
|
session.flush()
|
|
return row
|
|
|
|
|
|
def update_mail_server_endpoint(
|
|
session: Session,
|
|
*,
|
|
server: MailServerEndpoint,
|
|
user_id: str | None,
|
|
name: str | None = None,
|
|
config: Mapping[str, Any] | None = None,
|
|
inherit_to_lower_scopes: bool | None = None,
|
|
is_default: bool | None = None,
|
|
is_active: bool | None = None,
|
|
) -> MailServerEndpoint:
|
|
if name is not None:
|
|
server.name = _required_text(name, "Mail server name is required")
|
|
if config is not None:
|
|
next_config = _validated_server_config(server.protocol, config)
|
|
if next_config != dict(server.config or {}):
|
|
server.config = next_config
|
|
server.transport_revision = new_uuid()
|
|
if inherit_to_lower_scopes is not None:
|
|
server.inherit_to_lower_scopes = bool(inherit_to_lower_scopes)
|
|
if is_default is True and not server.is_default:
|
|
_clear_default_server(
|
|
session,
|
|
profile_id=server.profile_id,
|
|
protocol=server.protocol,
|
|
excluding_id=server.id,
|
|
)
|
|
server.is_default = True
|
|
elif is_default is False and server.is_default:
|
|
replacement = _first_active_server(
|
|
session,
|
|
profile_id=server.profile_id,
|
|
protocol=server.protocol,
|
|
excluding_id=server.id,
|
|
)
|
|
if replacement is None:
|
|
raise MailServerHierarchyError("At least one active server must remain the protocol default")
|
|
server.is_default = False
|
|
replacement.is_default = True
|
|
if is_active is not None and server.is_active != bool(is_active):
|
|
if not is_active and server.is_default:
|
|
replacement = _first_active_server(
|
|
session,
|
|
profile_id=server.profile_id,
|
|
protocol=server.protocol,
|
|
excluding_id=server.id,
|
|
)
|
|
if replacement is None:
|
|
server.is_default = False
|
|
else:
|
|
replacement.is_default = True
|
|
server.is_default = False
|
|
server.is_active = bool(is_active)
|
|
server.updated_by_user_id = _optional_text(user_id)
|
|
session.flush()
|
|
return server
|
|
|
|
|
|
def initialize_profile_hierarchy(
|
|
session: Session,
|
|
*,
|
|
profile: MailServerProfile,
|
|
smtp: SmtpConfig | Mapping[str, Any] | None,
|
|
imap: ImapConfig | Mapping[str, Any] | None,
|
|
user_id: str | None,
|
|
) -> tuple[MailServerEndpoint, ...]:
|
|
existing = {
|
|
row.protocol: row
|
|
for row in session.execute(
|
|
select(MailServerEndpoint).where(
|
|
MailServerEndpoint.profile_id == profile.id,
|
|
MailServerEndpoint.is_default.is_(True),
|
|
)
|
|
).scalars()
|
|
}
|
|
created: list[MailServerEndpoint] = []
|
|
for protocol, value in (("smtp", smtp), ("imap", imap)):
|
|
if value is None or protocol in existing:
|
|
continue
|
|
raw = value.model_dump(mode="json", exclude_none=True) if hasattr(value, "model_dump") else dict(value)
|
|
credentials = {
|
|
"username": raw.pop("username", None),
|
|
"password": raw.pop("password", None),
|
|
}
|
|
server = create_mail_server_endpoint(
|
|
session,
|
|
profile=profile,
|
|
protocol=protocol,
|
|
name=protocol.upper(),
|
|
config=raw,
|
|
user_id=user_id,
|
|
is_default=True,
|
|
is_active=bool(profile.is_active),
|
|
)
|
|
created.append(server)
|
|
if credentials["username"] or credentials["password"]:
|
|
create_and_bind_mail_credential(
|
|
session,
|
|
profile=profile,
|
|
server=server,
|
|
name=f"{profile.name} {protocol.upper()}",
|
|
username=credentials["username"],
|
|
password=credentials["password"],
|
|
user_id=user_id,
|
|
inherit_to_lower_scopes=bool(profile.inherit_to_lower_scopes),
|
|
allowed_modules=("mail",),
|
|
is_default=True,
|
|
)
|
|
return tuple(created)
|
|
|
|
|
|
def sync_default_profile_server(
|
|
session: Session,
|
|
*,
|
|
profile: MailServerProfile,
|
|
protocol: str,
|
|
config: Mapping[str, Any] | None,
|
|
user_id: str | None,
|
|
) -> MailServerEndpoint | None:
|
|
context = hierarchy_context_for_profile(profile, administrative=True)
|
|
server = get_default_mail_server_endpoint(
|
|
session,
|
|
profile=profile,
|
|
protocol=protocol,
|
|
context=context,
|
|
require_active=False,
|
|
)
|
|
if config is None:
|
|
if server is not None:
|
|
update_mail_server_endpoint(
|
|
session,
|
|
server=server,
|
|
user_id=user_id,
|
|
is_active=False,
|
|
)
|
|
return server
|
|
if server is None:
|
|
return create_mail_server_endpoint(
|
|
session,
|
|
profile=profile,
|
|
protocol=protocol,
|
|
name=protocol.upper(),
|
|
config=config,
|
|
user_id=user_id,
|
|
is_default=True,
|
|
)
|
|
return update_mail_server_endpoint(
|
|
session,
|
|
server=server,
|
|
user_id=user_id,
|
|
config=config,
|
|
is_active=True,
|
|
)
|
|
|
|
|
|
def create_and_bind_mail_credential(
|
|
session: Session,
|
|
*,
|
|
profile: MailServerProfile,
|
|
server: MailServerEndpoint,
|
|
name: str,
|
|
username: str | None,
|
|
password: str | None,
|
|
user_id: str | None,
|
|
description: str | None = None,
|
|
credential_kind: str = "username_password",
|
|
public_data: Mapping[str, Any] | None = None,
|
|
secret_data: Mapping[str, Any] | None = None,
|
|
inherit_to_lower_scopes: bool | None = None,
|
|
allowed_modules: Iterable[str] = ("mail",),
|
|
allowed_server_refs: Iterable[str] = (),
|
|
is_default: bool = False,
|
|
credential_scope_type: str | None = None,
|
|
credential_scope_id: str | None = None,
|
|
) -> CredentialEnvelope:
|
|
scope_type, scope_id = (
|
|
(credential_scope_type, credential_scope_id)
|
|
if credential_scope_type is not None
|
|
else profile_default_scope(profile)
|
|
)
|
|
next_public = dict(public_data or {})
|
|
next_secret = dict(secret_data or {})
|
|
if username is not None:
|
|
next_public["username"] = username
|
|
if password is not None:
|
|
next_secret["password"] = password
|
|
try:
|
|
credential = create_credential_envelope(
|
|
session,
|
|
tenant_id=profile.tenant_id,
|
|
scope_type=scope_type,
|
|
scope_id=scope_id,
|
|
name=name,
|
|
description=description,
|
|
credential_kind=credential_kind,
|
|
public_data=next_public,
|
|
secret_data=next_secret,
|
|
allowed_modules=tuple(allowed_modules),
|
|
allowed_server_refs=tuple(allowed_server_refs),
|
|
inherit_to_lower_scopes=(
|
|
bool(profile.inherit_to_lower_scopes)
|
|
if inherit_to_lower_scopes is None
|
|
else bool(inherit_to_lower_scopes)
|
|
),
|
|
user_id=user_id,
|
|
metadata={
|
|
"created_by_module": "mail",
|
|
"created_for_profile_id": profile.id,
|
|
**(
|
|
{"created_for_scope_id": credential_scope_id}
|
|
if credential_scope_type is not None and credential_scope_id
|
|
else {}
|
|
),
|
|
},
|
|
)
|
|
except CredentialEnvelopeError as exc:
|
|
raise MailServerHierarchyError(str(exc)) from exc
|
|
bind_mail_credential(
|
|
session,
|
|
server=server,
|
|
credential=credential,
|
|
user_id=user_id,
|
|
is_default=is_default,
|
|
)
|
|
return credential
|
|
|
|
|
|
def bind_mail_credential(
|
|
session: Session,
|
|
*,
|
|
server: MailServerEndpoint,
|
|
credential: CredentialEnvelope,
|
|
user_id: str | None,
|
|
is_default: bool = False,
|
|
) -> MailServerCredentialBinding:
|
|
if credential.deleted_at is not None:
|
|
raise MailServerHierarchyError("Deleted credentials cannot be bound")
|
|
if credential.tenant_id not in {None, server.tenant_id}:
|
|
raise MailServerHierarchyError("Credential and mail server belong to different tenants")
|
|
if credential.allowed_modules and "mail" not in credential.allowed_modules:
|
|
raise MailServerHierarchyError("Credential is not available to the Mail module")
|
|
server_ref = mail_server_ref(server.id)
|
|
if credential.allowed_server_refs and server_ref not in credential.allowed_server_refs:
|
|
raise MailServerHierarchyError("Credential is not available to this mail server")
|
|
existing = session.execute(
|
|
select(MailServerCredentialBinding).where(
|
|
MailServerCredentialBinding.server_id == server.id,
|
|
MailServerCredentialBinding.credential_id == credential.id,
|
|
)
|
|
).scalar_one_or_none()
|
|
if existing is not None:
|
|
if is_default and not existing.is_default:
|
|
_clear_default_credential(session, server_id=server.id, excluding_id=existing.id)
|
|
existing.is_default = True
|
|
session.flush()
|
|
return existing
|
|
if is_default:
|
|
_clear_default_credential(session, server_id=server.id)
|
|
row = MailServerCredentialBinding(
|
|
server_id=server.id,
|
|
credential_id=credential.id,
|
|
is_default=bool(is_default),
|
|
created_by_user_id=_optional_text(user_id),
|
|
)
|
|
session.add(row)
|
|
session.flush()
|
|
if not is_default and not _has_default_credential(
|
|
session,
|
|
server_id=server.id,
|
|
excluding_id=row.id,
|
|
):
|
|
row.is_default = True
|
|
session.flush()
|
|
return row
|
|
|
|
|
|
def list_available_mail_credentials(
|
|
session: Session,
|
|
*,
|
|
context: MailHierarchyContext,
|
|
server_id: str | None = None,
|
|
include_inactive: bool = False,
|
|
) -> list[CredentialEnvelope]:
|
|
try:
|
|
return list_credential_envelopes(
|
|
session,
|
|
context=context.credential_context(server_id=server_id),
|
|
include_inactive=include_inactive,
|
|
)
|
|
except CredentialEnvelopeError as exc:
|
|
raise MailServerHierarchyError(str(exc)) from exc
|
|
|
|
|
|
def get_available_mail_credential(
|
|
session: Session,
|
|
*,
|
|
credential_id: str,
|
|
context: MailHierarchyContext,
|
|
server_id: str | None = None,
|
|
require_active: bool = True,
|
|
for_update: bool = False,
|
|
) -> CredentialEnvelope:
|
|
try:
|
|
return get_credential_envelope(
|
|
session,
|
|
credential_id=credential_id,
|
|
context=context.credential_context(server_id=server_id),
|
|
require_active=require_active,
|
|
for_update=for_update,
|
|
)
|
|
except CredentialEnvelopeError as exc:
|
|
raise MailServerHierarchyError(str(exc)) from exc
|
|
|
|
|
|
def mail_credential_payload(
|
|
credential: CredentialEnvelope,
|
|
*,
|
|
server_id: str | None = None,
|
|
binding_id: str | None = None,
|
|
is_default: bool = False,
|
|
) -> dict[str, Any]:
|
|
payload = credential_envelope_summary(credential)
|
|
payload.update(
|
|
{
|
|
"binding_id": binding_id,
|
|
"server_id": server_id,
|
|
"is_default": bool(is_default),
|
|
}
|
|
)
|
|
return payload
|
|
|
|
|
|
def list_server_credential_summaries(
|
|
session: Session,
|
|
*,
|
|
server: MailServerEndpoint,
|
|
context: MailHierarchyContext,
|
|
include_inactive: bool = False,
|
|
) -> list[dict[str, Any]]:
|
|
statement = (
|
|
select(MailServerCredentialBinding, CredentialEnvelope)
|
|
.join(
|
|
CredentialEnvelope,
|
|
CredentialEnvelope.id == MailServerCredentialBinding.credential_id,
|
|
)
|
|
.where(
|
|
MailServerCredentialBinding.server_id == server.id,
|
|
CredentialEnvelope.deleted_at.is_(None),
|
|
)
|
|
.order_by(
|
|
MailServerCredentialBinding.is_default.desc(),
|
|
CredentialEnvelope.name,
|
|
CredentialEnvelope.id,
|
|
)
|
|
)
|
|
if not include_inactive:
|
|
statement = statement.where(CredentialEnvelope.is_active.is_(True))
|
|
credential_context = context.credential_context(server_id=server.id)
|
|
summaries: list[dict[str, Any]] = []
|
|
for binding, credential in session.execute(statement):
|
|
if not credential_visible_to_context(credential, credential_context):
|
|
continue
|
|
payload = credential_envelope_summary(credential)
|
|
payload.update(
|
|
{
|
|
"binding_id": binding.id,
|
|
"server_id": server.id,
|
|
"is_default": bool(binding.is_default),
|
|
}
|
|
)
|
|
summaries.append(payload)
|
|
return summaries
|
|
|
|
|
|
def get_bound_mail_credential(
|
|
session: Session,
|
|
*,
|
|
server: MailServerEndpoint,
|
|
credential_id: str,
|
|
context: MailHierarchyContext,
|
|
require_active: bool = True,
|
|
for_update: bool = False,
|
|
) -> tuple[MailServerCredentialBinding, CredentialEnvelope]:
|
|
statement = (
|
|
select(MailServerCredentialBinding, CredentialEnvelope)
|
|
.join(
|
|
CredentialEnvelope,
|
|
CredentialEnvelope.id == MailServerCredentialBinding.credential_id,
|
|
)
|
|
.where(
|
|
MailServerCredentialBinding.server_id == server.id,
|
|
CredentialEnvelope.id == _required_text(credential_id, "Credential id is required"),
|
|
CredentialEnvelope.deleted_at.is_(None),
|
|
)
|
|
)
|
|
if for_update:
|
|
statement = statement.with_for_update().execution_options(populate_existing=True)
|
|
result = session.execute(statement).first()
|
|
if result is None:
|
|
raise MailServerHierarchyError("Mail credential not found")
|
|
binding, credential = result
|
|
if not credential_visible_to_context(
|
|
credential,
|
|
context.credential_context(server_id=server.id),
|
|
):
|
|
raise MailServerHierarchyError("Mail credential not found")
|
|
if require_active and not credential.is_active:
|
|
raise MailServerHierarchyError("Mail credential is inactive")
|
|
return binding, credential
|
|
|
|
|
|
def update_bound_mail_credential(
|
|
session: Session,
|
|
*,
|
|
server: MailServerEndpoint,
|
|
credential: CredentialEnvelope,
|
|
context: MailHierarchyContext,
|
|
user_id: str | None,
|
|
name: str | None = None,
|
|
description: str | None = None,
|
|
description_supplied: bool = False,
|
|
username: str | None = None,
|
|
username_supplied: bool = False,
|
|
password: str | None = None,
|
|
password_supplied: bool = False,
|
|
public_data: Mapping[str, Any] | None = None,
|
|
secret_data: Mapping[str, Any] | None = None,
|
|
allowed_modules: Iterable[str] | None = None,
|
|
allowed_server_refs: Iterable[str] | None = None,
|
|
inherit_to_lower_scopes: bool | None = None,
|
|
is_active: bool | None = None,
|
|
is_default: bool | None = None,
|
|
) -> CredentialEnvelope:
|
|
_, current = get_bound_mail_credential(
|
|
session,
|
|
server=server,
|
|
credential_id=credential.id,
|
|
context=context,
|
|
require_active=False,
|
|
for_update=True,
|
|
)
|
|
next_public = dict(current.public_data or {}) if public_data is None else dict(public_data)
|
|
if allowed_modules is not None:
|
|
clean_modules = tuple(
|
|
sorted({str(value).strip() for value in allowed_modules if str(value).strip()})
|
|
)
|
|
if clean_modules and "mail" not in clean_modules:
|
|
raise MailServerHierarchyError(
|
|
"A credential bound to a mail server must remain available to the Mail module"
|
|
)
|
|
allowed_modules = clean_modules
|
|
if allowed_server_refs is not None:
|
|
clean_server_refs = tuple(
|
|
sorted(
|
|
{
|
|
str(value).strip()
|
|
for value in allowed_server_refs
|
|
if str(value).strip()
|
|
}
|
|
)
|
|
)
|
|
current_server_ref = mail_server_ref(server.id)
|
|
if clean_server_refs and current_server_ref not in clean_server_refs:
|
|
raise MailServerHierarchyError(
|
|
"A credential bound to a mail server must remain available to that server"
|
|
)
|
|
allowed_server_refs = clean_server_refs
|
|
if username_supplied:
|
|
if username is None:
|
|
next_public.pop("username", None)
|
|
else:
|
|
next_public["username"] = username
|
|
next_secret: dict[str, Any] | None = None
|
|
if secret_data is not None:
|
|
next_secret = dict(secret_data)
|
|
elif password_supplied:
|
|
resolved = resolve_credential_envelope(
|
|
session,
|
|
credential_id=current.id,
|
|
context=context.credential_context(server_id=server.id),
|
|
)
|
|
next_secret = dict(resolved.secret_data)
|
|
if password is None:
|
|
next_secret.pop("password", None)
|
|
else:
|
|
next_secret["password"] = password
|
|
try:
|
|
update_credential_envelope(
|
|
session,
|
|
current,
|
|
name=name,
|
|
description=description,
|
|
description_supplied=description_supplied,
|
|
public_data=next_public,
|
|
secret_data=next_secret,
|
|
allowed_modules=tuple(allowed_modules) if allowed_modules is not None else None,
|
|
allowed_server_refs=(
|
|
tuple(allowed_server_refs)
|
|
if allowed_server_refs is not None
|
|
else None
|
|
),
|
|
inherit_to_lower_scopes=inherit_to_lower_scopes,
|
|
is_active=is_active,
|
|
user_id=user_id,
|
|
)
|
|
except CredentialEnvelopeError as exc:
|
|
raise MailServerHierarchyError(str(exc)) from exc
|
|
if is_default is not None:
|
|
binding, _ = get_bound_mail_credential(
|
|
session,
|
|
server=server,
|
|
credential_id=current.id,
|
|
context=context,
|
|
require_active=False,
|
|
for_update=True,
|
|
)
|
|
if is_default and not binding.is_default:
|
|
_clear_default_credential(
|
|
session,
|
|
server_id=server.id,
|
|
excluding_id=binding.id,
|
|
)
|
|
binding.is_default = True
|
|
elif not is_default and binding.is_default:
|
|
replacement = _first_active_credential_binding(
|
|
session,
|
|
server_id=server.id,
|
|
excluding_id=binding.id,
|
|
)
|
|
if replacement is None:
|
|
raise MailServerHierarchyError(
|
|
"At least one active credential must remain the server default"
|
|
)
|
|
binding.is_default = False
|
|
replacement.is_default = True
|
|
session.flush()
|
|
return current
|
|
|
|
|
|
def sync_default_profile_credential(
|
|
session: Session,
|
|
*,
|
|
profile: MailServerProfile,
|
|
server: MailServerEndpoint,
|
|
username: str | None,
|
|
username_supplied: bool,
|
|
password: str | None,
|
|
password_supplied: bool,
|
|
user_id: str | None,
|
|
) -> CredentialEnvelope | None:
|
|
context = hierarchy_context_for_profile(profile, administrative=True)
|
|
statement = (
|
|
select(MailServerCredentialBinding, CredentialEnvelope)
|
|
.join(
|
|
CredentialEnvelope,
|
|
CredentialEnvelope.id == MailServerCredentialBinding.credential_id,
|
|
)
|
|
.where(
|
|
MailServerCredentialBinding.server_id == server.id,
|
|
CredentialEnvelope.deleted_at.is_(None),
|
|
)
|
|
.order_by(
|
|
MailServerCredentialBinding.is_default.desc(),
|
|
CredentialEnvelope.name,
|
|
CredentialEnvelope.id,
|
|
)
|
|
)
|
|
selected = session.execute(statement).first()
|
|
if selected is None:
|
|
if not (
|
|
(username_supplied and username is not None)
|
|
or (password_supplied and password is not None)
|
|
):
|
|
return None
|
|
return create_and_bind_mail_credential(
|
|
session,
|
|
profile=profile,
|
|
server=server,
|
|
name=f"{profile.name} {server.protocol.upper()}",
|
|
username=username if username_supplied else None,
|
|
password=password if password_supplied else None,
|
|
user_id=user_id,
|
|
inherit_to_lower_scopes=bool(profile.inherit_to_lower_scopes),
|
|
allowed_modules=("mail",),
|
|
is_default=True,
|
|
)
|
|
binding, credential = selected
|
|
update_bound_mail_credential(
|
|
session,
|
|
server=server,
|
|
credential=credential,
|
|
context=context,
|
|
user_id=user_id,
|
|
username=username,
|
|
username_supplied=username_supplied,
|
|
password=password,
|
|
password_supplied=password_supplied,
|
|
is_default=True if not binding.is_default else None,
|
|
)
|
|
return credential
|
|
|
|
|
|
def unlink_mail_credential(
|
|
session: Session,
|
|
*,
|
|
server: MailServerEndpoint,
|
|
credential: CredentialEnvelope,
|
|
context: MailHierarchyContext,
|
|
user_id: str | None,
|
|
retire_if_unused: bool = False,
|
|
) -> None:
|
|
binding, current = get_bound_mail_credential(
|
|
session,
|
|
server=server,
|
|
credential_id=credential.id,
|
|
context=context,
|
|
require_active=False,
|
|
for_update=True,
|
|
)
|
|
was_default = bool(binding.is_default)
|
|
session.delete(binding)
|
|
session.flush()
|
|
if was_default:
|
|
replacement = _first_active_credential_binding(
|
|
session,
|
|
server_id=server.id,
|
|
)
|
|
if replacement is not None:
|
|
replacement.is_default = True
|
|
session.flush()
|
|
if retire_if_unused:
|
|
remaining = session.scalar(
|
|
select(func.count())
|
|
.select_from(MailServerCredentialBinding)
|
|
.where(MailServerCredentialBinding.credential_id == current.id)
|
|
)
|
|
if not remaining:
|
|
try:
|
|
retire_credential_envelope(session, current, user_id=user_id)
|
|
except CredentialEnvelopeError as exc:
|
|
raise MailServerHierarchyError(str(exc)) from exc
|
|
|
|
|
|
def resolve_mail_transport(
|
|
session: Session,
|
|
*,
|
|
profile: MailServerProfile,
|
|
protocol: str,
|
|
context: MailHierarchyContext,
|
|
server_id: str | None = None,
|
|
credential_id: str | None = None,
|
|
) -> ResolvedMailTransport:
|
|
clean_protocol = _normalize_protocol(protocol)
|
|
server = (
|
|
get_mail_server_endpoint(
|
|
session,
|
|
profile=profile,
|
|
server_id=server_id,
|
|
context=context,
|
|
protocol=clean_protocol,
|
|
require_active=True,
|
|
)
|
|
if server_id
|
|
else get_default_mail_server_endpoint(
|
|
session,
|
|
profile=profile,
|
|
protocol=clean_protocol,
|
|
context=context,
|
|
require_active=True,
|
|
)
|
|
)
|
|
if server is None:
|
|
if clean_protocol == "pop3":
|
|
raise MailServerHierarchyError(
|
|
"The selected Mail profile has no active POP3 legacy-import server"
|
|
)
|
|
return _legacy_resolved_transport(profile, clean_protocol)
|
|
binding, credential = _selected_server_credential(
|
|
session,
|
|
server=server,
|
|
credential_id=credential_id,
|
|
context=context,
|
|
)
|
|
payload = dict(server.config or {})
|
|
if credential is not None:
|
|
try:
|
|
resolved = resolve_credential_envelope(
|
|
session,
|
|
credential_id=credential.id,
|
|
context=context.credential_context(server_id=server.id),
|
|
)
|
|
except CredentialEnvelopeError as exc:
|
|
raise MailServerHierarchyError(str(exc)) from exc
|
|
username = resolved.public_data.get("username")
|
|
password = resolved.secret_data.get("password")
|
|
if username is not None:
|
|
payload["username"] = str(username)
|
|
if password is not None:
|
|
payload["password"] = str(password)
|
|
elif _server_matches_legacy_transport(profile, server):
|
|
if clean_protocol == "smtp":
|
|
payload["username"] = profile.smtp_username
|
|
payload["password"] = decrypt_secret(profile.smtp_password_encrypted)
|
|
elif clean_protocol == "imap":
|
|
payload["username"] = profile.imap_username
|
|
payload["password"] = decrypt_secret(profile.imap_password_encrypted)
|
|
config: SmtpConfig | ImapConfig | Pop3Config
|
|
try:
|
|
if clean_protocol == "smtp":
|
|
config = SmtpConfig.model_validate(payload)
|
|
elif clean_protocol == "imap":
|
|
config = ImapConfig.model_validate(payload)
|
|
else:
|
|
config = Pop3Config.model_validate(payload)
|
|
except Exception as exc:
|
|
raise MailServerHierarchyError(
|
|
f"The selected {clean_protocol.upper()} server configuration is invalid"
|
|
) from exc
|
|
return ResolvedMailTransport(
|
|
profile=profile,
|
|
server=server,
|
|
credential=credential,
|
|
config=config,
|
|
transport_revision=server.transport_revision,
|
|
)
|
|
|
|
|
|
def select_mail_transport(
|
|
session: Session,
|
|
*,
|
|
profile: MailServerProfile,
|
|
protocol: str,
|
|
context: MailHierarchyContext,
|
|
server_id: str | None = None,
|
|
credential_id: str | None = None,
|
|
) -> SelectedMailTransport:
|
|
clean_protocol = _normalize_protocol(protocol)
|
|
server = (
|
|
get_mail_server_endpoint(
|
|
session,
|
|
profile=profile,
|
|
server_id=server_id,
|
|
context=context,
|
|
protocol=clean_protocol,
|
|
require_active=True,
|
|
)
|
|
if server_id
|
|
else get_default_mail_server_endpoint(
|
|
session,
|
|
profile=profile,
|
|
protocol=clean_protocol,
|
|
context=context,
|
|
require_active=True,
|
|
)
|
|
)
|
|
if server is None:
|
|
if clean_protocol == "pop3":
|
|
return SelectedMailTransport(
|
|
profile=profile,
|
|
server=None,
|
|
credential=None,
|
|
available=False,
|
|
transport_revision="unconfigured",
|
|
)
|
|
legacy_config = (
|
|
profile.smtp_config
|
|
if clean_protocol == "smtp"
|
|
else profile.imap_config
|
|
)
|
|
revision = (
|
|
profile.smtp_transport_revision
|
|
if clean_protocol == "smtp"
|
|
else profile.imap_transport_revision
|
|
)
|
|
if not revision:
|
|
raise MailServerHierarchyError(
|
|
f"Mail profile has no {clean_protocol.upper()} transport revision; run the Mail database migrations"
|
|
)
|
|
return SelectedMailTransport(
|
|
profile=profile,
|
|
server=None,
|
|
credential=None,
|
|
available=bool(
|
|
legacy_config
|
|
and legacy_config.get("host")
|
|
and legacy_config.get("port")
|
|
),
|
|
transport_revision=revision,
|
|
)
|
|
_, credential = _selected_server_credential(
|
|
session,
|
|
server=server,
|
|
credential_id=credential_id,
|
|
context=context,
|
|
)
|
|
return SelectedMailTransport(
|
|
profile=profile,
|
|
server=server,
|
|
credential=credential,
|
|
available=bool(
|
|
(server.config or {}).get("host")
|
|
and (server.config or {}).get("port")
|
|
),
|
|
transport_revision=server.transport_revision,
|
|
)
|
|
|
|
|
|
def hierarchy_context_for_profile(
|
|
profile: MailServerProfile,
|
|
*,
|
|
user_id: str | None = None,
|
|
group_ids: Iterable[str] = (),
|
|
target_scope_type: str | None = None,
|
|
target_scope_id: str | None = None,
|
|
administrative: bool = False,
|
|
) -> MailHierarchyContext:
|
|
profile_scope_type, profile_scope_id = profile_default_scope(profile)
|
|
return MailHierarchyContext(
|
|
tenant_id=profile.tenant_id,
|
|
user_id=user_id,
|
|
group_ids=frozenset(str(item) for item in group_ids),
|
|
target_scope_type=target_scope_type or profile_scope_type,
|
|
target_scope_id=(
|
|
target_scope_id
|
|
if target_scope_type is not None
|
|
else profile_scope_id
|
|
),
|
|
administrative=administrative,
|
|
)
|
|
|
|
|
|
def mail_server_visible_to_context(
|
|
server: MailServerEndpoint,
|
|
context: MailHierarchyContext,
|
|
) -> bool:
|
|
if context.administrative:
|
|
return server.scope_type == "system" or server.tenant_id == context.tenant_id
|
|
scope_type = str(server.scope_type or "tenant").strip().casefold()
|
|
target_type = str(context.target_scope_type or "tenant").strip().casefold()
|
|
target_id = _optional_text(context.target_scope_id)
|
|
if scope_type == "system":
|
|
return target_type == "system" or bool(server.inherit_to_lower_scopes)
|
|
if server.tenant_id != context.tenant_id:
|
|
return False
|
|
if scope_type == "tenant":
|
|
if target_type == "tenant":
|
|
return server.scope_id in {None, context.tenant_id, target_id}
|
|
return bool(server.inherit_to_lower_scopes)
|
|
if scope_type == "user":
|
|
return server.scope_id == context.user_id or (
|
|
target_type == "user" and server.scope_id == target_id
|
|
)
|
|
if scope_type == "group":
|
|
exact = server.scope_id in context.group_ids or (
|
|
target_type == "group" and server.scope_id == target_id
|
|
)
|
|
return exact and (
|
|
target_type == "group" or bool(server.inherit_to_lower_scopes)
|
|
)
|
|
if scope_type == "campaign":
|
|
return target_type == "campaign" and server.scope_id == target_id
|
|
return False
|
|
|
|
|
|
def _selected_server_credential(
|
|
session: Session,
|
|
*,
|
|
server: MailServerEndpoint,
|
|
credential_id: str | None,
|
|
context: MailHierarchyContext,
|
|
) -> tuple[MailServerCredentialBinding | None, CredentialEnvelope | None]:
|
|
if credential_id:
|
|
return get_bound_mail_credential(
|
|
session,
|
|
server=server,
|
|
credential_id=credential_id,
|
|
context=context,
|
|
require_active=True,
|
|
)
|
|
statement = (
|
|
select(MailServerCredentialBinding, CredentialEnvelope)
|
|
.join(
|
|
CredentialEnvelope,
|
|
CredentialEnvelope.id == MailServerCredentialBinding.credential_id,
|
|
)
|
|
.where(
|
|
MailServerCredentialBinding.server_id == server.id,
|
|
CredentialEnvelope.deleted_at.is_(None),
|
|
CredentialEnvelope.is_active.is_(True),
|
|
)
|
|
.order_by(
|
|
MailServerCredentialBinding.is_default.desc(),
|
|
CredentialEnvelope.name,
|
|
CredentialEnvelope.id,
|
|
)
|
|
)
|
|
for binding, credential in session.execute(statement):
|
|
if credential_visible_to_context(
|
|
credential,
|
|
context.credential_context(server_id=server.id),
|
|
):
|
|
return binding, credential
|
|
return None, None
|
|
|
|
|
|
def _legacy_resolved_transport(
|
|
profile: MailServerProfile,
|
|
protocol: str,
|
|
) -> ResolvedMailTransport:
|
|
if protocol == "smtp":
|
|
payload = dict(profile.smtp_config or {})
|
|
payload["username"] = profile.smtp_username or payload.get("username")
|
|
payload["password"] = decrypt_secret(profile.smtp_password_encrypted)
|
|
config: SmtpConfig | ImapConfig = SmtpConfig.model_validate(payload)
|
|
revision = profile.smtp_transport_revision
|
|
else:
|
|
if not profile.imap_config:
|
|
raise MailServerHierarchyError("Mail-server profile has no IMAP configuration")
|
|
payload = dict(profile.imap_config or {})
|
|
payload.pop("enabled", None)
|
|
payload["username"] = profile.imap_username or payload.get("username")
|
|
payload["password"] = decrypt_secret(profile.imap_password_encrypted)
|
|
config = ImapConfig.model_validate(payload)
|
|
revision = profile.imap_transport_revision
|
|
if not revision:
|
|
raise MailServerHierarchyError(
|
|
f"Mail profile has no {protocol.upper()} transport revision; run the Mail database migrations"
|
|
)
|
|
return ResolvedMailTransport(
|
|
profile=profile,
|
|
server=None,
|
|
credential=None,
|
|
config=config,
|
|
transport_revision=revision,
|
|
)
|
|
|
|
|
|
def _server_matches_legacy_transport(
|
|
profile: MailServerProfile,
|
|
server: MailServerEndpoint,
|
|
) -> bool:
|
|
if not server.is_default:
|
|
return False
|
|
if server.protocol == "smtp":
|
|
legacy_config = profile.smtp_config or {}
|
|
legacy_revision = profile.smtp_transport_revision
|
|
else:
|
|
legacy_config = profile.imap_config or {}
|
|
legacy_revision = profile.imap_transport_revision
|
|
return (
|
|
bool(legacy_config)
|
|
and _server_config_payload(legacy_config)
|
|
== _server_config_payload(server.config)
|
|
and bool(legacy_revision)
|
|
and server.transport_revision == legacy_revision
|
|
)
|
|
|
|
|
|
def _legacy_server_payloads(profile: MailServerProfile) -> list[dict[str, Any]]:
|
|
payloads: list[dict[str, Any]] = []
|
|
for protocol, config, username, password_configured, revision in (
|
|
(
|
|
"smtp",
|
|
profile.smtp_config,
|
|
profile.smtp_username,
|
|
bool(profile.smtp_password_encrypted),
|
|
profile.smtp_transport_revision,
|
|
),
|
|
(
|
|
"imap",
|
|
profile.imap_config,
|
|
profile.imap_username,
|
|
bool(profile.imap_password_encrypted),
|
|
profile.imap_transport_revision,
|
|
),
|
|
):
|
|
if not config:
|
|
continue
|
|
credential_payloads = []
|
|
if username or password_configured:
|
|
credential_payloads.append(
|
|
{
|
|
"id": f"legacy:{profile.id}:{protocol}",
|
|
"binding_id": None,
|
|
"server_id": f"legacy:{profile.id}:{protocol}",
|
|
"tenant_id": profile.tenant_id,
|
|
"scope_type": profile.scope_type,
|
|
"scope_id": profile.scope_id,
|
|
"name": f"{profile.name} {protocol.upper()}",
|
|
"description": None,
|
|
"credential_kind": "username_password",
|
|
"public_data": {"username": username},
|
|
"secret_keys": ["password"] if password_configured else [],
|
|
"secret_configured": password_configured,
|
|
"allowed_modules": ["mail"],
|
|
"allowed_server_refs": [],
|
|
"inherit_to_lower_scopes": True,
|
|
"is_default": True,
|
|
"is_active": bool(profile.is_active),
|
|
"revision": revision,
|
|
"created_at": profile.created_at,
|
|
"updated_at": profile.updated_at,
|
|
"deleted_at": None,
|
|
}
|
|
)
|
|
payloads.append(
|
|
{
|
|
"id": f"legacy:{profile.id}:{protocol}",
|
|
"profile_id": profile.id,
|
|
"tenant_id": profile.tenant_id,
|
|
"protocol": protocol,
|
|
"name": protocol.upper(),
|
|
"config": _server_config_payload(config),
|
|
"scope_type": profile.scope_type,
|
|
"scope_id": profile.scope_id,
|
|
"inherit_to_lower_scopes": True,
|
|
"is_default": True,
|
|
"is_active": bool(profile.is_active),
|
|
"transport_revision": revision,
|
|
"credentials": credential_payloads,
|
|
"created_at": profile.created_at,
|
|
"updated_at": profile.updated_at,
|
|
}
|
|
)
|
|
return payloads
|
|
|
|
|
|
def _validated_server_config(
|
|
protocol: str,
|
|
config: Mapping[str, Any],
|
|
) -> dict[str, Any]:
|
|
payload = dict(config or {})
|
|
payload.pop("username", None)
|
|
payload.pop("password", None)
|
|
payload.pop("enabled", None)
|
|
try:
|
|
if protocol == "smtp":
|
|
model = SmtpServerConfig.model_validate(payload)
|
|
elif protocol == "imap":
|
|
model = ImapServerConfig.model_validate(payload)
|
|
else:
|
|
model = Pop3ServerConfig.model_validate(payload)
|
|
except Exception as exc:
|
|
raise MailServerHierarchyError(
|
|
f"Invalid {protocol.upper()} server configuration"
|
|
) from exc
|
|
return model.model_dump(mode="json")
|
|
|
|
|
|
def _server_config_payload(config: Mapping[str, Any] | None) -> dict[str, Any]:
|
|
payload = dict(config or {})
|
|
payload.pop("username", None)
|
|
payload.pop("password", None)
|
|
payload.pop("enabled", None)
|
|
return payload
|
|
|
|
|
|
def _server_scope(
|
|
profile: MailServerProfile,
|
|
*,
|
|
scope_type: str | None,
|
|
scope_id: str | None,
|
|
) -> tuple[str, str | None]:
|
|
profile_scope_type, profile_scope_id = profile_default_scope(profile)
|
|
clean_type = str(scope_type or profile_scope_type).strip().casefold()
|
|
if clean_type != profile_scope_type:
|
|
raise MailServerHierarchyError(
|
|
"Mail servers must use the same scope as their server envelope"
|
|
)
|
|
clean_id = _optional_text(scope_id) if scope_type is not None else profile_scope_id
|
|
if clean_type == "system":
|
|
return "system", None
|
|
if clean_id != profile_scope_id:
|
|
raise MailServerHierarchyError(
|
|
"Mail server scope_id must match its server envelope"
|
|
)
|
|
return clean_type, clean_id
|
|
|
|
|
|
def _normalize_protocol(value: str) -> str:
|
|
clean = str(value or "").strip().casefold()
|
|
if clean not in MAIL_SERVER_PROTOCOLS:
|
|
raise MailServerHierarchyError("Mail server protocol must be smtp, imap or pop3")
|
|
return clean
|
|
|
|
|
|
def _clear_default_server(
|
|
session: Session,
|
|
*,
|
|
profile_id: str,
|
|
protocol: str,
|
|
excluding_id: str | None = None,
|
|
) -> None:
|
|
statement = select(MailServerEndpoint).where(
|
|
MailServerEndpoint.profile_id == profile_id,
|
|
MailServerEndpoint.protocol == protocol,
|
|
MailServerEndpoint.is_default.is_(True),
|
|
)
|
|
if excluding_id:
|
|
statement = statement.where(MailServerEndpoint.id != excluding_id)
|
|
for row in session.execute(statement.with_for_update()).scalars():
|
|
row.is_default = False
|
|
session.flush()
|
|
|
|
|
|
def _has_default_server(
|
|
session: Session,
|
|
*,
|
|
profile_id: str,
|
|
protocol: str,
|
|
excluding_id: str | None = None,
|
|
) -> bool:
|
|
statement = select(MailServerEndpoint.id).where(
|
|
MailServerEndpoint.profile_id == profile_id,
|
|
MailServerEndpoint.protocol == protocol,
|
|
MailServerEndpoint.is_default.is_(True),
|
|
)
|
|
if excluding_id:
|
|
statement = statement.where(MailServerEndpoint.id != excluding_id)
|
|
return session.execute(statement.limit(1)).scalar_one_or_none() is not None
|
|
|
|
|
|
def _first_active_server(
|
|
session: Session,
|
|
*,
|
|
profile_id: str,
|
|
protocol: str,
|
|
excluding_id: str | None = None,
|
|
) -> MailServerEndpoint | None:
|
|
statement = select(MailServerEndpoint).where(
|
|
MailServerEndpoint.profile_id == profile_id,
|
|
MailServerEndpoint.protocol == protocol,
|
|
MailServerEndpoint.is_active.is_(True),
|
|
)
|
|
if excluding_id:
|
|
statement = statement.where(MailServerEndpoint.id != excluding_id)
|
|
return session.execute(
|
|
statement.order_by(MailServerEndpoint.name, MailServerEndpoint.id).limit(1)
|
|
).scalar_one_or_none()
|
|
|
|
|
|
def _clear_default_credential(
|
|
session: Session,
|
|
*,
|
|
server_id: str,
|
|
excluding_id: str | None = None,
|
|
) -> None:
|
|
statement = select(MailServerCredentialBinding).where(
|
|
MailServerCredentialBinding.server_id == server_id,
|
|
MailServerCredentialBinding.is_default.is_(True),
|
|
)
|
|
if excluding_id:
|
|
statement = statement.where(MailServerCredentialBinding.id != excluding_id)
|
|
for row in session.execute(statement.with_for_update()).scalars():
|
|
row.is_default = False
|
|
session.flush()
|
|
|
|
|
|
def _has_default_credential(
|
|
session: Session,
|
|
*,
|
|
server_id: str,
|
|
excluding_id: str | None = None,
|
|
) -> bool:
|
|
statement = select(MailServerCredentialBinding.id).where(
|
|
MailServerCredentialBinding.server_id == server_id,
|
|
MailServerCredentialBinding.is_default.is_(True),
|
|
)
|
|
if excluding_id:
|
|
statement = statement.where(MailServerCredentialBinding.id != excluding_id)
|
|
return session.execute(statement.limit(1)).scalar_one_or_none() is not None
|
|
|
|
|
|
def _first_active_credential_binding(
|
|
session: Session,
|
|
*,
|
|
server_id: str,
|
|
excluding_id: str | None = None,
|
|
) -> MailServerCredentialBinding | None:
|
|
statement = (
|
|
select(MailServerCredentialBinding)
|
|
.join(
|
|
CredentialEnvelope,
|
|
CredentialEnvelope.id == MailServerCredentialBinding.credential_id,
|
|
)
|
|
.where(
|
|
MailServerCredentialBinding.server_id == server_id,
|
|
CredentialEnvelope.is_active.is_(True),
|
|
CredentialEnvelope.deleted_at.is_(None),
|
|
)
|
|
)
|
|
if excluding_id:
|
|
statement = statement.where(MailServerCredentialBinding.id != excluding_id)
|
|
return session.execute(
|
|
statement.order_by(
|
|
MailServerCredentialBinding.created_at,
|
|
MailServerCredentialBinding.id,
|
|
).limit(1)
|
|
).scalar_one_or_none()
|
|
|
|
|
|
def _required_text(value: object | None, message: str) -> str:
|
|
clean = _optional_text(value)
|
|
if not clean:
|
|
raise MailServerHierarchyError(message)
|
|
return clean
|
|
|
|
|
|
def _optional_text(value: object | None) -> str | None:
|
|
if value is None:
|
|
return None
|
|
clean = str(value).strip()
|
|
return clean or None
|
|
|
|
|
|
__all__ = [
|
|
"MAIL_SERVER_PROTOCOLS",
|
|
"MailHierarchyContext",
|
|
"MailServerHierarchyError",
|
|
"ResolvedMailTransport",
|
|
"SelectedMailTransport",
|
|
"bind_mail_credential",
|
|
"create_and_bind_mail_credential",
|
|
"create_mail_server_endpoint",
|
|
"get_bound_mail_credential",
|
|
"get_available_mail_credential",
|
|
"get_default_mail_server_endpoint",
|
|
"get_mail_server_endpoint",
|
|
"hierarchy_context_for_profile",
|
|
"initialize_profile_hierarchy",
|
|
"list_available_mail_credentials",
|
|
"list_mail_server_endpoints",
|
|
"list_server_credential_summaries",
|
|
"mail_credential_payload",
|
|
"mail_server_endpoint_payload",
|
|
"mail_server_ref",
|
|
"mail_server_visible_to_context",
|
|
"profile_hierarchy_payload",
|
|
"resolve_mail_transport",
|
|
"select_mail_transport",
|
|
"sync_default_profile_server",
|
|
"sync_default_profile_credential",
|
|
"unlink_mail_credential",
|
|
"update_bound_mail_credential",
|
|
"update_mail_server_endpoint",
|
|
]
|