Refactor campaign delivery decision paths

This commit is contained in:
2026-07-29 17:06:15 +02:00
parent 5240749ae1
commit dd9592a192
9 changed files with 1103 additions and 593 deletions

View File

@@ -222,6 +222,33 @@ class _MailChannelOutcome:
)
@dataclass(frozen=True, slots=True)
class _DeliveryOutcomeSummary:
mail_accepted: bool
postbox_accepted: int
postbox_rejected: int
outcome_unknown: bool
temporary_rejection: bool
@property
def accepted_count(self) -> int:
return int(self.mail_accepted) + self.postbox_accepted
@dataclass(frozen=True, slots=True)
class _ImapAppendContext:
snapshot: ExecutionSnapshot
message_bytes: bytes
folder: str
@dataclass(frozen=True, slots=True)
class _ClaimedImapAppend:
job: CampaignJob
attempt: ImapAppendAttempt
claim_token: str
QUEUEABLE_VALIDATION_STATUSES = {
JobValidationStatus.READY.value,
JobValidationStatus.WARNING.value,
@@ -2338,47 +2365,50 @@ def _final_multichannel_status(
mail: _MailChannelOutcome | None,
postbox: PostboxChannelOutcome | None,
) -> str:
mail_accepted = bool(mail and mail.accepted)
postbox_accepted = int(postbox.accepted_count if postbox else 0)
postbox_rejected = int(postbox.rejected_count if postbox else 0)
unknown = bool(
(mail and mail.outcome_unknown)
or (postbox and postbox.outcome_unknown)
)
if unknown:
outcome = _delivery_outcome_summary(mail=mail, postbox=postbox)
if outcome.outcome_unknown:
return JobSendStatus.OUTCOME_UNKNOWN.value
if not outcome.accepted_count:
return JobSendStatus.FAILED_TEMPORARY.value if outcome.temporary_rejection else JobSendStatus.FAILED_PERMANENT.value
classifier = _ACCEPTED_DELIVERY_CLASSIFIERS.get(channel_policy, _classify_fallback_delivery)
return classifier(outcome)
accepted_count = int(mail_accepted) + postbox_accepted
if accepted_count:
if channel_policy == DeliveryChannelPolicy.POSTBOX:
return (
JobSendStatus.PARTIALLY_ACCEPTED.value
if postbox_rejected
else JobSendStatus.POSTBOX_ACCEPTED.value
)
if channel_policy == DeliveryChannelPolicy.MAIL_AND_POSTBOX:
if mail_accepted and postbox_accepted and not postbox_rejected:
return JobSendStatus.DELIVERED.value
return JobSendStatus.PARTIALLY_ACCEPTED.value
if postbox_rejected:
return JobSendStatus.PARTIALLY_ACCEPTED.value
return (
JobSendStatus.SMTP_ACCEPTED.value
if mail_accepted
else JobSendStatus.POSTBOX_ACCEPTED.value
)
temporary = bool(
(mail and mail.rejected_temporary)
or (postbox and postbox.rejected_temporary)
)
return (
JobSendStatus.FAILED_TEMPORARY.value
if temporary
else JobSendStatus.FAILED_PERMANENT.value
def _delivery_outcome_summary(
*,
mail: _MailChannelOutcome | None,
postbox: PostboxChannelOutcome | None,
) -> _DeliveryOutcomeSummary:
return _DeliveryOutcomeSummary(
mail_accepted=bool(mail and mail.accepted),
postbox_accepted=int(postbox.accepted_count if postbox else 0),
postbox_rejected=int(postbox.rejected_count if postbox else 0),
outcome_unknown=bool((mail and mail.outcome_unknown) or (postbox and postbox.outcome_unknown)),
temporary_rejection=bool((mail and mail.rejected_temporary) or (postbox and postbox.rejected_temporary)),
)
def _classify_postbox_delivery(outcome: _DeliveryOutcomeSummary) -> str:
return JobSendStatus.PARTIALLY_ACCEPTED.value if outcome.postbox_rejected else JobSendStatus.POSTBOX_ACCEPTED.value
def _classify_dual_delivery(outcome: _DeliveryOutcomeSummary) -> str:
fully_delivered = outcome.mail_accepted and outcome.postbox_accepted > 0 and not outcome.postbox_rejected
return JobSendStatus.DELIVERED.value if fully_delivered else JobSendStatus.PARTIALLY_ACCEPTED.value
def _classify_fallback_delivery(outcome: _DeliveryOutcomeSummary) -> str:
if outcome.postbox_rejected:
return JobSendStatus.PARTIALLY_ACCEPTED.value
return JobSendStatus.SMTP_ACCEPTED.value if outcome.mail_accepted else JobSendStatus.POSTBOX_ACCEPTED.value
_ACCEPTED_DELIVERY_CLASSIFIERS = {
DeliveryChannelPolicy.POSTBOX: _classify_postbox_delivery,
DeliveryChannelPolicy.MAIL_AND_POSTBOX: _classify_dual_delivery,
}
def _multichannel_messages(
mail: _MailChannelOutcome | None,
postbox: PostboxChannelOutcome | None,
@@ -2931,22 +2961,29 @@ def _mark_imap_append_outcome_unknown_after_effect(
)
def append_sent_for_job(session: Session, *, job_id: str, dry_run: bool = False) -> AppendSentResult:
"""Append one successfully sent job's exact EML to the configured IMAP Sent folder."""
def _imap_append_precondition(
session: Session,
job: CampaignJob,
*,
dry_run: bool,
) -> AppendSentResult | None:
if not _mail_was_accepted(session, job.id, send_status=job.send_status):
return AppendSentResult(
job_id=job.id,
status="not_sent",
attempt_number=0,
dry_run=dry_run,
message="SMTP has not accepted this job",
)
return _imap_blocked_result(session, job, dry_run=dry_run)
job = session.get(CampaignJob, job_id)
if not job:
raise SendJobError(f"Job not found: {job_id}")
if not _mail_was_accepted(
session,
job.id,
send_status=job.send_status,
):
return AppendSentResult(job_id=job_id, status="not_sent", attempt_number=0, dry_run=dry_run, message="SMTP has not accepted this job")
blocked = _imap_blocked_result(session, job, dry_run=dry_run)
if blocked is not None:
return blocked
def _prepare_imap_append(
session: Session,
job: CampaignJob,
*,
dry_run: bool,
) -> _ImapAppendContext | AppendSentResult:
version = session.get(CampaignVersion, job.campaign_version_id)
if not version:
raise SendJobError("Campaign version not found")
@@ -2965,20 +3002,25 @@ def append_sent_for_job(session: Session, *, job_id: str, dry_run: bool = False)
session.add(job)
session.commit()
return AppendSentResult(job_id=job.id, status="skipped", attempt_number=0, dry_run=dry_run, message=job.last_error)
return _ImapAppendContext(
snapshot=snapshot,
message_bytes=_load_eml_bytes_for_job(job),
folder=snapshot.delivery.imap_append_sent.folder or "auto",
)
message_bytes = _load_eml_bytes_for_job(job)
folder = snapshot.delivery.imap_append_sent.folder or "auto"
if dry_run:
attempts = session.query(ImapAppendAttempt).filter(ImapAppendAttempt.job_id == job.id).count()
return AppendSentResult(
job_id=job.id,
status="dry_run",
attempt_number=attempts,
dry_run=True,
folder=folder,
message=f"Would append {len(message_bytes)} bytes to IMAP folder {folder!r}",
)
def _imap_append_dry_run(session: Session, job: CampaignJob, context: _ImapAppendContext) -> AppendSentResult:
return AppendSentResult(
job_id=job.id,
status="dry_run",
attempt_number=_imap_attempt_count(session, job.id),
dry_run=True,
folder=context.folder,
message=f"Would append {len(context.message_bytes)} bytes to IMAP folder {context.folder!r}",
)
def _claim_imap_append(session: Session, job: CampaignJob) -> _ClaimedImapAppend | AppendSentResult:
claim_token = _claim_job_for_imap_append(session, job)
if claim_token is None:
current = session.get(CampaignJob, job.id)
@@ -2993,80 +3035,98 @@ def append_sent_for_job(session: Session, *, job_id: str, dry_run: bool = False)
attempt_number=_imap_attempt_count(session, current.id),
message=f"Job is no longer eligible for IMAP append: {current.imap_status}",
)
job = session.get(CampaignJob, job.id)
if job is None:
claimed_job = session.get(CampaignJob, job.id)
if claimed_job is None:
raise SendJobError("Claimed campaign job disappeared before IMAP append")
attempt = _record_imap_attempt_start(session, job, claim_token)
return _ClaimedImapAppend(
job=claimed_job,
attempt=_record_imap_attempt_start(session, claimed_job, claim_token),
claim_token=claim_token,
)
def _record_imap_provider_error(
session: Session,
claimed: _ClaimedImapAppend,
*,
folder: str,
message: str,
outcome_unknown: bool,
) -> None:
owned = _record_imap_append_failure(
session,
job=claimed.job,
attempt=claimed.attempt,
claim_token=claimed.claim_token,
folder=folder,
message=message,
outcome_unknown=outcome_unknown,
)
if not owned:
_imap_result_after_lost_claim(
session,
job_id=claimed.job.id,
attempt=claimed.attempt,
provider_succeeded=outcome_unknown,
)
def _invoke_imap_append(session: Session, claimed: _ClaimedImapAppend, context: _ImapAppendContext):
snapshot = context.snapshot
job = claimed.job
return mail_integration().append_campaign_message_to_sent(
session,
tenant_id=job.tenant_id,
campaign_id=job.campaign_id,
profile_id=snapshot.mail_profile_id,
message_bytes=context.message_bytes,
folder=None if context.folder == "auto" else context.folder,
expected_smtp_transport_revision=snapshot.smtp_transport_revision or "",
expected_imap_transport_revision=snapshot.imap_transport_revision,
smtp_server_id=snapshot.smtp_server_id,
smtp_credential_id=snapshot.smtp_credential_id,
imap_server_id=snapshot.imap_server_id,
imap_credential_id=snapshot.imap_credential_id,
)
def _perform_imap_append(
session: Session,
claimed: _ClaimedImapAppend,
context: _ImapAppendContext,
) -> AppendSentResult:
try:
result = mail_integration().append_campaign_message_to_sent(
session,
tenant_id=job.tenant_id,
campaign_id=job.campaign_id,
profile_id=snapshot.mail_profile_id,
message_bytes=message_bytes,
folder=None if folder == "auto" else folder,
expected_smtp_transport_revision=snapshot.smtp_transport_revision or "",
expected_imap_transport_revision=snapshot.imap_transport_revision,
smtp_server_id=snapshot.smtp_server_id,
smtp_credential_id=snapshot.smtp_credential_id,
imap_server_id=snapshot.imap_server_id,
imap_credential_id=snapshot.imap_credential_id,
)
result = _invoke_imap_append(session, claimed, context)
except (MailProfileError, ImapConfigurationError, ImapAppendError) as exc:
outcome_unknown = bool(getattr(exc, "outcome_unknown", False))
owned = _record_imap_append_failure(
_record_imap_provider_error(
session,
job=job,
attempt=attempt,
claim_token=claim_token,
folder=folder,
claimed,
folder=context.folder,
message=str(exc),
outcome_unknown=outcome_unknown,
outcome_unknown=bool(getattr(exc, "outcome_unknown", False)),
)
if not owned:
_imap_result_after_lost_claim(
session,
job_id=job.id,
attempt=attempt,
provider_succeeded=outcome_unknown,
)
raise
except Exception:
reason = (
"The Sent-folder append outcome is unknown after an unexpected provider failure; "
"inspect and reconcile the mailbox before retrying."
)
owned = _record_imap_append_failure(
session,
job=job,
attempt=attempt,
claim_token=claim_token,
folder=folder,
message=reason,
outcome_unknown=True,
)
if not owned:
_imap_result_after_lost_claim(
session,
job_id=job.id,
attempt=attempt,
provider_succeeded=True,
)
_record_imap_provider_error(session, claimed, folder=context.folder, message=reason, outcome_unknown=True)
raise ImapAppendError(reason, outcome_unknown=True) from None
try:
return _record_imap_append_success(
session,
job=job,
attempt=attempt,
claim_token=claim_token,
job=claimed.job,
attempt=claimed.attempt,
claim_token=claimed.claim_token,
folder=result.folder,
)
except Exception:
return _mark_imap_append_outcome_unknown_after_effect(
session,
job_id=job.id,
attempt_id=attempt.id,
claim_token=claim_token,
job_id=claimed.job.id,
attempt_id=claimed.attempt.id,
claim_token=claimed.claim_token,
reason=(
"The IMAP provider accepted the append, but persisting the outcome failed. "
"Automatic retry is stopped until an operator reconciles the mailbox."
@@ -3074,6 +3134,26 @@ def append_sent_for_job(session: Session, *, job_id: str, dry_run: bool = False)
)
def append_sent_for_job(session: Session, *, job_id: str, dry_run: bool = False) -> AppendSentResult:
"""Append one successfully sent job's exact EML to the configured IMAP Sent folder."""
job = session.get(CampaignJob, job_id)
if not job:
raise SendJobError(f"Job not found: {job_id}")
blocked = _imap_append_precondition(session, job, dry_run=dry_run)
if blocked is not None:
return blocked
prepared = _prepare_imap_append(session, job, dry_run=dry_run)
if isinstance(prepared, AppendSentResult):
return prepared
if dry_run:
return _imap_append_dry_run(session, job, prepared)
claimed = _claim_imap_append(session, job)
if isinstance(claimed, AppendSentResult):
return claimed
return _perform_imap_append(session, claimed, prepared)
def enqueue_pending_imap_appends(
session: Session,
*,