Refactor campaign delivery decision paths
This commit is contained in:
@@ -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,
|
||||
*,
|
||||
|
||||
Reference in New Issue
Block a user