feat: enforce Mail-owned campaign transport boundary
This commit is contained in:
@@ -1,22 +1,299 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.modules import ModuleContext
|
||||
from govoplan_mail.backend.runtime import configure_runtime
|
||||
from govoplan_mail.backend.mail_profiles import (
|
||||
MailProfileError,
|
||||
apply_campaign_credentials,
|
||||
_assert_campaign_inherits_profile_credentials,
|
||||
assert_campaign_mail_policy_allows_json,
|
||||
assert_mail_policy_allows_send,
|
||||
effective_profile_credentials_inherited,
|
||||
campaign_profile_transport_revisions,
|
||||
effective_mail_profile_policy,
|
||||
ensure_mail_profile_allowed_for_campaign,
|
||||
get_mail_server_profile,
|
||||
imap_config_from_profile,
|
||||
mail_profile_id_from_campaign_json,
|
||||
materialize_campaign_mail_profile_config,
|
||||
smtp_config_from_profile,
|
||||
)
|
||||
from govoplan_mail.backend.sending.imap import ImapAppendError, ImapConfigurationError, append_message_to_sent
|
||||
from govoplan_mail.backend.runtime import configure_runtime
|
||||
from govoplan_mail.backend.sending.imap import (
|
||||
ImapAppendError,
|
||||
ImapConfigurationError,
|
||||
append_message_to_sent,
|
||||
)
|
||||
from govoplan_mail.backend.sending.rate_limit import wait_for_rate_limit
|
||||
from govoplan_mail.backend.sending.smtp import SmtpConfigurationError, SmtpSendError, send_email_bytes, send_email_message
|
||||
from govoplan_mail.backend.sending.smtp import SmtpConfigurationError, SmtpSendError, send_email_bytes
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CampaignSmtpDeliveryResult:
|
||||
envelope_recipients: list[str]
|
||||
refused_recipients: dict[str, dict[str, int | str]]
|
||||
|
||||
@property
|
||||
def accepted_count(self) -> int:
|
||||
return len(self.envelope_recipients) - len(self.refused_recipients)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CampaignImapAppendResult:
|
||||
folder: str
|
||||
|
||||
|
||||
def _sanitized_refusals(
|
||||
refused_recipients: dict[str, tuple[int, bytes | str]],
|
||||
) -> dict[str, dict[str, int | str]]:
|
||||
sanitized: dict[str, dict[str, int | str]] = {}
|
||||
for recipient, (raw_code, _provider_message) in refused_recipients.items():
|
||||
try:
|
||||
code = int(raw_code)
|
||||
except (TypeError, ValueError):
|
||||
code = 0
|
||||
if 400 <= code < 500:
|
||||
classification = "temporary"
|
||||
message = "Temporary recipient rejection"
|
||||
elif 500 <= code < 600:
|
||||
classification = "permanent"
|
||||
message = "Permanent recipient rejection"
|
||||
else:
|
||||
classification = "unknown"
|
||||
message = "Recipient rejected"
|
||||
sanitized[str(recipient)] = {
|
||||
"status_code": code,
|
||||
"classification": classification,
|
||||
"message": message,
|
||||
}
|
||||
return sanitized
|
||||
|
||||
|
||||
def _sanitized_smtp_error(exc: SmtpSendError) -> SmtpSendError:
|
||||
if exc.outcome_unknown:
|
||||
message = "Mail delivery outcome is unknown after transmission started."
|
||||
elif exc.temporary:
|
||||
message = "Mail delivery failed temporarily."
|
||||
else:
|
||||
message = "Mail delivery was rejected."
|
||||
return SmtpSendError(
|
||||
message,
|
||||
temporary=exc.temporary,
|
||||
outcome_unknown=exc.outcome_unknown,
|
||||
)
|
||||
|
||||
|
||||
def _sanitized_imap_error(exc: ImapAppendError) -> ImapAppendError:
|
||||
if exc.outcome_unknown:
|
||||
message = "The Sent-folder append outcome is unknown; inspect the mailbox before retrying."
|
||||
elif exc.temporary:
|
||||
message = "Appending the sent message failed temporarily."
|
||||
else:
|
||||
message = "Appending the sent message was rejected."
|
||||
return ImapAppendError(
|
||||
message,
|
||||
temporary=exc.temporary,
|
||||
outcome_unknown=exc.outcome_unknown,
|
||||
)
|
||||
|
||||
|
||||
def _authorized_campaign_profile(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
campaign_id: str,
|
||||
profile_id: str,
|
||||
):
|
||||
profile = ensure_mail_profile_allowed_for_campaign(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
profile_id=profile_id,
|
||||
require_active=True,
|
||||
)
|
||||
policy = effective_mail_profile_policy(session, tenant_id=tenant_id, campaign_id=campaign_id)
|
||||
_assert_campaign_inherits_profile_credentials(profile, policy)
|
||||
return profile
|
||||
|
||||
|
||||
def campaign_profile_delivery_summary(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
profile_id: str,
|
||||
campaign_id: str | None = None,
|
||||
owner_user_id: str | None = None,
|
||||
owner_group_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Return only non-secret capabilities and opaque drift evidence."""
|
||||
|
||||
if campaign_id:
|
||||
profile = _authorized_campaign_profile(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
profile_id=profile_id,
|
||||
)
|
||||
else:
|
||||
assert_campaign_mail_policy_allows_json(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
raw_json={"server": {"mail_profile_id": profile_id}},
|
||||
owner_user_id=owner_user_id,
|
||||
owner_group_id=owner_group_id,
|
||||
)
|
||||
profile = get_mail_server_profile(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
profile_id=profile_id,
|
||||
require_active=True,
|
||||
)
|
||||
revisions = campaign_profile_transport_revisions(profile)
|
||||
smtp = profile.smtp_config or {}
|
||||
imap = profile.imap_config or {}
|
||||
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"],
|
||||
}
|
||||
|
||||
|
||||
def send_campaign_email_bytes(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
campaign_id: str,
|
||||
profile_id: str,
|
||||
message_bytes: bytes,
|
||||
envelope_from: str,
|
||||
envelope_recipients: list[str],
|
||||
from_header: str | None,
|
||||
expected_smtp_transport_revision: str,
|
||||
) -> CampaignSmtpDeliveryResult:
|
||||
try:
|
||||
profile = _authorized_campaign_profile(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
profile_id=profile_id,
|
||||
)
|
||||
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:
|
||||
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)
|
||||
except MailProfileError:
|
||||
raise
|
||||
except Exception:
|
||||
raise SmtpConfigurationError("The selected Mail profile's SMTP configuration is unusable.") from None
|
||||
try:
|
||||
assert_mail_policy_allows_send(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
smtp=smtp,
|
||||
imap=profile.imap_config or None,
|
||||
envelope_sender=envelope_from,
|
||||
from_header=from_header,
|
||||
recipients=envelope_recipients,
|
||||
)
|
||||
except MailProfileError:
|
||||
raise MailProfileError("Mail delivery is blocked by the effective Mail policy.") from None
|
||||
try:
|
||||
result = send_email_bytes(
|
||||
message_bytes,
|
||||
smtp_config=smtp,
|
||||
envelope_from=envelope_from,
|
||||
envelope_recipients=envelope_recipients,
|
||||
)
|
||||
except SmtpSendError as exc:
|
||||
raise _sanitized_smtp_error(exc) from None
|
||||
except SmtpConfigurationError:
|
||||
raise SmtpConfigurationError("The selected Mail profile's SMTP configuration is unusable.") from None
|
||||
except Exception:
|
||||
raise SmtpSendError(
|
||||
"Mail delivery outcome is unknown after the provider effect started.",
|
||||
outcome_unknown=True,
|
||||
) from None
|
||||
return CampaignSmtpDeliveryResult(
|
||||
envelope_recipients=list(result.envelope_recipients),
|
||||
refused_recipients=_sanitized_refusals(result.refused_recipients),
|
||||
)
|
||||
|
||||
|
||||
def append_campaign_message_to_sent(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
campaign_id: str,
|
||||
profile_id: str,
|
||||
message_bytes: bytes,
|
||||
folder: str | None,
|
||||
expected_smtp_transport_revision: str,
|
||||
expected_imap_transport_revision: str | None,
|
||||
) -> CampaignImapAppendResult:
|
||||
try:
|
||||
profile = _authorized_campaign_profile(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
profile_id=profile_id,
|
||||
)
|
||||
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:
|
||||
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:
|
||||
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)
|
||||
except MailProfileError:
|
||||
raise
|
||||
except Exception:
|
||||
raise ImapConfigurationError("The selected Mail profile's IMAP configuration is unusable.") from None
|
||||
if imap is None:
|
||||
raise ImapConfigurationError("The selected Mail profile has no IMAP configuration")
|
||||
try:
|
||||
assert_mail_policy_allows_send(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
smtp=profile.smtp_config or None,
|
||||
imap=imap,
|
||||
)
|
||||
except MailProfileError:
|
||||
raise MailProfileError("Appending to Sent is blocked by the effective Mail policy.") from None
|
||||
try:
|
||||
result = append_message_to_sent(message_bytes, imap_config=imap, folder=folder)
|
||||
except ImapAppendError as exc:
|
||||
raise _sanitized_imap_error(exc) from None
|
||||
except ImapConfigurationError:
|
||||
raise ImapConfigurationError("The selected Mail profile's IMAP configuration is unusable.") from None
|
||||
except Exception:
|
||||
raise ImapAppendError(
|
||||
"The Sent-folder append outcome is unknown; inspect the mailbox before retrying.",
|
||||
outcome_unknown=True,
|
||||
) from None
|
||||
return CampaignImapAppendResult(folder=result.folder)
|
||||
|
||||
|
||||
class MailCampaignCapability:
|
||||
@@ -25,19 +302,12 @@ class MailCampaignCapability:
|
||||
SmtpSendError = SmtpSendError
|
||||
ImapConfigurationError = ImapConfigurationError
|
||||
ImapAppendError = ImapAppendError
|
||||
materialize_campaign_mail_profile_config = staticmethod(materialize_campaign_mail_profile_config)
|
||||
assert_campaign_mail_policy_allows_json = staticmethod(assert_campaign_mail_policy_allows_json)
|
||||
assert_mail_policy_allows_send = staticmethod(assert_mail_policy_allows_send)
|
||||
mail_profile_id_from_campaign_json = staticmethod(mail_profile_id_from_campaign_json)
|
||||
ensure_mail_profile_allowed_for_campaign = staticmethod(ensure_mail_profile_allowed_for_campaign)
|
||||
smtp_config_from_profile = staticmethod(smtp_config_from_profile)
|
||||
imap_config_from_profile = staticmethod(imap_config_from_profile)
|
||||
effective_profile_credentials_inherited = staticmethod(effective_profile_credentials_inherited)
|
||||
apply_campaign_credentials = staticmethod(apply_campaign_credentials)
|
||||
campaign_profile_delivery_summary = staticmethod(campaign_profile_delivery_summary)
|
||||
send_campaign_email_bytes = staticmethod(send_campaign_email_bytes)
|
||||
append_campaign_message_to_sent = staticmethod(append_campaign_message_to_sent)
|
||||
wait_for_rate_limit = staticmethod(wait_for_rate_limit)
|
||||
send_email_bytes = staticmethod(send_email_bytes)
|
||||
append_message_to_sent = staticmethod(append_message_to_sent)
|
||||
send_email_message = staticmethod(send_email_message)
|
||||
|
||||
@staticmethod
|
||||
def mock_mailbox():
|
||||
|
||||
@@ -33,9 +33,11 @@ class MailServerProfile(Base, TimestampMixin):
|
||||
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)
|
||||
smtp_transport_revision: Mapped[str] = mapped_column(String(36), default=new_uuid, nullable=False)
|
||||
imap_config: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||
imap_username: Mapped[str | None] = mapped_column(String(320))
|
||||
imap_password_encrypted: Mapped[str | None] = mapped_column(Text)
|
||||
imap_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)
|
||||
|
||||
|
||||
@@ -124,11 +124,11 @@ def _mail_policy_user_text(policy: dict[str, Any]) -> tuple[str, str]:
|
||||
summary = "You can choose from approved mail servers, but you cannot add your own mail server for this tenant."
|
||||
body = "This is set by tenant policy. If the mail server you need is not offered, ask an administrator to add it as an approved mail profile."
|
||||
elif approved_profile_limit:
|
||||
summary = "You can use approved mail servers. Some local mail-server settings may also be allowed, depending on where you work."
|
||||
body = "The available choices are limited by tenant policy. If you are working in a user, group, or campaign area that allows local settings, GovOPlaN still checks the server, sender, recipient, and credential rules."
|
||||
summary = "You can use approved Mail profiles. Some scopes may also define additional reusable profiles."
|
||||
body = "The available profiles are limited by tenant policy. Campaigns select one profile by reference; SMTP/IMAP settings and credentials remain managed in Mail."
|
||||
elif lower_scopes:
|
||||
summary = "You may be able to add a mail server in selected areas, as long as it follows the active tenant rules."
|
||||
body = "GovOPlaN checks mail server settings before they are used. If a setting is blocked, it usually means the tenant has limited hosts, senders, recipients, or credentials."
|
||||
summary = "You may be able to define reusable Mail profiles in selected scopes, subject to tenant rules."
|
||||
body = "GovOPlaN checks each profile before use. Campaigns reference an available profile and never store its SMTP/IMAP settings or credentials."
|
||||
else:
|
||||
summary = "Mail servers are managed centrally for this tenant."
|
||||
body = "You cannot add a personal, group, or campaign mail server here. Choose one of the configured options or ask an administrator to add another approved profile."
|
||||
@@ -151,16 +151,16 @@ def _user_mail_policy_translations(policy: dict[str, Any]) -> dict[str, dict[str
|
||||
return {
|
||||
"de": {
|
||||
"title": "Mailserver auswaehlen",
|
||||
"summary": "Sie koennen freigegebene Mailserver nutzen. In manchen Bereichen koennen zusaetzliche lokale Einstellungen erlaubt sein.",
|
||||
"body": "Die Auswahl wird durch Tenant-Regeln begrenzt. Auch wenn lokale Einstellungen erlaubt sind, prueft GovOPlaN Server, Absender, Empfaenger und Zugangsdaten.",
|
||||
"summary": "Sie koennen freigegebene Mailprofile nutzen. In manchen Bereichen koennen weitere wiederverwendbare Profile angelegt werden.",
|
||||
"body": "Die Auswahl wird durch Tenant-Regeln begrenzt. Kampagnen speichern nur die Profilreferenz; SMTP-/IMAP-Einstellungen und Zugangsdaten verbleiben im Mail-Modul.",
|
||||
},
|
||||
}
|
||||
if lower_scopes:
|
||||
return {
|
||||
"de": {
|
||||
"title": "Mailserver auswaehlen",
|
||||
"summary": "In bestimmten Bereichen koennen Sie einen Mailserver eintragen, solange die aktiven Tenant-Regeln eingehalten werden.",
|
||||
"body": "GovOPlaN prueft Mailserver-Einstellungen, bevor sie verwendet werden. Wenn eine Einstellung blockiert wird, begrenzen die Tenant-Regeln meist Server, Absender, Empfaenger oder Zugangsdaten.",
|
||||
"summary": "In bestimmten Bereichen koennen Sie wiederverwendbare Mailprofile anlegen, solange die aktiven Tenant-Regeln eingehalten werden.",
|
||||
"body": "GovOPlaN prueft jedes Profil vor der Verwendung. Kampagnen referenzieren ein freigegebenes Profil und speichern keine SMTP-/IMAP-Einstellungen oder Zugangsdaten.",
|
||||
},
|
||||
}
|
||||
return {
|
||||
@@ -190,7 +190,11 @@ def _lower_scope_line(lower_scopes: tuple[str, ...]) -> str:
|
||||
def _credential_line(policy: dict[str, Any]) -> str:
|
||||
smtp_inherit = bool((policy.get("smtp_credentials") or {}).get("inherit", True))
|
||||
imap_inherit = bool((policy.get("imap_credentials") or {}).get("inherit", True))
|
||||
return f"Credential inheritance: SMTP {'inherits' if smtp_inherit else 'requires local credentials'}; IMAP {'inherits' if imap_inherit else 'requires local credentials'}."
|
||||
return (
|
||||
f"Credential inheritance: SMTP {'inherits' if smtp_inherit else 'requires local credentials'}; "
|
||||
f"IMAP {'inherits' if imap_inherit else 'requires local credentials'}. "
|
||||
"Campaign delivery is available only for protocols that inherit credentials from the selected Mail profile."
|
||||
)
|
||||
|
||||
|
||||
def _allowed_lower_scopes(policy: dict[str, Any]) -> tuple[str, ...]:
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import fnmatch
|
||||
import json
|
||||
import re
|
||||
@@ -11,6 +10,7 @@ from sqlalchemy import and_, or_, text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.admin.settings import get_system_settings
|
||||
from govoplan_core.audit.logging import audit_event
|
||||
from govoplan_core.core.access import CAPABILITY_ACCESS_DIRECTORY, AccessDirectory
|
||||
from govoplan_core.core.campaigns import (
|
||||
CAPABILITY_CAMPAIGNS_MAIL_POLICY_CONTEXT,
|
||||
@@ -19,7 +19,7 @@ from govoplan_core.core.campaigns import (
|
||||
)
|
||||
from govoplan_core.core.policy import policy_source_step
|
||||
from govoplan_core.core.runtime import get_registry
|
||||
from govoplan_mail.backend.db.models import MailProfilePolicy, MailServerProfile
|
||||
from govoplan_mail.backend.db.models import MailProfilePolicy, MailServerProfile, new_uuid
|
||||
from govoplan_mail.backend.config import ImapConfig, SmtpConfig
|
||||
from govoplan_core.security.secrets import decrypt_secret, encrypt_secret
|
||||
|
||||
@@ -961,89 +961,44 @@ def _credential_policy_for_protocol(policy: EffectiveMailProfilePolicy, protocol
|
||||
raise MailProfileError("Credential policy protocol must be smtp or imap")
|
||||
|
||||
|
||||
def _legacy_profile_credentials_inherited(server: dict[str, Any], protocol: str) -> bool:
|
||||
return server.get(f"inherit_{protocol}_credentials") is not False
|
||||
|
||||
|
||||
def _local_credential_inheritance_override_allowed(policy: EffectiveMailProfilePolicy, protocol: str) -> bool:
|
||||
credential_policy = _credential_policy_for_protocol(policy, protocol)
|
||||
return (
|
||||
credential_policy.inherit_source != "campaign"
|
||||
and policy.allow_lower_level_limits.get(f"{protocol}_credentials.inherit", True)
|
||||
)
|
||||
|
||||
|
||||
def profile_credentials_inherited_for_server(policy: EffectiveMailProfilePolicy, server: dict[str, Any], protocol: str) -> bool:
|
||||
credential_policy = _credential_policy_for_protocol(policy, protocol)
|
||||
legacy_key = f"inherit_{protocol}_credentials"
|
||||
if legacy_key in server and _local_credential_inheritance_override_allowed(policy, protocol):
|
||||
return _legacy_profile_credentials_inherited(server, protocol)
|
||||
return credential_policy.inherit
|
||||
|
||||
|
||||
def effective_profile_credentials_inherited(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
server: dict[str, Any],
|
||||
protocol: str,
|
||||
campaign_id: str | None = None,
|
||||
owner_user_id: str | None = None,
|
||||
owner_group_id: str | None = None,
|
||||
) -> bool:
|
||||
policy = effective_mail_profile_policy(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
owner_user_id=owner_user_id,
|
||||
owner_group_id=owner_group_id,
|
||||
)
|
||||
return profile_credentials_inherited_for_server(policy, server, protocol)
|
||||
|
||||
|
||||
def _profile_has_password(profile: MailServerProfile, protocol: str) -> bool:
|
||||
if protocol == "smtp":
|
||||
return bool(profile.smtp_password_encrypted)
|
||||
if protocol == "imap":
|
||||
return bool(profile.imap_password_encrypted)
|
||||
return False
|
||||
|
||||
|
||||
def _profile_has_transport(profile: MailServerProfile, protocol: str) -> bool:
|
||||
if protocol == "smtp":
|
||||
return True
|
||||
return bool(profile.imap_config)
|
||||
|
||||
|
||||
def _assert_profile_credentials_allowed_for_server(profile: MailServerProfile, policy: EffectiveMailProfilePolicy, server: dict[str, Any]) -> None:
|
||||
_CAMPAIGN_MAIL_REFERENCE_KEYS = frozenset({"mail_profile_id"})
|
||||
|
||||
|
||||
def _campaign_mail_profile_reference_id(server: dict[str, Any]) -> str | None:
|
||||
unexpected = sorted(key for key in server if key not in _CAMPAIGN_MAIL_REFERENCE_KEYS)
|
||||
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."
|
||||
)
|
||||
value = server.get("mail_profile_id")
|
||||
if value is None:
|
||||
return None
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise MailProfileError("server.mail_profile_id must be a non-empty Mail profile identifier")
|
||||
return value.strip()
|
||||
|
||||
|
||||
def _assert_campaign_inherits_profile_credentials(
|
||||
profile: MailServerProfile,
|
||||
policy: EffectiveMailProfilePolicy,
|
||||
) -> None:
|
||||
for protocol in ("smtp", "imap"):
|
||||
if not _profile_has_transport(profile, protocol):
|
||||
continue
|
||||
credential_policy = _credential_policy_for_protocol(policy, protocol)
|
||||
legacy_key = f"inherit_{protocol}_credentials"
|
||||
if legacy_key in server:
|
||||
legacy_inherit = _legacy_profile_credentials_inherited(server, protocol)
|
||||
if not _local_credential_inheritance_override_allowed(policy, protocol) and legacy_inherit != credential_policy.inherit:
|
||||
raise MailProfileError(f"{protocol.upper()} credential inheritance is locked by the effective mail profile policy")
|
||||
inherited = profile_credentials_inherited_for_server(policy, server, protocol)
|
||||
username, password = _transport_credentials_from_server(server, protocol)
|
||||
if inherited and (username or password):
|
||||
raise MailProfileError(f"{protocol.upper()} credentials must not be stored on the campaign while inherited profile credentials are enforced")
|
||||
if not inherited and _profile_has_password(profile, protocol) and not password:
|
||||
raise MailProfileError(f"{protocol.upper()} credentials must be supplied by this campaign or scope policy")
|
||||
|
||||
|
||||
def _inline_transport_configured(server: dict[str, Any], protocol: str) -> bool:
|
||||
value = server.get(protocol) if isinstance(server.get(protocol), dict) else {}
|
||||
credentials = server.get("credentials") if isinstance(server.get("credentials"), dict) else {}
|
||||
credential_value = credentials.get(protocol) if isinstance(credentials.get(protocol), dict) else {}
|
||||
for key in ("host",):
|
||||
if str(value.get(key) or "").strip():
|
||||
return True
|
||||
for key in ("username", "password"):
|
||||
if str(credential_value.get(key) or value.get(key) or "").strip():
|
||||
return True
|
||||
return False
|
||||
if not _credential_policy_for_protocol(policy, protocol).inherit:
|
||||
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."
|
||||
)
|
||||
|
||||
|
||||
def assert_campaign_mail_policy_allows_json(
|
||||
@@ -1057,7 +1012,7 @@ def assert_campaign_mail_policy_allows_json(
|
||||
) -> None:
|
||||
data = raw_json if isinstance(raw_json, dict) else {}
|
||||
server = data.get("server") if isinstance(data.get("server"), dict) else {}
|
||||
profile_id = server.get("mail_profile_id") if isinstance(server, dict) else None
|
||||
profile_id = _campaign_mail_profile_reference_id(server)
|
||||
if profile_id:
|
||||
if campaign_id:
|
||||
profile = ensure_mail_profile_allowed_for_campaign(
|
||||
@@ -1068,7 +1023,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_profile_credentials_allowed_for_server(profile, policy, server)
|
||||
_assert_campaign_inherits_profile_credentials(profile, policy)
|
||||
return
|
||||
profile = get_mail_server_profile(session, tenant_id=tenant_id, profile_id=str(profile_id), require_active=True)
|
||||
policy = effective_mail_profile_policy(
|
||||
@@ -1085,25 +1040,9 @@ 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_profile_credentials_allowed_for_server(profile, policy, server)
|
||||
_assert_campaign_inherits_profile_credentials(profile, policy)
|
||||
return
|
||||
smtp = server.get("smtp") if isinstance(server, dict) and isinstance(server.get("smtp"), dict) else None
|
||||
imap = server.get("imap") if isinstance(server, dict) and isinstance(server.get("imap"), dict) else None
|
||||
if campaign_id:
|
||||
policy = effective_mail_profile_policy(session, tenant_id=tenant_id, campaign_id=campaign_id)
|
||||
if not policy.allow_campaign_profiles and (_inline_transport_configured(server, "smtp") or _inline_transport_configured(server, "imap")):
|
||||
raise MailProfileError("Campaign-local inline mail settings are disabled by policy")
|
||||
_assert_transport_values_allowed(policy, smtp, imap)
|
||||
return
|
||||
assert_mail_policy_allows_transport(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
smtp=smtp,
|
||||
imap=imap,
|
||||
campaign_id=campaign_id,
|
||||
owner_user_id=owner_user_id,
|
||||
owner_group_id=owner_group_id,
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
def create_mail_server_profile(
|
||||
@@ -1174,6 +1113,7 @@ def update_mail_server_profile(
|
||||
profile: MailServerProfile,
|
||||
*,
|
||||
user_id: str | None,
|
||||
api_key_id: str | None = None,
|
||||
tenant_id: str,
|
||||
name: str | None = None,
|
||||
slug: str | None = None,
|
||||
@@ -1205,7 +1145,15 @@ def update_mail_server_profile(
|
||||
|
||||
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)
|
||||
_apply_profile_transport_update(profile, smtp=smtp, imap=imap, clear_imap=clear_imap)
|
||||
_apply_profile_transport_update(
|
||||
session,
|
||||
profile,
|
||||
user_id=user_id,
|
||||
api_key_id=api_key_id,
|
||||
smtp=smtp,
|
||||
imap=imap,
|
||||
clear_imap=clear_imap,
|
||||
)
|
||||
profile.updated_by_user_id = user_id
|
||||
session.add(profile)
|
||||
session.flush()
|
||||
@@ -1248,36 +1196,148 @@ def _assert_profile_transport_allowed(
|
||||
|
||||
|
||||
def _apply_profile_transport_update(
|
||||
session: Session,
|
||||
profile: MailServerProfile,
|
||||
*,
|
||||
user_id: str | None,
|
||||
api_key_id: str | None,
|
||||
smtp: SmtpConfig | None,
|
||||
imap: ImapConfig | None,
|
||||
clear_imap: bool,
|
||||
) -> None:
|
||||
if smtp is not None:
|
||||
_apply_profile_transport_payload(profile, "smtp", smtp)
|
||||
_apply_profile_transport_payload(
|
||||
session,
|
||||
profile,
|
||||
"smtp",
|
||||
smtp,
|
||||
user_id=user_id,
|
||||
api_key_id=api_key_id,
|
||||
)
|
||||
if clear_imap:
|
||||
had_imap_identity = bool(
|
||||
profile.imap_config
|
||||
or profile.imap_username
|
||||
or profile.imap_password_encrypted
|
||||
)
|
||||
_audit_profile_credential_mutation(
|
||||
session,
|
||||
profile=profile,
|
||||
protocol="imap",
|
||||
password_supplied=True,
|
||||
next_password=None,
|
||||
user_id=user_id,
|
||||
api_key_id=api_key_id,
|
||||
)
|
||||
profile.imap_config = None
|
||||
profile.imap_username = None
|
||||
profile.imap_password_encrypted = None
|
||||
if had_imap_identity:
|
||||
profile.imap_transport_revision = new_uuid()
|
||||
elif imap is not None:
|
||||
_apply_profile_transport_payload(profile, "imap", imap)
|
||||
_apply_profile_transport_payload(
|
||||
session,
|
||||
profile,
|
||||
"imap",
|
||||
imap,
|
||||
user_id=user_id,
|
||||
api_key_id=api_key_id,
|
||||
)
|
||||
|
||||
|
||||
def _apply_profile_transport_payload(profile: MailServerProfile, protocol: str, config: SmtpConfig | ImapConfig) -> None:
|
||||
def _apply_profile_transport_payload(
|
||||
session: Session,
|
||||
profile: MailServerProfile,
|
||||
protocol: str,
|
||||
config: SmtpConfig | ImapConfig,
|
||||
*,
|
||||
user_id: str | None,
|
||||
api_key_id: str | None,
|
||||
) -> None:
|
||||
payload, username, password, username_supplied, password_supplied = _transport_payload(config)
|
||||
current_payload = dict(profile.smtp_config or {}) if protocol == "smtp" else dict(profile.imap_config or {})
|
||||
for field_name in ("username", "password", "enabled"):
|
||||
current_payload.pop(field_name, None)
|
||||
current_username = _profile_username(profile, protocol)
|
||||
current_password_encrypted = (
|
||||
profile.smtp_password_encrypted
|
||||
if protocol == "smtp"
|
||||
else profile.imap_password_encrypted
|
||||
)
|
||||
current_password = decrypt_secret(current_password_encrypted) if password_supplied else None
|
||||
identity_changed = (
|
||||
current_payload != payload
|
||||
or (username_supplied and current_username != username)
|
||||
)
|
||||
_audit_profile_credential_mutation(
|
||||
session,
|
||||
profile=profile,
|
||||
protocol=protocol,
|
||||
password_supplied=password_supplied and current_password != password,
|
||||
next_password=password,
|
||||
user_id=user_id,
|
||||
api_key_id=api_key_id,
|
||||
)
|
||||
if protocol == "smtp":
|
||||
profile.smtp_config = payload
|
||||
if username_supplied:
|
||||
profile.smtp_username = username
|
||||
if password_supplied:
|
||||
if password_supplied and current_password != password:
|
||||
profile.smtp_password_encrypted = encrypt_secret(password)
|
||||
if identity_changed:
|
||||
profile.smtp_transport_revision = new_uuid()
|
||||
return
|
||||
profile.imap_config = payload
|
||||
if username_supplied:
|
||||
profile.imap_username = username
|
||||
if password_supplied:
|
||||
if password_supplied and current_password != password:
|
||||
profile.imap_password_encrypted = encrypt_secret(password)
|
||||
if identity_changed:
|
||||
profile.imap_transport_revision = new_uuid()
|
||||
|
||||
|
||||
def _audit_profile_credential_mutation(
|
||||
session: Session,
|
||||
*,
|
||||
profile: MailServerProfile,
|
||||
protocol: str,
|
||||
password_supplied: bool,
|
||||
next_password: str | None,
|
||||
user_id: str | None,
|
||||
api_key_id: str | None,
|
||||
) -> None:
|
||||
encrypted = (
|
||||
profile.smtp_password_encrypted
|
||||
if protocol == "smtp"
|
||||
else profile.imap_password_encrypted
|
||||
)
|
||||
if not password_supplied or not encrypted:
|
||||
return
|
||||
replacing = next_password not in (None, "")
|
||||
audit_event(
|
||||
session,
|
||||
tenant_id=profile.tenant_id,
|
||||
scope="system" if profile.scope_type == "system" else "tenant",
|
||||
user_id=user_id,
|
||||
api_key_id=api_key_id,
|
||||
action=(
|
||||
"mail.profile_credentials_replaced"
|
||||
if replacing
|
||||
else "mail.profile_credentials_deleted"
|
||||
),
|
||||
object_type="mail_server_profile",
|
||||
object_id=profile.id,
|
||||
details={
|
||||
"scope_type": profile.scope_type,
|
||||
"scope_id": profile.scope_id,
|
||||
"protocol": protocol,
|
||||
"reason": "profile_updated",
|
||||
},
|
||||
)
|
||||
# Force the non-secret audit record to durable storage before replacing or
|
||||
# clearing the in-memory ciphertext. The surrounding transaction keeps the
|
||||
# two changes atomic at commit time.
|
||||
session.flush()
|
||||
|
||||
|
||||
def _profile_username(profile: MailServerProfile, protocol: str) -> str | None:
|
||||
@@ -1307,28 +1367,100 @@ def imap_config_from_profile(profile: MailServerProfile) -> ImapConfig | None:
|
||||
return ImapConfig.model_validate(payload)
|
||||
|
||||
|
||||
def inherit_profile_credentials(server: dict[str, Any], protocol: str) -> bool:
|
||||
return _legacy_profile_credentials_inherited(server, protocol)
|
||||
def campaign_profile_transport_revisions(profile: MailServerProfile) -> dict[str, str | None]:
|
||||
"""Return Mail-owned opaque revisions without exposing transport identity.
|
||||
|
||||
Revisions are random persisted values, backfilled by migration and rotated
|
||||
whenever the corresponding transport or account identity changes.
|
||||
They cannot be dictionary-tested to recover a host or account name.
|
||||
"""
|
||||
|
||||
smtp_revision = str(getattr(profile, "smtp_transport_revision", "") or "")
|
||||
if not smtp_revision:
|
||||
raise MailProfileError("Mail profile has no SMTP transport revision; run the Mail database migrations")
|
||||
imap_revision = None
|
||||
if profile.imap_config:
|
||||
imap_revision = str(getattr(profile, "imap_transport_revision", "") or "")
|
||||
if not imap_revision:
|
||||
raise MailProfileError("Mail profile has no IMAP transport revision; run the Mail database migrations")
|
||||
return {"smtp": smtp_revision, "imap": imap_revision}
|
||||
|
||||
|
||||
def _transport_credentials_from_server(server: dict[str, Any], protocol: str) -> tuple[str | None, str | None]:
|
||||
credentials = server.get("credentials") if isinstance(server.get("credentials"), dict) else {}
|
||||
config = credentials.get(protocol) if isinstance(credentials.get(protocol), dict) else None
|
||||
legacy_config = server.get(protocol) if isinstance(server.get(protocol), dict) else None
|
||||
username = config.get("username") if config is not None else None
|
||||
password = config.get("password") if config is not None else None
|
||||
if username in (None, "") and legacy_config is not None:
|
||||
username = legacy_config.get("username")
|
||||
if password in (None, "") and legacy_config is not None:
|
||||
password = legacy_config.get("password")
|
||||
return (str(username) if username not in (None, "") else None, str(password) if password not in (None, "") else None)
|
||||
def delete_mail_profile_credentials(
|
||||
session: Session,
|
||||
*,
|
||||
profile: MailServerProfile,
|
||||
deletion_reason: str,
|
||||
user_id: str | None = None,
|
||||
api_key_id: str | None = None,
|
||||
) -> tuple[str, ...]:
|
||||
"""Immediately scrub Mail-owned profile secrets and record the deletion.
|
||||
|
||||
The audit record contains only protocol names and non-secret profile scope
|
||||
metadata. Any audit/storage failure aborts the caller's transaction, so a
|
||||
profile cannot appear deleted while its encrypted passwords remain.
|
||||
"""
|
||||
|
||||
deleted_protocols = tuple(
|
||||
protocol
|
||||
for protocol, encrypted in (
|
||||
("smtp", profile.smtp_password_encrypted),
|
||||
("imap", profile.imap_password_encrypted),
|
||||
)
|
||||
if encrypted
|
||||
)
|
||||
if not deleted_protocols:
|
||||
return ()
|
||||
audit_event(
|
||||
session,
|
||||
tenant_id=profile.tenant_id,
|
||||
scope="system" if profile.scope_type == "system" else "tenant",
|
||||
user_id=user_id,
|
||||
api_key_id=api_key_id,
|
||||
action="mail.profile_credentials_deleted",
|
||||
object_type="mail_server_profile",
|
||||
object_id=profile.id,
|
||||
details={
|
||||
"scope_type": profile.scope_type,
|
||||
"scope_id": profile.scope_id,
|
||||
"deleted_protocols": list(deleted_protocols),
|
||||
"deletion_reason": deletion_reason,
|
||||
},
|
||||
)
|
||||
# Fail before mutating the ORM object if the audit record cannot be stored.
|
||||
session.flush()
|
||||
profile.smtp_password_encrypted = None
|
||||
profile.imap_password_encrypted = None
|
||||
session.add(profile)
|
||||
session.flush()
|
||||
return deleted_protocols
|
||||
|
||||
|
||||
def apply_campaign_credentials(payload: dict[str, Any], server: dict[str, Any], protocol: str) -> dict[str, Any]:
|
||||
username, password = _transport_credentials_from_server(server, protocol)
|
||||
payload["username"] = username
|
||||
payload["password"] = password
|
||||
return payload
|
||||
def delete_mail_profile_credentials_for_retirement(session: Session) -> int:
|
||||
"""Scrub and audit every remaining Mail-owned secret before table drop."""
|
||||
|
||||
profiles = (
|
||||
session.query(MailServerProfile)
|
||||
.filter(
|
||||
or_(
|
||||
MailServerProfile.smtp_password_encrypted.is_not(None),
|
||||
MailServerProfile.imap_password_encrypted.is_not(None),
|
||||
)
|
||||
)
|
||||
.order_by(MailServerProfile.id.asc())
|
||||
.all()
|
||||
)
|
||||
deleted = 0
|
||||
for profile in profiles:
|
||||
deleted += len(
|
||||
delete_mail_profile_credentials(
|
||||
session,
|
||||
profile=profile,
|
||||
deletion_reason="module_data_retired",
|
||||
)
|
||||
)
|
||||
session.flush()
|
||||
return deleted
|
||||
|
||||
|
||||
def _server_config_payload(value: dict[str, Any] | None) -> dict[str, Any]:
|
||||
@@ -1530,87 +1662,9 @@ def set_mail_profile_policy(
|
||||
return normalized
|
||||
|
||||
|
||||
def _write_transport_to_server(server: dict[str, Any], protocol: str, payload: dict[str, Any]) -> None:
|
||||
server_payload = dict(payload)
|
||||
username = server_payload.pop("username", None)
|
||||
password = server_payload.pop("password", None)
|
||||
server_payload.pop("enabled", None)
|
||||
server[protocol] = server_payload
|
||||
if username not in (None, "") or password not in (None, ""):
|
||||
credentials = server.get("credentials") if isinstance(server.get("credentials"), dict) else {}
|
||||
protocol_credentials = credentials.get(protocol) if isinstance(credentials.get(protocol), dict) else {}
|
||||
if username not in (None, ""):
|
||||
protocol_credentials["username"] = username
|
||||
if password not in (None, ""):
|
||||
protocol_credentials["password"] = password
|
||||
credentials[protocol] = protocol_credentials
|
||||
server["credentials"] = credentials
|
||||
|
||||
|
||||
def materialize_campaign_mail_profile_config(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
raw_json: dict[str, Any],
|
||||
campaign_id: str | None = None,
|
||||
owner_user_id: str | None = None,
|
||||
owner_group_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
data = copy.deepcopy(raw_json if isinstance(raw_json, dict) else {})
|
||||
server = data.get("server")
|
||||
if not isinstance(server, dict):
|
||||
return data
|
||||
profile_id = server.get("mail_profile_id")
|
||||
if not profile_id:
|
||||
assert_campaign_mail_policy_allows_json(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
raw_json=data,
|
||||
campaign_id=campaign_id,
|
||||
owner_user_id=owner_user_id,
|
||||
owner_group_id=owner_group_id,
|
||||
)
|
||||
return data
|
||||
if campaign_id:
|
||||
profile = ensure_mail_profile_allowed_for_campaign(session, tenant_id=tenant_id, campaign_id=campaign_id, profile_id=str(profile_id), require_active=True)
|
||||
else:
|
||||
profile = get_mail_server_profile(session, tenant_id=tenant_id, profile_id=str(profile_id), require_active=True)
|
||||
assert_campaign_mail_policy_allows_json(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
raw_json=data,
|
||||
owner_user_id=owner_user_id,
|
||||
owner_group_id=owner_group_id,
|
||||
)
|
||||
policy = effective_mail_profile_policy(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
owner_user_id=owner_user_id,
|
||||
owner_group_id=owner_group_id,
|
||||
)
|
||||
_assert_profile_credentials_allowed_for_server(profile, policy, server)
|
||||
resolved_server = dict(server)
|
||||
smtp_payload = smtp_config_from_profile(profile).model_dump(mode="json")
|
||||
if not profile_credentials_inherited_for_server(policy, server, "smtp"):
|
||||
smtp_payload = apply_campaign_credentials(smtp_payload, server, "smtp")
|
||||
_write_transport_to_server(resolved_server, "smtp", smtp_payload)
|
||||
imap = imap_config_from_profile(profile)
|
||||
if imap is not None:
|
||||
imap_payload = imap.model_dump(mode="json")
|
||||
if not profile_credentials_inherited_for_server(policy, server, "imap"):
|
||||
imap_payload = apply_campaign_credentials(imap_payload, server, "imap")
|
||||
_write_transport_to_server(resolved_server, "imap", imap_payload)
|
||||
else:
|
||||
resolved_server.pop("imap", None)
|
||||
data["server"] = resolved_server
|
||||
return data
|
||||
|
||||
|
||||
def mail_profile_id_from_campaign_json(raw_json: dict[str, Any] | None) -> str | None:
|
||||
server = raw_json.get("server") if isinstance(raw_json, dict) else None
|
||||
value = server.get("mail_profile_id") if isinstance(server, dict) else None
|
||||
return str(value) if value else None
|
||||
return _campaign_mail_profile_reference_id(server) if isinstance(server, dict) else None
|
||||
|
||||
|
||||
def group_ids_for_user(session: Session, *, tenant_id: str, user_id: str) -> list[str]:
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import inspect
|
||||
|
||||
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
|
||||
from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
|
||||
from govoplan_core.core.modules import (
|
||||
@@ -23,6 +26,40 @@ from govoplan_mail.backend.documentation import documentation_topics
|
||||
from govoplan_mail.backend.db import models as mail_models # noqa: F401 - populate Mail ORM metadata
|
||||
|
||||
|
||||
_mail_table_retirement_provider = drop_table_retirement_provider(
|
||||
mail_models.MailServerProfile,
|
||||
mail_models.MailProfilePolicy,
|
||||
mail_models.MailMailboxFolderIndex,
|
||||
mail_models.MailMailboxMessageIndex,
|
||||
label="Mail",
|
||||
)
|
||||
|
||||
|
||||
def _mail_retirement_provider(session: object | None, module_id: str):
|
||||
plan = _mail_table_retirement_provider(session, module_id)
|
||||
base_executor = plan.destroy_data_executor
|
||||
if base_executor is None:
|
||||
return plan
|
||||
|
||||
def executor(execute_session: object, execute_module_id: str) -> None:
|
||||
if not hasattr(execute_session, "get_bind") or not hasattr(execute_session, "query"):
|
||||
raise RuntimeError("No database session is available for Mail credential retirement.")
|
||||
if inspect(execute_session.get_bind()).has_table(mail_models.MailServerProfile.__tablename__):
|
||||
from govoplan_mail.backend.mail_profiles import delete_mail_profile_credentials_for_retirement
|
||||
|
||||
delete_mail_profile_credentials_for_retirement(execute_session)
|
||||
base_executor(execute_session, execute_module_id)
|
||||
|
||||
return replace(
|
||||
plan,
|
||||
destroy_data_warnings=(
|
||||
*plan.destroy_data_warnings,
|
||||
"Mail-owned encrypted SMTP/IMAP passwords are scrubbed with non-secret audit records immediately before tables are dropped; any scrub or audit failure blocks retirement.",
|
||||
),
|
||||
destroy_data_executor=executor,
|
||||
)
|
||||
|
||||
|
||||
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
|
||||
module_id, resource, action = scope.split(":", 2)
|
||||
return PermissionDefinition(
|
||||
@@ -89,11 +126,11 @@ def _mail_router(context: ModuleContext):
|
||||
manifest = ModuleManifest(
|
||||
id="mail",
|
||||
name="Mail",
|
||||
version="0.1.8",
|
||||
version="0.1.9",
|
||||
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
||||
optional_dependencies=("campaigns", "addresses"),
|
||||
provides_interfaces=(
|
||||
ModuleInterfaceProvider(name="mail.campaign_delivery", version="0.1.6"),
|
||||
ModuleInterfaceProvider(name="mail.campaign_delivery", version="0.2.0"),
|
||||
),
|
||||
requires_interfaces=(
|
||||
ModuleInterfaceRequirement(
|
||||
@@ -123,14 +160,8 @@ manifest = ModuleManifest(
|
||||
metadata=Base.metadata,
|
||||
script_location=str(Path(__file__).with_name("migrations") / "versions"),
|
||||
retirement_supported=True,
|
||||
retirement_provider=drop_table_retirement_provider(
|
||||
mail_models.MailServerProfile,
|
||||
mail_models.MailProfilePolicy,
|
||||
mail_models.MailMailboxFolderIndex,
|
||||
mail_models.MailMailboxMessageIndex,
|
||||
label="Mail",
|
||||
),
|
||||
retirement_notes="Destructive retirement drops mail-owned database tables after the installer captures a database snapshot.",
|
||||
retirement_provider=_mail_retirement_provider,
|
||||
retirement_notes="Destructive retirement first scrubs and audits Mail-owned credentials, then drops Mail-owned database tables after the installer captures a database snapshot.",
|
||||
),
|
||||
uninstall_guard_providers=(
|
||||
persistent_table_uninstall_guard(
|
||||
@@ -149,7 +180,7 @@ manifest = ModuleManifest(
|
||||
id="mail.profiles-and-policy",
|
||||
title="Mail profiles and policy hierarchy",
|
||||
summary="Mail sending is configured through reusable SMTP/IMAP profiles and an effective policy assembled from system, tenant, owner, and campaign scope where applicable.",
|
||||
body="The active policy decides whether users can only choose approved profiles or whether user, group, and campaign scopes may define their own mail-server settings. Runtime documentation adds the current tenant posture when the actor may read mail profile policy.",
|
||||
body="The active policy decides whether users can only choose approved profiles or whether user, group, and campaign scopes may define additional reusable profiles. Runtime documentation adds the current tenant posture when the actor may read mail profile policy.",
|
||||
layer="configured",
|
||||
documentation_types=("admin",),
|
||||
audience=("tenant_admin", "mail_admin", "campaign_admin"),
|
||||
@@ -170,6 +201,51 @@ manifest = ModuleManifest(
|
||||
related_modules=("campaigns",),
|
||||
unlocks=("Campaign-local delivery rules become visible when govoplan-campaign is installed.",),
|
||||
configuration_keys=("mail_profile_policy",),
|
||||
metadata={
|
||||
"kind": "reference",
|
||||
"route": "/mail",
|
||||
"screen": "Mail profiles and policy",
|
||||
"section": "Effective profile policy",
|
||||
"related_topic_ids": [
|
||||
"mail.profile-ownership-and-consumers",
|
||||
"campaigns.mail-profile-governance",
|
||||
],
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="mail.profile-ownership-and-consumers",
|
||||
title="Mail owns transport profiles and credentials",
|
||||
summary="Other modules select authorized Mail profiles by stable identifier; they do not copy SMTP/IMAP settings or secrets.",
|
||||
body="Mail encrypts credentials, tests connections, evaluates profile scope and policy, and performs revision-gated transport effects without returning resolved configuration. Random persisted revisions rotate when normalized transport or account identity changes, while password-only rotation remains transparent to built business intent. Campaign stores only server.mail_profile_id plus sanitized delivery evidence. Campaign-local transport fields are rejected, and legacy campaign records fail closed until an explicit profile migration preserves the source audit record and updates an editable version.",
|
||||
layer="available",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("mail_user", "mail_admin", "campaign_manager", "campaign_sender"),
|
||||
order=40,
|
||||
conditions=(
|
||||
DocumentationCondition(
|
||||
required_modules=("mail",),
|
||||
any_scopes=("mail:profile:read", "mail:profile:use", "mail:profile:write"),
|
||||
),
|
||||
),
|
||||
links=(
|
||||
DocumentationLink(label="Mail profiles", href="/mail", kind="runtime"),
|
||||
DocumentationLink(label="Mail profiles API", href="/api/v1/mail/profiles", kind="api"),
|
||||
DocumentationLink(label="Campaigns", href="/campaigns", kind="runtime"),
|
||||
),
|
||||
related_modules=("campaigns", "access"),
|
||||
unlocks=("Reusable, governed delivery identities without cross-module secret duplication.",),
|
||||
metadata={
|
||||
"kind": "reference",
|
||||
"route": "/mail",
|
||||
"screen": "Mail profiles",
|
||||
"section": "Profile ownership, credentials, and consumers",
|
||||
"related_topic_ids": [
|
||||
"mail.profiles-and-policy",
|
||||
"campaigns.mail-profile-user-journey",
|
||||
"campaigns.mail-profile-governance",
|
||||
"campaigns.mail-profile-operations",
|
||||
],
|
||||
},
|
||||
),
|
||||
),
|
||||
documentation_providers=(documentation_topics,),
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"""add opaque Mail-owned transport revisions
|
||||
|
||||
Revision ID: 608192abcdef
|
||||
Revises: 5f708192abcd
|
||||
Create Date: 2026-07-21 00:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "608192abcdef"
|
||||
down_revision = "5f708192abcd"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
_SMTP_COLUMN = "smtp_transport_revision"
|
||||
_IMAP_COLUMN = "imap_transport_revision"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
if "mail_server_profiles" not in inspector.get_table_names():
|
||||
return
|
||||
columns = {column["name"] for column in inspector.get_columns("mail_server_profiles")}
|
||||
with op.batch_alter_table("mail_server_profiles") as batch:
|
||||
if _SMTP_COLUMN not in columns:
|
||||
batch.add_column(sa.Column(_SMTP_COLUMN, sa.String(length=36), nullable=True))
|
||||
if _IMAP_COLUMN not in columns:
|
||||
batch.add_column(sa.Column(_IMAP_COLUMN, sa.String(length=36), nullable=True))
|
||||
|
||||
rows = list(bind.execute(
|
||||
sa.text(
|
||||
"SELECT id, smtp_transport_revision, imap_transport_revision "
|
||||
"FROM mail_server_profiles"
|
||||
)
|
||||
).mappings())
|
||||
for row in rows:
|
||||
values: dict[str, str] = {}
|
||||
if not row[_SMTP_COLUMN]:
|
||||
values[_SMTP_COLUMN] = str(uuid.uuid4())
|
||||
if not row[_IMAP_COLUMN]:
|
||||
values[_IMAP_COLUMN] = str(uuid.uuid4())
|
||||
if values:
|
||||
bind.execute(
|
||||
sa.text(
|
||||
"UPDATE mail_server_profiles "
|
||||
"SET smtp_transport_revision = COALESCE(:smtp_revision, smtp_transport_revision), "
|
||||
"imap_transport_revision = COALESCE(:imap_revision, imap_transport_revision) "
|
||||
"WHERE id = :profile_id"
|
||||
),
|
||||
{
|
||||
"profile_id": row["id"],
|
||||
"smtp_revision": values.get(_SMTP_COLUMN),
|
||||
"imap_revision": values.get(_IMAP_COLUMN),
|
||||
},
|
||||
)
|
||||
|
||||
with op.batch_alter_table("mail_server_profiles") as batch:
|
||||
batch.alter_column(_SMTP_COLUMN, existing_type=sa.String(length=36), nullable=False)
|
||||
batch.alter_column(_IMAP_COLUMN, existing_type=sa.String(length=36), nullable=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if "mail_server_profiles" not in inspector.get_table_names():
|
||||
return
|
||||
columns = {column["name"] for column in inspector.get_columns("mail_server_profiles")}
|
||||
with op.batch_alter_table("mail_server_profiles") as batch:
|
||||
if _IMAP_COLUMN in columns:
|
||||
batch.drop_column(_IMAP_COLUMN)
|
||||
if _SMTP_COLUMN in columns:
|
||||
batch.drop_column(_SMTP_COLUMN)
|
||||
@@ -0,0 +1,78 @@
|
||||
"""add opaque Mail-owned transport revisions
|
||||
|
||||
Revision ID: 608192abcdef
|
||||
Revises: 5f708192abcd
|
||||
Create Date: 2026-07-21 00:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "608192abcdef"
|
||||
down_revision = "5f708192abcd"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
_SMTP_COLUMN = "smtp_transport_revision"
|
||||
_IMAP_COLUMN = "imap_transport_revision"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
if "mail_server_profiles" not in inspector.get_table_names():
|
||||
return
|
||||
columns = {column["name"] for column in inspector.get_columns("mail_server_profiles")}
|
||||
with op.batch_alter_table("mail_server_profiles") as batch:
|
||||
if _SMTP_COLUMN not in columns:
|
||||
batch.add_column(sa.Column(_SMTP_COLUMN, sa.String(length=36), nullable=True))
|
||||
if _IMAP_COLUMN not in columns:
|
||||
batch.add_column(sa.Column(_IMAP_COLUMN, sa.String(length=36), nullable=True))
|
||||
|
||||
rows = list(bind.execute(
|
||||
sa.text(
|
||||
"SELECT id, smtp_transport_revision, imap_transport_revision "
|
||||
"FROM mail_server_profiles"
|
||||
)
|
||||
).mappings())
|
||||
for row in rows:
|
||||
values: dict[str, str] = {}
|
||||
if not row[_SMTP_COLUMN]:
|
||||
values[_SMTP_COLUMN] = str(uuid.uuid4())
|
||||
if not row[_IMAP_COLUMN]:
|
||||
values[_IMAP_COLUMN] = str(uuid.uuid4())
|
||||
if values:
|
||||
bind.execute(
|
||||
sa.text(
|
||||
"UPDATE mail_server_profiles "
|
||||
"SET smtp_transport_revision = COALESCE(:smtp_revision, smtp_transport_revision), "
|
||||
"imap_transport_revision = COALESCE(:imap_revision, imap_transport_revision) "
|
||||
"WHERE id = :profile_id"
|
||||
),
|
||||
{
|
||||
"profile_id": row["id"],
|
||||
"smtp_revision": values.get(_SMTP_COLUMN),
|
||||
"imap_revision": values.get(_IMAP_COLUMN),
|
||||
},
|
||||
)
|
||||
|
||||
with op.batch_alter_table("mail_server_profiles") as batch:
|
||||
batch.alter_column(_SMTP_COLUMN, existing_type=sa.String(length=36), nullable=False)
|
||||
batch.alter_column(_IMAP_COLUMN, existing_type=sa.String(length=36), nullable=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if "mail_server_profiles" not in inspector.get_table_names():
|
||||
return
|
||||
columns = {column["name"] for column in inspector.get_columns("mail_server_profiles")}
|
||||
with op.batch_alter_table("mail_server_profiles") as batch:
|
||||
if _IMAP_COLUMN in columns:
|
||||
batch.drop_column(_IMAP_COLUMN)
|
||||
if _SMTP_COLUMN in columns:
|
||||
batch.drop_column(_SMTP_COLUMN)
|
||||
@@ -68,9 +68,16 @@ class ImapAppendError(RuntimeError):
|
||||
configuration or mailbox choice probably needs user/admin attention.
|
||||
"""
|
||||
|
||||
def __init__(self, message: str, *, temporary: bool | None = None):
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
temporary: bool | None = None,
|
||||
outcome_unknown: bool = False,
|
||||
):
|
||||
super().__init__(message)
|
||||
self.temporary = temporary
|
||||
self.outcome_unknown = outcome_unknown
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -1100,10 +1107,12 @@ def append_message_to_sent(
|
||||
)
|
||||
|
||||
client: imaplib.IMAP4 | None = None
|
||||
append_started = False
|
||||
try:
|
||||
client = _open_imap(imap_config)
|
||||
target_folder = _effective_sent_folder(config=imap_config, requested_folder=folder, client=client)
|
||||
internal_date = imaplib.Time2Internaldate(time.time())
|
||||
append_started = True
|
||||
typ, data = client.append(_quote_mailbox_name(target_folder), "\\Seen", internal_date, message_bytes)
|
||||
if typ != "OK":
|
||||
raise ImapAppendError(f"IMAP APPEND failed for folder {target_folder!r}: {data!r}", temporary=False)
|
||||
@@ -1119,9 +1128,17 @@ def append_message_to_sent(
|
||||
except ImapAppendError:
|
||||
raise
|
||||
except (OSError, socket.timeout, imaplib.IMAP4.abort) as exc:
|
||||
raise ImapAppendError(f"IMAP append failed: {exc}", temporary=True) from exc
|
||||
raise ImapAppendError(
|
||||
f"IMAP append failed: {exc}",
|
||||
temporary=not append_started,
|
||||
outcome_unknown=append_started,
|
||||
) from exc
|
||||
except imaplib.IMAP4.error as exc:
|
||||
raise ImapAppendError(f"IMAP append failed: {exc}", temporary=False) from exc
|
||||
raise ImapAppendError(
|
||||
f"IMAP append failed: {exc}",
|
||||
temporary=False,
|
||||
outcome_unknown=append_started,
|
||||
) from exc
|
||||
finally:
|
||||
if client is not None:
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user