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