feat: enforce Mail-owned campaign transport boundary

This commit is contained in:
2026-07-21 17:11:48 +02:00
parent a86a779d5b
commit 88d5012dea
18 changed files with 1548 additions and 260 deletions

View File

@@ -19,9 +19,41 @@ This repository owns:
Core owns auth, tenants, RBAC evaluation, database/session primitives, secret helpers, CSRF/API helpers, and shell layout.
## Credential inheritance policy
## Profile and credential ownership
SMTP and IMAP each have one credential inheritance decision: descendants inherit profile credentials, may inherit profile credentials, or must provide local credentials. The lower-level override switch for `smtp_credentials.inherit` and `imap_credentials.inherit` decides whether child scopes may change that decision. There is no separate "override the override" credential policy field.
Mail profiles are separate governed definitions. Mail owns their SMTP/IMAP
endpoints, encrypted credentials, tests, scope, and policy. Consumers such as
Campaign store only a stable profile identifier and resolve the authorized,
active profile through `mail.campaign_delivery`; they never copy or override
transport settings or credentials in their own JSON.
The campaign capability returns read-only availability flags and random,
persisted Mail-owned transport revisions without decrypting secrets. Effect calls perform authorization,
revision comparison, credential resolution, policy checks, and SMTP/IMAP
effects inside Mail. Consumer-visible outcomes are sanitized: provider banners,
raw response bytes, hosts, account identities, and credentials are not returned.
A remaining observability slice, tracked in [govoplan-mail#17](https://git.add-ideas.de/add-ideas/govoplan-mail/issues/17), is a Mail-owned durable outbox and transport-attempt store with
restricted diagnostic access, retention controls, and correlation identifiers.
Until that exists, Campaign retains only sanitized delivery evidence; raw
provider diagnostics must not be copied into consumer records.
SMTP effects decrypt only SMTP credentials; Sent-folder effects decrypt only
IMAP credentials. A connection loss after an effect starts is surfaced as an
unknown outcome. Campaign does not automatically retry an unknown IMAP append,
preventing silent duplicate Sent copies while an operator inspects the mailbox.
The existing SMTP/IMAP credential-inheritance policy remains part of the Mail
policy model for compatibility. Campaign delivery requires effective
inheritance: a policy that requires campaign-local credentials now fails
closed with guidance to store those credentials on a Mail profile and enable
inheritance.
Deleting a profile deactivates its non-secret tombstone metadata and scrubs
both encrypted SMTP and IMAP passwords immediately in the same transaction as
a non-secret audit event. Destructive module retirement applies the same rule
to every remaining profile before any Mail table is dropped; a scrub or audit
failure blocks retirement.
## Development
@@ -54,8 +86,9 @@ Frontend package:
@govoplan/mail-webui
```
The campaign module consumes `mail.campaign_delivery` for sending,
append-to-Sent behavior, profile selection, and policy checks. Mail does not
The campaign module consumes `mail.campaign_delivery` for authorized runtime
profile resolution, sending, append-to-Sent behavior, profile selection, and
policy checks. Mail does not
import campaign internals; campaign-scoped policy and owner context are resolved
through the core `campaigns.mailPolicyContext` capability when the campaign
module is installed.
@@ -70,6 +103,8 @@ SMTP/IMAP first, prefer JMAP for modern mailbox sync/search later, and add POP3
only for explicit legacy-download requirements.
Platform RBAC and governance rules are documented in `govoplan-core/docs/`.
The [Mail handbook](docs/MAIL_HANDBOOK.md) provides the adaptive user,
governance, technical, security, and operations perspectives.
## Release packaging

View File

@@ -1,6 +1,6 @@
{
"name": "@govoplan/mail-webui",
"version": "0.1.8",
"version": "0.1.9",
"private": true,
"type": "module",
"main": "webui/src/index.ts",

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-mail"
version = "0.1.8"
version = "0.1.9"
description = "GovOPlaN mail module with backend and WebUI integration."
readme = "README.md"
requires-python = ">=3.12"

View File

@@ -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():

View File

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

View File

@@ -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, ...]:

View File

@@ -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,
)
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]:

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,322 @@
from __future__ import annotations
import json
import unittest
from dataclasses import asdict
from types import SimpleNamespace
from unittest.mock import patch
from govoplan_mail.backend.capabilities import (
MailCampaignCapability,
append_campaign_message_to_sent,
campaign_profile_delivery_summary,
send_campaign_email_bytes,
)
from govoplan_mail.backend.config import ImapConfig, SmtpConfig
from govoplan_mail.backend.mail_profiles import MailProfileError
from govoplan_mail.backend.sending.imap import ImapAppendError
from govoplan_mail.backend.sending.smtp import SmtpSendError
def _profile() -> SimpleNamespace:
return SimpleNamespace(
smtp_config={
"host": "smtp.internal.example",
"port": 587,
"security": "starttls",
},
smtp_username="service-account",
smtp_password_encrypted="encrypted-secret",
smtp_transport_revision="smtp-revision",
imap_config=None,
imap_username=None,
imap_password_encrypted=None,
imap_transport_revision="imap-revision",
)
class CampaignMailCapabilityTests(unittest.TestCase):
def test_summary_does_not_materialize_or_decrypt_credentials(self) -> None:
profile = _profile()
with (
patch(
"govoplan_mail.backend.capabilities.ensure_mail_profile_allowed_for_campaign",
return_value=profile,
),
patch("govoplan_mail.backend.capabilities.effective_mail_profile_policy", return_value=object()),
patch("govoplan_mail.backend.capabilities._assert_campaign_inherits_profile_credentials"),
patch(
"govoplan_mail.backend.capabilities.smtp_config_from_profile",
side_effect=AssertionError("summary must not decrypt SMTP credentials"),
),
patch(
"govoplan_mail.backend.capabilities.imap_config_from_profile",
side_effect=AssertionError("summary must not decrypt IMAP credentials"),
),
):
summary = campaign_profile_delivery_summary(
object(), # type: ignore[arg-type]
tenant_id="tenant-1",
campaign_id="campaign-1",
profile_id="profile-1",
)
self.assertTrue(summary["smtp_available"])
self.assertFalse(summary["imap_available"])
self.assertNotIn("service-account", repr(summary))
self.assertNotIn("internal.example", repr(summary))
self.assertNotIn("secret", repr(summary))
def test_send_compares_revision_before_provider_effect(self) -> None:
profile = _profile()
with (
patch(
"govoplan_mail.backend.capabilities._authorized_campaign_profile",
return_value=profile,
),
patch(
"govoplan_mail.backend.capabilities.smtp_config_from_profile",
side_effect=AssertionError("stale revisions must be rejected before SMTP credential decryption"),
),
patch(
"govoplan_mail.backend.capabilities.campaign_profile_transport_revisions",
return_value={"smtp": "current", "imap": None},
),
patch("govoplan_mail.backend.capabilities.send_email_bytes") as provider_send,
):
with self.assertRaisesRegex(MailProfileError, "changed after this campaign was built"):
send_campaign_email_bytes(
object(), # type: ignore[arg-type]
tenant_id="tenant-1",
campaign_id="campaign-1",
profile_id="profile-1",
message_bytes=b"message",
envelope_from="sender@example.test",
envelope_recipients=["recipient@example.test"],
from_header="sender@example.test",
expected_smtp_transport_revision="stale",
)
provider_send.assert_not_called()
def test_stale_imap_revision_is_rejected_before_credential_decryption(self) -> None:
profile = _profile()
profile.imap_config = {
"host": "imap.internal.example",
"port": 993,
"security": "tls",
}
with (
patch(
"govoplan_mail.backend.capabilities._authorized_campaign_profile",
return_value=profile,
),
patch(
"govoplan_mail.backend.capabilities.campaign_profile_transport_revisions",
return_value={"smtp": "smtp-current", "imap": "imap-current"},
),
patch(
"govoplan_mail.backend.capabilities.imap_config_from_profile",
side_effect=AssertionError("stale revisions must be rejected before IMAP credential decryption"),
),
patch("govoplan_mail.backend.capabilities.append_message_to_sent") as provider_append,
):
with self.assertRaisesRegex(MailProfileError, "changed after this campaign was built"):
append_campaign_message_to_sent(
object(), # type: ignore[arg-type]
tenant_id="tenant-1",
campaign_id="campaign-1",
profile_id="profile-1",
message_bytes=b"message",
folder="Sent",
expected_smtp_transport_revision="smtp-current",
expected_imap_transport_revision="imap-stale",
)
provider_append.assert_not_called()
def test_provider_response_is_sanitized_before_campaign_evidence(self) -> None:
profile = _profile()
smtp = SmtpConfig(host="smtp.internal.example", port=587)
provider_secret = b"smtp.internal.example says credential=provider-secret"
provider_result = SimpleNamespace(
envelope_recipients=["ok@example.test", "blocked@example.test"],
refused_recipients={"blocked@example.test": (550, provider_secret)},
)
with (
patch(
"govoplan_mail.backend.capabilities._authorized_campaign_profile",
return_value=profile,
),
patch("govoplan_mail.backend.capabilities.smtp_config_from_profile", return_value=smtp),
patch(
"govoplan_mail.backend.capabilities.imap_config_from_profile",
side_effect=AssertionError("SMTP delivery must not decrypt IMAP credentials"),
),
patch(
"govoplan_mail.backend.capabilities.campaign_profile_transport_revisions",
return_value={"smtp": "current", "imap": None},
),
patch("govoplan_mail.backend.capabilities.assert_mail_policy_allows_send"),
patch("govoplan_mail.backend.capabilities.send_email_bytes", return_value=provider_result),
):
result = send_campaign_email_bytes(
object(), # type: ignore[arg-type]
tenant_id="tenant-1",
campaign_id="campaign-1",
profile_id="profile-1",
message_bytes=b"message",
envelope_from="sender@example.test",
envelope_recipients=provider_result.envelope_recipients,
from_header="sender@example.test",
expected_smtp_transport_revision="current",
)
evidence = json.dumps(asdict(result), sort_keys=True)
self.assertEqual(result.accepted_count, 1)
self.assertIn('"status_code": 550', evidence)
self.assertIn('"classification": "permanent"', evidence)
self.assertNotIn("smtp.internal.example", evidence)
self.assertNotIn("provider-secret", evidence)
def test_append_materializes_only_imap_credentials_and_preserves_unknown_outcome(self) -> None:
profile = _profile()
profile.imap_config = {
"host": "imap.internal.example",
"port": 993,
"security": "tls",
}
imap = ImapConfig(host="imap.internal.example", port=993)
with (
patch(
"govoplan_mail.backend.capabilities._authorized_campaign_profile",
return_value=profile,
),
patch("govoplan_mail.backend.capabilities.imap_config_from_profile", return_value=imap),
patch(
"govoplan_mail.backend.capabilities.smtp_config_from_profile",
side_effect=AssertionError("IMAP append must not decrypt SMTP credentials"),
),
patch(
"govoplan_mail.backend.capabilities.campaign_profile_transport_revisions",
return_value={"smtp": "smtp-current", "imap": "imap-current"},
),
patch("govoplan_mail.backend.capabilities.assert_mail_policy_allows_send"),
patch(
"govoplan_mail.backend.capabilities.append_message_to_sent",
side_effect=ImapAppendError(
"imap.internal.example provider-secret",
outcome_unknown=True,
),
),
):
with self.assertRaises(ImapAppendError) as captured:
append_campaign_message_to_sent(
object(), # type: ignore[arg-type]
tenant_id="tenant-1",
campaign_id="campaign-1",
profile_id="profile-1",
message_bytes=b"message",
folder="Sent",
expected_smtp_transport_revision="smtp-current",
expected_imap_transport_revision="imap-current",
)
self.assertTrue(captured.exception.outcome_unknown)
self.assertFalse(captured.exception.temporary)
self.assertIn("inspect the mailbox", str(captured.exception))
self.assertNotIn("internal.example", str(captured.exception))
self.assertNotIn("provider-secret", str(captured.exception))
def test_provider_exception_is_sanitized_and_bounded(self) -> None:
profile = _profile()
smtp = SmtpConfig(host="smtp.internal.example", port=587)
with (
patch(
"govoplan_mail.backend.capabilities._authorized_campaign_profile",
return_value=profile,
),
patch("govoplan_mail.backend.capabilities.smtp_config_from_profile", return_value=smtp),
patch(
"govoplan_mail.backend.capabilities.campaign_profile_transport_revisions",
return_value={"smtp": "current", "imap": None},
),
patch("govoplan_mail.backend.capabilities.assert_mail_policy_allows_send"),
patch(
"govoplan_mail.backend.capabilities.send_email_bytes",
side_effect=SmtpSendError(
"smtp.internal.example provider-secret " + ("x" * 1_000),
temporary=True,
),
),
):
with self.assertRaises(SmtpSendError) as captured:
send_campaign_email_bytes(
object(), # type: ignore[arg-type]
tenant_id="tenant-1",
campaign_id="campaign-1",
profile_id="profile-1",
message_bytes=b"message",
envelope_from="sender@example.test",
envelope_recipients=["recipient@example.test"],
from_header="sender@example.test",
expected_smtp_transport_revision="current",
)
message = str(captured.exception)
self.assertEqual(message, "Mail delivery failed temporarily.")
self.assertNotIn("internal.example", message)
self.assertLessEqual(len(message), 80)
self.assertIsNone(captured.exception.__cause__)
def test_unclassified_provider_exception_is_outcome_unknown(self) -> None:
profile = _profile()
smtp = SmtpConfig(host="smtp.internal.example", port=587)
with (
patch(
"govoplan_mail.backend.capabilities._authorized_campaign_profile",
return_value=profile,
),
patch("govoplan_mail.backend.capabilities.smtp_config_from_profile", return_value=smtp),
patch(
"govoplan_mail.backend.capabilities.campaign_profile_transport_revisions",
return_value={"smtp": "current", "imap": None},
),
patch("govoplan_mail.backend.capabilities.assert_mail_policy_allows_send"),
patch(
"govoplan_mail.backend.capabilities.send_email_bytes",
side_effect=RuntimeError("smtp.internal.example provider-secret"),
),
):
with self.assertRaises(SmtpSendError) as captured:
send_campaign_email_bytes(
object(), # type: ignore[arg-type]
tenant_id="tenant-1",
campaign_id="campaign-1",
profile_id="profile-1",
message_bytes=b"message",
envelope_from="sender@example.test",
envelope_recipients=["recipient@example.test"],
from_header="sender@example.test",
expected_smtp_transport_revision="current",
)
self.assertTrue(captured.exception.outcome_unknown)
self.assertNotIn("internal.example", str(captured.exception))
self.assertIsNone(captured.exception.__cause__)
def test_capability_does_not_export_raw_profile_or_transport_helpers(self) -> None:
capability = MailCampaignCapability()
for name in (
"smtp_config_from_profile",
"imap_config_from_profile",
"send_email_bytes",
"send_email_message",
"materialize_campaign_mail_profile_config",
):
self.assertFalse(hasattr(capability, name), name)
if __name__ == "__main__":
unittest.main()

View File

@@ -247,6 +247,22 @@ class ImapMailboxCommandTests(unittest.TestCase):
self.assertEqual(result.folder, "Gesendete Elemente")
self.assertEqual(result.bytes_appended, len(b"Subject: test\r\n\r\nBody"))
def test_connection_loss_after_append_starts_has_unknown_outcome(self):
class Client:
def append(self, _mailbox, _flags, _date_time, _message):
raise OSError("provider detail")
def logout(self):
return "BYE", [b"logged out"]
config = ImapConfig(host="imap.example.org", username="user", password="secret", sent_folder="Sent")
with patch("govoplan_mail.backend.sending.imap._open_imap", return_value=Client()):
with self.assertRaises(ImapAppendError) as captured:
append_message_to_sent(b"Subject: test\r\n\r\nBody", imap_config=config, folder="Sent")
self.assertTrue(captured.exception.outcome_unknown)
self.assertFalse(captured.exception.temporary)
if __name__ == "__main__":
unittest.main()

View File

@@ -2,20 +2,144 @@ from __future__ import annotations
import unittest
from types import SimpleNamespace
from unittest.mock import patch
from govoplan_core.security.secrets import decrypt_secret, encrypt_secret
from govoplan_mail.backend.config import ImapConfig, SmtpConfig
from govoplan_mail.backend.mail_profiles import (
EffectiveCredentialPolicy,
EffectiveMailProfilePolicy,
MailProfileError,
_assert_campaign_inherits_profile_credentials,
_campaign_mail_profile_reference_id,
_apply_profile_transport_update,
campaign_profile_transport_revisions,
_merge_policy,
_next_profile_transport_state,
_policy_parent_lock_message,
_policy_parent_lock_violations,
delete_mail_profile_credentials,
)
class MailProfileTransportHelperTests(unittest.TestCase):
def test_campaign_transport_revisions_are_random_opaque_values(self):
profile = SimpleNamespace(
smtp_config={"host": "smtp.example.org", "port": 587, "security": "starttls", "timeout_seconds": 30},
smtp_username="first-user",
smtp_password_encrypted="first-secret-ciphertext",
smtp_transport_revision="032837ce-fd97-401a-8a9f-8df67f3ab41d",
imap_config=None,
imap_username=None,
imap_password_encrypted=None,
imap_transport_revision="9ca09521-c543-42ed-a8f2-f06783f1ebd3",
)
original = campaign_profile_transport_revisions(profile)
profile.smtp_password_encrypted = "second-secret-ciphertext"
self.assertEqual(campaign_profile_transport_revisions(profile), original)
self.assertNotIn("smtp.example.org", repr(original))
self.assertEqual(original["smtp"], profile.smtp_transport_revision)
def test_profile_deletion_scrubs_both_passwords_and_writes_non_secret_audit(self):
profile = SimpleNamespace(
id="profile-1",
tenant_id="tenant-1",
scope_type="tenant",
scope_id="tenant-1",
smtp_password_encrypted="smtp-ciphertext",
imap_password_encrypted="imap-ciphertext",
)
session = SimpleNamespace(add=lambda _value: None, flush=lambda: None)
with patch("govoplan_mail.backend.mail_profiles.audit_event") as audit:
deleted = delete_mail_profile_credentials(
session, # type: ignore[arg-type]
profile=profile, # type: ignore[arg-type]
deletion_reason="profile_deactivated",
user_id="user-1",
)
self.assertEqual(deleted, ("smtp", "imap"))
self.assertIsNone(profile.smtp_password_encrypted)
self.assertIsNone(profile.imap_password_encrypted)
self.assertEqual(audit.call_count, 1)
self.assertEqual(audit.call_args.kwargs["action"], "mail.profile_credentials_deleted")
self.assertEqual(audit.call_args.kwargs["scope"], "tenant")
self.assertEqual(audit.call_args.kwargs["details"]["deletion_reason"], "profile_deactivated")
self.assertNotIn("ciphertext", repr(audit.call_args.kwargs))
def test_system_profile_secret_deletion_uses_system_audit_scope(self):
profile = SimpleNamespace(
id="profile-system",
tenant_id=None,
scope_type="system",
scope_id=None,
smtp_password_encrypted="smtp-ciphertext",
imap_password_encrypted=None,
)
session = SimpleNamespace(add=lambda _value: None, flush=lambda: None)
with patch("govoplan_mail.backend.mail_profiles.audit_event") as audit:
delete_mail_profile_credentials(
session, # type: ignore[arg-type]
profile=profile, # type: ignore[arg-type]
deletion_reason="module_data_retired",
)
self.assertEqual(audit.call_args.kwargs["scope"], "system")
self.assertIsNone(audit.call_args.kwargs["tenant_id"])
self.assertNotIn("ciphertext", repr(audit.call_args.kwargs))
def test_repeated_profile_secret_deletion_is_an_idempotent_no_op(self):
profile = SimpleNamespace(
id="profile-1",
tenant_id="tenant-1",
scope_type="tenant",
scope_id="tenant-1",
smtp_password_encrypted=None,
imap_password_encrypted=None,
)
session = SimpleNamespace(add=lambda _value: self.fail("no-op must not add"), flush=lambda: self.fail("no-op must not flush"))
with patch("govoplan_mail.backend.mail_profiles.audit_event") as audit:
deleted = delete_mail_profile_credentials(
session, # type: ignore[arg-type]
profile=profile, # type: ignore[arg-type]
deletion_reason="profile_deactivated",
)
self.assertEqual(deleted, ())
audit.assert_not_called()
def test_profile_secret_deletion_does_not_mutate_ciphertext_when_audit_fails(self):
profile = SimpleNamespace(
id="profile-1",
tenant_id="tenant-1",
scope_type="tenant",
scope_id="tenant-1",
smtp_password_encrypted="smtp-ciphertext",
imap_password_encrypted="imap-ciphertext",
)
session = SimpleNamespace(add=lambda _value: None, flush=lambda: None)
with (
patch(
"govoplan_mail.backend.mail_profiles.audit_event",
side_effect=RuntimeError("audit unavailable"),
),
self.assertRaisesRegex(RuntimeError, "audit unavailable"),
):
delete_mail_profile_credentials(
session, # type: ignore[arg-type]
profile=profile, # type: ignore[arg-type]
deletion_reason="profile_deactivated",
)
self.assertEqual(profile.smtp_password_encrypted, "smtp-ciphertext")
self.assertEqual(profile.imap_password_encrypted, "imap-ciphertext")
def test_next_transport_state_uses_saved_profile_credentials(self):
profile = SimpleNamespace(
tenant_id="tenant-1",
@@ -41,16 +165,26 @@ class MailProfileTransportHelperTests(unittest.TestCase):
def test_apply_transport_update_preserves_unsupplied_passwords(self):
profile = SimpleNamespace(
id="profile-1",
tenant_id="tenant-1",
scope_type="tenant",
scope_id="tenant-1",
smtp_config={"host": "smtp.example.org", "port": 587, "security": "starttls", "timeout_seconds": 30},
smtp_username="saved-smtp",
smtp_password_encrypted=encrypt_secret("smtp-secret"),
smtp_transport_revision="smtp-before",
imap_config={"host": "imap.example.org", "port": 993, "security": "tls", "sent_folder": "Sent", "timeout_seconds": 30},
imap_username="saved-imap",
imap_password_encrypted=encrypt_secret("imap-secret"),
imap_transport_revision="imap-before",
)
session = SimpleNamespace(flush=lambda: None)
_apply_profile_transport_update(
session, # type: ignore[arg-type]
profile,
user_id="user-1",
api_key_id=None,
smtp=SmtpConfig(host="smtp2.example.org", username="new-smtp"),
imap=ImapConfig(host="imap2.example.org", username="new-imap"),
clear_imap=False,
@@ -62,14 +196,158 @@ class MailProfileTransportHelperTests(unittest.TestCase):
self.assertEqual(profile.imap_config["host"], "imap2.example.org")
self.assertEqual(profile.imap_username, "new-imap")
self.assertEqual(decrypt_secret(profile.imap_password_encrypted), "imap-secret")
self.assertNotEqual(profile.smtp_transport_revision, "smtp-before")
self.assertNotEqual(profile.imap_transport_revision, "imap-before")
_apply_profile_transport_update(profile, smtp=None, imap=None, clear_imap=True)
with patch("govoplan_mail.backend.mail_profiles.audit_event") as audit:
_apply_profile_transport_update(
session, # type: ignore[arg-type]
profile,
user_id="user-1",
api_key_id=None,
smtp=None,
imap=None,
clear_imap=True,
)
self.assertIsNone(profile.imap_config)
self.assertIsNone(profile.imap_username)
self.assertIsNone(profile.imap_password_encrypted)
self.assertEqual(audit.call_args.kwargs["action"], "mail.profile_credentials_deleted")
self.assertEqual(audit.call_args.kwargs["details"]["protocol"], "imap")
self.assertNotIn("imap-secret", repr(audit.call_args.kwargs))
def test_password_replacement_preserves_revision_and_is_audited_without_secret(self):
profile = SimpleNamespace(
id="profile-1",
tenant_id="tenant-1",
scope_type="tenant",
scope_id="tenant-1",
smtp_config={"host": "smtp.example.org", "port": 587, "security": "starttls", "timeout_seconds": 30},
smtp_username="saved-smtp",
smtp_password_encrypted=encrypt_secret("old-secret"),
smtp_transport_revision="smtp-before",
imap_config=None,
imap_username=None,
imap_password_encrypted=None,
imap_transport_revision="imap-before",
)
session = SimpleNamespace(flush=lambda: None)
with patch("govoplan_mail.backend.mail_profiles.audit_event") as audit:
_apply_profile_transport_update(
session, # type: ignore[arg-type]
profile,
user_id="user-1",
api_key_id="key-1",
smtp=SmtpConfig(
host="smtp.example.org",
port=587,
security="starttls",
timeout_seconds=30,
password="new-secret",
),
imap=None,
clear_imap=False,
)
self.assertEqual(decrypt_secret(profile.smtp_password_encrypted), "new-secret")
self.assertEqual(profile.smtp_transport_revision, "smtp-before")
self.assertEqual(audit.call_args.kwargs["action"], "mail.profile_credentials_replaced")
self.assertEqual(audit.call_args.kwargs["details"]["protocol"], "smtp")
self.assertNotIn("old-secret", repr(audit.call_args.kwargs))
self.assertNotIn("new-secret", repr(audit.call_args.kwargs))
def test_audit_failure_leaves_existing_ciphertext_and_revision_unchanged(self):
encrypted = encrypt_secret("old-secret")
profile = SimpleNamespace(
id="profile-1",
tenant_id="tenant-1",
scope_type="tenant",
scope_id="tenant-1",
smtp_config={"host": "smtp.example.org", "port": 587, "security": "starttls", "timeout_seconds": 30},
smtp_username="saved-smtp",
smtp_password_encrypted=encrypted,
smtp_transport_revision="smtp-before",
imap_config=None,
imap_username=None,
imap_password_encrypted=None,
imap_transport_revision="imap-before",
)
session = SimpleNamespace(flush=lambda: None)
with (
patch(
"govoplan_mail.backend.mail_profiles.audit_event",
side_effect=RuntimeError("audit unavailable"),
),
self.assertRaisesRegex(RuntimeError, "audit unavailable"),
):
_apply_profile_transport_update(
session, # type: ignore[arg-type]
profile,
user_id="user-1",
api_key_id=None,
smtp=SmtpConfig(host="smtp.example.org", password="new-secret"),
imap=None,
clear_imap=False,
)
self.assertEqual(profile.smtp_password_encrypted, encrypted)
self.assertEqual(profile.smtp_transport_revision, "smtp-before")
def test_exact_password_update_is_an_idempotent_no_op(self):
encrypted = encrypt_secret("same-secret")
profile = SimpleNamespace(
id="profile-1",
tenant_id="tenant-1",
scope_type="tenant",
scope_id="tenant-1",
smtp_config={"host": "smtp.example.org", "port": 587, "security": "starttls", "timeout_seconds": 30},
smtp_username="saved-smtp",
smtp_password_encrypted=encrypted,
smtp_transport_revision="smtp-before",
imap_config=None,
imap_username=None,
imap_password_encrypted=None,
imap_transport_revision="imap-before",
)
session = SimpleNamespace(flush=lambda: None)
with patch("govoplan_mail.backend.mail_profiles.audit_event") as audit:
_apply_profile_transport_update(
session, # type: ignore[arg-type]
profile,
user_id="user-1",
api_key_id=None,
smtp=SmtpConfig(host="smtp.example.org", password="same-secret"),
imap=None,
clear_imap=False,
)
self.assertEqual(profile.smtp_password_encrypted, encrypted)
self.assertEqual(profile.smtp_transport_revision, "smtp-before")
audit.assert_not_called()
class MailProfilePolicyHelperTests(unittest.TestCase):
def test_campaign_contract_accepts_only_mail_profile_reference(self):
self.assertEqual(
_campaign_mail_profile_reference_id({"mail_profile_id": " profile-1 "}),
"profile-1",
)
for legacy in ("smtp", "imap", "credentials", "inherit_smtp_credentials"):
with self.subTest(legacy=legacy), self.assertRaisesRegex(MailProfileError, "select a Mail profile"):
_campaign_mail_profile_reference_id({"mail_profile_id": "profile-1", legacy: {}})
def test_campaign_delivery_fails_when_policy_requires_local_credentials(self):
profile = SimpleNamespace(imap_config=None)
policy = EffectiveMailProfilePolicy(
smtp_credentials=EffectiveCredentialPolicy(inherit=False),
)
with self.assertRaisesRegex(MailProfileError, "Store the credentials on a Mail profile"):
_assert_campaign_inherits_profile_credentials(profile, policy)
def test_merge_policy_respects_locked_lower_level_limits(self):
policy = EffectiveMailProfilePolicy()
_merge_policy(

View File

@@ -1,9 +1,11 @@
from __future__ import annotations
import unittest
from types import SimpleNamespace
from unittest.mock import patch
from govoplan_core.core.modules import ModuleManifest
from govoplan_mail.backend.manifest import get_manifest
from govoplan_core.core.modules import MigrationRetirementPlan, ModuleManifest
from govoplan_mail.backend.manifest import _mail_retirement_provider, get_manifest
class MailManifestTests(unittest.TestCase):
@@ -14,6 +16,62 @@ class MailManifestTests(unittest.TestCase):
self.assertEqual(manifest.id, "mail")
self.assertIn("addresses", manifest.optional_dependencies)
self.assertIn("addresses.lookup", {interface.name for interface in manifest.requires_interfaces})
self.assertIn(
{"name": "mail.campaign_delivery", "version": "0.2.0"},
[
{"name": interface.name, "version": interface.version}
for interface in manifest.provides_interfaces
],
)
topics = {topic.id: topic for topic in manifest.documentation}
ownership = topics["mail.profile-ownership-and-consumers"]
self.assertEqual(ownership.metadata["kind"], "reference")
self.assertEqual(ownership.metadata["route"], "/mail")
self.assertIn("campaigns.mail-profile-user-journey", ownership.metadata["related_topic_ids"])
def test_retirement_scrubs_credentials_before_dropping_mail_tables(self) -> None:
events: list[str] = []
def table_provider(_session, _module_id):
return MigrationRetirementPlan(
supported=True,
summary="Mail tables",
destroy_data_supported=True,
destroy_data_executor=lambda _execute_session, _execute_module_id: events.append("drop"),
)
session = SimpleNamespace(get_bind=lambda: object(), query=lambda *_args: None)
inspector = SimpleNamespace(has_table=lambda _name: True)
with (
patch("govoplan_mail.backend.manifest._mail_table_retirement_provider", table_provider),
patch("govoplan_mail.backend.manifest.inspect", return_value=inspector),
patch(
"govoplan_mail.backend.mail_profiles.delete_mail_profile_credentials_for_retirement",
side_effect=lambda _session: events.append("scrub"),
),
):
plan = _mail_retirement_provider(session, "mail")
assert plan.destroy_data_executor is not None
plan.destroy_data_executor(session, "mail")
self.assertEqual(events, ["scrub", "drop"])
self.assertTrue(any("audit" in warning for warning in plan.destroy_data_warnings))
events.clear()
with (
patch("govoplan_mail.backend.manifest._mail_table_retirement_provider", table_provider),
patch("govoplan_mail.backend.manifest.inspect", return_value=inspector),
patch(
"govoplan_mail.backend.mail_profiles.delete_mail_profile_credentials_for_retirement",
side_effect=RuntimeError("audit unavailable"),
),
):
blocked_plan = _mail_retirement_provider(session, "mail")
assert blocked_plan.destroy_data_executor is not None
with self.assertRaisesRegex(RuntimeError, "audit unavailable"):
blocked_plan.destroy_data_executor(session, "mail")
self.assertEqual(events, [])
if __name__ == "__main__":

View File

@@ -1,12 +1,12 @@
{
"name": "@govoplan/mail-webui",
"version": "0.1.8",
"version": "0.1.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@govoplan/mail-webui",
"version": "0.1.8",
"version": "0.1.9",
"devDependencies": {
"typescript": "^5.7.2"
},

View File

@@ -1,6 +1,6 @@
{
"name": "@govoplan/mail-webui",
"version": "0.1.8",
"version": "0.1.9",
"private": true,
"type": "module",
"main": "src/index.ts",

View File

@@ -17,15 +17,15 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-mail.bytes_b": "{value0} B",
"i18n:govoplan-mail.bytes_kb": "{value0} KB",
"i18n:govoplan-mail.bytes_mb": "{value0} MB",
"i18n:govoplan-mail.campaign_local_settings.920ecb62": "campaign-local settings",
"i18n:govoplan-mail.campaign_local_settings.eb0f1061": "Campaign-local settings",
"i18n:govoplan-mail.campaign_local_settings.920ecb62": "campaign-scoped profiles",
"i18n:govoplan-mail.campaign_local_settings.eb0f1061": "Campaign-scoped profiles",
"i18n:govoplan-mail.campaigns": "Campaigns",
"i18n:govoplan-mail.campaign.69390e16": "Campaign",
"i18n:govoplan-mail.cancel.77dfd213": "Cancel",
"i18n:govoplan-mail.cc.1fd6a880": "Cc",
"i18n:govoplan-mail.clear_allow_list.f69c8c67": "Clear allow-list",
"i18n:govoplan-mail.clear_message_search.cc9f2800": "Clear message search",
"i18n:govoplan-mail.controls_whether_campaigns_may_use_inline_smtp_i.fa45cbbc": "Controls whether campaigns may use inline SMTP/IMAP settings instead of reusable profiles.",
"i18n:govoplan-mail.controls_whether_campaigns_may_use_inline_smtp_i.fa45cbbc": "Controls whether reusable campaign-scoped Mail profiles may be defined below this scope.",
"i18n:govoplan-mail.controls_whether_group_scoped_mail_profiles_may_.0b832ea4": "Controls whether group-scoped mail profiles may be defined below this scope.",
"i18n:govoplan-mail.controls_whether_user_scoped_mail_profiles_may_b.00c0e0e7": "Controls whether user-scoped mail profiles may be defined below this scope.",
"i18n:govoplan-mail.create_mail_profile.4d2f8f9f": "Create mail profile",
@@ -37,8 +37,8 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-mail.deactivate_value_campaign_drafts_using_it_will_n.656f1b9e": "Deactivate {value0}? Campaign drafts using it will need another allowed profile before sending.",
"i18n:govoplan-mail.deactivate_value.a276a667": "Deactivate {value0}",
"i18n:govoplan-mail.deactivate.d65ded94": "Deactivate",
"i18n:govoplan-mail.decides_whether_lower_scopes_inherit_saved_imap_.ac607ee3": "Decides whether lower scopes inherit saved IMAP credentials from a selected profile or must provide local IMAP credentials.",
"i18n:govoplan-mail.decides_whether_lower_scopes_inherit_saved_smtp_.93f1c4d0": "Decides whether lower scopes inherit saved SMTP credentials from a selected profile or must provide local SMTP credentials.",
"i18n:govoplan-mail.decides_whether_lower_scopes_inherit_saved_imap_.ac607ee3": "Controls credential inheritance for compatible consumers. Campaign delivery requires credentials to remain on and inherit from the selected Mail profile.",
"i18n:govoplan-mail.decides_whether_lower_scopes_inherit_saved_smtp_.93f1c4d0": "Controls credential inheritance for compatible consumers. Campaign delivery requires credentials to remain on and inherit from the selected Mail profile.",
"i18n:govoplan-mail.deny.53577bb5": "Deny",
"i18n:govoplan-mail.description.55f8ebc8": "Description",
"i18n:govoplan-mail.development_mock_mailbox.1a379865": "Development mock mailbox",
@@ -214,15 +214,15 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-mail.bytes_b": "{value0} B",
"i18n:govoplan-mail.bytes_kb": "{value0} KB",
"i18n:govoplan-mail.bytes_mb": "{value0} MB",
"i18n:govoplan-mail.campaign_local_settings.920ecb62": "campaign-local settings",
"i18n:govoplan-mail.campaign_local_settings.eb0f1061": "Campaign-local settings",
"i18n:govoplan-mail.campaign_local_settings.920ecb62": "kampagnenbezogene Profile",
"i18n:govoplan-mail.campaign_local_settings.eb0f1061": "Kampagnenbezogene Profile",
"i18n:govoplan-mail.campaigns": "Kampagnen",
"i18n:govoplan-mail.campaign.69390e16": "Campaign",
"i18n:govoplan-mail.cancel.77dfd213": "Abbrechen",
"i18n:govoplan-mail.cc.1fd6a880": "Cc",
"i18n:govoplan-mail.clear_allow_list.f69c8c67": "Clear allow-list",
"i18n:govoplan-mail.clear_message_search.cc9f2800": "Clear message search",
"i18n:govoplan-mail.controls_whether_campaigns_may_use_inline_smtp_i.fa45cbbc": "Controls whether campaigns may use inline SMTP/IMAP settings instead of reusable profiles.",
"i18n:govoplan-mail.controls_whether_campaigns_may_use_inline_smtp_i.fa45cbbc": "Legt fest, ob unterhalb dieses Bereichs wiederverwendbare kampagnenbezogene Mailprofile angelegt werden duerfen.",
"i18n:govoplan-mail.controls_whether_group_scoped_mail_profiles_may_.0b832ea4": "Controls whether group-scoped mail profiles may be defined below this scope.",
"i18n:govoplan-mail.controls_whether_user_scoped_mail_profiles_may_b.00c0e0e7": "Controls whether user-scoped mail profiles may be defined below this scope.",
"i18n:govoplan-mail.create_mail_profile.4d2f8f9f": "Create mail profile",
@@ -234,8 +234,8 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-mail.deactivate_value_campaign_drafts_using_it_will_n.656f1b9e": "Deactivate {value0}? Campaign drafts using it will need another allowed profile before sending.",
"i18n:govoplan-mail.deactivate_value.a276a667": "Deactivate {value0}",
"i18n:govoplan-mail.deactivate.d65ded94": "Deactivate",
"i18n:govoplan-mail.decides_whether_lower_scopes_inherit_saved_imap_.ac607ee3": "Decides whether lower scopes inherit saved IMAP credentials from a selected profile or must provide local IMAP credentials.",
"i18n:govoplan-mail.decides_whether_lower_scopes_inherit_saved_smtp_.93f1c4d0": "Decides whether lower scopes inherit saved SMTP credentials from a selected profile or must provide local SMTP credentials.",
"i18n:govoplan-mail.decides_whether_lower_scopes_inherit_saved_imap_.ac607ee3": "Steuert die Vererbung fuer kompatible Verbraucher. Kampagnen muessen die Zugangsdaten aus dem ausgewaehlten Mailprofil erben.",
"i18n:govoplan-mail.decides_whether_lower_scopes_inherit_saved_smtp_.93f1c4d0": "Steuert die Vererbung fuer kompatible Verbraucher. Kampagnen muessen die Zugangsdaten aus dem ausgewaehlten Mailprofil erben.",
"i18n:govoplan-mail.deny.53577bb5": "Deny",
"i18n:govoplan-mail.description.55f8ebc8": "Beschreibung",
"i18n:govoplan-mail.development_mock_mailbox.1a379865": "Entwicklungs-Mailbox",