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
+85 -11
View File
@@ -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": {