feat: enforce Mail-owned campaign transport boundary
This commit is contained in:
@@ -1,22 +1,299 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.modules import ModuleContext
|
||||
from govoplan_mail.backend.runtime import configure_runtime
|
||||
from govoplan_mail.backend.mail_profiles import (
|
||||
MailProfileError,
|
||||
apply_campaign_credentials,
|
||||
_assert_campaign_inherits_profile_credentials,
|
||||
assert_campaign_mail_policy_allows_json,
|
||||
assert_mail_policy_allows_send,
|
||||
effective_profile_credentials_inherited,
|
||||
campaign_profile_transport_revisions,
|
||||
effective_mail_profile_policy,
|
||||
ensure_mail_profile_allowed_for_campaign,
|
||||
get_mail_server_profile,
|
||||
imap_config_from_profile,
|
||||
mail_profile_id_from_campaign_json,
|
||||
materialize_campaign_mail_profile_config,
|
||||
smtp_config_from_profile,
|
||||
)
|
||||
from govoplan_mail.backend.sending.imap import ImapAppendError, ImapConfigurationError, append_message_to_sent
|
||||
from govoplan_mail.backend.runtime import configure_runtime
|
||||
from govoplan_mail.backend.sending.imap import (
|
||||
ImapAppendError,
|
||||
ImapConfigurationError,
|
||||
append_message_to_sent,
|
||||
)
|
||||
from govoplan_mail.backend.sending.rate_limit import wait_for_rate_limit
|
||||
from govoplan_mail.backend.sending.smtp import SmtpConfigurationError, SmtpSendError, send_email_bytes, send_email_message
|
||||
from govoplan_mail.backend.sending.smtp import SmtpConfigurationError, SmtpSendError, send_email_bytes
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CampaignSmtpDeliveryResult:
|
||||
envelope_recipients: list[str]
|
||||
refused_recipients: dict[str, dict[str, int | str]]
|
||||
|
||||
@property
|
||||
def accepted_count(self) -> int:
|
||||
return len(self.envelope_recipients) - len(self.refused_recipients)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CampaignImapAppendResult:
|
||||
folder: str
|
||||
|
||||
|
||||
def _sanitized_refusals(
|
||||
refused_recipients: dict[str, tuple[int, bytes | str]],
|
||||
) -> dict[str, dict[str, int | str]]:
|
||||
sanitized: dict[str, dict[str, int | str]] = {}
|
||||
for recipient, (raw_code, _provider_message) in refused_recipients.items():
|
||||
try:
|
||||
code = int(raw_code)
|
||||
except (TypeError, ValueError):
|
||||
code = 0
|
||||
if 400 <= code < 500:
|
||||
classification = "temporary"
|
||||
message = "Temporary recipient rejection"
|
||||
elif 500 <= code < 600:
|
||||
classification = "permanent"
|
||||
message = "Permanent recipient rejection"
|
||||
else:
|
||||
classification = "unknown"
|
||||
message = "Recipient rejected"
|
||||
sanitized[str(recipient)] = {
|
||||
"status_code": code,
|
||||
"classification": classification,
|
||||
"message": message,
|
||||
}
|
||||
return sanitized
|
||||
|
||||
|
||||
def _sanitized_smtp_error(exc: SmtpSendError) -> SmtpSendError:
|
||||
if exc.outcome_unknown:
|
||||
message = "Mail delivery outcome is unknown after transmission started."
|
||||
elif exc.temporary:
|
||||
message = "Mail delivery failed temporarily."
|
||||
else:
|
||||
message = "Mail delivery was rejected."
|
||||
return SmtpSendError(
|
||||
message,
|
||||
temporary=exc.temporary,
|
||||
outcome_unknown=exc.outcome_unknown,
|
||||
)
|
||||
|
||||
|
||||
def _sanitized_imap_error(exc: ImapAppendError) -> ImapAppendError:
|
||||
if exc.outcome_unknown:
|
||||
message = "The Sent-folder append outcome is unknown; inspect the mailbox before retrying."
|
||||
elif exc.temporary:
|
||||
message = "Appending the sent message failed temporarily."
|
||||
else:
|
||||
message = "Appending the sent message was rejected."
|
||||
return ImapAppendError(
|
||||
message,
|
||||
temporary=exc.temporary,
|
||||
outcome_unknown=exc.outcome_unknown,
|
||||
)
|
||||
|
||||
|
||||
def _authorized_campaign_profile(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
campaign_id: str,
|
||||
profile_id: str,
|
||||
):
|
||||
profile = ensure_mail_profile_allowed_for_campaign(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
profile_id=profile_id,
|
||||
require_active=True,
|
||||
)
|
||||
policy = effective_mail_profile_policy(session, tenant_id=tenant_id, campaign_id=campaign_id)
|
||||
_assert_campaign_inherits_profile_credentials(profile, policy)
|
||||
return profile
|
||||
|
||||
|
||||
def campaign_profile_delivery_summary(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
profile_id: str,
|
||||
campaign_id: str | None = None,
|
||||
owner_user_id: str | None = None,
|
||||
owner_group_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Return only non-secret capabilities and opaque drift evidence."""
|
||||
|
||||
if campaign_id:
|
||||
profile = _authorized_campaign_profile(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
profile_id=profile_id,
|
||||
)
|
||||
else:
|
||||
assert_campaign_mail_policy_allows_json(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
raw_json={"server": {"mail_profile_id": profile_id}},
|
||||
owner_user_id=owner_user_id,
|
||||
owner_group_id=owner_group_id,
|
||||
)
|
||||
profile = get_mail_server_profile(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
profile_id=profile_id,
|
||||
require_active=True,
|
||||
)
|
||||
revisions = campaign_profile_transport_revisions(profile)
|
||||
smtp = profile.smtp_config or {}
|
||||
imap = profile.imap_config or {}
|
||||
return {
|
||||
"mail_profile_id": profile_id,
|
||||
"smtp_available": bool(smtp.get("host") and smtp.get("port")),
|
||||
"imap_available": bool(imap.get("host") and imap.get("port")),
|
||||
"smtp_transport_revision": revisions["smtp"],
|
||||
"imap_transport_revision": revisions["imap"],
|
||||
}
|
||||
|
||||
|
||||
def send_campaign_email_bytes(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
campaign_id: str,
|
||||
profile_id: str,
|
||||
message_bytes: bytes,
|
||||
envelope_from: str,
|
||||
envelope_recipients: list[str],
|
||||
from_header: str | None,
|
||||
expected_smtp_transport_revision: str,
|
||||
) -> CampaignSmtpDeliveryResult:
|
||||
try:
|
||||
profile = _authorized_campaign_profile(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
profile_id=profile_id,
|
||||
)
|
||||
except MailProfileError:
|
||||
raise
|
||||
except Exception:
|
||||
raise SmtpConfigurationError("The selected Mail profile's SMTP configuration is unusable.") from None
|
||||
revisions = campaign_profile_transport_revisions(profile)
|
||||
if revisions["smtp"] != expected_smtp_transport_revision:
|
||||
raise MailProfileError(
|
||||
"The selected Mail profile's SMTP settings changed after this campaign was built. "
|
||||
"Revalidate and rebuild the campaign before delivery."
|
||||
)
|
||||
try:
|
||||
smtp = smtp_config_from_profile(profile)
|
||||
except MailProfileError:
|
||||
raise
|
||||
except Exception:
|
||||
raise SmtpConfigurationError("The selected Mail profile's SMTP configuration is unusable.") from None
|
||||
try:
|
||||
assert_mail_policy_allows_send(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
smtp=smtp,
|
||||
imap=profile.imap_config or None,
|
||||
envelope_sender=envelope_from,
|
||||
from_header=from_header,
|
||||
recipients=envelope_recipients,
|
||||
)
|
||||
except MailProfileError:
|
||||
raise MailProfileError("Mail delivery is blocked by the effective Mail policy.") from None
|
||||
try:
|
||||
result = send_email_bytes(
|
||||
message_bytes,
|
||||
smtp_config=smtp,
|
||||
envelope_from=envelope_from,
|
||||
envelope_recipients=envelope_recipients,
|
||||
)
|
||||
except SmtpSendError as exc:
|
||||
raise _sanitized_smtp_error(exc) from None
|
||||
except SmtpConfigurationError:
|
||||
raise SmtpConfigurationError("The selected Mail profile's SMTP configuration is unusable.") from None
|
||||
except Exception:
|
||||
raise SmtpSendError(
|
||||
"Mail delivery outcome is unknown after the provider effect started.",
|
||||
outcome_unknown=True,
|
||||
) from None
|
||||
return CampaignSmtpDeliveryResult(
|
||||
envelope_recipients=list(result.envelope_recipients),
|
||||
refused_recipients=_sanitized_refusals(result.refused_recipients),
|
||||
)
|
||||
|
||||
|
||||
def append_campaign_message_to_sent(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
campaign_id: str,
|
||||
profile_id: str,
|
||||
message_bytes: bytes,
|
||||
folder: str | None,
|
||||
expected_smtp_transport_revision: str,
|
||||
expected_imap_transport_revision: str | None,
|
||||
) -> CampaignImapAppendResult:
|
||||
try:
|
||||
profile = _authorized_campaign_profile(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
profile_id=profile_id,
|
||||
)
|
||||
except MailProfileError:
|
||||
raise
|
||||
except Exception:
|
||||
raise ImapConfigurationError("The selected Mail profile's IMAP configuration is unusable.") from None
|
||||
revisions = campaign_profile_transport_revisions(profile)
|
||||
if revisions["smtp"] != expected_smtp_transport_revision:
|
||||
raise MailProfileError(
|
||||
"The selected Mail profile's SMTP settings changed after this campaign was built. "
|
||||
"Revalidate and rebuild the campaign before append-to-Sent delivery."
|
||||
)
|
||||
if revisions["imap"] != expected_imap_transport_revision:
|
||||
raise MailProfileError(
|
||||
"The selected Mail profile's IMAP settings changed after this campaign was built. "
|
||||
"Revalidate and rebuild the campaign before append-to-Sent delivery."
|
||||
)
|
||||
try:
|
||||
imap = imap_config_from_profile(profile)
|
||||
except MailProfileError:
|
||||
raise
|
||||
except Exception:
|
||||
raise ImapConfigurationError("The selected Mail profile's IMAP configuration is unusable.") from None
|
||||
if imap is None:
|
||||
raise ImapConfigurationError("The selected Mail profile has no IMAP configuration")
|
||||
try:
|
||||
assert_mail_policy_allows_send(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
smtp=profile.smtp_config or None,
|
||||
imap=imap,
|
||||
)
|
||||
except MailProfileError:
|
||||
raise MailProfileError("Appending to Sent is blocked by the effective Mail policy.") from None
|
||||
try:
|
||||
result = append_message_to_sent(message_bytes, imap_config=imap, folder=folder)
|
||||
except ImapAppendError as exc:
|
||||
raise _sanitized_imap_error(exc) from None
|
||||
except ImapConfigurationError:
|
||||
raise ImapConfigurationError("The selected Mail profile's IMAP configuration is unusable.") from None
|
||||
except Exception:
|
||||
raise ImapAppendError(
|
||||
"The Sent-folder append outcome is unknown; inspect the mailbox before retrying.",
|
||||
outcome_unknown=True,
|
||||
) from None
|
||||
return CampaignImapAppendResult(folder=result.folder)
|
||||
|
||||
|
||||
class MailCampaignCapability:
|
||||
@@ -25,19 +302,12 @@ class MailCampaignCapability:
|
||||
SmtpSendError = SmtpSendError
|
||||
ImapConfigurationError = ImapConfigurationError
|
||||
ImapAppendError = ImapAppendError
|
||||
materialize_campaign_mail_profile_config = staticmethod(materialize_campaign_mail_profile_config)
|
||||
assert_campaign_mail_policy_allows_json = staticmethod(assert_campaign_mail_policy_allows_json)
|
||||
assert_mail_policy_allows_send = staticmethod(assert_mail_policy_allows_send)
|
||||
mail_profile_id_from_campaign_json = staticmethod(mail_profile_id_from_campaign_json)
|
||||
ensure_mail_profile_allowed_for_campaign = staticmethod(ensure_mail_profile_allowed_for_campaign)
|
||||
smtp_config_from_profile = staticmethod(smtp_config_from_profile)
|
||||
imap_config_from_profile = staticmethod(imap_config_from_profile)
|
||||
effective_profile_credentials_inherited = staticmethod(effective_profile_credentials_inherited)
|
||||
apply_campaign_credentials = staticmethod(apply_campaign_credentials)
|
||||
campaign_profile_delivery_summary = staticmethod(campaign_profile_delivery_summary)
|
||||
send_campaign_email_bytes = staticmethod(send_campaign_email_bytes)
|
||||
append_campaign_message_to_sent = staticmethod(append_campaign_message_to_sent)
|
||||
wait_for_rate_limit = staticmethod(wait_for_rate_limit)
|
||||
send_email_bytes = staticmethod(send_email_bytes)
|
||||
append_message_to_sent = staticmethod(append_message_to_sent)
|
||||
send_email_message = staticmethod(send_email_message)
|
||||
|
||||
@staticmethod
|
||||
def mock_mailbox():
|
||||
|
||||
Reference in New Issue
Block a user