322 lines
11 KiB
Python
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()
|