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