feat: add governed postbox delivery and report hardening
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user