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

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

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: