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_mail_owner_context, 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.server_hierarchy import ( MailHierarchyContext, MailServerHierarchyError, resolve_mail_transport, select_mail_transport, ) 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, selection: dict[str, str | None] | None = None, ): 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, selection) return profile def _campaign_hierarchy_context( session: Session, *, tenant_id: str, campaign_id: str, ) -> MailHierarchyContext: campaign = campaign_mail_owner_context( session, tenant_id=tenant_id, campaign_id=campaign_id, ) return MailHierarchyContext( tenant_id=tenant_id, user_id=campaign.owner_user_id, group_ids=( frozenset({campaign.owner_group_id}) if campaign.owner_group_id else frozenset() ), target_scope_type="campaign", target_scope_id=campaign.id, ) def _selection_payload( *, profile_id: str, smtp_server_id: str | None = None, smtp_credential_id: str | None = None, imap_server_id: str | None = None, imap_credential_id: str | None = None, ) -> dict[str, str | None]: return { "mail_profile_id": profile_id, "smtp_server_id": smtp_server_id, "smtp_credential_id": smtp_credential_id, "imap_server_id": imap_server_id, "imap_credential_id": imap_credential_id, } def _supports_hierarchy(session: object) -> bool: return callable(getattr(session, "execute", None)) 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, smtp_server_id: str | None = None, smtp_credential_id: str | None = None, imap_server_id: str | None = None, imap_credential_id: str | None = None, ) -> dict[str, Any]: """Return only non-secret capabilities and opaque drift evidence.""" selection = _selection_payload( profile_id=profile_id, smtp_server_id=smtp_server_id, smtp_credential_id=smtp_credential_id, imap_server_id=imap_server_id, imap_credential_id=imap_credential_id, ) if campaign_id: profile = _authorized_campaign_profile( session, tenant_id=tenant_id, campaign_id=campaign_id, profile_id=profile_id, selection=selection, ) else: assert_campaign_mail_policy_allows_json( session, tenant_id=tenant_id, raw_json={"server": {key: value for key, value in selection.items() if value}}, 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, ) if campaign_id and _supports_hierarchy(session): context = _campaign_hierarchy_context( session, tenant_id=tenant_id, campaign_id=campaign_id, ) try: smtp = select_mail_transport( session, profile=profile, protocol="smtp", context=context, server_id=smtp_server_id, credential_id=smtp_credential_id, ) imap = select_mail_transport( session, profile=profile, protocol="imap", context=context, server_id=imap_server_id, credential_id=imap_credential_id, ) except MailServerHierarchyError as exc: raise MailProfileError(str(exc)) from exc smtp_available = smtp.available imap_available = imap.available smtp_revision = smtp.transport_revision imap_revision = imap.transport_revision if imap.available else None resolved_smtp_server_id = smtp.server.id if smtp.server else None resolved_smtp_credential_id = smtp.credential.id if smtp.credential else None resolved_imap_server_id = imap.server.id if imap.server else None resolved_imap_credential_id = imap.credential.id if imap.credential else None else: revisions = campaign_profile_transport_revisions(profile) smtp_config = profile.smtp_config or {} imap_config = profile.imap_config or {} smtp_available = bool(smtp_config.get("host") and smtp_config.get("port")) imap_available = bool(imap_config.get("host") and imap_config.get("port")) smtp_revision = revisions["smtp"] imap_revision = revisions["imap"] resolved_smtp_server_id = smtp_server_id resolved_smtp_credential_id = smtp_credential_id resolved_imap_server_id = imap_server_id resolved_imap_credential_id = imap_credential_id return { "mail_profile_id": profile_id, "smtp_server_id": resolved_smtp_server_id, "smtp_credential_id": resolved_smtp_credential_id, "imap_server_id": resolved_imap_server_id, "imap_credential_id": resolved_imap_credential_id, "smtp_available": smtp_available, "imap_available": imap_available, "smtp_transport_revision": smtp_revision, "imap_transport_revision": imap_revision, } 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, smtp_server_id: str | None = None, smtp_credential_id: str | None = None, ) -> CampaignSmtpDeliveryResult: selection = _selection_payload( profile_id=profile_id, smtp_server_id=smtp_server_id, smtp_credential_id=smtp_credential_id, ) try: profile = _authorized_campaign_profile( session, tenant_id=tenant_id, campaign_id=campaign_id, profile_id=profile_id, selection=selection, ) except MailProfileError: raise except Exception: raise SmtpConfigurationError("The selected Mail profile's SMTP configuration is unusable.") from None resolved_smtp = None if _supports_hierarchy(session): context = _campaign_hierarchy_context( session, tenant_id=tenant_id, campaign_id=campaign_id, ) try: selected_smtp = select_mail_transport( session, profile=profile, protocol="smtp", context=context, server_id=smtp_server_id, credential_id=smtp_credential_id, ) except MailServerHierarchyError as exc: raise MailProfileError(str(exc)) from exc current_smtp_revision = selected_smtp.transport_revision else: current_smtp_revision = campaign_profile_transport_revisions(profile)["smtp"] if current_smtp_revision != 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: if _supports_hierarchy(session): resolved_smtp = resolve_mail_transport( session, profile=profile, protocol="smtp", context=context, server_id=smtp_server_id, credential_id=smtp_credential_id, ) smtp = resolved_smtp.config else: 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=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, smtp_server_id: str | None = None, smtp_credential_id: str | None = None, imap_server_id: str | None = None, imap_credential_id: str | None = None, ) -> CampaignImapAppendResult: selection = _selection_payload( profile_id=profile_id, smtp_server_id=smtp_server_id, smtp_credential_id=smtp_credential_id, imap_server_id=imap_server_id, imap_credential_id=imap_credential_id, ) try: profile = _authorized_campaign_profile( session, tenant_id=tenant_id, campaign_id=campaign_id, profile_id=profile_id, selection=selection, ) except MailProfileError: raise except Exception: raise ImapConfigurationError("The selected Mail profile's IMAP configuration is unusable.") from None if _supports_hierarchy(session): context = _campaign_hierarchy_context( session, tenant_id=tenant_id, campaign_id=campaign_id, ) try: selected_smtp = select_mail_transport( session, profile=profile, protocol="smtp", context=context, server_id=smtp_server_id, credential_id=smtp_credential_id, ) selected_imap = select_mail_transport( session, profile=profile, protocol="imap", context=context, server_id=imap_server_id, credential_id=imap_credential_id, ) except MailServerHierarchyError as exc: raise MailProfileError(str(exc)) from exc smtp_revision = selected_smtp.transport_revision imap_revision = selected_imap.transport_revision else: revisions = campaign_profile_transport_revisions(profile) smtp_revision = revisions["smtp"] imap_revision = revisions["imap"] if smtp_revision != 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 imap_revision != 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: if _supports_hierarchy(session): imap = resolve_mail_transport( session, profile=profile, protocol="imap", context=context, server_id=imap_server_id, credential_id=imap_credential_id, ).config else: 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=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()