feat: add governed postbox delivery and report hardening

This commit is contained in:
2026-07-29 14:16:28 +02:00
parent f11c56e890
commit 5240749ae1
47 changed files with 5538 additions and 288 deletions

View File

@@ -10,6 +10,7 @@ from pathlib import Path
from typing import Any
from uuid import uuid4
from sqlalchemy import func
from sqlalchemy.orm import Session
from govoplan_core.core.notifications import NotificationDispatchRequest, notification_dispatch_provider
@@ -22,12 +23,15 @@ from govoplan_campaign.backend.db.models import (
CampaignVersionWorkflowState,
JobBuildStatus,
JobImapStatus,
JobPostboxStatus,
JobQueueStatus,
JobSendStatus,
JobValidationStatus,
ImapAppendAttempt,
PostboxDeliveryAttempt,
SendAttempt,
)
from govoplan_campaign.backend.campaign.models import DeliveryChannelPolicy
from govoplan_campaign.backend.delivery_policy import (
CampaignDeliveryPolicyError,
SynchronousSendPolicy,
@@ -39,6 +43,10 @@ from govoplan_campaign.backend.sending.execution import (
ensure_execution_snapshot,
profile_delivery_summary,
)
from govoplan_campaign.backend.sending.postbox_delivery import (
PostboxChannelOutcome,
deliver_campaign_job_to_postboxes,
)
from govoplan_campaign.backend.runtime import get_registry
from govoplan_campaign.backend.integrations import (
ImapAppendError,
@@ -180,6 +188,7 @@ class AppendSentResult:
@dataclass(frozen=True, slots=True)
class _CampaignDeliveryCounts:
accepted: int
partial: int
unknown: int
failed: int
active: int
@@ -192,15 +201,41 @@ class _SendJobDeliveryContext:
version: CampaignVersion
snapshot: ExecutionSnapshot
message_bytes: bytes
envelope_from: str
envelope_from: str | None
envelope_recipients: list[str]
@dataclass(frozen=True, slots=True)
class _MailChannelOutcome:
accepted: bool = False
rejected_temporary: bool = False
rejected_permanent: bool = False
outcome_unknown: bool = False
message: str | None = None
@property
def rejected_before_acceptance(self) -> bool:
return (
not self.accepted
and not self.outcome_unknown
and (self.rejected_temporary or self.rejected_permanent)
)
QUEUEABLE_VALIDATION_STATUSES = {
JobValidationStatus.READY.value,
JobValidationStatus.WARNING.value,
}
SMTP_ACCEPTED_STATUSES = {JobSendStatus.SMTP_ACCEPTED.value, JobSendStatus.SENT.value}
DELIVERY_ACCEPTED_STATUSES = SMTP_ACCEPTED_STATUSES | {
JobSendStatus.POSTBOX_ACCEPTED.value,
JobSendStatus.DELIVERED.value,
JobSendStatus.PARTIALLY_ACCEPTED.value,
}
FULLY_ACCEPTED_STATUSES = SMTP_ACCEPTED_STATUSES | {
JobSendStatus.POSTBOX_ACCEPTED.value,
JobSendStatus.DELIVERED.value,
}
DELIVERY_MODE_SYNCHRONOUS = "synchronous"
DELIVERY_MODE_WORKER_QUEUE = "worker_queue"
DELIVERY_MODE_DATABASE_QUEUE = "database_queue"
@@ -211,7 +246,7 @@ DELIVERY_MODES = {
}
AUTOMATICALLY_SENDABLE_STATUSES = {JobSendStatus.QUEUED.value}
EXPLICIT_RETRY_STATUSES = {JobSendStatus.FAILED_TEMPORARY.value, JobSendStatus.FAILED_PERMANENT.value}
INITIAL_QUEUE_SKIPPED_SEND_STATUSES = SMTP_ACCEPTED_STATUSES | {
INITIAL_QUEUE_SKIPPED_SEND_STATUSES = DELIVERY_ACCEPTED_STATUSES | {
JobSendStatus.SKIPPED.value,
JobSendStatus.CLAIMED.value,
JobSendStatus.SENDING.value,
@@ -835,7 +870,7 @@ def send_campaign_now(
)
result_dict = result.as_dict()
results.append(result_dict)
if result.status in {JobSendStatus.SMTP_ACCEPTED.value, "already_accepted"}:
if result.status in DELIVERY_ACCEPTED_STATUSES | {"already_accepted"}:
sent_count += 1
elif result.status == JobSendStatus.OUTCOME_UNKNOWN.value:
outcome_unknown_count += 1
@@ -902,12 +937,18 @@ def _preflight_synchronous_send_batch(
f"Job {job.id} changed to {state_result.status} during synchronous preflight"
)
contexts[job.id] = _send_job_delivery_context(session, job)
profile_summary = profile_delivery_summary(session, version)
expected_revision = next(iter(contexts.values())).snapshot.smtp_transport_revision
if profile_summary.get("smtp_transport_revision") != expected_revision:
raise SendJobError(
"The selected Mail profile changed after this execution was built. Revalidate and rebuild before delivery."
)
mail_contexts = [
context
for context in contexts.values()
if getattr(context.snapshot, "uses_mail", True)
]
if mail_contexts:
profile_summary = profile_delivery_summary(session, version)
expected_revision = mail_contexts[0].snapshot.smtp_transport_revision
if profile_summary.get("smtp_transport_revision") != expected_revision:
raise SendJobError(
"The selected Mail profile changed after this execution was built. Revalidate and rebuild before delivery."
)
except (ExecutionSnapshotError, MailProfileError, SmtpConfigurationError, SendJobError, OSError) as exc:
raise SynchronousSendRejected(
(
@@ -1019,7 +1060,7 @@ def cancel_campaign_jobs(session: Session, *, tenant_id: str, campaign_id: str)
if job.send_status == JobSendStatus.SKIPPED.value:
skipped_count += 1
continue
if job.send_status in SMTP_ACCEPTED_STATUSES | {
if job.send_status in DELIVERY_ACCEPTED_STATUSES | {
JobSendStatus.OUTCOME_UNKNOWN.value,
JobSendStatus.CLAIMED.value,
JobSendStatus.SENDING.value,
@@ -1074,13 +1115,21 @@ def queue_failed_jobs_for_retry(
enqueue_celery: bool = True,
dry_run: bool = False,
) -> dict[str, Any]:
"""Explicitly queue failed jobs; never includes uncertain or accepted jobs."""
"""Queue known failures and incomplete multi-channel deliveries.
Accepted SMTP attempts and accepted Postbox targets remain immutable and
are skipped by the channel orchestrator. Unknown outcomes are deliberately
excluded until an operator reconciles them.
"""
campaign = _get_campaign_for_tenant(session, campaign_id=campaign_id, tenant_id=tenant_id)
version = _get_version_for_campaign(session, campaign, version_id=version_id)
_ensure_version_validated_and_locked(version)
snapshot = ensure_execution_snapshot(session, version)
allowed = {JobSendStatus.FAILED_TEMPORARY.value}
allowed = {
JobSendStatus.FAILED_TEMPORARY.value,
JobSendStatus.PARTIALLY_ACCEPTED.value,
}
if include_permanent:
allowed.add(JobSendStatus.FAILED_PERMANENT.value)
@@ -1096,7 +1145,19 @@ def queue_failed_jobs_for_retry(
if job.send_status not in allowed:
skipped.append({"job_id": job.id, "reason": f"status {job.send_status} is not an explicit retry candidate"})
continue
if not force_max_attempts and job.attempt_count >= snapshot.delivery.retry.max_attempts:
delivery_rounds = max(
job.attempt_count,
int(
session.query(func.max(PostboxDeliveryAttempt.attempt_number))
.filter(PostboxDeliveryAttempt.job_id == job.id)
.scalar()
or 0
),
)
if (
not force_max_attempts
and delivery_rounds >= snapshot.delivery.retry.max_attempts
):
skipped.append({"job_id": job.id, "reason": "configured maximum attempts reached"})
continue
selected.append(job)
@@ -1165,6 +1226,7 @@ def queue_unattempted_jobs(
):
eligible = (
job.attempt_count == 0
and job.postbox_attempt_count == 0
and job.send_status in {JobSendStatus.NOT_QUEUED.value, JobSendStatus.CANCELLED.value}
and job.build_status == JobBuildStatus.BUILT.value
and job.validation_status in QUEUEABLE_VALIDATION_STATUSES
@@ -1251,7 +1313,11 @@ def send_single_campaign_job(
status="dry_run",
attempt_number=job.attempt_count,
dry_run=True,
message=f"Would send to {len(context.envelope_recipients)} recipient(s) from {context.envelope_from}",
message=(
f"Would deliver via {job.delivery_channel_policy}: "
f"{len(context.envelope_recipients)} Mail recipient(s), "
f"{len(job.resolved_postbox_targets or [])} Postbox target(s)"
),
).as_dict(),
}
@@ -1300,14 +1366,20 @@ def _prepare_single_job_for_send(
) -> str:
if job.queue_status == JobQueueStatus.QUEUED.value and job.send_status == JobSendStatus.QUEUED.value:
return "already_queued"
if job.send_status in SMTP_ACCEPTED_STATUSES:
raise QueueingError("This message has already been accepted by SMTP and cannot be sent again from preview.")
if job.send_status in DELIVERY_ACCEPTED_STATUSES:
raise QueueingError(
"This message has already been accepted by a delivery channel and "
"cannot be sent again from preview."
)
if job.send_status in {JobSendStatus.CLAIMED.value, JobSendStatus.SENDING.value, JobSendStatus.OUTCOME_UNKNOWN.value}:
raise QueueingError(f"This message is in delivery state {job.send_status}; reconcile or wait before sending it again.")
if job.send_status in {JobSendStatus.FAILED_TEMPORARY.value, JobSendStatus.FAILED_PERMANENT.value}:
raise QueueingError("This message has failed before. Use the explicit retry action so the retry is visible in the delivery protocol.")
if job.attempt_count > 0:
raise QueueingError("This message already has SMTP attempts. Use retry or reconciliation instead of preview send.")
if job.attempt_count > 0 or job.postbox_attempt_count > 0:
raise QueueingError(
"This message already has delivery attempts. Use retry or "
"reconciliation instead of preview send."
)
if job.build_status != JobBuildStatus.BUILT.value:
raise QueueingError("This message has not been built yet.")
if not _single_job_validation_allowed(version, job, include_warnings=include_warnings):
@@ -1356,6 +1428,7 @@ def reconcile_job_outcome(
job_id: str,
decision: str,
note: str | None = None,
attempt_id: str | None = None,
commit: bool = True,
) -> dict[str, Any]:
"""Record an operator decision for an uncertain/incomplete worker state.
@@ -1379,6 +1452,16 @@ def reconcile_job_outcome(
note=note,
commit=commit,
)
if decision in {"postbox_accepted", "postbox_not_accepted"}:
return _reconcile_postbox_outcome(
session,
campaign=campaign,
job=job,
decision=decision,
note=note,
attempt_id=attempt_id,
commit=commit,
)
evidence_note = (note or "").strip()
if not evidence_note:
raise QueueingError("SMTP reconciliation requires an evidence note")
@@ -1430,6 +1513,131 @@ def reconcile_job_outcome(
}
def _postbox_outcome_from_attempts(
session: Session,
job: CampaignJob,
) -> PostboxChannelOutcome:
attempts = (
session.query(PostboxDeliveryAttempt)
.filter(PostboxDeliveryAttempt.job_id == job.id)
.order_by(
PostboxDeliveryAttempt.target_index.asc(),
PostboxDeliveryAttempt.attempt_number.desc(),
)
.all()
)
latest_by_target: dict[str, PostboxDeliveryAttempt] = {}
for attempt in attempts:
latest_by_target.setdefault(attempt.target_key, attempt)
outcome = PostboxChannelOutcome()
for attempt in latest_by_target.values():
if attempt.status == JobPostboxStatus.ACCEPTED.value:
outcome.accepted += 1
elif attempt.status == JobPostboxStatus.ACCEPTED_VACANT.value:
outcome.accepted_vacant += 1
elif attempt.status == JobPostboxStatus.REJECTED_TEMPORARY.value:
outcome.rejected_temporary += 1
elif attempt.status == JobPostboxStatus.REJECTED_PERMANENT.value:
outcome.rejected_permanent += 1
elif attempt.status in {
JobPostboxStatus.OUTCOME_UNKNOWN.value,
JobPostboxStatus.DELIVERING.value,
}:
outcome.outcome_unknown += 1
return outcome
def _reconcile_postbox_outcome(
session: Session,
*,
campaign: Campaign,
job: CampaignJob,
decision: str,
note: str | None,
attempt_id: str | None,
commit: bool,
) -> dict[str, Any]:
evidence_note = (note or "").strip()
if not evidence_note:
raise QueueingError("Postbox reconciliation requires an evidence note")
unknown_query = session.query(PostboxDeliveryAttempt).filter(
PostboxDeliveryAttempt.job_id == job.id,
PostboxDeliveryAttempt.status.in_(
[
JobPostboxStatus.OUTCOME_UNKNOWN.value,
JobPostboxStatus.DELIVERING.value,
]
),
)
if attempt_id:
unknown_query = unknown_query.filter(
PostboxDeliveryAttempt.id == attempt_id
)
unknown_attempts = unknown_query.order_by(
PostboxDeliveryAttempt.target_index.asc(),
PostboxDeliveryAttempt.attempt_number.desc(),
).all()
if not unknown_attempts:
raise QueueingError(
"No unresolved Postbox attempt matches this reconciliation."
)
if not attempt_id and len(unknown_attempts) > 1:
raise QueueingError(
"More than one Postbox attempt is unresolved; select a specific "
"attempt before reconciling."
)
attempt = unknown_attempts[0]
evidence = dict(attempt.evidence or {})
evidence["operator_reconciliation"] = {
"decision": decision,
"note": evidence_note,
"at": _utcnow().isoformat(),
}
attempt.evidence = evidence
attempt.error_type = "OperatorReconciliation"
attempt.error_message = evidence_note
attempt.finished_at = attempt.finished_at or _utcnow()
attempt.status = (
JobPostboxStatus.ACCEPTED.value
if decision == "postbox_accepted"
else JobPostboxStatus.REJECTED_TEMPORARY.value
)
session.add(attempt)
session.flush()
postbox_outcome = _postbox_outcome_from_attempts(session, job)
postbox_outcome.messages.append(evidence_note)
job.postbox_status = postbox_outcome.status
mail_outcome = (
_MailChannelOutcome(accepted=True)
if _mail_was_accepted(
session,
job.id,
send_status=job.send_status,
)
else None
)
result = _finalize_multichannel_job(
session,
job_id=job.id,
channel_policy=DeliveryChannelPolicy(job.delivery_channel_policy),
mail=mail_outcome,
postbox=postbox_outcome,
commit=commit,
)
return {
"campaign_id": campaign.id,
"version_id": job.campaign_version_id,
"job_id": job.id,
"channel": "postbox",
"attempt_id": attempt.id,
"decision": decision,
"send_status": result.status,
"postbox_status": job.postbox_status,
"note": evidence_note,
}
def _reconcile_imap_append_outcome(
session: Session,
*,
@@ -1444,7 +1652,11 @@ def _reconcile_imap_append_outcome(
evidence_note = (note or "").strip()
if not evidence_note:
raise QueueingError("IMAP reconciliation requires an evidence note")
if job.send_status not in SMTP_ACCEPTED_STATUSES:
if not _mail_was_accepted(
session,
job.id,
send_status=job.send_status,
):
raise QueueingError("Only an SMTP-accepted job can reconcile a Sent-folder append")
if job.imap_status != JobImapStatus.OUTCOME_UNKNOWN.value:
raise QueueingError(f"IMAP status {job.imap_status} does not require reconciliation")
@@ -1682,7 +1894,11 @@ def _campaign_delivery_counts(session: Session, *, campaign_id: str, version_id:
for status in {item.value for item in JobSendStatus}
}
return _CampaignDeliveryCounts(
accepted=sum(counts.get(status, 0) for status in SMTP_ACCEPTED_STATUSES),
accepted=sum(
counts.get(status, 0)
for status in DELIVERY_ACCEPTED_STATUSES
),
partial=counts.get(JobSendStatus.PARTIALLY_ACCEPTED.value, 0),
unknown=counts.get(JobSendStatus.OUTCOME_UNKNOWN.value, 0),
failed=counts.get(JobSendStatus.FAILED_TEMPORARY.value, 0) + counts.get(JobSendStatus.FAILED_PERMANENT.value, 0),
active=(
@@ -1732,7 +1948,13 @@ def _apply_campaign_delivery_status(
def _campaign_delivery_status_pair(counts: _CampaignDeliveryCounts) -> tuple[str, str] | None:
if counts.active:
return CampaignStatus.SENDING.value, CampaignVersionWorkflowState.SENDING.value
if counts.accepted and (counts.failed or counts.unknown or counts.cancelled or counts.not_started):
if counts.accepted and (
counts.partial
or counts.failed
or counts.unknown
or counts.cancelled
or counts.not_started
):
return CampaignStatus.PARTIALLY_COMPLETED.value, CampaignVersionWorkflowState.PARTIALLY_COMPLETED.value
if counts.unknown:
return CampaignStatus.OUTCOME_UNKNOWN.value, CampaignVersionWorkflowState.OUTCOME_UNKNOWN.value
@@ -1762,12 +1984,22 @@ def send_campaign_job(
context = _send_job_delivery_context(session, job)
if dry_run:
policy = DeliveryChannelPolicy(job.delivery_channel_policy)
descriptions: list[str] = []
if policy.uses_mail:
descriptions.append(
f"Mail to {len(context.envelope_recipients)} recipient(s)"
)
if policy.uses_postbox:
descriptions.append(
f"Postbox to {len(job.resolved_postbox_targets or [])} target(s)"
)
return SendJobResult(
job_id=job.id,
status="dry_run",
attempt_number=job.attempt_count,
dry_run=True,
message=f"Would send to {len(context.envelope_recipients)} recipient(s) from {context.envelope_from}",
message=f"Would deliver via {'; '.join(descriptions)}",
)
claimed = _claimed_campaign_job_for_delivery(session, job)
@@ -1794,7 +2026,7 @@ def _preflight_send_campaign_job(
return SendJobResult(job_id=job.id, status="cancelled", attempt_number=job.attempt_count, dry_run=dry_run)
if job.queue_status == JobQueueStatus.PAUSED.value:
return SendJobResult(job_id=job.id, status="paused", attempt_number=job.attempt_count, dry_run=dry_run)
if job.send_status in SMTP_ACCEPTED_STATUSES:
if job.send_status in FULLY_ACCEPTED_STATUSES:
return SendJobResult(job_id=job.id, status="already_accepted", attempt_number=job.attempt_count, dry_run=dry_run)
if job.send_status == JobSendStatus.OUTCOME_UNKNOWN.value:
return SendJobResult(
@@ -1802,13 +2034,13 @@ def _preflight_send_campaign_job(
status=JobSendStatus.OUTCOME_UNKNOWN.value,
attempt_number=job.attempt_count,
dry_run=dry_run,
message="SMTP outcome is unresolved; reconcile it before any retry.",
message="A delivery outcome is unresolved; reconcile it before any retry.",
)
if job.send_status == JobSendStatus.SENDING.value:
return mark_job_outcome_unknown(
session,
job,
reason="A delivery task resumed while the previous SMTP attempt was still marked in progress. Automatic resend was stopped.",
reason="A delivery task resumed while the previous channel attempt was still marked in progress. Automatic redelivery was stopped.",
)
if job.send_status == JobSendStatus.CLAIMED.value:
return SendJobResult(
@@ -1833,10 +2065,18 @@ def _send_job_delivery_context(session: Session, job: CampaignJob) -> _SendJobDe
raise SendJobError(str(exc)) from exc
message_bytes = _load_eml_bytes_for_job(job)
envelope_from = _sender_from_job(job)
envelope_recipients = _recipients_from_job(job)
if not envelope_recipients:
raise SmtpConfigurationError("No envelope recipients could be determined")
channel_policy = DeliveryChannelPolicy(
getattr(job, "delivery_channel_policy", DeliveryChannelPolicy.MAIL.value)
)
envelope_from: str | None = None
envelope_recipients: list[str] = []
if channel_policy.uses_mail:
envelope_from = _sender_from_job(job)
envelope_recipients = _recipients_from_job(job)
if not envelope_recipients:
raise SmtpConfigurationError(
"No envelope recipients could be determined"
)
return _SendJobDeliveryContext(
version=version,
snapshot=snapshot,
@@ -1887,6 +2127,42 @@ def _send_claimed_campaign_job(
use_rate_limit: bool,
enqueue_imap_task: bool,
) -> SendJobResult:
channel_policy = DeliveryChannelPolicy(
getattr(job, "delivery_channel_policy", DeliveryChannelPolicy.MAIL.value)
)
if channel_policy == DeliveryChannelPolicy.MAIL:
return _send_claimed_mail_only_job(
session,
job=job,
claim_token=claim_token,
context=context,
use_rate_limit=use_rate_limit,
enqueue_imap_task=enqueue_imap_task,
)
return _send_claimed_multichannel_job(
session,
job=job,
claim_token=claim_token,
context=context,
channel_policy=channel_policy,
use_rate_limit=use_rate_limit,
enqueue_imap_task=enqueue_imap_task,
)
def _send_claimed_mail_only_job(
session: Session,
*,
job: CampaignJob,
claim_token: str,
context: _SendJobDeliveryContext,
use_rate_limit: bool,
enqueue_imap_task: bool,
) -> SendJobResult:
if context.envelope_from is None or not context.envelope_recipients:
raise SmtpConfigurationError(
"Mail delivery has no frozen envelope sender or recipients."
)
mail_integration().wait_for_rate_limit(
key=f"tenant:{job.tenant_id}:campaign:{job.campaign_id}",
messages_per_minute=context.snapshot.delivery.rate_limit.messages_per_minute,
@@ -1954,6 +2230,310 @@ def _send_claimed_campaign_job(
return success
def _mail_was_accepted(
session: Session,
job_id: str,
*,
send_status: str | None = None,
) -> bool:
if send_status in {
JobSendStatus.SMTP_ACCEPTED.value,
JobSendStatus.SENT.value,
JobSendStatus.DELIVERED.value,
}:
return True
return (
session.query(SendAttempt.id)
.filter(
SendAttempt.job_id == job_id,
SendAttempt.status.in_(
[
JobSendStatus.SMTP_ACCEPTED.value,
"smtp_accepted_with_refusals",
"reconciled_smtp_accepted",
]
),
)
.first()
is not None
)
def _deliver_mail_channel(
session: Session,
*,
job: CampaignJob,
claim_token: str,
context: _SendJobDeliveryContext,
use_rate_limit: bool,
enqueue_imap_task: bool,
) -> _MailChannelOutcome:
if _mail_was_accepted(
session,
job.id,
send_status=job.send_status,
):
return _MailChannelOutcome(accepted=True)
try:
result = _send_claimed_mail_only_job(
session,
job=job,
claim_token=claim_token,
context=context,
use_rate_limit=use_rate_limit,
enqueue_imap_task=enqueue_imap_task,
)
except Exception as exc:
current = session.get(CampaignJob, job.id)
if current is None:
raise SendJobError(
"Campaign job disappeared while recording Mail delivery."
) from exc
if current.send_status == JobSendStatus.OUTCOME_UNKNOWN.value:
return _MailChannelOutcome(
outcome_unknown=True,
message=current.last_error or str(exc),
)
if current.send_status == JobSendStatus.FAILED_TEMPORARY.value:
return _MailChannelOutcome(
rejected_temporary=True,
message=current.last_error or str(exc),
)
if current.send_status == JobSendStatus.FAILED_PERMANENT.value:
return _MailChannelOutcome(
rejected_permanent=True,
message=current.last_error or str(exc),
)
unknown = mark_job_outcome_unknown(
session,
current,
reason=(
"Mail delivery raised an unexpected error after its durable "
"attempt started. The outcome is unknown and no fallback was "
f"started: {exc}"
),
)
return _MailChannelOutcome(
outcome_unknown=True,
message=unknown.message,
)
return _MailChannelOutcome(
accepted=result.status
in {
JobSendStatus.SMTP_ACCEPTED.value,
"already_accepted",
},
outcome_unknown=result.status == JobSendStatus.OUTCOME_UNKNOWN.value,
message=result.message,
)
def _empty_postbox_outcome() -> PostboxChannelOutcome:
return PostboxChannelOutcome()
def _final_multichannel_status(
*,
channel_policy: DeliveryChannelPolicy,
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:
return JobSendStatus.OUTCOME_UNKNOWN.value
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 _multichannel_messages(
mail: _MailChannelOutcome | None,
postbox: PostboxChannelOutcome | None,
) -> list[str]:
values: list[str] = []
if mail and mail.message:
values.append(f"Mail: {mail.message}")
if postbox:
values.extend(
f"Postbox: {message}"
for message in postbox.messages
if message
)
return values
def _finalize_multichannel_job(
session: Session,
*,
job_id: str,
channel_policy: DeliveryChannelPolicy,
mail: _MailChannelOutcome | None,
postbox: PostboxChannelOutcome | None,
commit: bool = True,
) -> SendJobResult:
job = session.get(CampaignJob, job_id)
if job is None:
raise SendJobError(
"Campaign job disappeared while finalizing delivery."
)
status = _final_multichannel_status(
channel_policy=channel_policy,
mail=mail,
postbox=postbox,
)
accepted = status in DELIVERY_ACCEPTED_STATUSES
unknown = status == JobSendStatus.OUTCOME_UNKNOWN.value
messages = _multichannel_messages(mail, postbox)
job.queue_status = JobQueueStatus.DRAFT.value
job.send_status = status
job.claim_token = None
job.last_error = "\n".join(messages) or None
job.outcome_unknown_at = _utcnow() if unknown else None
if accepted or (unknown and bool((mail and mail.accepted) or (postbox and postbox.accepted_count))):
job.sent_at = job.sent_at or _utcnow()
files_integration().mark_job_attachment_uses_sent(session, job)
if not (mail and mail.accepted):
job.imap_status = JobImapStatus.NOT_REQUESTED.value
session.add(job)
_update_campaign_after_job(
session,
job.campaign_id,
job.campaign_version_id,
)
if commit:
session.commit()
else:
session.flush()
return SendJobResult(
job_id=job.id,
status=status,
attempt_number=job.attempt_count + job.postbox_attempt_count,
message=job.last_error,
)
def _send_claimed_multichannel_job(
session: Session,
*,
job: CampaignJob,
claim_token: str,
context: _SendJobDeliveryContext,
channel_policy: DeliveryChannelPolicy,
use_rate_limit: bool,
enqueue_imap_task: bool,
) -> SendJobResult:
mail_outcome: _MailChannelOutcome | None = None
postbox_outcome: PostboxChannelOutcome | None = None
if channel_policy == DeliveryChannelPolicy.MAIL_THEN_POSTBOX:
prior_postbox_outcome = _postbox_outcome_from_attempts(session, job)
if prior_postbox_outcome.outcome_unknown:
postbox_outcome = prior_postbox_outcome
elif prior_postbox_outcome.accepted_count:
postbox_outcome = deliver_campaign_job_to_postboxes(
session,
job=job,
message_bytes=context.message_bytes,
classification=context.snapshot.delivery.postbox.classification,
)
else:
mail_outcome = _deliver_mail_channel(
session,
job=job,
claim_token=claim_token,
context=context,
use_rate_limit=use_rate_limit,
enqueue_imap_task=enqueue_imap_task,
)
if mail_outcome.rejected_before_acceptance:
current = session.get(CampaignJob, job.id)
if current is None:
raise SendJobError(
"Campaign job disappeared before Postbox fallback."
)
postbox_outcome = deliver_campaign_job_to_postboxes(
session,
job=current,
message_bytes=context.message_bytes,
classification=context.snapshot.delivery.postbox.classification,
)
else:
current = session.get(CampaignJob, job.id)
if current is not None:
current.postbox_status = JobPostboxStatus.SKIPPED.value
session.add(current)
session.commit()
else:
postbox_outcome = deliver_campaign_job_to_postboxes(
session,
job=job,
message_bytes=context.message_bytes,
classification=context.snapshot.delivery.postbox.classification,
)
should_deliver_mail = (
channel_policy == DeliveryChannelPolicy.MAIL_AND_POSTBOX
or (
channel_policy == DeliveryChannelPolicy.POSTBOX_THEN_MAIL
and postbox_outcome.all_rejected_before_acceptance
)
)
if should_deliver_mail:
current = session.get(CampaignJob, job.id)
if current is None:
raise SendJobError(
"Campaign job disappeared before Mail delivery."
)
mail_outcome = _deliver_mail_channel(
session,
job=current,
claim_token=claim_token,
context=context,
use_rate_limit=use_rate_limit,
enqueue_imap_task=enqueue_imap_task,
)
return _finalize_multichannel_job(
session,
job_id=job.id,
channel_policy=channel_policy,
mail=mail_outcome,
postbox=postbox_outcome,
)
def _record_smtp_send_success(
session: Session,
*,
@@ -2057,12 +2637,21 @@ def _imap_attempt_count(session: Session, job_id: str) -> int:
def _claim_job_for_imap_append(session: Session, job: CampaignJob) -> str | None:
"""Atomically grant one worker permission to invoke the IMAP provider."""
if not _mail_was_accepted(
session,
job.id,
send_status=getattr(
job,
"send_status",
JobSendStatus.SMTP_ACCEPTED.value,
),
):
return None
claim_token = str(uuid4())
changed = (
session.query(CampaignJob)
.filter(
CampaignJob.id == job.id,
CampaignJob.send_status.in_(list(SMTP_ACCEPTED_STATUSES)),
CampaignJob.imap_status.in_([JobImapStatus.PENDING.value, JobImapStatus.FAILED.value]),
CampaignJob.imap_claim_token.is_(None),
)
@@ -2348,7 +2937,11 @@ def append_sent_for_job(session: Session, *, job_id: str, dry_run: bool = False)
job = session.get(CampaignJob, job_id)
if not job:
raise SendJobError(f"Job not found: {job_id}")
if job.send_status not in SMTP_ACCEPTED_STATUSES:
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:
@@ -2496,12 +3089,20 @@ def enqueue_pending_imap_appends(
.filter(
CampaignJob.tenant_id == tenant_id,
CampaignJob.campaign_id == campaign.id,
CampaignJob.send_status.in_(list(SMTP_ACCEPTED_STATUSES)),
CampaignJob.imap_status.in_([JobImapStatus.PENDING.value, JobImapStatus.FAILED.value]),
)
.order_by(CampaignJob.entry_index.asc())
.all()
)
jobs = [
job
for job in jobs
if _mail_was_accepted(
session,
job.id,
send_status=job.send_status,
)
]
should_enqueue = _should_enqueue_celery(enqueue_celery) and not dry_run and not run_inline
results: list[dict[str, Any]] = []
appended_count = 0