Add hierarchical mail servers and credentials

This commit is contained in:
2026-07-28 19:32:56 +02:00
parent 2813c671c0
commit 3735027b3d
15 changed files with 4105 additions and 246 deletions

View File

@@ -11,6 +11,7 @@ from govoplan_mail.backend.mail_profiles import (
_assert_campaign_inherits_profile_credentials, _assert_campaign_inherits_profile_credentials,
assert_campaign_mail_policy_allows_json, assert_campaign_mail_policy_allows_json,
assert_mail_policy_allows_send, assert_mail_policy_allows_send,
campaign_mail_owner_context,
campaign_profile_transport_revisions, campaign_profile_transport_revisions,
effective_mail_profile_policy, effective_mail_profile_policy,
ensure_mail_profile_allowed_for_campaign, ensure_mail_profile_allowed_for_campaign,
@@ -19,6 +20,12 @@ from govoplan_mail.backend.mail_profiles import (
mail_profile_id_from_campaign_json, mail_profile_id_from_campaign_json,
smtp_config_from_profile, 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.runtime import configure_runtime
from govoplan_mail.backend.sending.imap import ( from govoplan_mail.backend.sending.imap import (
ImapAppendError, ImapAppendError,
@@ -104,6 +111,7 @@ def _authorized_campaign_profile(
tenant_id: str, tenant_id: str,
campaign_id: str, campaign_id: str,
profile_id: str, profile_id: str,
selection: dict[str, str | None] | None = None,
): ):
profile = ensure_mail_profile_allowed_for_campaign( profile = ensure_mail_profile_allowed_for_campaign(
session, session,
@@ -113,10 +121,55 @@ def _authorized_campaign_profile(
require_active=True, require_active=True,
) )
policy = effective_mail_profile_policy(session, tenant_id=tenant_id, campaign_id=campaign_id) 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 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( def campaign_profile_delivery_summary(
session: Session, session: Session,
*, *,
@@ -125,21 +178,33 @@ def campaign_profile_delivery_summary(
campaign_id: str | None = None, campaign_id: str | None = None,
owner_user_id: str | None = None, owner_user_id: str | None = None,
owner_group_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]: ) -> dict[str, Any]:
"""Return only non-secret capabilities and opaque drift evidence.""" """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: if campaign_id:
profile = _authorized_campaign_profile( profile = _authorized_campaign_profile(
session, session,
tenant_id=tenant_id, tenant_id=tenant_id,
campaign_id=campaign_id, campaign_id=campaign_id,
profile_id=profile_id, profile_id=profile_id,
selection=selection,
) )
else: else:
assert_campaign_mail_policy_allows_json( assert_campaign_mail_policy_allows_json(
session, session,
tenant_id=tenant_id, 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_user_id=owner_user_id,
owner_group_id=owner_group_id, owner_group_id=owner_group_id,
) )
@@ -149,15 +214,61 @@ def campaign_profile_delivery_summary(
profile_id=profile_id, profile_id=profile_id,
require_active=True, require_active=True,
) )
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) revisions = campaign_profile_transport_revisions(profile)
smtp = profile.smtp_config or {} smtp_config = profile.smtp_config or {}
imap = profile.imap_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 { return {
"mail_profile_id": profile_id, "mail_profile_id": profile_id,
"smtp_available": bool(smtp.get("host") and smtp.get("port")), "smtp_server_id": resolved_smtp_server_id,
"imap_available": bool(imap.get("host") and imap.get("port")), "smtp_credential_id": resolved_smtp_credential_id,
"smtp_transport_revision": revisions["smtp"], "imap_server_id": resolved_imap_server_id,
"imap_transport_revision": revisions["imap"], "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,25 +283,64 @@ def send_campaign_email_bytes(
envelope_recipients: list[str], envelope_recipients: list[str],
from_header: str | None, from_header: str | None,
expected_smtp_transport_revision: str, expected_smtp_transport_revision: str,
smtp_server_id: str | None = None,
smtp_credential_id: str | None = None,
) -> CampaignSmtpDeliveryResult: ) -> CampaignSmtpDeliveryResult:
selection = _selection_payload(
profile_id=profile_id,
smtp_server_id=smtp_server_id,
smtp_credential_id=smtp_credential_id,
)
try: try:
profile = _authorized_campaign_profile( profile = _authorized_campaign_profile(
session, session,
tenant_id=tenant_id, tenant_id=tenant_id,
campaign_id=campaign_id, campaign_id=campaign_id,
profile_id=profile_id, profile_id=profile_id,
selection=selection,
) )
except MailProfileError: except MailProfileError:
raise raise
except Exception: except Exception:
raise SmtpConfigurationError("The selected Mail profile's SMTP configuration is unusable.") from None raise SmtpConfigurationError("The selected Mail profile's SMTP configuration is unusable.") from None
revisions = campaign_profile_transport_revisions(profile) resolved_smtp = None
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,
)
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( raise MailProfileError(
"The selected Mail profile's SMTP settings changed after this campaign was built. " "The selected Mail profile's SMTP settings changed after this campaign was built. "
"Revalidate and rebuild the campaign before delivery." "Revalidate and rebuild the campaign before delivery."
) )
try: try:
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) smtp = smtp_config_from_profile(profile)
except MailProfileError: except MailProfileError:
raise raise
@@ -202,7 +352,7 @@ def send_campaign_email_bytes(
tenant_id=tenant_id, tenant_id=tenant_id,
campaign_id=campaign_id, campaign_id=campaign_id,
smtp=smtp, smtp=smtp,
imap=profile.imap_config or None, imap=None,
envelope_sender=envelope_from, envelope_sender=envelope_from,
from_header=from_header, from_header=from_header,
recipients=envelope_recipients, recipients=envelope_recipients,
@@ -241,30 +391,82 @@ def append_campaign_message_to_sent(
folder: str | None, folder: str | None,
expected_smtp_transport_revision: str, expected_smtp_transport_revision: str,
expected_imap_transport_revision: str | None, 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: ) -> 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: try:
profile = _authorized_campaign_profile( profile = _authorized_campaign_profile(
session, session,
tenant_id=tenant_id, tenant_id=tenant_id,
campaign_id=campaign_id, campaign_id=campaign_id,
profile_id=profile_id, profile_id=profile_id,
selection=selection,
) )
except MailProfileError: except MailProfileError:
raise raise
except Exception: except Exception:
raise ImapConfigurationError("The selected Mail profile's IMAP configuration is unusable.") from None raise ImapConfigurationError("The selected Mail profile's IMAP configuration is unusable.") from 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,
)
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) revisions = campaign_profile_transport_revisions(profile)
if revisions["smtp"] != expected_smtp_transport_revision: smtp_revision = revisions["smtp"]
imap_revision = revisions["imap"]
if smtp_revision != expected_smtp_transport_revision:
raise MailProfileError( raise MailProfileError(
"The selected Mail profile's SMTP settings changed after this campaign was built. " "The selected Mail profile's SMTP settings changed after this campaign was built. "
"Revalidate and rebuild the campaign before append-to-Sent delivery." "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( raise MailProfileError(
"The selected Mail profile's IMAP settings changed after this campaign was built. " "The selected Mail profile's IMAP settings changed after this campaign was built. "
"Revalidate and rebuild the campaign before append-to-Sent delivery." "Revalidate and rebuild the campaign before append-to-Sent delivery."
) )
try: try:
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) imap = imap_config_from_profile(profile)
except MailProfileError: except MailProfileError:
raise raise
@@ -277,7 +479,7 @@ def append_campaign_message_to_sent(
session, session,
tenant_id=tenant_id, tenant_id=tenant_id,
campaign_id=campaign_id, campaign_id=campaign_id,
smtp=profile.smtp_config or None, smtp=None,
imap=imap, imap=imap,
) )
except MailProfileError: except MailProfileError:

View File

@@ -9,6 +9,7 @@ from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, Index, Integer
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from govoplan_core.db.base import Base, TimestampMixin 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: def new_uuid() -> str:
@@ -30,6 +31,7 @@ class MailServerProfile(Base, TimestampMixin):
slug: Mapped[str] = mapped_column(String(100), nullable=False) slug: Mapped[str] = mapped_column(String(100), nullable=False)
description: Mapped[str | None] = mapped_column(Text) description: Mapped[str | None] = mapped_column(Text)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, index=True) 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_config: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
smtp_username: Mapped[str | None] = mapped_column(String(320)) smtp_username: Mapped[str | None] = mapped_column(String(320))
smtp_password_encrypted: Mapped[str | None] = mapped_column(Text) 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) 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): class MailProfilePolicy(Base, TimestampMixin):
__tablename__ = "mail_profile_policies" __tablename__ = "mail_profile_policies"
__table_args__ = ( __table_args__ = (

View File

@@ -4,7 +4,7 @@ import fnmatch
import json import json
import re import re
from dataclasses import dataclass, field 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 import and_, or_, select, text
from sqlalchemy.orm import Session 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( def mail_profile_scope_visible_to_actor(
session: Session, session: Session,
*, *,
@@ -1335,7 +1348,15 @@ def _profile_has_transport(profile: MailServerProfile, protocol: str) -> bool:
return bool(profile.imap_config) 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: 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: if unexpected:
paths = ", ".join(f"server.{key}" for key in unexpected) paths = ", ".join(f"server.{key}" for key in unexpected)
raise MailProfileError( raise MailProfileError(
"Campaign JSON may only reference a Mail-owned profile through server.mail_profile_id; " "Campaign JSON may only reference Mail-owned profile, server, and credential identifiers; "
f"remove campaign-local SMTP/IMAP settings ({paths}), select a Mail profile, and save a new campaign version." f"remove campaign-local SMTP/IMAP settings ({paths}), select Mail resources, and save a new campaign version."
) )
value = server.get("mail_profile_id") value = server.get("mail_profile_id")
if value is None: if value is None:
@@ -1354,18 +1375,63 @@ def _campaign_mail_profile_reference_id(server: dict[str, Any]) -> str | None:
return value.strip() 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( def _assert_campaign_inherits_profile_credentials(
profile: MailServerProfile, profile: MailServerProfile,
policy: EffectiveMailProfilePolicy, policy: EffectiveMailProfilePolicy,
selection: Mapping[str, str | None] | None = None,
) -> None: ) -> None:
for protocol in ("smtp", "imap"): for protocol in ("smtp", "imap"):
if not _profile_has_transport(profile, protocol): if not _profile_has_transport(profile, protocol):
continue 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( raise MailProfileError(
f"Campaign delivery cannot use the selected profile because the effective {protocol.upper()} " 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 " "credential policy requires an explicit credential selection for this campaign."
"and change the policy to inherit them."
) )
@@ -1379,8 +1445,8 @@ def assert_campaign_mail_policy_allows_json(
owner_group_id: str | None = None, owner_group_id: str | None = None,
) -> None: ) -> None:
data = raw_json if isinstance(raw_json, dict) else {} data = raw_json if isinstance(raw_json, dict) else {}
server = data.get("server") if isinstance(data.get("server"), dict) else {} selection = campaign_mail_selection_from_json(data)
profile_id = _campaign_mail_profile_reference_id(server) profile_id = selection["mail_profile_id"]
if profile_id: if profile_id:
if campaign_id: if campaign_id:
profile = ensure_mail_profile_allowed_for_campaign( profile = ensure_mail_profile_allowed_for_campaign(
@@ -1391,7 +1457,7 @@ def assert_campaign_mail_policy_allows_json(
require_active=True, require_active=True,
) )
policy = effective_mail_profile_policy(session, tenant_id=tenant_id, campaign_id=campaign_id) 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 return
profile = get_mail_server_profile(session, tenant_id=tenant_id, profile_id=str(profile_id), require_active=True) profile = get_mail_server_profile(session, tenant_id=tenant_id, profile_id=str(profile_id), require_active=True)
policy = effective_mail_profile_policy( policy = effective_mail_profile_policy(
@@ -1408,7 +1474,7 @@ def assert_campaign_mail_policy_allows_json(
owner_group_id=owner_group_id, owner_group_id=owner_group_id,
): ):
raise MailProfileError("Mail-server profile is not allowed by the effective policy") 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
return return
@@ -1424,6 +1490,7 @@ def create_mail_server_profile(
smtp: SmtpConfig, smtp: SmtpConfig,
imap: ImapConfig | None, imap: ImapConfig | None,
is_active: bool = True, is_active: bool = True,
inherit_to_lower_scopes: bool = True,
scope_type: str = "tenant", scope_type: str = "tenant",
scope_id: str | None = None, scope_id: str | None = None,
) -> MailServerProfile: ) -> MailServerProfile:
@@ -1466,6 +1533,7 @@ def create_mail_server_profile(
slug=clean_slug, slug=clean_slug,
description=description, description=description,
is_active=is_active, is_active=is_active,
inherit_to_lower_scopes=bool(inherit_to_lower_scopes),
smtp_config=smtp_payload, smtp_config=smtp_payload,
smtp_username=smtp_username, smtp_username=smtp_username,
smtp_password_encrypted=encrypt_secret(smtp_password), smtp_password_encrypted=encrypt_secret(smtp_password),
@@ -1491,6 +1559,7 @@ def update_mail_server_profile(
slug: str | None = None, slug: str | None = None,
description: str | None = None, description: str | None = None,
is_active: bool | None = None, is_active: bool | None = None,
inherit_to_lower_scopes: bool | None = None,
smtp: SmtpConfig | None = None, smtp: SmtpConfig | None = None,
imap: ImapConfig | None = None, imap: ImapConfig | None = None,
clear_imap: bool = False, clear_imap: bool = False,
@@ -1538,6 +1607,8 @@ def update_mail_server_profile(
profile.description = description profile.description = description
if is_active is not None: if is_active is not None:
profile.is_active = is_active 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) 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) _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, "slug": profile.slug,
"description": profile.description, "description": profile.description,
"is_active": profile.is_active, "is_active": profile.is_active,
"inherit_to_lower_scopes": bool(
getattr(profile, "inherit_to_lower_scopes", True)
),
"smtp": _server_config_payload(profile.smtp_config), "smtp": _server_config_payload(profile.smtp_config),
"imap": _server_config_payload(profile.imap_config) if profile.imap_config else None, "imap": _server_config_payload(profile.imap_config) if profile.imap_config else None,
"credentials": { "credentials": {

View File

@@ -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_table_retirement_provider = drop_table_retirement_provider(
mail_models.MailServerCredentialBinding,
mail_models.MailServerEndpoint,
mail_models.MailServerProfile, mail_models.MailServerProfile,
mail_models.MailProfilePolicy, mail_models.MailProfilePolicy,
mail_models.MailMailboxFolderIndex, mail_models.MailMailboxFolderIndex,
@@ -194,6 +196,8 @@ manifest = ModuleManifest(
), ),
uninstall_guard_providers=( uninstall_guard_providers=(
persistent_table_uninstall_guard( persistent_table_uninstall_guard(
mail_models.MailServerCredentialBinding,
mail_models.MailServerEndpoint,
mail_models.MailServerProfile, mail_models.MailServerProfile,
mail_models.MailProfilePolicy, mail_models.MailProfilePolicy,
mail_models.MailMailboxFolderIndex, mail_models.MailMailboxFolderIndex,

View File

@@ -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

View File

@@ -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 {}

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
from datetime import datetime from datetime import datetime
from typing import Any, Literal 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_core.api.v1.schemas import DeltaDeletedItem
from govoplan_mail.backend.config import ( from govoplan_mail.backend.config import (
@@ -103,6 +103,7 @@ class MailServerProfileCreateRequest(BaseModel):
slug: str | None = Field(default=None, max_length=100) slug: str | None = Field(default=None, max_length=100)
description: str | None = None description: str | None = None
is_active: bool = True is_active: bool = True
inherit_to_lower_scopes: bool = True
scope_type: MailProfileScope = "tenant" scope_type: MailProfileScope = "tenant"
scope_id: str | None = None scope_id: str | None = None
smtp: SmtpServerConfig smtp: SmtpServerConfig
@@ -137,6 +138,7 @@ class MailServerProfileUpdateRequest(BaseModel):
slug: str | None = Field(default=None, max_length=100) slug: str | None = Field(default=None, max_length=100)
description: str | None = None description: str | None = None
is_active: bool | None = None is_active: bool | None = None
inherit_to_lower_scopes: bool | None = None
smtp: SmtpServerConfig | None = None smtp: SmtpServerConfig | None = None
imap: ImapServerConfig | None = None imap: ImapServerConfig | None = None
credentials: MailServerProfileCredentialsPayload = Field(default_factory=MailServerProfileCredentialsPayload) 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): class MailServerProfileResponse(BaseModel):
id: str id: str
tenant_id: str | None = None tenant_id: str | None = None
@@ -176,11 +299,13 @@ class MailServerProfileResponse(BaseModel):
slug: str slug: str
description: str | None = None description: str | None = None
is_active: bool is_active: bool
inherit_to_lower_scopes: bool = True
smtp: dict[str, Any] smtp: dict[str, Any]
imap: dict[str, Any] | None = None imap: dict[str, Any] | None = None
credentials: dict[str, Any] = Field(default_factory=dict) credentials: dict[str, Any] = Field(default_factory=dict)
smtp_password_configured: bool = False smtp_password_configured: bool = False
imap_password_configured: bool = False imap_password_configured: bool = False
servers: list[MailServerEndpointResponse] = Field(default_factory=list)
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime

File diff suppressed because it is too large Load Diff

View File

@@ -13,6 +13,7 @@ from govoplan_mail.backend.mail_profiles import (
_assert_campaign_inherits_profile_credentials, _assert_campaign_inherits_profile_credentials,
_campaign_mail_profile_reference_id, _campaign_mail_profile_reference_id,
_apply_profile_transport_update, _apply_profile_transport_update,
campaign_mail_selection_from_json,
campaign_profile_transport_revisions, campaign_profile_transport_revisions,
create_mail_server_profile, create_mail_server_profile,
_merge_policy, _merge_policy,
@@ -496,14 +497,26 @@ class MailProfileTransportHelperTests(unittest.TestCase):
class MailProfilePolicyHelperTests(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( self.assertEqual(
_campaign_mail_profile_reference_id({"mail_profile_id": " profile-1 "}), _campaign_mail_profile_reference_id({"mail_profile_id": " profile-1 "}),
"profile-1", "profile-1",
) )
for legacy in ("smtp", "imap", "credentials", "inherit_smtp_credentials"): 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: {}}) _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): def test_campaign_delivery_fails_when_policy_requires_local_credentials(self):
profile = SimpleNamespace(imap_config=None) profile = SimpleNamespace(imap_config=None)
@@ -511,7 +524,7 @@ class MailProfilePolicyHelperTests(unittest.TestCase):
smtp_credentials=EffectiveCredentialPolicy(inherit=False), 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) _assert_campaign_inherits_profile_credentials(profile, policy)
def test_merge_policy_respects_locked_lower_level_limits(self): def test_merge_policy_respects_locked_lower_level_limits(self):

View File

@@ -409,6 +409,7 @@ class ProfileActorAuthorizationTests(unittest.TestCase):
"govoplan_mail.backend.router._profile_response", "govoplan_mail.backend.router._profile_response",
return_value=profile, return_value=profile,
), ),
patch("govoplan_mail.backend.router.sync_default_profile_server"),
): ):
result = router.update_profile( result = router.update_profile(
profile.id, profile.id,
@@ -446,6 +447,7 @@ class ProfileActorAuthorizationTests(unittest.TestCase):
"govoplan_mail.backend.router._profile_response", "govoplan_mail.backend.router._profile_response",
return_value=profile, return_value=profile,
), ),
patch("govoplan_mail.backend.router.sync_default_profile_server"),
): ):
router.update_profile( router.update_profile(
profile.id, profile.id,

View File

@@ -166,6 +166,7 @@ class MailProfileDeletionRouteTests(unittest.TestCase):
with ( with (
patch("govoplan_mail.backend.router._require_profile_write_scope"), 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.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._record_mail_change") as create_change,
patch("govoplan_mail.backend.router._profile_response", return_value=system_profile), patch("govoplan_mail.backend.router._profile_response", return_value=system_profile),
): ):

View File

@@ -4,10 +4,12 @@ import type {
MailConnectionTestResponse, MailConnectionTestResponse,
MailImapFolderListResponse, MailImapFolderListResponse,
MailImapTestPayload, MailImapTestPayload,
MailCredentialEnvelope,
MailProfilePolicy, MailProfilePolicy,
MailProfilePolicyResponse, MailProfilePolicyResponse,
MailProfileScope, MailProfileScope,
MailSecurity, MailSecurity,
MailServerEndpoint,
MailServerProfile, MailServerProfile,
MailServerProfilePayload, MailServerProfilePayload,
MailSmtpTestPayload, MailSmtpTestPayload,
@@ -18,6 +20,7 @@ import { apiFetch, apiGetList, apiPath, apiPost, apiPostJson } from "./client";
export { mailProfilePatternKeys, mailProfilePolicyLimitKeys } from "@govoplan/core-webui"; export { mailProfilePatternKeys, mailProfilePolicyLimitKeys } from "@govoplan/core-webui";
export type { export type {
MailConnectionTestResponse, MailConnectionTestResponse,
MailCredentialEnvelope,
MailCredentialPolicy, MailCredentialPolicy,
MailImapFolderListResponse, MailImapFolderListResponse,
MailImapFolderResponse, MailImapFolderResponse,
@@ -30,6 +33,7 @@ export type {
MailProfilePolicyResponse, MailProfilePolicyResponse,
MailProfileScope, MailProfileScope,
MailSecurity, MailSecurity,
MailServerEndpoint,
MailServerProfile, MailServerProfile,
MailServerProfileCredentialsPayload, MailServerProfileCredentialsPayload,
MailServerProfileListResponse, MailServerProfileListResponse,
@@ -176,6 +180,33 @@ export async function createMailServerProfile(settings: ApiSettings, payload: Ma
export type MailServerProfileUpdatePayload = Partial<MailServerProfilePayload> & { clear_imap?: boolean }; export type MailServerProfileUpdatePayload = Partial<MailServerProfilePayload> & { clear_imap?: boolean };
export type MailServerEndpointPayload = {
protocol: "smtp" | "imap";
name: string;
config: Record<string, unknown>;
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<string, unknown>;
secret_data?: Record<string, unknown>;
inherit_to_lower_scopes?: boolean | null;
allowed_modules?: string[];
allowed_server_refs?: string[];
is_default?: boolean;
};
export type MailCredentialUpdatePayload = Partial<MailCredentialCreatePayload> & {
is_active?: boolean;
};
export async function updateMailServerProfile(settings: ApiSettings, profileId: string, payload: MailServerProfileUpdatePayload): Promise<MailServerProfile> { export async function updateMailServerProfile(settings: ApiSettings, profileId: string, payload: MailServerProfileUpdatePayload): Promise<MailServerProfile> {
return apiFetch<MailServerProfile>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}`, { return apiFetch<MailServerProfile>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}`, {
method: "PATCH", method: "PATCH",
@@ -187,6 +218,118 @@ export async function deactivateMailServerProfile(settings: ApiSettings, profile
return apiFetch<MailServerProfile>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}`, { method: "DELETE" }); return apiFetch<MailServerProfile>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}`, { method: "DELETE" });
} }
export async function createMailServerEndpoint(
settings: ApiSettings,
profileId: string,
payload: MailServerEndpointPayload
): Promise<MailServerEndpoint> {
return apiFetch<MailServerEndpoint>(
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<Omit<MailServerEndpointPayload, "protocol">>
): Promise<MailServerEndpoint> {
return apiFetch<MailServerEndpoint>(
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<MailServerEndpoint> {
return apiFetch<MailServerEndpoint>(
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<MailCredentialEnvelope[]> {
return apiGetList<MailCredentialEnvelope, "credentials">(
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<MailCredentialEnvelope> {
return apiFetch<MailCredentialEnvelope>(
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<MailCredentialEnvelope> {
return apiFetch<MailCredentialEnvelope>(
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<MailCredentialEnvelope> {
return apiFetch<MailCredentialEnvelope>(
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<void> {
await apiFetch<void>(
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( export async function getMailProfilePolicy(
settings: ApiSettings, settings: ApiSettings,
scopeType: MailProfileScope, scopeType: MailProfileScope,
@@ -211,16 +354,55 @@ export async function updateMailProfilePolicy(
}); });
} }
export async function testMailProfileSmtp(settings: ApiSettings, profileId: string): Promise<MailConnectionTestResponse> { export async function testMailProfileSmtp(
return apiPost<MailConnectionTestResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/test-smtp`); settings: ApiSettings,
profileId: string,
serverId?: string | null,
credentialId?: string | null,
campaignId?: string | null
): Promise<MailConnectionTestResponse> {
return apiPost<MailConnectionTestResponse>(
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<MailConnectionTestResponse> { export async function testMailProfileImap(
return apiPost<MailConnectionTestResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/test-imap`); settings: ApiSettings,
profileId: string,
serverId?: string | null,
credentialId?: string | null,
campaignId?: string | null
): Promise<MailConnectionTestResponse> {
return apiPost<MailConnectionTestResponse>(
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<MailImapFolderListResponse> { export async function listMailProfileImapFolders(
return apiPost<MailImapFolderListResponse>(settings, `/api/v1/mail/profiles/${encodeURIComponent(profileId)}/list-imap-folders`); settings: ApiSettings,
profileId: string,
serverId?: string | null,
credentialId?: string | null,
campaignId?: string | null
): Promise<MailImapFolderListResponse> {
return apiPost<MailImapFolderListResponse>(
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<MailImapFolderListResponse> { export async function listMailboxFolders(settings: ApiSettings, profileId: string, includeStatus = false, refresh = false): Promise<MailImapFolderListResponse> {

File diff suppressed because it is too large Load Diff

View File

@@ -5,8 +5,8 @@ export type MailProfilePanelMode = "all" | "server" | "credentials";
export type MailProfileEditTarget = export type MailProfileEditTarget =
| {kind: "create";} | {kind: "create";}
| {kind: "profile";} | {kind: "profile";}
| {kind: "server";protocol: MailProfileProtocol;} | {kind: "server";protocol: MailProfileProtocol;serverId?: string;}
| {kind: "credentials";protocol: MailProfileProtocol;}; | {kind: "credentials";protocol: MailProfileProtocol;serverId?: string;credentialId?: string;};
export type MailProfileTransportLike = { export type MailProfileTransportLike = {
host?: string | null; host?: string | null;