524 lines
18 KiB
Python
524 lines
18 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_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()
|