Files
govoplan-mail/src/govoplan_mail/backend/capabilities.py

322 lines
11 KiB
Python

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.mail_profiles import (
MailProfileError,
_assert_campaign_inherits_profile_credentials,
assert_campaign_mail_policy_allows_json,
assert_mail_policy_allows_send,
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,
smtp_config_from_profile,
)
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
@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:
MailProfileError = MailProfileError
SmtpConfigurationError = SmtpConfigurationError
SmtpSendError = SmtpSendError
ImapConfigurationError = ImapConfigurationError
ImapAppendError = ImapAppendError
assert_campaign_mail_policy_allows_json = staticmethod(assert_campaign_mail_policy_allows_json)
mail_profile_id_from_campaign_json = staticmethod(mail_profile_id_from_campaign_json)
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)
@staticmethod
def mock_mailbox():
from govoplan_mail.backend.dev import mock_mailbox
return mock_mailbox
def campaign_capability(context: ModuleContext) -> MailCampaignCapability:
configure_runtime(settings=context.settings)
return MailCampaignCapability()