feat: add reusable SMTP batch sessions
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
from dataclasses import dataclass
|
||||
from email.message import EmailMessage
|
||||
from email.utils import formatdate, make_msgid
|
||||
from typing import Any
|
||||
from typing import Any, Iterator
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -40,13 +42,27 @@ from govoplan_mail.backend.sending.imap import (
|
||||
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
|
||||
from govoplan_mail.backend.sending.smtp import (
|
||||
SmtpBatchSession,
|
||||
SmtpConfigurationError,
|
||||
SmtpSendError,
|
||||
send_email_bytes,
|
||||
)
|
||||
|
||||
|
||||
_ACTIVE_SMTP_BATCH: ContextVar[SmtpBatchSession | None] = ContextVar(
|
||||
"govoplan_mail_active_smtp_batch",
|
||||
default=None,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CampaignSmtpDeliveryResult:
|
||||
envelope_recipients: list[str]
|
||||
refused_recipients: dict[str, dict[str, int | str]]
|
||||
connection_sequence: int = 1
|
||||
session_reused: bool = False
|
||||
reconnect_count: int = 0
|
||||
|
||||
@property
|
||||
def accepted_count(self) -> int:
|
||||
@@ -58,6 +74,23 @@ class CampaignImapAppendResult:
|
||||
folder: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CampaignSmtpBatchState:
|
||||
session: SmtpBatchSession
|
||||
|
||||
@property
|
||||
def status(self) -> str:
|
||||
return "ready"
|
||||
|
||||
@property
|
||||
def connection_count(self) -> int:
|
||||
return self.session.connection_count
|
||||
|
||||
@property
|
||||
def reconnect_count(self) -> int:
|
||||
return self.session.reconnect_count
|
||||
|
||||
|
||||
def _sanitized_refusals(
|
||||
refused_recipients: dict[str, tuple[int, bytes | str]],
|
||||
) -> dict[str, dict[str, int | str]]:
|
||||
@@ -95,6 +128,9 @@ def _sanitized_smtp_error(exc: SmtpSendError) -> SmtpSendError:
|
||||
message,
|
||||
temporary=exc.temporary,
|
||||
outcome_unknown=exc.outcome_unknown,
|
||||
systemic=exc.systemic,
|
||||
reason_code=exc.reason_code,
|
||||
phase=exc.phase,
|
||||
)
|
||||
|
||||
|
||||
@@ -279,6 +315,106 @@ def campaign_profile_delivery_summary(
|
||||
}
|
||||
|
||||
|
||||
@contextmanager
|
||||
def campaign_smtp_batch(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
campaign_id: str,
|
||||
profile_id: str,
|
||||
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,
|
||||
) -> Iterator[CampaignSmtpBatchState]:
|
||||
"""Preflight and retain one authorized SMTP connection for a batch."""
|
||||
|
||||
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
|
||||
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_revision = selected_smtp.transport_revision
|
||||
else:
|
||||
context = None
|
||||
current_revision = campaign_profile_transport_revisions(profile)["smtp"]
|
||||
if current_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:
|
||||
smtp = (
|
||||
resolve_mail_transport(
|
||||
session,
|
||||
profile=profile,
|
||||
protocol="smtp",
|
||||
context=context,
|
||||
server_id=smtp_server_id,
|
||||
credential_id=smtp_credential_id,
|
||||
).config
|
||||
if context is not None
|
||||
else 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
|
||||
|
||||
smtp_session = SmtpBatchSession(smtp)
|
||||
smtp_session.preflight()
|
||||
token = _ACTIVE_SMTP_BATCH.set(smtp_session)
|
||||
try:
|
||||
yield CampaignSmtpBatchState(session=smtp_session)
|
||||
finally:
|
||||
_ACTIVE_SMTP_BATCH.reset(token)
|
||||
smtp_session.close()
|
||||
|
||||
|
||||
def send_campaign_email_bytes(
|
||||
session: Session,
|
||||
*,
|
||||
@@ -394,6 +530,7 @@ def send_campaign_email_bytes(
|
||||
smtp_config=smtp,
|
||||
envelope_from=envelope_from,
|
||||
envelope_recipients=envelope_recipients,
|
||||
batch_session=_ACTIVE_SMTP_BATCH.get(),
|
||||
)
|
||||
except SmtpSendError as exc:
|
||||
sanitized = _sanitized_smtp_error(exc)
|
||||
@@ -420,6 +557,9 @@ def send_campaign_email_bytes(
|
||||
sanitized_result = CampaignSmtpDeliveryResult(
|
||||
envelope_recipients=list(result.envelope_recipients),
|
||||
refused_recipients=_sanitized_refusals(result.refused_recipients),
|
||||
connection_sequence=getattr(result, "connection_sequence", 0),
|
||||
session_reused=getattr(result, "session_reused", False),
|
||||
reconnect_count=getattr(result, "reconnect_count", 0),
|
||||
)
|
||||
if recovery is not None:
|
||||
try:
|
||||
@@ -604,6 +744,7 @@ class MailCampaignCapability:
|
||||
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)
|
||||
campaign_smtp_batch = staticmethod(campaign_smtp_batch)
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user