Files
govoplan-mail/src/govoplan_mail/backend/server_hierarchy.py
T
zemion 218fef11f1
Module Package Release / publish-packages (push) Successful in 11s
feat(mail): add governed POP3 legacy import
2026-08-22 04:52:25 +02:00

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",
]