From 3735027b3d76c705df783f8353f5746f62adb65b Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Tue, 28 Jul 2026 19:32:56 +0200 Subject: [PATCH] Add hierarchical mail servers and credentials --- src/govoplan_mail/backend/capabilities.py | 238 ++- src/govoplan_mail/backend/db/models.py | 52 + src/govoplan_mail/backend/mail_profiles.py | 96 +- src/govoplan_mail/backend/manifest.py | 4 + .../7192a3bcdef0_mail_server_hierarchy.py | 22 + .../7192a3bcdef0_mail_server_hierarchy.py | 265 +++ src/govoplan_mail/backend/router.py | 1108 +++++++++++- src/govoplan_mail/backend/schemas.py | 127 +- src/govoplan_mail/backend/server_hierarchy.py | 1513 +++++++++++++++++ tests/test_mail_profile_helpers.py | 19 +- tests/test_profile_actor_authorization.py | 2 + tests/test_router_profile_deletion.py | 1 + webui/src/api/mail.ts | 194 ++- .../features/mail/MailProfileManagement.tsx | 706 ++++++-- .../features/mail/mailProfileEditorModel.ts | 4 +- 15 files changed, 4105 insertions(+), 246 deletions(-) create mode 100644 src/govoplan_mail/backend/migrations/dev_versions/7192a3bcdef0_mail_server_hierarchy.py create mode 100644 src/govoplan_mail/backend/migrations/versions/7192a3bcdef0_mail_server_hierarchy.py create mode 100644 src/govoplan_mail/backend/server_hierarchy.py diff --git a/src/govoplan_mail/backend/capabilities.py b/src/govoplan_mail/backend/capabilities.py index 6964824..e1ad0ec 100644 --- a/src/govoplan_mail/backend/capabilities.py +++ b/src/govoplan_mail/backend/capabilities.py @@ -11,6 +11,7 @@ from govoplan_mail.backend.mail_profiles import ( _assert_campaign_inherits_profile_credentials, assert_campaign_mail_policy_allows_json, assert_mail_policy_allows_send, + campaign_mail_owner_context, campaign_profile_transport_revisions, effective_mail_profile_policy, ensure_mail_profile_allowed_for_campaign, @@ -19,6 +20,12 @@ from govoplan_mail.backend.mail_profiles import ( mail_profile_id_from_campaign_json, smtp_config_from_profile, ) +from govoplan_mail.backend.server_hierarchy import ( + MailHierarchyContext, + MailServerHierarchyError, + resolve_mail_transport, + select_mail_transport, +) from govoplan_mail.backend.runtime import configure_runtime from govoplan_mail.backend.sending.imap import ( ImapAppendError, @@ -104,6 +111,7 @@ def _authorized_campaign_profile( tenant_id: str, campaign_id: str, profile_id: str, + selection: dict[str, str | None] | None = None, ): profile = ensure_mail_profile_allowed_for_campaign( session, @@ -113,10 +121,55 @@ def _authorized_campaign_profile( require_active=True, ) policy = effective_mail_profile_policy(session, tenant_id=tenant_id, campaign_id=campaign_id) - _assert_campaign_inherits_profile_credentials(profile, policy) + _assert_campaign_inherits_profile_credentials(profile, policy, selection) return profile +def _campaign_hierarchy_context( + session: Session, + *, + tenant_id: str, + campaign_id: str, +) -> MailHierarchyContext: + campaign = campaign_mail_owner_context( + session, + tenant_id=tenant_id, + campaign_id=campaign_id, + ) + return MailHierarchyContext( + tenant_id=tenant_id, + user_id=campaign.owner_user_id, + group_ids=( + frozenset({campaign.owner_group_id}) + if campaign.owner_group_id + else frozenset() + ), + target_scope_type="campaign", + target_scope_id=campaign.id, + ) + + +def _selection_payload( + *, + profile_id: str, + smtp_server_id: str | None = None, + smtp_credential_id: str | None = None, + imap_server_id: str | None = None, + imap_credential_id: str | None = None, +) -> dict[str, str | None]: + return { + "mail_profile_id": profile_id, + "smtp_server_id": smtp_server_id, + "smtp_credential_id": smtp_credential_id, + "imap_server_id": imap_server_id, + "imap_credential_id": imap_credential_id, + } + + +def _supports_hierarchy(session: object) -> bool: + return callable(getattr(session, "execute", None)) + + def campaign_profile_delivery_summary( session: Session, *, @@ -125,21 +178,33 @@ def campaign_profile_delivery_summary( campaign_id: str | None = None, owner_user_id: str | None = None, owner_group_id: str | None = None, + smtp_server_id: str | None = None, + smtp_credential_id: str | None = None, + imap_server_id: str | None = None, + imap_credential_id: str | None = None, ) -> dict[str, Any]: """Return only non-secret capabilities and opaque drift evidence.""" + selection = _selection_payload( + profile_id=profile_id, + smtp_server_id=smtp_server_id, + smtp_credential_id=smtp_credential_id, + imap_server_id=imap_server_id, + imap_credential_id=imap_credential_id, + ) if campaign_id: profile = _authorized_campaign_profile( session, tenant_id=tenant_id, campaign_id=campaign_id, profile_id=profile_id, + selection=selection, ) else: assert_campaign_mail_policy_allows_json( session, tenant_id=tenant_id, - raw_json={"server": {"mail_profile_id": profile_id}}, + raw_json={"server": {key: value for key, value in selection.items() if value}}, owner_user_id=owner_user_id, owner_group_id=owner_group_id, ) @@ -149,15 +214,61 @@ def campaign_profile_delivery_summary( profile_id=profile_id, require_active=True, ) - revisions = campaign_profile_transport_revisions(profile) - smtp = profile.smtp_config or {} - imap = profile.imap_config or {} + if campaign_id and _supports_hierarchy(session): + context = _campaign_hierarchy_context( + session, + tenant_id=tenant_id, + campaign_id=campaign_id, + ) + try: + smtp = select_mail_transport( + session, + profile=profile, + protocol="smtp", + context=context, + server_id=smtp_server_id, + credential_id=smtp_credential_id, + ) + imap = select_mail_transport( + session, + profile=profile, + protocol="imap", + context=context, + server_id=imap_server_id, + credential_id=imap_credential_id, + ) + except MailServerHierarchyError as exc: + raise MailProfileError(str(exc)) from exc + smtp_available = smtp.available + imap_available = imap.available + smtp_revision = smtp.transport_revision + imap_revision = imap.transport_revision if imap.available else None + resolved_smtp_server_id = smtp.server.id if smtp.server else None + resolved_smtp_credential_id = smtp.credential.id if smtp.credential else None + resolved_imap_server_id = imap.server.id if imap.server else None + resolved_imap_credential_id = imap.credential.id if imap.credential else None + else: + revisions = campaign_profile_transport_revisions(profile) + smtp_config = profile.smtp_config or {} + imap_config = profile.imap_config or {} + smtp_available = bool(smtp_config.get("host") and smtp_config.get("port")) + imap_available = bool(imap_config.get("host") and imap_config.get("port")) + smtp_revision = revisions["smtp"] + imap_revision = revisions["imap"] + resolved_smtp_server_id = smtp_server_id + resolved_smtp_credential_id = smtp_credential_id + resolved_imap_server_id = imap_server_id + resolved_imap_credential_id = imap_credential_id return { "mail_profile_id": profile_id, - "smtp_available": bool(smtp.get("host") and smtp.get("port")), - "imap_available": bool(imap.get("host") and imap.get("port")), - "smtp_transport_revision": revisions["smtp"], - "imap_transport_revision": revisions["imap"], + "smtp_server_id": resolved_smtp_server_id, + "smtp_credential_id": resolved_smtp_credential_id, + "imap_server_id": resolved_imap_server_id, + "imap_credential_id": resolved_imap_credential_id, + "smtp_available": smtp_available, + "imap_available": imap_available, + "smtp_transport_revision": smtp_revision, + "imap_transport_revision": imap_revision, } @@ -172,26 +283,65 @@ def send_campaign_email_bytes( envelope_recipients: list[str], from_header: str | None, expected_smtp_transport_revision: str, + smtp_server_id: str | None = None, + smtp_credential_id: str | None = None, ) -> CampaignSmtpDeliveryResult: + selection = _selection_payload( + profile_id=profile_id, + smtp_server_id=smtp_server_id, + smtp_credential_id=smtp_credential_id, + ) try: profile = _authorized_campaign_profile( session, tenant_id=tenant_id, campaign_id=campaign_id, profile_id=profile_id, + selection=selection, ) except MailProfileError: raise except Exception: raise SmtpConfigurationError("The selected Mail profile's SMTP configuration is unusable.") from None - revisions = campaign_profile_transport_revisions(profile) - if revisions["smtp"] != expected_smtp_transport_revision: + resolved_smtp = None + if _supports_hierarchy(session): + context = _campaign_hierarchy_context( + session, + tenant_id=tenant_id, + campaign_id=campaign_id, + ) + try: + selected_smtp = select_mail_transport( + session, + profile=profile, + protocol="smtp", + context=context, + server_id=smtp_server_id, + credential_id=smtp_credential_id, + ) + except MailServerHierarchyError as exc: + raise MailProfileError(str(exc)) from exc + current_smtp_revision = selected_smtp.transport_revision + else: + current_smtp_revision = campaign_profile_transport_revisions(profile)["smtp"] + if current_smtp_revision != expected_smtp_transport_revision: raise MailProfileError( "The selected Mail profile's SMTP settings changed after this campaign was built. " "Revalidate and rebuild the campaign before delivery." ) try: - smtp = smtp_config_from_profile(profile) + if _supports_hierarchy(session): + resolved_smtp = resolve_mail_transport( + session, + profile=profile, + protocol="smtp", + context=context, + server_id=smtp_server_id, + credential_id=smtp_credential_id, + ) + smtp = resolved_smtp.config + else: + smtp = smtp_config_from_profile(profile) except MailProfileError: raise except Exception: @@ -202,7 +352,7 @@ def send_campaign_email_bytes( tenant_id=tenant_id, campaign_id=campaign_id, smtp=smtp, - imap=profile.imap_config or None, + imap=None, envelope_sender=envelope_from, from_header=from_header, recipients=envelope_recipients, @@ -241,31 +391,83 @@ def append_campaign_message_to_sent( folder: str | None, expected_smtp_transport_revision: str, expected_imap_transport_revision: str | None, + smtp_server_id: str | None = None, + smtp_credential_id: str | None = None, + imap_server_id: str | None = None, + imap_credential_id: str | None = None, ) -> CampaignImapAppendResult: + selection = _selection_payload( + profile_id=profile_id, + smtp_server_id=smtp_server_id, + smtp_credential_id=smtp_credential_id, + imap_server_id=imap_server_id, + imap_credential_id=imap_credential_id, + ) try: profile = _authorized_campaign_profile( session, tenant_id=tenant_id, campaign_id=campaign_id, profile_id=profile_id, + selection=selection, ) except MailProfileError: raise except Exception: raise ImapConfigurationError("The selected Mail profile's IMAP configuration is unusable.") from None - revisions = campaign_profile_transport_revisions(profile) - if revisions["smtp"] != expected_smtp_transport_revision: + if _supports_hierarchy(session): + context = _campaign_hierarchy_context( + session, + tenant_id=tenant_id, + campaign_id=campaign_id, + ) + try: + selected_smtp = select_mail_transport( + session, + profile=profile, + protocol="smtp", + context=context, + server_id=smtp_server_id, + credential_id=smtp_credential_id, + ) + selected_imap = select_mail_transport( + session, + profile=profile, + protocol="imap", + context=context, + server_id=imap_server_id, + credential_id=imap_credential_id, + ) + except MailServerHierarchyError as exc: + raise MailProfileError(str(exc)) from exc + smtp_revision = selected_smtp.transport_revision + imap_revision = selected_imap.transport_revision + else: + revisions = campaign_profile_transport_revisions(profile) + smtp_revision = revisions["smtp"] + imap_revision = revisions["imap"] + if smtp_revision != expected_smtp_transport_revision: raise MailProfileError( "The selected Mail profile's SMTP settings changed after this campaign was built. " "Revalidate and rebuild the campaign before append-to-Sent delivery." ) - if revisions["imap"] != expected_imap_transport_revision: + if imap_revision != expected_imap_transport_revision: raise MailProfileError( "The selected Mail profile's IMAP settings changed after this campaign was built. " "Revalidate and rebuild the campaign before append-to-Sent delivery." ) try: - imap = imap_config_from_profile(profile) + if _supports_hierarchy(session): + imap = resolve_mail_transport( + session, + profile=profile, + protocol="imap", + context=context, + server_id=imap_server_id, + credential_id=imap_credential_id, + ).config + else: + imap = imap_config_from_profile(profile) except MailProfileError: raise except Exception: @@ -277,7 +479,7 @@ def append_campaign_message_to_sent( session, tenant_id=tenant_id, campaign_id=campaign_id, - smtp=profile.smtp_config or None, + smtp=None, imap=imap, ) except MailProfileError: diff --git a/src/govoplan_mail/backend/db/models.py b/src/govoplan_mail/backend/db/models.py index 3e89710..5461955 100644 --- a/src/govoplan_mail/backend/db/models.py +++ b/src/govoplan_mail/backend/db/models.py @@ -9,6 +9,7 @@ from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, Index, Integer from sqlalchemy.orm import Mapped, mapped_column from govoplan_core.db.base import Base, TimestampMixin +from govoplan_core.security import credential_envelopes as core_credential_models # noqa: F401 def new_uuid() -> str: @@ -30,6 +31,7 @@ class MailServerProfile(Base, TimestampMixin): slug: Mapped[str] = mapped_column(String(100), nullable=False) description: Mapped[str | None] = mapped_column(Text) is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, index=True) + inherit_to_lower_scopes: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) smtp_config: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False) smtp_username: Mapped[str | None] = mapped_column(String(320)) smtp_password_encrypted: Mapped[str | None] = mapped_column(Text) @@ -42,6 +44,56 @@ class MailServerProfile(Base, TimestampMixin): updated_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True) +class MailServerEndpoint(Base, TimestampMixin): + __tablename__ = "mail_server_endpoints" + __table_args__ = ( + UniqueConstraint("profile_id", "protocol", "name", name="uq_mail_server_endpoints_profile_protocol_name"), + Index("ix_mail_server_endpoints_profile_protocol", "profile_id", "protocol", "is_active"), + Index("ix_mail_server_endpoints_scope", "tenant_id", "scope_type", "scope_id"), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + profile_id: Mapped[str] = mapped_column( + ForeignKey("mail_server_profiles.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + tenant_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True) + protocol: Mapped[str] = mapped_column(String(20), nullable=False, index=True) + name: Mapped[str] = mapped_column(String(255), nullable=False) + config: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False) + scope_type: Mapped[str] = mapped_column(String(20), default="tenant", nullable=False, index=True) + scope_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True) + inherit_to_lower_scopes: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, index=True) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, index=True) + transport_revision: Mapped[str] = mapped_column(String(36), default=new_uuid, nullable=False) + created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True) + updated_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True) + + +class MailServerCredentialBinding(Base, TimestampMixin): + __tablename__ = "mail_server_credential_bindings" + __table_args__ = ( + UniqueConstraint("server_id", "credential_id", name="uq_mail_server_credential_bindings_server_credential"), + Index("ix_mail_server_credential_bindings_default", "server_id", "is_default"), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + server_id: Mapped[str] = mapped_column( + ForeignKey("mail_server_endpoints.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + credential_id: Mapped[str] = mapped_column( + ForeignKey("core_credential_envelopes.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, index=True) + created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("access_users.id", ondelete="SET NULL"), nullable=True, index=True) + + class MailProfilePolicy(Base, TimestampMixin): __tablename__ = "mail_profile_policies" __table_args__ = ( diff --git a/src/govoplan_mail/backend/mail_profiles.py b/src/govoplan_mail/backend/mail_profiles.py index 2507e99..92bfbe2 100644 --- a/src/govoplan_mail/backend/mail_profiles.py +++ b/src/govoplan_mail/backend/mail_profiles.py @@ -4,7 +4,7 @@ import fnmatch import json import re from dataclasses import dataclass, field -from typing import Any, Iterable +from typing import Any, Iterable, Mapping from sqlalchemy import and_, or_, select, text from sqlalchemy.orm import Session @@ -919,6 +919,19 @@ def campaign_mail_context_visible_to_actor( ) +def campaign_mail_owner_context( + session: Session, + *, + tenant_id: str, + campaign_id: str, +) -> CampaignMailPolicyContext: + return _campaign_policy_context( + session, + tenant_id=tenant_id, + campaign_id=campaign_id, + ) + + def mail_profile_scope_visible_to_actor( session: Session, *, @@ -1335,7 +1348,15 @@ def _profile_has_transport(profile: MailServerProfile, protocol: str) -> bool: return bool(profile.imap_config) -_CAMPAIGN_MAIL_REFERENCE_KEYS = frozenset({"mail_profile_id"}) +_CAMPAIGN_MAIL_REFERENCE_KEYS = frozenset( + { + "mail_profile_id", + "smtp_server_id", + "smtp_credential_id", + "imap_server_id", + "imap_credential_id", + } +) def _campaign_mail_profile_reference_id(server: dict[str, Any]) -> str | None: @@ -1343,8 +1364,8 @@ def _campaign_mail_profile_reference_id(server: dict[str, Any]) -> str | None: if unexpected: paths = ", ".join(f"server.{key}" for key in unexpected) raise MailProfileError( - "Campaign JSON may only reference a Mail-owned profile through server.mail_profile_id; " - f"remove campaign-local SMTP/IMAP settings ({paths}), select a Mail profile, and save a new campaign version." + "Campaign JSON may only reference Mail-owned profile, server, and credential identifiers; " + f"remove campaign-local SMTP/IMAP settings ({paths}), select Mail resources, and save a new campaign version." ) value = server.get("mail_profile_id") if value is None: @@ -1354,18 +1375,63 @@ def _campaign_mail_profile_reference_id(server: dict[str, Any]) -> str | None: return value.strip() +def campaign_mail_selection_from_json( + raw_json: dict[str, Any] | None, +) -> dict[str, str | None]: + data = raw_json if isinstance(raw_json, dict) else {} + server = data.get("server") if isinstance(data.get("server"), dict) else {} + profile_id = _campaign_mail_profile_reference_id(server) + selection: dict[str, str | None] = {"mail_profile_id": profile_id} + for key in ( + "smtp_server_id", + "smtp_credential_id", + "imap_server_id", + "imap_credential_id", + ): + value = server.get(key) + if value is None: + selection[key] = None + continue + if not isinstance(value, str) or not value.strip(): + raise MailProfileError(f"server.{key} must be a non-empty Mail identifier") + selection[key] = value.strip() + if profile_id is None and any( + selection[key] + for key in selection + if key != "mail_profile_id" + ): + raise MailProfileError( + "Mail server or credential selections require server.mail_profile_id" + ) + for protocol in ("smtp", "imap"): + if ( + selection[f"{protocol}_credential_id"] + and not selection[f"{protocol}_server_id"] + ): + raise MailProfileError( + f"server.{protocol}_credential_id requires server.{protocol}_server_id" + ) + return selection + + def _assert_campaign_inherits_profile_credentials( profile: MailServerProfile, policy: EffectiveMailProfilePolicy, + selection: Mapping[str, str | None] | None = None, ) -> None: for protocol in ("smtp", "imap"): if not _profile_has_transport(profile, protocol): continue - if not _credential_policy_for_protocol(policy, protocol).inherit: + explicit_credential = ( + selection or {} + ).get(f"{protocol}_credential_id") + if ( + not _credential_policy_for_protocol(policy, protocol).inherit + and not explicit_credential + ): raise MailProfileError( f"Campaign delivery cannot use the selected profile because the effective {protocol.upper()} " - "credential policy requires campaign-local credentials. Store the credentials on a Mail profile " - "and change the policy to inherit them." + "credential policy requires an explicit credential selection for this campaign." ) @@ -1379,8 +1445,8 @@ def assert_campaign_mail_policy_allows_json( owner_group_id: str | None = None, ) -> None: data = raw_json if isinstance(raw_json, dict) else {} - server = data.get("server") if isinstance(data.get("server"), dict) else {} - profile_id = _campaign_mail_profile_reference_id(server) + selection = campaign_mail_selection_from_json(data) + profile_id = selection["mail_profile_id"] if profile_id: if campaign_id: profile = ensure_mail_profile_allowed_for_campaign( @@ -1391,7 +1457,7 @@ def assert_campaign_mail_policy_allows_json( require_active=True, ) policy = effective_mail_profile_policy(session, tenant_id=tenant_id, campaign_id=campaign_id) - _assert_campaign_inherits_profile_credentials(profile, policy) + _assert_campaign_inherits_profile_credentials(profile, policy, selection) return profile = get_mail_server_profile(session, tenant_id=tenant_id, profile_id=str(profile_id), require_active=True) policy = effective_mail_profile_policy( @@ -1408,7 +1474,7 @@ def assert_campaign_mail_policy_allows_json( owner_group_id=owner_group_id, ): raise MailProfileError("Mail-server profile is not allowed by the effective policy") - _assert_campaign_inherits_profile_credentials(profile, policy) + _assert_campaign_inherits_profile_credentials(profile, policy, selection) return return @@ -1424,6 +1490,7 @@ def create_mail_server_profile( smtp: SmtpConfig, imap: ImapConfig | None, is_active: bool = True, + inherit_to_lower_scopes: bool = True, scope_type: str = "tenant", scope_id: str | None = None, ) -> MailServerProfile: @@ -1466,6 +1533,7 @@ def create_mail_server_profile( slug=clean_slug, description=description, is_active=is_active, + inherit_to_lower_scopes=bool(inherit_to_lower_scopes), smtp_config=smtp_payload, smtp_username=smtp_username, smtp_password_encrypted=encrypt_secret(smtp_password), @@ -1491,6 +1559,7 @@ def update_mail_server_profile( slug: str | None = None, description: str | None = None, is_active: bool | None = None, + inherit_to_lower_scopes: bool | None = None, smtp: SmtpConfig | None = None, imap: ImapConfig | None = None, clear_imap: bool = False, @@ -1538,6 +1607,8 @@ def update_mail_server_profile( profile.description = description if is_active is not None: profile.is_active = is_active + if inherit_to_lower_scopes is not None: + profile.inherit_to_lower_scopes = bool(inherit_to_lower_scopes) next_smtp, next_imap = _next_profile_transport_state(profile, smtp=smtp, imap=imap, clear_imap=clear_imap) _assert_profile_transport_allowed(session, tenant_id=tenant_id, profile=profile, smtp=next_smtp, imap=next_imap) @@ -1883,6 +1954,9 @@ def profile_response_payload(profile: MailServerProfile) -> dict[str, Any]: "slug": profile.slug, "description": profile.description, "is_active": profile.is_active, + "inherit_to_lower_scopes": bool( + getattr(profile, "inherit_to_lower_scopes", True) + ), "smtp": _server_config_payload(profile.smtp_config), "imap": _server_config_payload(profile.imap_config) if profile.imap_config else None, "credentials": { diff --git a/src/govoplan_mail/backend/manifest.py b/src/govoplan_mail/backend/manifest.py index 21e1f6d..bcbcd3b 100644 --- a/src/govoplan_mail/backend/manifest.py +++ b/src/govoplan_mail/backend/manifest.py @@ -27,6 +27,8 @@ from govoplan_mail.backend.db import models as mail_models # noqa: F401 - popul _mail_table_retirement_provider = drop_table_retirement_provider( + mail_models.MailServerCredentialBinding, + mail_models.MailServerEndpoint, mail_models.MailServerProfile, mail_models.MailProfilePolicy, mail_models.MailMailboxFolderIndex, @@ -194,6 +196,8 @@ manifest = ModuleManifest( ), uninstall_guard_providers=( persistent_table_uninstall_guard( + mail_models.MailServerCredentialBinding, + mail_models.MailServerEndpoint, mail_models.MailServerProfile, mail_models.MailProfilePolicy, mail_models.MailMailboxFolderIndex, diff --git a/src/govoplan_mail/backend/migrations/dev_versions/7192a3bcdef0_mail_server_hierarchy.py b/src/govoplan_mail/backend/migrations/dev_versions/7192a3bcdef0_mail_server_hierarchy.py new file mode 100644 index 0000000..8d30ee9 --- /dev/null +++ b/src/govoplan_mail/backend/migrations/dev_versions/7192a3bcdef0_mail_server_hierarchy.py @@ -0,0 +1,22 @@ +"""split mail envelopes into servers and reusable credential bindings + +Revision ID: 7192a3bcdef0 +Revises: 608192abcdef +Create Date: 2026-07-23 00:00:00.000000 +""" +from __future__ import annotations + +from importlib import import_module + + +hierarchy = import_module( + "govoplan_mail.backend.migrations.versions.7192a3bcdef0_mail_server_hierarchy" +) + + +revision = hierarchy.revision +down_revision = hierarchy.down_revision +branch_labels = hierarchy.branch_labels +depends_on = hierarchy.depends_on +upgrade = hierarchy.upgrade +downgrade = hierarchy.downgrade diff --git a/src/govoplan_mail/backend/migrations/versions/7192a3bcdef0_mail_server_hierarchy.py b/src/govoplan_mail/backend/migrations/versions/7192a3bcdef0_mail_server_hierarchy.py new file mode 100644 index 0000000..c1ce9ae --- /dev/null +++ b/src/govoplan_mail/backend/migrations/versions/7192a3bcdef0_mail_server_hierarchy.py @@ -0,0 +1,265 @@ +"""split mail envelopes into servers and reusable credential bindings + +Revision ID: 7192a3bcdef0 +Revises: 608192abcdef +Create Date: 2026-07-23 00:00:00.000000 +""" +from __future__ import annotations + +import json +import uuid +from datetime import datetime, timezone + +from alembic import op +import sqlalchemy as sa + + +revision = "7192a3bcdef0" +down_revision = "608192abcdef" +branch_labels = None +depends_on = "c91f0a72be34" + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + tables = set(inspector.get_table_names()) + if "mail_server_profiles" not in tables: + return + + profile_columns = {column["name"] for column in inspector.get_columns("mail_server_profiles")} + if "inherit_to_lower_scopes" not in profile_columns: + with op.batch_alter_table("mail_server_profiles") as batch: + batch.add_column( + sa.Column( + "inherit_to_lower_scopes", + sa.Boolean(), + nullable=False, + server_default=sa.true(), + ) + ) + + if "mail_server_endpoints" not in tables: + op.create_table( + "mail_server_endpoints", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("profile_id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=True), + sa.Column("protocol", sa.String(length=20), nullable=False), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("config", sa.JSON(), nullable=False), + sa.Column("scope_type", sa.String(length=20), nullable=False), + sa.Column("scope_id", sa.String(length=36), nullable=True), + sa.Column("inherit_to_lower_scopes", sa.Boolean(), nullable=False), + sa.Column("is_default", sa.Boolean(), nullable=False), + sa.Column("is_active", sa.Boolean(), nullable=False), + sa.Column("transport_revision", sa.String(length=36), nullable=False), + sa.Column("created_by_user_id", sa.String(length=36), nullable=True), + sa.Column("updated_by_user_id", sa.String(length=36), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint( + ["profile_id"], + ["mail_server_profiles.id"], + name=op.f("fk_mail_server_endpoints_profile_id_mail_server_profiles"), + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + ["tenant_id"], + ["core_scopes.id"], + name=op.f("fk_mail_server_endpoints_tenant_id_core_scopes"), + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + ["created_by_user_id"], + ["access_users.id"], + name=op.f("fk_mail_server_endpoints_created_by_user_id_access_users"), + ondelete="SET NULL", + ), + sa.ForeignKeyConstraint( + ["updated_by_user_id"], + ["access_users.id"], + name=op.f("fk_mail_server_endpoints_updated_by_user_id_access_users"), + ondelete="SET NULL", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_mail_server_endpoints")), + sa.UniqueConstraint( + "profile_id", + "protocol", + "name", + name="uq_mail_server_endpoints_profile_protocol_name", + ), + ) + _create_endpoint_indexes() + + inspector = sa.inspect(bind) + if "mail_server_credential_bindings" not in inspector.get_table_names(): + op.create_table( + "mail_server_credential_bindings", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("server_id", sa.String(length=36), nullable=False), + sa.Column("credential_id", sa.String(length=36), nullable=False), + sa.Column("is_default", sa.Boolean(), nullable=False), + sa.Column("created_by_user_id", sa.String(length=36), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint( + ["server_id"], + ["mail_server_endpoints.id"], + name=op.f("fk_mail_server_credential_bindings_server_id_mail_server_endpoints"), + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + ["credential_id"], + ["core_credential_envelopes.id"], + name=op.f("fk_mail_server_credential_bindings_credential_id_core_credential_envelopes"), + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + ["created_by_user_id"], + ["access_users.id"], + name=op.f("fk_mail_server_credential_bindings_created_by_user_id_access_users"), + ondelete="SET NULL", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_mail_server_credential_bindings")), + sa.UniqueConstraint( + "server_id", + "credential_id", + name="uq_mail_server_credential_bindings_server_credential", + ), + ) + op.create_index( + "ix_mail_server_credential_bindings_default", + "mail_server_credential_bindings", + ["server_id", "is_default"], + unique=False, + ) + for column in ("server_id", "credential_id", "is_default", "created_by_user_id"): + op.create_index( + op.f(f"ix_mail_server_credential_bindings_{column}"), + "mail_server_credential_bindings", + [column], + unique=False, + ) + + _seed_legacy_endpoints(bind) + + +def downgrade() -> None: + inspector = sa.inspect(op.get_bind()) + tables = set(inspector.get_table_names()) + if "mail_server_credential_bindings" in tables: + op.drop_table("mail_server_credential_bindings") + if "mail_server_endpoints" in tables: + op.drop_table("mail_server_endpoints") + if "mail_server_profiles" in tables: + columns = {column["name"] for column in inspector.get_columns("mail_server_profiles")} + if "inherit_to_lower_scopes" in columns: + with op.batch_alter_table("mail_server_profiles") as batch: + batch.drop_column("inherit_to_lower_scopes") + + +def _create_endpoint_indexes() -> None: + op.create_index( + "ix_mail_server_endpoints_profile_protocol", + "mail_server_endpoints", + ["profile_id", "protocol", "is_active"], + unique=False, + ) + op.create_index( + "ix_mail_server_endpoints_scope", + "mail_server_endpoints", + ["tenant_id", "scope_type", "scope_id"], + unique=False, + ) + for column in ( + "profile_id", + "tenant_id", + "protocol", + "scope_type", + "scope_id", + "is_default", + "is_active", + "created_by_user_id", + "updated_by_user_id", + ): + op.create_index( + op.f(f"ix_mail_server_endpoints_{column}"), + "mail_server_endpoints", + [column], + unique=False, + ) + + +def _seed_legacy_endpoints(bind) -> None: + existing = { + (row.profile_id, row.protocol) + for row in bind.execute( + sa.text("SELECT profile_id, protocol FROM mail_server_endpoints WHERE is_default = :is_default"), + {"is_default": True}, + ) + } + rows = bind.execute( + sa.text( + "SELECT id, tenant_id, scope_type, scope_id, smtp_config, imap_config, " + "smtp_transport_revision, imap_transport_revision, created_by_user_id, updated_by_user_id " + "FROM mail_server_profiles" + ) + ).mappings() + now = datetime.now(timezone.utc) + table = sa.table( + "mail_server_endpoints", + sa.column("id", sa.String), + sa.column("profile_id", sa.String), + sa.column("tenant_id", sa.String), + sa.column("protocol", sa.String), + sa.column("name", sa.String), + sa.column("config", sa.JSON), + sa.column("scope_type", sa.String), + sa.column("scope_id", sa.String), + sa.column("inherit_to_lower_scopes", sa.Boolean), + sa.column("is_default", sa.Boolean), + sa.column("is_active", sa.Boolean), + sa.column("transport_revision", sa.String), + sa.column("created_by_user_id", sa.String), + sa.column("updated_by_user_id", sa.String), + sa.column("created_at", sa.DateTime(timezone=True)), + sa.column("updated_at", sa.DateTime(timezone=True)), + ) + for row in rows: + for protocol, config_key, revision_key in ( + ("smtp", "smtp_config", "smtp_transport_revision"), + ("imap", "imap_config", "imap_transport_revision"), + ): + config = _json_object(row[config_key]) + if not config or (row["id"], protocol) in existing: + continue + bind.execute( + table.insert().values( + id=str(uuid.uuid4()), + profile_id=row["id"], + tenant_id=row["tenant_id"], + protocol=protocol, + name=protocol.upper(), + config=config, + scope_type=row["scope_type"] or "tenant", + scope_id=row["scope_id"], + inherit_to_lower_scopes=True, + is_default=True, + is_active=True, + transport_revision=row[revision_key] or str(uuid.uuid4()), + created_by_user_id=row["created_by_user_id"], + updated_by_user_id=row["updated_by_user_id"], + created_at=now, + updated_at=now, + ) + ) + + +def _json_object(value): + if isinstance(value, dict): + return value + if isinstance(value, str) and value.strip(): + parsed = json.loads(value) + return parsed if isinstance(parsed, dict) else {} + return {} diff --git a/src/govoplan_mail/backend/router.py b/src/govoplan_mail/backend/router.py index a37374d..e5a3d85 100644 --- a/src/govoplan_mail/backend/router.py +++ b/src/govoplan_mail/backend/router.py @@ -12,6 +12,12 @@ from govoplan_mail.backend.schemas import ( MailAddressLookupCandidate, MailAddressLookupResponse, MailConnectionTestResponse, + MailCampaignCredentialCreateRequest, + MailCredentialBindRequest, + MailCredentialCreateRequest, + MailCredentialEnvelopeResponse, + MailCredentialListResponse, + MailCredentialUpdateRequest, MailImapFolderListResponse, MailImapFolderResponse, MailMailboxBootstrapResponse, @@ -25,6 +31,9 @@ from govoplan_mail.backend.schemas import ( MailProfilePolicyUpdateRequest, MailSettingsDeltaResponse, MailServerProfileCreateRequest, + MailServerEndpointCreateRequest, + MailServerEndpointResponse, + MailServerEndpointUpdateRequest, MailServerProfileListResponse, MailServerProfileResponse, MailServerProfileUpdateRequest, @@ -50,6 +59,7 @@ from govoplan_mail.backend.mailbox_index import ( ) from govoplan_mail.backend.mail_profiles import ( MailProfileError, + campaign_mail_owner_context, campaign_mail_context_visible_to_actor, create_mail_server_profile, delete_mail_profile_credentials, @@ -69,6 +79,29 @@ from govoplan_mail.backend.mail_profiles import ( ) from govoplan_mail.backend.config import ImapConfig, SmtpConfig from govoplan_mail.backend.runtime import get_registry +from govoplan_mail.backend.server_hierarchy import ( + MailHierarchyContext, + MailServerHierarchyError, + bind_mail_credential, + create_and_bind_mail_credential, + create_mail_server_endpoint, + get_available_mail_credential, + get_bound_mail_credential, + get_mail_server_endpoint, + hierarchy_context_for_profile, + initialize_profile_hierarchy, + list_available_mail_credentials, + mail_credential_payload, + mail_server_ref, + mail_server_endpoint_payload, + profile_hierarchy_payload, + resolve_mail_transport, + sync_default_profile_credential, + sync_default_profile_server, + unlink_mail_credential, + update_bound_mail_credential, + update_mail_server_endpoint, +) from govoplan_mail.backend.sending.imap import ImapAppendError, ImapConfigurationError, get_imap_message, list_imap_folders, list_imap_messages, load_imap_mailbox_bootstrap, test_imap_login from govoplan_mail.backend.sending.smtp import test_smtp_login @@ -80,6 +113,8 @@ MAIL_POLICIES_COLLECTION = "mail.profile_policies" MAIL_SETTINGS_COLLECTIONS = (MAIL_PROFILES_COLLECTION, MAIL_POLICIES_COLLECTION) MAIL_PROFILE_RESOURCE = "mail_profile" MAIL_POLICY_RESOURCE = "mail_profile_policy" +MAIL_SERVER_RESOURCE = "mail_server" +MAIL_CREDENTIAL_RESOURCE = "mail_credential" MAILBOX_MESSAGES_CURSOR_SCOPE = "mail.mailbox.messages.v1" DEFAULT_MAILBOX_MESSAGE_LIMIT = 50 CAPABILITY_ADDRESSES_LOOKUP = "addresses.lookup" @@ -212,6 +247,90 @@ def _profile_for_mutation( return profile +def _server_for_mutation( + session: Session, + *, + principal: ApiPrincipal, + profile_id: str, + server_id: str, +): + profile = _profile_for_mutation( + session, + principal=principal, + profile_id=profile_id, + ) + try: + server = get_mail_server_endpoint( + session, + profile=profile, + server_id=server_id, + context=hierarchy_context_for_profile( + profile, + user_id=principal.user.id, + group_ids=principal.group_ids, + administrative=True, + ), + require_active=False, + for_update=True, + ) + except MailServerHierarchyError as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Mail server not found", + ) from exc + return profile, server + + +def _server_response( + session: Session, + *, + principal: ApiPrincipal, + profile, + server, +) -> MailServerEndpointResponse: + context = hierarchy_context_for_profile( + profile, + user_id=principal.user.id, + group_ids=principal.group_ids, + administrative=True, + ) + return MailServerEndpointResponse.model_validate( + mail_server_endpoint_payload( + session, + server=server, + context=context, + include_inactive_credentials=True, + ) + ) + + +def _record_profile_child_change( + session: Session, + *, + principal: ApiPrincipal, + profile, + operation: str, + resource_type: str, + resource_id: str, +) -> None: + _record_mail_change( + session, + collection=MAIL_PROFILES_COLLECTION, + resource_type=MAIL_PROFILE_RESOURCE, + resource_id=profile.id, + operation="updated", + principal=principal, + tenant_id=profile.tenant_id, + payload={ + "scope_type": profile.scope_type, + "scope_id": profile.scope_id, + "child_operation": operation, + "child_resource_type": resource_type, + "child_resource_id": resource_id, + }, + ) + + _TRANSPORT_ENDPOINT_FIELDS = ("host", "port", "security") @@ -414,8 +533,94 @@ def _require_campaign_profile_mutation_access( ) -def _profile_response(profile) -> MailServerProfileResponse: - return MailServerProfileResponse.model_validate(profile_response_payload(profile)) +def _mail_hierarchy_context( + principal: ApiPrincipal, + *, + target_scope_type: str | None = None, + target_scope_id: str | None = None, + administrative: bool = False, +) -> MailHierarchyContext: + resolved_scope_type = target_scope_type or "user" + resolved_scope_id = ( + target_scope_id + if target_scope_type is not None + else principal.user.id + ) + return MailHierarchyContext( + tenant_id=principal.tenant_id, + user_id=principal.user.id, + group_ids=frozenset(str(item) for item in principal.group_ids), + target_scope_type=resolved_scope_type, + target_scope_id=resolved_scope_id, + administrative=administrative, + ) + + +def _profile_response( + profile, + *, + session: Session | None = None, + principal: ApiPrincipal | None = None, + target_scope_type: str | None = None, + target_scope_id: str | None = None, + include_inactive: bool = False, + administrative: bool = False, +) -> MailServerProfileResponse: + payload = profile_response_payload(profile) + if session is None or principal is None: + return MailServerProfileResponse.model_validate(payload) + context = _mail_hierarchy_context( + principal, + target_scope_type=target_scope_type, + target_scope_id=target_scope_id, + administrative=administrative, + ) + servers = profile_hierarchy_payload( + session, + profile=profile, + context=context, + include_inactive=include_inactive, + ) + payload["servers"] = servers + credentials: dict[str, dict[str, Any]] = { + "smtp": {"username": None}, + "imap": {"username": None}, + } + for protocol in ("smtp", "imap"): + candidates = [server for server in servers if server["protocol"] == protocol] + selected = next( + ( + server + for server in candidates + if server["is_default"] and server["is_active"] + ), + candidates[0] if candidates else None, + ) + if selected is None: + continue + payload[protocol] = dict(selected["config"]) + bound_credentials = list(selected.get("credentials") or []) + selected_credential = next( + ( + credential + for credential in bound_credentials + if credential.get("is_default") and credential.get("is_active") + ), + bound_credentials[0] if bound_credentials else None, + ) + if selected_credential is not None: + credentials[protocol] = { + "username": ( + selected_credential.get("public_data") or {} + ).get("username") + } + payload[f"{protocol}_password_configured"] = any( + credential.get("secret_configured") + for server in candidates + for credential in (server.get("credentials") or []) + ) + payload["credentials"] = credentials + return MailServerProfileResponse.model_validate(payload) def _policy_response( @@ -753,17 +958,67 @@ def _cached_folder_selection(folders, requested: str, detected_sent_folder: str return folders[0].name if folders else requested -def _imap_config_for_principal(session: Session, *, principal: ApiPrincipal, profile_id: str): +def _transport_context_for_principal( + session: Session, + *, + principal: ApiPrincipal, + campaign_id: str | None = None, +) -> MailHierarchyContext: + if not campaign_id: + return _mail_hierarchy_context(principal) + _require_campaign_context_visibility( + session, + principal=principal, + campaign_id=campaign_id, + ) + campaign = campaign_mail_owner_context( + session, + tenant_id=principal.tenant_id, + campaign_id=campaign_id, + ) + return MailHierarchyContext( + tenant_id=principal.tenant_id, + user_id=campaign.owner_user_id, + group_ids=( + frozenset({campaign.owner_group_id}) + if campaign.owner_group_id + else frozenset() + ), + target_scope_type="campaign", + target_scope_id=campaign.id, + ) + + +def _imap_config_for_principal( + session: Session, + *, + principal: ApiPrincipal, + profile_id: str, + server_id: str | None = None, + credential_id: str | None = None, + campaign_id: str | None = None, +): profile = _get_profile_for_principal( session, principal=principal, profile_id=profile_id, require_active=True, ) - imap = imap_config_from_profile(profile) - if imap is None: - raise MailProfileError("Mail-server profile has no IMAP configuration") - return imap + try: + return resolve_mail_transport( + session, + profile=profile, + protocol="imap", + context=_transport_context_for_principal( + session, + principal=principal, + campaign_id=campaign_id, + ), + server_id=server_id, + credential_id=credential_id, + ).config + except MailServerHierarchyError as exc: + raise MailProfileError(str(exc)) from exc def _parent_mail_profile_policy_payload(session: Session, *, tenant_id: str, scope_type: str, scope_id: str | None): @@ -789,7 +1044,18 @@ def _full_mail_settings_delta_response( **_profile_actor_kwargs(principal, administrative_visibility=True), ) return MailSettingsDeltaResponse( - profiles=[_profile_response(profile) for profile in profiles], + profiles=[ + _profile_response( + profile, + session=session, + principal=principal, + target_scope_type=scope_type, + target_scope_id=scope_id, + include_inactive=include_inactive, + administrative=True, + ) + for profile in profiles + ], policy=_policy_response(session, tenant_id=principal.tenant_id, scope_type=scope_type, scope_id=scope_id, campaign_id=campaign_id), changed_sections=["profiles", "policy"], deleted=[], @@ -897,7 +1163,18 @@ def mail_settings_delta( if entry.resource_id not in visible_profiles ] return MailSettingsDeltaResponse( - profiles=[_profile_response(profile) for profile in visible_profiles.values()], + profiles=[ + _profile_response( + profile, + session=session, + principal=principal, + target_scope_type=clean_scope_type, + target_scope_id=scope_id, + include_inactive=include_inactive, + administrative=True, + ) + for profile in visible_profiles.values() + ], policy=_policy_response(session, tenant_id=principal.tenant_id, scope_type=clean_scope_type, scope_id=scope_id, campaign_id=campaign_id) if policy_changed else None, changed_sections=changed_sections, deleted=deleted, @@ -923,11 +1200,643 @@ def list_profiles( campaign_id=campaign_id, **_profile_actor_kwargs(principal, administrative_visibility=True), ) - return MailServerProfileListResponse(profiles=[_profile_response(profile) for profile in profiles]) + return MailServerProfileListResponse( + profiles=[ + _profile_response( + profile, + session=session, + principal=principal, + target_scope_type="campaign" if campaign_id else None, + target_scope_id=campaign_id, + include_inactive=include_inactive, + administrative=include_inactive, + ) + for profile in profiles + ] + ) except MailProfileError as exc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc +@router.post( + "/profiles/{profile_id}/servers", + response_model=MailServerEndpointResponse, + status_code=status.HTTP_201_CREATED, +) +def create_profile_server( + profile_id: str, + payload: MailServerEndpointCreateRequest, + principal: ApiPrincipal = Depends(get_api_principal), + session: Session = Depends(get_session), +): + try: + profile = _profile_for_mutation( + session, + principal=principal, + profile_id=profile_id, + ) + server = create_mail_server_endpoint( + session, + profile=profile, + protocol=payload.protocol, + name=payload.name, + config=payload.config, + user_id=principal.user.id, + inherit_to_lower_scopes=payload.inherit_to_lower_scopes, + is_default=payload.is_default, + is_active=payload.is_active, + ) + _record_profile_child_change( + session, + principal=principal, + profile=profile, + operation="created", + resource_type=MAIL_SERVER_RESOURCE, + resource_id=server.id, + ) + session.commit() + session.refresh(server) + return _server_response( + session, + principal=principal, + profile=profile, + server=server, + ) + except (MailProfileError, MailServerHierarchyError) as exc: + session.rollback() + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail=str(exc), + ) from exc + + +@router.patch( + "/profiles/{profile_id}/servers/{server_id}", + response_model=MailServerEndpointResponse, +) +def update_profile_server( + profile_id: str, + server_id: str, + payload: MailServerEndpointUpdateRequest, + principal: ApiPrincipal = Depends(get_api_principal), + session: Session = Depends(get_session), +): + try: + profile, server = _server_for_mutation( + session, + principal=principal, + profile_id=profile_id, + server_id=server_id, + ) + update_mail_server_endpoint( + session, + server=server, + user_id=principal.user.id, + name=payload.name, + config=payload.config, + inherit_to_lower_scopes=payload.inherit_to_lower_scopes, + is_default=payload.is_default, + is_active=payload.is_active, + ) + _record_profile_child_change( + session, + principal=principal, + profile=profile, + operation="updated", + resource_type=MAIL_SERVER_RESOURCE, + resource_id=server.id, + ) + session.commit() + session.refresh(server) + return _server_response( + session, + principal=principal, + profile=profile, + server=server, + ) + except MailServerHierarchyError as exc: + session.rollback() + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail=str(exc), + ) from exc + + +@router.delete( + "/profiles/{profile_id}/servers/{server_id}", + response_model=MailServerEndpointResponse, +) +def deactivate_profile_server( + profile_id: str, + server_id: str, + principal: ApiPrincipal = Depends(get_api_principal), + session: Session = Depends(get_session), +): + try: + profile, server = _server_for_mutation( + session, + principal=principal, + profile_id=profile_id, + server_id=server_id, + ) + update_mail_server_endpoint( + session, + server=server, + user_id=principal.user.id, + is_active=False, + ) + _record_profile_child_change( + session, + principal=principal, + profile=profile, + operation="deactivated", + resource_type=MAIL_SERVER_RESOURCE, + resource_id=server.id, + ) + session.commit() + session.refresh(server) + return _server_response( + session, + principal=principal, + profile=profile, + server=server, + ) + except MailServerHierarchyError as exc: + session.rollback() + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail=str(exc), + ) from exc + + +def _credential_hierarchy_context(principal: ApiPrincipal, profile) -> MailHierarchyContext: + can_manage_all = has_scope(principal, "mail:secret:manage") or ( + profile.scope_type == "system" + and has_scope(principal, "system:settings:write") + ) + return hierarchy_context_for_profile( + profile, + user_id=principal.user.id, + group_ids=principal.group_ids, + administrative=can_manage_all, + ) + + +@router.get( + "/profiles/{profile_id}/servers/{server_id}/available-credentials", + response_model=MailCredentialListResponse, +) +def list_profile_server_available_credentials( + profile_id: str, + server_id: str, + include_inactive: bool = False, + principal: ApiPrincipal = Depends(get_api_principal), + session: Session = Depends(get_session), +): + profile = _get_profile_for_principal( + session, + principal=principal, + profile_id=profile_id, + administrative_visibility=True, + ) + _require_profile_credentials_scope( + principal, + profile.scope_type or "tenant", + profile.scope_id, + ) + context = _credential_hierarchy_context(principal, profile) + try: + server = get_mail_server_endpoint( + session, + profile=profile, + server_id=server_id, + context=context, + require_active=False, + ) + credentials = list_available_mail_credentials( + session, + context=context, + server_id=server.id, + include_inactive=include_inactive, + ) + return MailCredentialListResponse( + credentials=[ + MailCredentialEnvelopeResponse.model_validate( + mail_credential_payload(credential) + ) + for credential in credentials + ] + ) + except MailServerHierarchyError as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=str(exc), + ) from exc + + +@router.post( + "/profiles/{profile_id}/servers/{server_id}/credentials", + response_model=MailCredentialEnvelopeResponse, + status_code=status.HTTP_201_CREATED, +) +def create_profile_server_credential( + profile_id: str, + server_id: str, + payload: MailCredentialCreateRequest, + principal: ApiPrincipal = Depends(get_api_principal), + session: Session = Depends(get_session), +): + try: + profile, server = _server_for_mutation( + session, + principal=principal, + profile_id=profile_id, + server_id=server_id, + ) + _require_profile_credentials_scope( + principal, + profile.scope_type or "tenant", + profile.scope_id, + ) + credential = create_and_bind_mail_credential( + session, + profile=profile, + server=server, + name=payload.name, + description=payload.description, + credential_kind=payload.credential_kind, + username=payload.username, + password=payload.password, + public_data=payload.public_data, + secret_data=payload.secret_data, + user_id=principal.user.id, + inherit_to_lower_scopes=payload.inherit_to_lower_scopes, + allowed_modules=payload.allowed_modules, + allowed_server_refs=payload.allowed_server_refs, + is_default=payload.is_default, + ) + binding, _ = get_bound_mail_credential( + session, + server=server, + credential_id=credential.id, + context=_credential_hierarchy_context(principal, profile), + require_active=False, + ) + _record_profile_child_change( + session, + principal=principal, + profile=profile, + operation="created", + resource_type=MAIL_CREDENTIAL_RESOURCE, + resource_id=credential.id, + ) + session.commit() + session.refresh(credential) + return MailCredentialEnvelopeResponse.model_validate( + mail_credential_payload( + credential, + server_id=server.id, + binding_id=binding.id, + is_default=binding.is_default, + ) + ) + except (MailProfileError, MailServerHierarchyError) as exc: + session.rollback() + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail=str(exc), + ) from exc + + +@router.post( + "/profiles/{profile_id}/campaign-credentials", + response_model=MailCredentialEnvelopeResponse, + status_code=status.HTTP_201_CREATED, +) +def create_campaign_profile_credential( + profile_id: str, + payload: MailCampaignCredentialCreateRequest, + campaign_id: str = Query(...), + principal: ApiPrincipal = Depends(get_api_principal), + session: Session = Depends(get_session), +): + _require_any_scope( + principal, + "campaigns:campaign:update", + "mail:secret:manage", + ) + _require_campaign_context_visibility( + session, + principal=principal, + campaign_id=campaign_id, + ) + try: + profile = next( + ( + item + for item in list_mail_server_profiles( + session, + tenant_id=principal.tenant_id, + campaign_id=campaign_id, + **_profile_actor_kwargs( + principal, + administrative_visibility=False, + ), + ) + if item.id == profile_id + ), + None, + ) + if profile is None: + raise MailServerHierarchyError("Mail-server profile not found") + context = hierarchy_context_for_profile( + profile, + user_id=principal.user.id, + group_ids=principal.group_ids, + target_scope_type="campaign", + target_scope_id=campaign_id, + ) + server_ids = list(dict.fromkeys(item.strip() for item in payload.server_ids if item.strip())) + servers = [ + get_mail_server_endpoint( + session, + profile=profile, + server_id=server_id, + context=context, + require_active=True, + for_update=True, + ) + for server_id in server_ids + ] + if not servers: + raise MailServerHierarchyError("Select at least one mail server") + allowed_server_refs = [ + ref + for ref in (mail_server_ref(server.id) for server in servers) + if ref is not None + ] + credential = create_and_bind_mail_credential( + session, + profile=profile, + server=servers[0], + name=payload.name, + username=payload.username, + password=payload.password.get_secret_value(), + user_id=principal.user.id, + inherit_to_lower_scopes=False, + allowed_modules=("mail",), + allowed_server_refs=allowed_server_refs, + credential_scope_type="campaign", + credential_scope_id=campaign_id, + ) + for server in servers[1:]: + bind_mail_credential( + session, + server=server, + credential=credential, + user_id=principal.user.id, + ) + first_binding, _ = get_bound_mail_credential( + session, + server=servers[0], + credential_id=credential.id, + context=context, + ) + _record_profile_child_change( + session, + principal=principal, + profile=profile, + operation="created", + resource_type=MAIL_CREDENTIAL_RESOURCE, + resource_id=credential.id, + ) + session.commit() + session.refresh(credential) + return MailCredentialEnvelopeResponse.model_validate( + mail_credential_payload( + credential, + server_id=servers[0].id, + binding_id=first_binding.id, + is_default=False, + ) + ) + except (MailProfileError, MailServerHierarchyError) as exc: + session.rollback() + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail=str(exc), + ) from exc + + +@router.post( + "/profiles/{profile_id}/servers/{server_id}/credential-bindings", + response_model=MailCredentialEnvelopeResponse, + status_code=status.HTTP_201_CREATED, +) +def bind_profile_server_credential( + profile_id: str, + server_id: str, + payload: MailCredentialBindRequest, + principal: ApiPrincipal = Depends(get_api_principal), + session: Session = Depends(get_session), +): + try: + profile, server = _server_for_mutation( + session, + principal=principal, + profile_id=profile_id, + server_id=server_id, + ) + _require_profile_credentials_scope( + principal, + profile.scope_type or "tenant", + profile.scope_id, + ) + context = _credential_hierarchy_context(principal, profile) + credential = get_available_mail_credential( + session, + credential_id=payload.credential_id, + context=context, + server_id=server.id, + ) + binding = bind_mail_credential( + session, + server=server, + credential=credential, + user_id=principal.user.id, + is_default=payload.is_default, + ) + _record_profile_child_change( + session, + principal=principal, + profile=profile, + operation="bound", + resource_type=MAIL_CREDENTIAL_RESOURCE, + resource_id=credential.id, + ) + session.commit() + session.refresh(credential) + return MailCredentialEnvelopeResponse.model_validate( + mail_credential_payload( + credential, + server_id=server.id, + binding_id=binding.id, + is_default=binding.is_default, + ) + ) + except (MailProfileError, MailServerHierarchyError) as exc: + session.rollback() + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail=str(exc), + ) from exc + + +@router.patch( + "/profiles/{profile_id}/servers/{server_id}/credentials/{credential_id}", + response_model=MailCredentialEnvelopeResponse, +) +def update_profile_server_credential( + profile_id: str, + server_id: str, + credential_id: str, + payload: MailCredentialUpdateRequest, + principal: ApiPrincipal = Depends(get_api_principal), + session: Session = Depends(get_session), +): + try: + profile, server = _server_for_mutation( + session, + principal=principal, + profile_id=profile_id, + server_id=server_id, + ) + _require_profile_credentials_scope( + principal, + profile.scope_type or "tenant", + profile.scope_id, + ) + context = _credential_hierarchy_context(principal, profile) + binding, credential = get_bound_mail_credential( + session, + server=server, + credential_id=credential_id, + context=context, + require_active=False, + for_update=True, + ) + update_bound_mail_credential( + session, + server=server, + credential=credential, + context=context, + user_id=principal.user.id, + name=payload.name, + description=payload.description, + description_supplied="description" in payload.model_fields_set, + username=payload.username, + username_supplied="username" in payload.model_fields_set, + password=payload.password, + password_supplied="password" in payload.model_fields_set, + public_data=payload.public_data, + secret_data=payload.secret_data, + allowed_modules=payload.allowed_modules, + allowed_server_refs=payload.allowed_server_refs, + inherit_to_lower_scopes=payload.inherit_to_lower_scopes, + is_active=payload.is_active, + is_default=payload.is_default, + ) + binding, credential = get_bound_mail_credential( + session, + server=server, + credential_id=credential_id, + context=context, + require_active=False, + ) + _record_profile_child_change( + session, + principal=principal, + profile=profile, + operation="updated", + resource_type=MAIL_CREDENTIAL_RESOURCE, + resource_id=credential.id, + ) + session.commit() + session.refresh(credential) + return MailCredentialEnvelopeResponse.model_validate( + mail_credential_payload( + credential, + server_id=server.id, + binding_id=binding.id, + is_default=binding.is_default, + ) + ) + except (MailProfileError, MailServerHierarchyError) as exc: + session.rollback() + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail=str(exc), + ) from exc + + +@router.delete( + "/profiles/{profile_id}/servers/{server_id}/credentials/{credential_id}", + status_code=status.HTTP_204_NO_CONTENT, +) +def unlink_profile_server_credential( + profile_id: str, + server_id: str, + credential_id: str, + retire_if_unused: bool = Query(default=False), + principal: ApiPrincipal = Depends(get_api_principal), + session: Session = Depends(get_session), +): + try: + profile, server = _server_for_mutation( + session, + principal=principal, + profile_id=profile_id, + server_id=server_id, + ) + _require_profile_credentials_scope( + principal, + profile.scope_type or "tenant", + profile.scope_id, + ) + context = _credential_hierarchy_context(principal, profile) + _, credential = get_bound_mail_credential( + session, + server=server, + credential_id=credential_id, + context=context, + require_active=False, + ) + unlink_mail_credential( + session, + server=server, + credential=credential, + context=context, + user_id=principal.user.id, + retire_if_unused=retire_if_unused, + ) + _record_profile_child_change( + session, + principal=principal, + profile=profile, + operation="unlinked", + resource_type=MAIL_CREDENTIAL_RESOURCE, + resource_id=credential.id, + ) + session.commit() + return None + except (MailProfileError, MailServerHierarchyError) as exc: + session.rollback() + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail=str(exc), + ) from exc + + @router.post("/profiles", response_model=MailServerProfileResponse, status_code=status.HTTP_201_CREATED) def create_profile( payload: MailServerProfileCreateRequest, @@ -951,8 +1860,10 @@ def create_profile( principal, payload.scope_type, authorization_scope_id, - ) + ) try: + smtp_config = payload.smtp_config() + imap_config = payload.imap_config() profile = create_mail_server_profile( session, tenant_id=principal.tenant_id, @@ -960,12 +1871,20 @@ def create_profile( name=payload.name, slug=payload.slug, description=payload.description, - smtp=payload.smtp_config(), - imap=payload.imap_config(), + smtp=smtp_config, + imap=imap_config, is_active=payload.is_active, + inherit_to_lower_scopes=payload.inherit_to_lower_scopes, scope_type=payload.scope_type, scope_id=payload.scope_id, ) + initialize_profile_hierarchy( + session, + profile=profile, + smtp=smtp_config, + imap=imap_config, + user_id=principal.user.id, + ) _record_mail_change( session, collection=MAIL_PROFILES_COLLECTION, @@ -978,8 +1897,16 @@ def create_profile( ) session.commit() session.refresh(profile) - return _profile_response(profile) - except MailProfileError as exc: + return _profile_response( + profile, + session=session, + principal=principal, + target_scope_type=profile.scope_type, + target_scope_id=profile.scope_id, + include_inactive=True, + administrative=True, + ) + except (MailProfileError, MailServerHierarchyError) as exc: session.rollback() raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc @@ -992,13 +1919,20 @@ def get_profile( ): _require_any_scope(principal, "mail:profile:read", "system:settings:read") try: - return _profile_response( - _get_profile_for_principal( + profile = _get_profile_for_principal( session, principal=principal, profile_id=profile_id, administrative_visibility=True, ) + return _profile_response( + profile, + session=session, + principal=principal, + target_scope_type=profile.scope_type, + target_scope_id=profile.scope_id, + include_inactive=True, + administrative=True, ) except MailProfileError as exc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc @@ -1054,10 +1988,57 @@ def update_profile( slug=payload.slug, description=payload.description if "description" in payload.model_fields_set else None, is_active=payload.is_active, + inherit_to_lower_scopes=payload.inherit_to_lower_scopes, smtp=smtp_config, imap=imap_config, clear_imap=payload.clear_imap, ) + smtp_server = None + if smtp_config is not None: + smtp_server = sync_default_profile_server( + session, + profile=profile, + protocol="smtp", + config=smtp_config.model_dump(mode="json"), + user_id=principal.user.id, + ) + imap_server = None + if imap_config is not None or payload.clear_imap: + imap_server = sync_default_profile_server( + session, + profile=profile, + protocol="imap", + config=( + imap_config.model_dump(mode="json") + if imap_config is not None and not payload.clear_imap + else None + ), + user_id=principal.user.id, + ) + if smtp_server is not None and "smtp" in payload.credentials.model_fields_set: + smtp_credentials = payload.credentials.smtp + sync_default_profile_credential( + session, + profile=profile, + server=smtp_server, + username=smtp_credentials.username, + username_supplied="username" in smtp_credentials.model_fields_set, + password=smtp_credentials.password, + password_supplied="password" in smtp_credentials.model_fields_set, + user_id=principal.user.id, + ) + if imap_server is not None and "imap" in payload.credentials.model_fields_set: + imap_credentials = payload.credentials.imap + sync_default_profile_credential( + session, + profile=profile, + server=imap_server, + username=imap_credentials.username, + username_supplied="username" in imap_credentials.model_fields_set, + password=imap_credentials.password, + password_supplied="password" in imap_credentials.model_fields_set, + user_id=principal.user.id, + ) _record_mail_change( session, collection=MAIL_PROFILES_COLLECTION, @@ -1070,8 +2051,16 @@ def update_profile( ) session.commit() session.refresh(profile) - return _profile_response(profile) - except MailProfileError as exc: + return _profile_response( + profile, + session=session, + principal=principal, + target_scope_type=profile.scope_type, + target_scope_id=profile.scope_id, + include_inactive=True, + administrative=True, + ) + except (MailProfileError, MailServerHierarchyError) as exc: session.rollback() raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc except Exception: @@ -1135,7 +2124,15 @@ def deactivate_profile( ) session.commit() session.refresh(profile) - return _profile_response(profile) + return _profile_response( + profile, + session=session, + principal=principal, + target_scope_type=profile.scope_type, + target_scope_id=profile.scope_id, + include_inactive=True, + administrative=True, + ) except MailProfileError as exc: session.rollback() raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc @@ -1224,6 +2221,9 @@ def write_mail_profile_policy( @router.post("/profiles/{profile_id}/test-smtp", response_model=MailConnectionTestResponse) def test_profile_smtp( profile_id: str, + server_id: str | None = Query(default=None), + credential_id: str | None = Query(default=None), + campaign_id: str | None = Query(default=None), principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session), ): @@ -1236,10 +2236,24 @@ def test_profile_smtp( profile_id=profile_id, require_active=True, ) - smtp = smtp_config_from_profile(profile) + if callable(getattr(session, "execute", None)): + smtp = resolve_mail_transport( + session, + profile=profile, + protocol="smtp", + context=_transport_context_for_principal( + session, + principal=principal, + campaign_id=campaign_id, + ), + server_id=server_id, + credential_id=credential_id, + ).config + else: + smtp = smtp_config_from_profile(profile) result = test_smtp_login(smtp_config=smtp) return MailConnectionTestResponse(ok=True, protocol="smtp", host=result.host, port=result.port, security=result.security, message="SMTP connection successful.", details={"authenticated": result.authenticated}) - except MailProfileError as exc: + except (MailProfileError, MailServerHierarchyError) as exc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc except Exception as exc: return MailConnectionTestResponse(ok=False, protocol="smtp", message=_safe_error_message(exc), details={"error_type": exc.__class__.__name__}) @@ -1248,6 +2262,9 @@ def test_profile_smtp( @router.post("/profiles/{profile_id}/test-imap", response_model=MailConnectionTestResponse) def test_profile_imap( profile_id: str, + server_id: str | None = Query(default=None), + credential_id: str | None = Query(default=None), + campaign_id: str | None = Query(default=None), principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session), ): @@ -1260,12 +2277,26 @@ def test_profile_imap( profile_id=profile_id, require_active=True, ) - imap = imap_config_from_profile(profile) - if imap is None: - raise MailProfileError("Mail-server profile has no IMAP configuration") + if callable(getattr(session, "execute", None)): + imap = resolve_mail_transport( + session, + profile=profile, + protocol="imap", + context=_transport_context_for_principal( + session, + principal=principal, + campaign_id=campaign_id, + ), + server_id=server_id, + credential_id=credential_id, + ).config + else: + imap = imap_config_from_profile(profile) + if imap is None: + raise MailProfileError("Mail-server profile has no IMAP configuration") result = test_imap_login(imap_config=imap) return MailConnectionTestResponse(ok=True, protocol="imap", host=result.host, port=result.port, security=result.security, message="IMAP connection successful.", details={"authenticated": result.authenticated}) - except MailProfileError as exc: + except (MailProfileError, MailServerHierarchyError) as exc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc except Exception as exc: return MailConnectionTestResponse(ok=False, protocol="imap", message=_safe_error_message(exc), details={"error_type": exc.__class__.__name__}) @@ -1274,6 +2305,9 @@ def test_profile_imap( @router.post("/profiles/{profile_id}/list-imap-folders", response_model=MailImapFolderListResponse) def list_profile_imap_folders( profile_id: str, + server_id: str | None = Query(default=None), + credential_id: str | None = Query(default=None), + campaign_id: str | None = Query(default=None), principal: ApiPrincipal = Depends(get_api_principal), session: Session = Depends(get_session), ): @@ -1286,12 +2320,26 @@ def list_profile_imap_folders( profile_id=profile_id, require_active=True, ) - imap = imap_config_from_profile(profile) - if imap is None: - raise MailProfileError("Mail-server profile has no IMAP configuration") + if callable(getattr(session, "execute", None)): + imap = resolve_mail_transport( + session, + profile=profile, + protocol="imap", + context=_transport_context_for_principal( + session, + principal=principal, + campaign_id=campaign_id, + ), + server_id=server_id, + credential_id=credential_id, + ).config + else: + imap = imap_config_from_profile(profile) + if imap is None: + raise MailProfileError("Mail-server profile has no IMAP configuration") result = list_imap_folders(imap_config=imap) return _mailbox_folder_response(result) - except MailProfileError as exc: + except (MailProfileError, MailServerHierarchyError) as exc: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc except Exception as exc: return MailImapFolderListResponse(ok=False, message=_safe_error_message(exc), folders=[], detected_sent_folder=None, details={"error_type": exc.__class__.__name__}) diff --git a/src/govoplan_mail/backend/schemas.py b/src/govoplan_mail/backend/schemas.py index d1183c8..1d0bbad 100644 --- a/src/govoplan_mail/backend/schemas.py +++ b/src/govoplan_mail/backend/schemas.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import datetime from typing import Any, Literal -from pydantic import BaseModel, ConfigDict, Field, model_validator +from pydantic import BaseModel, ConfigDict, Field, SecretStr, model_validator from govoplan_core.api.v1.schemas import DeltaDeletedItem from govoplan_mail.backend.config import ( @@ -103,6 +103,7 @@ class MailServerProfileCreateRequest(BaseModel): slug: str | None = Field(default=None, max_length=100) description: str | None = None is_active: bool = True + inherit_to_lower_scopes: bool = True scope_type: MailProfileScope = "tenant" scope_id: str | None = None smtp: SmtpServerConfig @@ -137,6 +138,7 @@ class MailServerProfileUpdateRequest(BaseModel): slug: str | None = Field(default=None, max_length=100) description: str | None = None is_active: bool | None = None + inherit_to_lower_scopes: bool | None = None smtp: SmtpServerConfig | None = None imap: ImapServerConfig | None = None credentials: MailServerProfileCredentialsPayload = Field(default_factory=MailServerProfileCredentialsPayload) @@ -167,6 +169,127 @@ class MailServerProfileUpdateRequest(BaseModel): ) +class MailCredentialEnvelopeResponse(BaseModel): + id: str + binding_id: str | None = None + server_id: str | None = None + tenant_id: str | None = None + scope_type: MailProfileScope + scope_id: str | None = None + name: str + description: str | None = None + credential_kind: str + public_data: dict[str, Any] = Field(default_factory=dict) + secret_keys: list[str] = Field(default_factory=list) + secret_configured: bool = False + allowed_modules: list[str] = Field(default_factory=list) + allowed_server_refs: list[str] = Field(default_factory=list) + inherit_to_lower_scopes: bool = False + is_default: bool = False + is_active: bool = True + revision: str + created_at: datetime + updated_at: datetime + deleted_at: datetime | None = None + + +class MailServerEndpointResponse(BaseModel): + id: str + profile_id: str + tenant_id: str | None = None + protocol: Literal["smtp", "imap"] + name: str + config: dict[str, Any] = Field(default_factory=dict) + scope_type: MailProfileScope + scope_id: str | None = None + inherit_to_lower_scopes: bool = True + is_default: bool = False + is_active: bool = True + transport_revision: str + credentials: list[MailCredentialEnvelopeResponse] = Field(default_factory=list) + created_at: datetime + updated_at: datetime + + +class MailServerEndpointCreateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + protocol: Literal["smtp", "imap"] + name: str = Field(min_length=1, max_length=255) + config: dict[str, Any] = Field(default_factory=dict) + inherit_to_lower_scopes: bool | None = None + is_default: bool = False + is_active: bool = True + + +class MailServerEndpointUpdateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + name: str | None = Field(default=None, max_length=255) + config: dict[str, Any] | None = None + inherit_to_lower_scopes: bool | None = None + is_default: bool | None = None + is_active: bool | None = None + + +class MailCredentialCreateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + name: str = Field(min_length=1, max_length=255) + description: str | None = None + credential_kind: str = "username_password" + username: str | None = None + password: str | None = None + public_data: dict[str, Any] = Field(default_factory=dict) + secret_data: dict[str, Any] = Field(default_factory=dict) + inherit_to_lower_scopes: bool | None = None + allowed_modules: list[str] = Field(default_factory=lambda: ["mail"]) + allowed_server_refs: list[str] = Field(default_factory=list) + is_default: bool = False + + +class MailCampaignCredentialCreateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + name: str = Field(min_length=1, max_length=255) + username: str = Field(min_length=1, max_length=320) + password: SecretStr + server_ids: list[str] = Field(min_length=1) + + +class MailCredentialBindRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + credential_id: str = Field(min_length=1) + is_default: bool = False + + +class MailCredentialUpdateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + name: str | None = Field(default=None, max_length=255) + description: str | None = None + username: str | None = None + password: str | None = None + public_data: dict[str, Any] | None = None + secret_data: dict[str, Any] | None = None + inherit_to_lower_scopes: bool | None = None + allowed_modules: list[str] | None = None + allowed_server_refs: list[str] | None = None + is_default: bool | None = None + is_active: bool | None = None + + +class MailCredentialUnlinkRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + retire_if_unused: bool = False + + +class MailCredentialListResponse(BaseModel): + credentials: list[MailCredentialEnvelopeResponse] = Field(default_factory=list) + + class MailServerProfileResponse(BaseModel): id: str tenant_id: str | None = None @@ -176,11 +299,13 @@ class MailServerProfileResponse(BaseModel): slug: str description: str | None = None is_active: bool + inherit_to_lower_scopes: bool = True smtp: dict[str, Any] imap: dict[str, Any] | None = None credentials: dict[str, Any] = Field(default_factory=dict) smtp_password_configured: bool = False imap_password_configured: bool = False + servers: list[MailServerEndpointResponse] = Field(default_factory=list) created_at: datetime updated_at: datetime diff --git a/src/govoplan_mail/backend/server_hierarchy.py b/src/govoplan_mail/backend/server_hierarchy.py new file mode 100644 index 0000000..93f49ae --- /dev/null +++ b/src/govoplan_mail/backend/server_hierarchy.py @@ -0,0 +1,1513 @@ +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, + SmtpConfig, + SmtpServerConfig, +) +from govoplan_mail.backend.db.models import ( + MailServerCredentialBinding, + MailServerEndpoint, + MailServerProfile, + new_uuid, +) + + +MAIL_SERVER_PROTOCOLS = frozenset({"smtp", "imap"}) + + +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 + 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") 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: + 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) + else: + payload["username"] = profile.imap_username + payload["password"] = decrypt_secret(profile.imap_password_encrypted) + config: SmtpConfig | ImapConfig + try: + config = ( + SmtpConfig.model_validate(payload) + if clean_protocol == "smtp" + else ImapConfig.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: + 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: + model = ( + SmtpServerConfig.model_validate(payload) + if protocol == "smtp" + else ImapServerConfig.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 or imap") + 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", +] diff --git a/tests/test_mail_profile_helpers.py b/tests/test_mail_profile_helpers.py index 4be6243..6213a72 100644 --- a/tests/test_mail_profile_helpers.py +++ b/tests/test_mail_profile_helpers.py @@ -13,6 +13,7 @@ from govoplan_mail.backend.mail_profiles import ( _assert_campaign_inherits_profile_credentials, _campaign_mail_profile_reference_id, _apply_profile_transport_update, + campaign_mail_selection_from_json, campaign_profile_transport_revisions, create_mail_server_profile, _merge_policy, @@ -496,14 +497,26 @@ class MailProfileTransportHelperTests(unittest.TestCase): class MailProfilePolicyHelperTests(unittest.TestCase): - def test_campaign_contract_accepts_only_mail_profile_reference(self): + def test_campaign_contract_accepts_only_mail_owned_references(self): self.assertEqual( _campaign_mail_profile_reference_id({"mail_profile_id": " profile-1 "}), "profile-1", ) for legacy in ("smtp", "imap", "credentials", "inherit_smtp_credentials"): - with self.subTest(legacy=legacy), self.assertRaisesRegex(MailProfileError, "select a Mail profile"): + with self.subTest(legacy=legacy), self.assertRaisesRegex(MailProfileError, "select Mail resources"): _campaign_mail_profile_reference_id({"mail_profile_id": "profile-1", legacy: {}}) + self.assertEqual( + campaign_mail_selection_from_json( + { + "server": { + "mail_profile_id": "profile-1", + "smtp_server_id": "smtp-1", + "smtp_credential_id": "credential-1", + } + } + )["smtp_credential_id"], + "credential-1", + ) def test_campaign_delivery_fails_when_policy_requires_local_credentials(self): profile = SimpleNamespace(imap_config=None) @@ -511,7 +524,7 @@ class MailProfilePolicyHelperTests(unittest.TestCase): smtp_credentials=EffectiveCredentialPolicy(inherit=False), ) - with self.assertRaisesRegex(MailProfileError, "Store the credentials on a Mail profile"): + with self.assertRaisesRegex(MailProfileError, "explicit credential selection"): _assert_campaign_inherits_profile_credentials(profile, policy) def test_merge_policy_respects_locked_lower_level_limits(self): diff --git a/tests/test_profile_actor_authorization.py b/tests/test_profile_actor_authorization.py index e6d60ca..74fc227 100644 --- a/tests/test_profile_actor_authorization.py +++ b/tests/test_profile_actor_authorization.py @@ -409,6 +409,7 @@ class ProfileActorAuthorizationTests(unittest.TestCase): "govoplan_mail.backend.router._profile_response", return_value=profile, ), + patch("govoplan_mail.backend.router.sync_default_profile_server"), ): result = router.update_profile( profile.id, @@ -446,6 +447,7 @@ class ProfileActorAuthorizationTests(unittest.TestCase): "govoplan_mail.backend.router._profile_response", return_value=profile, ), + patch("govoplan_mail.backend.router.sync_default_profile_server"), ): router.update_profile( profile.id, diff --git a/tests/test_router_profile_deletion.py b/tests/test_router_profile_deletion.py index cb7a19f..b0d51ac 100644 --- a/tests/test_router_profile_deletion.py +++ b/tests/test_router_profile_deletion.py @@ -166,6 +166,7 @@ class MailProfileDeletionRouteTests(unittest.TestCase): with ( patch("govoplan_mail.backend.router._require_profile_write_scope"), patch("govoplan_mail.backend.router.create_mail_server_profile", return_value=system_profile), + patch("govoplan_mail.backend.router.initialize_profile_hierarchy"), patch("govoplan_mail.backend.router._record_mail_change") as create_change, patch("govoplan_mail.backend.router._profile_response", return_value=system_profile), ): diff --git a/webui/src/api/mail.ts b/webui/src/api/mail.ts index 68cbced..8d43396 100644 --- a/webui/src/api/mail.ts +++ b/webui/src/api/mail.ts @@ -4,10 +4,12 @@ import type { MailConnectionTestResponse, MailImapFolderListResponse, MailImapTestPayload, + MailCredentialEnvelope, MailProfilePolicy, MailProfilePolicyResponse, MailProfileScope, MailSecurity, + MailServerEndpoint, MailServerProfile, MailServerProfilePayload, MailSmtpTestPayload, @@ -18,6 +20,7 @@ import { apiFetch, apiGetList, apiPath, apiPost, apiPostJson } from "./client"; export { mailProfilePatternKeys, mailProfilePolicyLimitKeys } from "@govoplan/core-webui"; export type { MailConnectionTestResponse, + MailCredentialEnvelope, MailCredentialPolicy, MailImapFolderListResponse, MailImapFolderResponse, @@ -30,6 +33,7 @@ export type { MailProfilePolicyResponse, MailProfileScope, MailSecurity, + MailServerEndpoint, MailServerProfile, MailServerProfileCredentialsPayload, MailServerProfileListResponse, @@ -176,6 +180,33 @@ export async function createMailServerProfile(settings: ApiSettings, payload: Ma export type MailServerProfileUpdatePayload = Partial & { clear_imap?: boolean }; +export type MailServerEndpointPayload = { + protocol: "smtp" | "imap"; + name: string; + config: Record; + inherit_to_lower_scopes?: boolean | null; + is_default?: boolean; + is_active?: boolean; +}; + +export type MailCredentialCreatePayload = { + name: string; + description?: string | null; + credential_kind?: string; + username?: string | null; + password?: string | null; + public_data?: Record; + secret_data?: Record; + inherit_to_lower_scopes?: boolean | null; + allowed_modules?: string[]; + allowed_server_refs?: string[]; + is_default?: boolean; +}; + +export type MailCredentialUpdatePayload = Partial & { + is_active?: boolean; +}; + export async function updateMailServerProfile(settings: ApiSettings, profileId: string, payload: MailServerProfileUpdatePayload): Promise { return apiFetch(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}`, { method: "PATCH", @@ -187,6 +218,118 @@ export async function deactivateMailServerProfile(settings: ApiSettings, profile return apiFetch(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}`, { method: "DELETE" }); } +export async function createMailServerEndpoint( + settings: ApiSettings, + profileId: string, + payload: MailServerEndpointPayload +): Promise { + return apiFetch( + settings, + `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/servers`, + { method: "POST", body: JSON.stringify(payload) } + ); +} + +export async function updateMailServerEndpoint( + settings: ApiSettings, + profileId: string, + serverId: string, + payload: Partial> +): Promise { + return apiFetch( + settings, + `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/servers/${encodeURIComponent(serverId)}`, + { method: "PATCH", body: JSON.stringify(payload) } + ); +} + +export async function deactivateMailServerEndpoint( + settings: ApiSettings, + profileId: string, + serverId: string +): Promise { + return apiFetch( + settings, + `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/servers/${encodeURIComponent(serverId)}`, + { method: "DELETE" } + ); +} + +export async function listAvailableMailCredentials( + settings: ApiSettings, + profileId: string, + serverId: string, + includeInactive = false +): Promise { + return apiGetList( + settings, + `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/servers/${encodeURIComponent(serverId)}/available-credentials`, + "credentials", + { include_inactive: includeInactive ? true : undefined } + ); +} + +export async function createMailServerCredential( + settings: ApiSettings, + profileId: string, + serverId: string, + payload: MailCredentialCreatePayload +): Promise { + return apiFetch( + settings, + `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/servers/${encodeURIComponent(serverId)}/credentials`, + { method: "POST", body: JSON.stringify(payload) } + ); +} + +export async function bindMailServerCredential( + settings: ApiSettings, + profileId: string, + serverId: string, + credentialId: string, + isDefault = false +): Promise { + return apiFetch( + settings, + `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/servers/${encodeURIComponent(serverId)}/credential-bindings`, + { + method: "POST", + body: JSON.stringify({ credential_id: credentialId, is_default: isDefault }) + } + ); +} + +export async function updateMailServerCredential( + settings: ApiSettings, + profileId: string, + serverId: string, + credentialId: string, + payload: MailCredentialUpdatePayload +): Promise { + return apiFetch( + settings, + `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/servers/${encodeURIComponent(serverId)}/credentials/${encodeURIComponent(credentialId)}`, + { method: "PATCH", body: JSON.stringify(payload) } + ); +} + +export async function unlinkMailServerCredential( + settings: ApiSettings, + profileId: string, + serverId: string, + credentialId: string, + retireIfUnused = false +): Promise { + await apiFetch( + settings, + apiPath( + `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/servers/${encodeURIComponent(serverId)}/credentials/${encodeURIComponent(credentialId)}`, + { retire_if_unused: retireIfUnused ? true : undefined } + ), + { method: "DELETE" } + ); +} + export async function getMailProfilePolicy( settings: ApiSettings, scopeType: MailProfileScope, @@ -211,16 +354,55 @@ export async function updateMailProfilePolicy( }); } -export async function testMailProfileSmtp(settings: ApiSettings, profileId: string): Promise { - return apiPost(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/test-smtp`); +export async function testMailProfileSmtp( + settings: ApiSettings, + profileId: string, + serverId?: string | null, + credentialId?: string | null, + campaignId?: string | null +): Promise { + return apiPost( + settings, + apiPath(`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/test-smtp`, { + server_id: serverId, + credential_id: credentialId, + campaign_id: campaignId + }) + ); } -export async function testMailProfileImap(settings: ApiSettings, profileId: string): Promise { - return apiPost(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/test-imap`); +export async function testMailProfileImap( + settings: ApiSettings, + profileId: string, + serverId?: string | null, + credentialId?: string | null, + campaignId?: string | null +): Promise { + return apiPost( + settings, + apiPath(`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/test-imap`, { + server_id: serverId, + credential_id: credentialId, + campaign_id: campaignId + }) + ); } -export async function listMailProfileImapFolders(settings: ApiSettings, profileId: string): Promise { - return apiPost(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/list-imap-folders`); +export async function listMailProfileImapFolders( + settings: ApiSettings, + profileId: string, + serverId?: string | null, + credentialId?: string | null, + campaignId?: string | null +): Promise { + return apiPost( + settings, + apiPath(`/api/v1/mail/profiles/${encodeURIComponent(profileId)}/list-imap-folders`, { + server_id: serverId, + credential_id: credentialId, + campaign_id: campaignId + }) + ); } export async function listMailboxFolders(settings: ApiSettings, profileId: string, includeStatus = false, refresh = false): Promise { diff --git a/webui/src/features/mail/MailProfileManagement.tsx b/webui/src/features/mail/MailProfileManagement.tsx index 29ee5a2..85866c6 100644 --- a/webui/src/features/mail/MailProfileManagement.tsx +++ b/webui/src/features/mail/MailProfileManagement.tsx @@ -1,20 +1,29 @@ import { useEffect, useMemo, useState, type ReactNode } from "react"; import { AdminSelectionList, ConnectionTree, FieldLabel, LoadingFrame, MailServerSettingsPanel, PolicyLockedHint, PolicyPathHelp, PolicyRow, PolicySourcePath, PolicyTable, StatusBadge, TableActionGroup, ToggleSwitch, hasMailImapSettings, mailImapSettingsPayload, mailServerSecurityOptions, mailSmtpSettingsPayload, mailTextOrNull, mailTransportCredentialsPayload, mergeDeltaRows, normalizeMailServerSecurity, normalizePolicySourcePathItems, useDeltaWatermarks, type ConnectionTreeColumn, type MailServerConnectionTestResult, type MailServerCredentialSettings, type MailServerImapSettings, type MailServerSmtpSettings, type NormalizedPolicySourcePathItem, type PolicySourcePathItem } from "@govoplan/core-webui"; -import { Pencil, Plus, Trash2 } from "lucide-react"; +import { Link2, Pencil, Plus, Trash2, Unlink } from "lucide-react"; import type { ApiSettings } from "../../types"; import { + bindMailServerCredential, + createMailServerCredential, + createMailServerEndpoint, createMailServerProfile, + deactivateMailServerEndpoint, deactivateMailServerProfile, fetchMailSettingsDelta, getMailProfilePolicy, mailProfilePatternKeys, mailProfilePolicyLimitKeys, + listAvailableMailCredentials, updateMailProfilePolicy, testImapSettings, testMailProfileImap, testMailProfileSmtp, testSmtpSettings, + unlinkMailServerCredential, + updateMailServerCredential, + updateMailServerEndpoint, updateMailServerProfile, + type MailCredentialEnvelope, type MailCredentialPolicy, type MailImapTestPayload, type MailProfilePatternKey, @@ -23,6 +32,7 @@ import { type MailProfilePolicyLimitKey, type MailProfileScope, type MailSecurity, + type MailServerEndpoint, type MailServerProfile, type MailServerProfilePayload, type MailServerProfileUpdatePayload, @@ -36,14 +46,12 @@ import { Dialog } from "@govoplan/core-webui"; import { DismissibleAlert } from "@govoplan/core-webui"; import { FormField, i18nMessage, useUnsavedDraftGuard } from "@govoplan/core-webui"; import { - mailProfileChildDescriptors, mailProfileEditTargetInitialSection, mailProfileEditTargetPanelMode, mailProfileEditTargetShowsProfileFields, mailProfileEditTargetShowsSettingsPanel, mailProfileEditTargetVisibleSections, mailProfileCreateCredentialsPayload, - mailProfileTargetedUpdatePayload, type MailProfileEditTarget, type MailProfileProtocol } from "./mailProfileEditorModel"; @@ -58,6 +66,17 @@ type ProfileDraft = { slug: string; description: string; isActive: boolean; + inheritToLowerScopes: boolean; + serverName: string; + serverIsDefault: boolean; + serverIsActive: boolean; + serverInheritToLowerScopes: boolean; + credentialName: string; + credentialDescription: string; + credentialAllowedModules: string; + credentialAllowedServerRefs: string; + credentialInheritToLowerScopes: boolean; + credentialIsDefault: boolean; smtpHost: string; smtpPort: string; smtpSecurity: MailSecurity; @@ -103,11 +122,15 @@ type MailProfilePolicyEditorProps = { type EditingProfile = MailServerProfile | "new" | null; type PolicyFlagValue = "inherit" | "allow" | "deny"; -type CredentialInheritanceValue = "inherit" | "profile" | "local"; type MailProfileTreeRow = {kind: "profile";id: string;profile: MailServerProfile;} | -{kind: "server";id: string;profile: MailServerProfile;protocol: MailProfileProtocol;} | -{kind: "credential";id: string;profile: MailServerProfile;protocol: MailProfileProtocol;}; +{kind: "server";id: string;profile: MailServerProfile;protocol: MailProfileProtocol;server: MailServerEndpoint;} | +{kind: "credential";id: string;profile: MailServerProfile;protocol: MailProfileProtocol;server: MailServerEndpoint;credential: MailCredentialEnvelope;}; + +type PendingHierarchyRemoval = + | {kind: "server";profile: MailServerProfile;server: MailServerEndpoint;} + | {kind: "credential";profile: MailServerProfile;server: MailServerEndpoint;credential: MailCredentialEnvelope;} + | null; const securityOptions = mailServerSecurityOptions as readonly MailSecurity[]; @@ -148,6 +171,10 @@ export function MailProfileScopeManager({ const [editing, setEditing] = useState(null); const [editingTarget, setEditingTarget] = useState({ kind: "create" }); const [deactivating, setDeactivating] = useState(null); + const [pendingHierarchyRemoval, setPendingHierarchyRemoval] = useState(null); + const [availableCredentials, setAvailableCredentials] = useState([]); + const [reuseCredentialId, setReuseCredentialId] = useState(""); + const [savedReuseCredentialId, setSavedReuseCredentialId] = useState(""); const [draft, setDraft] = useState(emptyProfileDraft()); const [savedProfileDraftKey, setSavedProfileDraftKey] = useState(profileDraftKey(emptyProfileDraft())); const [loading, setLoading] = useState(true); @@ -164,7 +191,10 @@ export function MailProfileScopeManager({ const deltaKey = `mail:settings:${scopeType}:${activeScopeId ?? ""}`; const scopeReady = !requiresTarget || Boolean(activeScopeId); const targetEmptyText = i18nMessage("i18n:govoplan-mail.no_value_available", { value0: targetPluralLabel(scopeType, targetLabel) }); - const profileDirty = editing !== null && profileDraftKey(draft) !== savedProfileDraftKey; + const profileDirty = editing !== null && ( + profileDraftKey(draft) !== savedProfileDraftKey + || reuseCredentialId !== savedReuseCredentialId + ); useUnsavedDraftGuard({ dirty: profileDirty, @@ -245,6 +275,9 @@ export function MailProfileScopeManager({ const nextDraft = emptyProfileDraft(); setDraft(nextDraft); setSavedProfileDraftKey(profileDraftKey(nextDraft)); + setAvailableCredentials([]); + setReuseCredentialId(""); + setSavedReuseCredentialId(""); setEditingTarget({ kind: "create" }); setEditing("new"); setError(""); @@ -252,13 +285,40 @@ export function MailProfileScopeManager({ } function openEdit(profile: MailServerProfile, target: MailProfileEditTarget = { kind: "profile" }) { - const nextDraft = profileToDraft(profile); + const nextDraft = profileToDraft(profile, target); setDraft(nextDraft); setSavedProfileDraftKey(profileDraftKey(nextDraft)); + setAvailableCredentials([]); + setReuseCredentialId(""); + setSavedReuseCredentialId(""); setEditingTarget(target); setEditing(profile); setError(""); setSuccess(""); + if (target.kind === "credentials" && target.serverId && !target.credentialId) { + void loadReusableCredentials(profile.id, target.serverId); + } + } + + function openCreateServer(profile: MailServerProfile, protocol: MailProfileProtocol) { + openEdit(profile, { kind: "server", protocol }); + } + + function openCreateCredential(profile: MailServerProfile, server: MailServerEndpoint) { + openEdit(profile, { + kind: "credentials", + protocol: server.protocol, + serverId: server.id + }); + } + + async function loadReusableCredentials(profileId: string, serverId: string) { + try { + setAvailableCredentials(await listAvailableMailCredentials(settings, profileId, serverId)); + } catch (err) { + setAvailableCredentials([]); + setError(errorMessage(err)); + } } function closeProfileEditor() { @@ -266,6 +326,9 @@ export function MailProfileScopeManager({ const nextDraft = emptyProfileDraft(); setDraft(nextDraft); setSavedProfileDraftKey(profileDraftKey(nextDraft)); + setAvailableCredentials([]); + setReuseCredentialId(""); + setSavedReuseCredentialId(""); setEditingTarget({ kind: "create" }); } @@ -279,12 +342,55 @@ export function MailProfileScopeManager({ const payload = createProfilePayload(draft, scopeType, activeScopeId); const created = await createMailServerProfile(settings, payload); setSuccess(i18nMessage("i18n:govoplan-mail.profile_value_created.2a088d8d", { value0: created.name })); + } else if (editingTarget.kind === "server") { + const serverPayload = { + name: draft.serverName.trim(), + config: editingTarget.protocol === "smtp" ? smtpServerPayload(draft) : imapServerPayload(draft), + inherit_to_lower_scopes: draft.serverInheritToLowerScopes, + is_default: draft.serverIsDefault, + is_active: draft.serverIsActive + }; + if (editingTarget.serverId) { + await updateMailServerEndpoint(settings, editing.id, editingTarget.serverId, serverPayload); + } else { + await createMailServerEndpoint(settings, editing.id, { + protocol: editingTarget.protocol, + ...serverPayload + }); + } + setSuccess(`${draft.serverName.trim()} saved`); + } else if (editingTarget.kind === "credentials" && editingTarget.serverId) { + if (!editingTarget.credentialId && reuseCredentialId) { + const reused = availableCredentials.find((credential) => credential.id === reuseCredentialId); + await bindMailServerCredential( + settings, + editing.id, + editingTarget.serverId, + reuseCredentialId, + draft.credentialIsDefault + ); + setSuccess(`${reused?.name || "Credential"} linked`); + } else { + const payload = credentialPayload(draft, editingTarget.protocol, Boolean(editingTarget.credentialId)); + if (editingTarget.credentialId) { + await updateMailServerCredential( + settings, + editing.id, + editingTarget.serverId, + editingTarget.credentialId, + payload + ); + } else { + await createMailServerCredential(settings, editing.id, editingTarget.serverId, payload); + } + setSuccess(`${draft.credentialName.trim()} saved`); + } } else { - const payload = updateProfilePayload(draft, editing, editingTarget); + const payload = updateProfilePayload(draft); const updated = await updateMailServerProfile(settings, editing.id, payload); setSuccess(i18nMessage("i18n:govoplan-mail.profile_value_updated.fdbad0ea", { value0: updated.name })); } - setEditing(null); + closeProfileEditor(); await loadProfiles(); return true; } catch (err) { @@ -312,6 +418,37 @@ export function MailProfileScopeManager({ } } + async function removeHierarchyItem() { + if (!pendingHierarchyRemoval) return; + setBusy(true); + setError(""); + setSuccess(""); + try { + if (pendingHierarchyRemoval.kind === "server") { + await deactivateMailServerEndpoint( + settings, + pendingHierarchyRemoval.profile.id, + pendingHierarchyRemoval.server.id + ); + setSuccess(`${pendingHierarchyRemoval.server.name} deactivated`); + } else { + await unlinkMailServerCredential( + settings, + pendingHierarchyRemoval.profile.id, + pendingHierarchyRemoval.server.id, + pendingHierarchyRemoval.credential.id + ); + setSuccess(`${pendingHierarchyRemoval.credential.name} unlinked`); + } + setPendingHierarchyRemoval(null); + await loadProfiles(); + } catch (err) { + setError(errorMessage(err)); + } finally { + setBusy(false); + } + } + const treeRows = useMemo( () => scopedProfiles.map((profile) => ({ kind: "profile", id: `profile:${profile.id}`, profile })), [scopedProfiles] @@ -324,8 +461,8 @@ export function MailProfileScopeManager({ render: (row) => row.kind === "profile" ?
{row.profile.name}
{row.profile.slug} · {scopeLabel(row.profile)}
: row.kind === "server" ? - : - + : + }, { id: "transport", @@ -333,13 +470,19 @@ export function MailProfileScopeManager({ width: "minmax(220px, 1fr)", render: (row) => row.kind === "profile" ? {transportLabel(row.profile.smtp)}{row.profile.imap ? i18nMessage("i18n:govoplan-mail.value.48afe802", { value0: transportLabel(row.profile.imap) }) : ""} : - + row.kind === "server" ? + : + {String(row.credential.public_data?.username || "No username")} }, { id: "policy", header: "i18n:govoplan-mail.policy.bb9cf141", width: "minmax(150px, 0.8fr)", - render: (row) => row.kind === "profile" ? scopeLabel(row.profile) : row.kind === "server" ? row.protocol.toUpperCase() : mailCredentialPolicySummary(profileEffectivePolicy, row.protocol) + render: (row) => row.kind === "profile" ? + scopeLabel(row.profile) : + row.kind === "server" ? + (row.server.inherit_to_lower_scopes ? "Inherited by lower scopes" : "Current scope only") : + credentialAvailabilityLabel(row.credential) }, { id: "status", @@ -348,31 +491,96 @@ export function MailProfileScopeManager({ render: (row) => row.kind === "profile" ? : row.kind === "server" ? - : - + : + }], [profileEffectivePolicy]); function mailProfileChildren(row: MailProfileTreeRow): MailProfileTreeRow[] { - if (row.kind !== "profile") return []; - const children: MailProfileTreeRow[] = [ - ...mailProfileChildDescriptors(row.profile).map((child) => ({ ...child, profile: row.profile })) - ]; - return children; + if (row.kind === "profile") { + return (row.profile.servers ?? []).map((server) => ({ + kind: "server", + id: `server:${server.id}`, + profile: row.profile, + protocol: server.protocol, + server + })); + } + if (row.kind === "server") { + return row.server.credentials.map((credential) => ({ + kind: "credential", + id: `credential:${row.server.id}:${credential.id}`, + profile: row.profile, + protocol: row.protocol, + server: row.server, + credential + })); + } + return []; } function renderMailProfileActions(row: MailProfileTreeRow) { if (row.kind === "server") { const label = `Edit ${row.protocol.toUpperCase()} server`; - return , disabled: !canWriteProfiles || busy, onClick: () => openEdit(row.profile, { kind: "server", protocol: row.protocol }) - }]} />; + return , + disabled: !canWriteProfiles || !canManageCredentials || busy, + onClick: () => openCreateCredential(row.profile, row.server) + }, + { + id: "edit-server", + label, + icon: , + disabled: !canWriteProfiles || busy, + onClick: () => openEdit(row.profile, { + kind: "server", + protocol: row.protocol, + serverId: row.server.id + }) + }, + { + id: "deactivate-server", + label: `Deactivate ${row.server.name}`, + icon: , + variant: "danger", + applicable: row.server.is_active, + disabled: !canWriteProfiles || busy, + onClick: () => setPendingHierarchyRemoval({ kind: "server", profile: row.profile, server: row.server }) + } + ]} />; } if (row.kind === "credential") { - return , disabled: !canWriteProfiles || !canManageCredentials || busy, onClick: () => openEdit(row.profile, { kind: "credentials", protocol: row.protocol }) - }]} />; + return , + disabled: !canWriteProfiles || !canManageCredentials || busy, + onClick: () => openEdit(row.profile, { + kind: "credentials", + protocol: row.protocol, + serverId: row.server.id, + credentialId: row.credential.id + }) + }, + { + id: "unlink-credentials", + label: `Unlink ${row.credential.name}`, + icon: , + variant: "danger", + disabled: !canWriteProfiles || !canManageCredentials || busy, + onClick: () => setPendingHierarchyRemoval({ + kind: "credential", + profile: row.profile, + server: row.server, + credential: row.credential + }) + } + ]} />; } const deactivationDeletesCredentials = Boolean( @@ -380,6 +588,8 @@ export function MailProfileScopeManager({ ); return , disabled: !canWriteProfiles || busy, onClick: () => openEdit(row.profile) }, + { id: "add-smtp", label: "Add SMTP server", icon: , disabled: !canWriteProfiles || busy, onClick: () => openCreateServer(row.profile, "smtp") }, + { id: "add-imap", label: "Add IMAP server", icon: , disabled: !canWriteProfiles || busy, onClick: () => openCreateServer(row.profile, "imap") }, { id: "deactivate", label: i18nMessage("i18n:govoplan-mail.deactivate_value.a276a667", { value0: row.profile.name }), icon: , variant: "danger", applicable: row.profile.is_active, disabled: !canWriteProfiles || busy || (deactivationDeletesCredentials && !canManageCredentials), onClick: () => setDeactivating(row.profile) } ]} />; @@ -443,9 +653,21 @@ export function MailProfileScopeManager({ onClose={() => !busy && closeProfileEditor()} className="admin-dialog admin-dialog-wide mail-profile-dialog" closeDisabled={busy} - footer={<>}> + footer={<>}> - + setDeactivating(null)} onConfirm={() => void deactivateProfile()} /> + setPendingHierarchyRemoval(null)} + onConfirm={() => void removeHierarchyItem()} /> + ); } @@ -567,8 +801,6 @@ export function MailProfilePolicyEditor({ const parentBlocksUserProfiles = parentPolicy?.allow_user_profiles === false; const parentBlocksGroupProfiles = parentPolicy?.allow_group_profiles === false; const parentBlocksCampaignProfiles = parentPolicy?.allow_campaign_profiles === false; - const smtpCredentialLocked = !parentAllowsMailLimit("smtp_credentials.inherit"); - const imapCredentialLocked = !parentAllowsMailLimit("imap_credentials.inherit"); const showAllowColumn = scopeType !== "campaign"; const showEffectiveColumn = !isSystem; const profileAllowListLocked = !parentAllowsMailLimit("allowed_profile_ids"); @@ -577,7 +809,6 @@ export function MailProfilePolicyEditor({ parentBlocksGroupProfiles ? "group" : "", parentBlocksCampaignProfiles ? "i18n:govoplan-mail.campaign_local_settings.920ecb62" : ""]. filter(Boolean).join(", "); - const lockedCredentialKinds = [smtpCredentialLocked ? "i18n:govoplan-mail.smtp.efff9cca" : "", imapCredentialLocked ? "i18n:govoplan-mail.imap.271f9ef2" : ""].filter(Boolean).join(" and "); const effectivePolicyPath = effectivePolicySources.length > 0 ? effectivePolicySources : mailPolicySourcePath(scopeType); function patchPolicy(patch: Partial) { @@ -588,11 +819,6 @@ export function MailProfilePolicyEditor({ patchPolicy({ [key]: flagToBoolean(value) }); } - function setCredentialPolicy(protocol: "smtp" | "imap", patch: Partial) { - const key = protocol === "smtp" ? "smtp_credentials" : "imap_credentials"; - patchPolicy({ [key]: normalizeCredentialPolicy({ ...(policy[key] ?? {}), ...patch }) }); - } - function setPattern(kind: "whitelist" | "blacklist", key: MailProfilePatternKey, text: string) { const nextRules = { ...(policy[kind] ?? {}) }; const parsed = parsePatternList(text); @@ -713,39 +939,6 @@ export function MailProfilePolicyEditor({ {blockedProfileDefinitions && i18n:govoplan-mail.explicit_allow_is_unavailable_for.8d05fd4a {blockedProfileDefinitions} i18n:govoplan-mail.because_an_ancestor_policy_blocks_those_definiti.5de3e30d} -
-

i18n:govoplan-mail.credential_inheritance.ec8d7191

- - setCredentialPolicy("smtp", { inherit: valueToCredentialInheritance(value) })} />} - effective={showEffectiveColumn ? effectivePolicy ? credentialInheritanceLabel(effectivePolicy.smtp_credentials) : "i18n:govoplan-mail.loading.b04ba49f" : undefined} - allowControl={showAllowColumn ?
{lowerLevelLimitToggle("smtp_credentials.inherit")}
: undefined} - effectiveHelp={showEffectiveColumn ? mailCredentialPolicyPathHelp("smtp_credentials", effectivePolicyPath) : undefined} /> - - setCredentialPolicy("imap", { inherit: valueToCredentialInheritance(value) })} />} - effective={showEffectiveColumn ? effectivePolicy ? credentialInheritanceLabel(effectivePolicy.imap_credentials) : "i18n:govoplan-mail.loading.b04ba49f" : undefined} - allowControl={showAllowColumn ?
{lowerLevelLimitToggle("imap_credentials.inherit")}
: undefined} - effectiveHelp={showEffectiveColumn ? mailCredentialPolicyPathHelp("imap_credentials", effectivePolicyPath) : undefined} /> - -
- {lockedCredentialKinds && {lockedCredentialKinds} i18n:govoplan-mail.credential_inheritance_is_locked_by_an_ancestor_.1ee5d263} -
-

i18n:govoplan-mail.wildcard_rules.54fb3fc0

@@ -795,8 +988,24 @@ function ProfileForm({ canWrite, canManageCredentials, effectivePolicy, - editTarget -}: {settings: ApiSettings;draft: ProfileDraft;setDraft: (draft: ProfileDraft) => void;editing: EditingProfile;busy: boolean;canWrite: boolean;canManageCredentials: boolean;effectivePolicy?: MailProfilePolicy | null;editTarget: MailProfileEditTarget;}) { + editTarget, + availableCredentials, + reuseCredentialId, + setReuseCredentialId +}: { + settings: ApiSettings; + draft: ProfileDraft; + setDraft: (draft: ProfileDraft) => void; + editing: EditingProfile; + busy: boolean; + canWrite: boolean; + canManageCredentials: boolean; + effectivePolicy?: MailProfilePolicy | null; + editTarget: MailProfileEditTarget; + availableCredentials: MailCredentialEnvelope[]; + reuseCredentialId: string; + setReuseCredentialId: (credentialId: string) => void; +}) { const [smtpTestResult, setSmtpTestResult] = useState(null); const [imapTestResult, setImapTestResult] = useState(null); const [mailActionState, setMailActionState] = useState<"smtp" | "imap" | null>(null); @@ -810,8 +1019,27 @@ function ProfileForm({ const showProfileFields = mailProfileEditTargetShowsProfileFields(editTarget); const showSettingsPanel = mailProfileEditTargetShowsSettingsPanel(editTarget); const draftHasImap = hasDraftImapSettings(draft); - const useSavedSmtpTest = Boolean(existingProfile && !draft.smtpPassword && existingProfile.smtp_password_configured); - const useSavedImapTest = Boolean(existingProfile && !draft.imapPassword && existingProfile.imap_password_configured); + const selectedServerId = editTarget.kind === "server" || editTarget.kind === "credentials" ? editTarget.serverId : undefined; + const selectedCredentialId = editTarget.kind === "credentials" ? editTarget.credentialId : undefined; + const selectedCredential = selectedCredentialId && existingProfile + ? existingProfile.servers?.flatMap((server) => server.credentials).find((credential) => credential.id === selectedCredentialId) + : null; + const useSavedSmtpTest = Boolean( + existingProfile + && (editTarget.kind === "server" || editTarget.kind === "credentials") + && editTarget.protocol === "smtp" + && selectedServerId + && !draft.smtpPassword + ); + const useSavedImapTest = Boolean( + existingProfile + && (editTarget.kind === "server" || editTarget.kind === "credentials") + && editTarget.protocol === "imap" + && selectedServerId + && !draft.imapPassword + ); + const creatingCredential = editTarget.kind === "credentials" && !editTarget.credentialId; + const creatingNewCredential = creatingCredential && !reuseCredentialId; const policyMessages = useMemo(() => validateMailPolicy(effectivePolicy, { smtpHost: draft.smtpHost, imapHost: draft.imapHost @@ -865,7 +1093,7 @@ function ProfileForm({ setSmtpTestResult(null); try { setSmtpTestResult(useSavedSmtpTest && existingProfile ? - await testMailProfileSmtp(settings, existingProfile.id) : + await testMailProfileSmtp(settings, existingProfile.id, selectedServerId, selectedCredentialId) : await testSmtpSettings(settings, rawSmtpPayload(draft, false))); } catch (err) { setSmtpTestResult({ ok: false, protocol: "smtp", message: errorMessage(err), details: {} }); @@ -880,7 +1108,7 @@ function ProfileForm({ setImapTestResult(null); try { setImapTestResult(useSavedImapTest && existingProfile ? - await testMailProfileImap(settings, existingProfile.id) : + await testMailProfileImap(settings, existingProfile.id, selectedServerId, selectedCredentialId) : await testImapSettings(settings, rawImapPayload(draft, false))); } catch (err) { setImapTestResult({ ok: false, protocol: "imap", message: errorMessage(err), details: {} }); @@ -897,6 +1125,13 @@ function ProfileForm({ setDraft({ ...draft, slug: event.target.value })} placeholder="i18n:govoplan-mail.generated_from_name.33d69a91" />