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

@@ -9,7 +9,10 @@ from pydantic import BaseModel, ConfigDict
from sqlalchemy.orm import Session
from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignVersion, JobValidationStatus
from govoplan_campaign.backend.campaign.models import DeliveryConfig
from govoplan_campaign.backend.campaign.models import (
DeliveryChannelPolicy,
DeliveryConfig,
)
from govoplan_campaign.backend.campaign.mail_profile_boundary import (
CampaignMailProfileBoundaryError,
assert_campaign_uses_mail_profile_reference,
@@ -19,7 +22,8 @@ from govoplan_campaign.backend.campaign.mail_profile_boundary import (
from govoplan_campaign.backend.integrations import MailProfileError, files_integration, mail_integration
from govoplan_campaign.backend.path_security import CampaignPathSecurityError, assert_server_safe_campaign_paths
SNAPSHOT_VERSION = "6"
SNAPSHOT_VERSION = "7"
SUPPORTED_SNAPSHOT_VERSIONS = {"6", SNAPSHOT_VERSION}
class ExecutionSnapshotError(RuntimeError):
@@ -41,7 +45,7 @@ class ExecutionSnapshot(BaseModel):
snapshot_version: str = SNAPSHOT_VERSION
campaign_version_id: str
campaign_json_sha256: str
mail_profile_id: str
mail_profile_id: str | None = None
smtp_server_id: str | None = None
smtp_credential_id: str | None = None
imap_server_id: str | None = None
@@ -55,6 +59,8 @@ class ExecutionSnapshot(BaseModel):
effective_policy_sha256: str | None = None
smtp_transport_revision: str | None = None
imap_transport_revision: str | None = None
uses_mail: bool = True
uses_postbox: bool = False
delivery: DeliveryConfig
@@ -72,7 +78,7 @@ def snapshot_hash(payload: dict[str, Any]) -> str:
def profile_delivery_summary(session: Session, version: CampaignVersion) -> dict[str, Any]:
raw_json = version.raw_json if isinstance(version.raw_json, dict) else {}
_assert_version_mail_profile_boundary(raw_json)
_assert_version_mail_profile_boundary(raw_json, require_profile=True)
mail = mail_integration()
profile_id = campaign_mail_profile_id(raw_json)
if profile_id is None: # Kept explicit for static typing; the assertion above requires it.
@@ -106,7 +112,12 @@ def profile_transport_revisions(session: Session, version: CampaignVersion) -> d
def _assert_snapshot_profile_matches_version(version: CampaignVersion, snapshot: ExecutionSnapshot) -> None:
raw_json = version.raw_json if isinstance(version.raw_json, dict) else {}
_assert_version_mail_profile_boundary(raw_json)
_assert_version_mail_profile_boundary(
raw_json,
require_profile=snapshot.uses_mail,
)
if not snapshot.uses_mail:
return
if campaign_mail_profile_id(raw_json) != snapshot.mail_profile_id:
raise ExecutionSnapshotError(
"The campaign's Mail profile reference differs from the built execution snapshot. "
@@ -127,19 +138,35 @@ def _assert_snapshot_profile_matches_version(version: CampaignVersion, snapshot:
)
def _assert_version_mail_profile_boundary(raw_json: dict[str, Any]) -> None:
def _assert_version_mail_profile_boundary(
raw_json: dict[str, Any],
*,
require_profile: bool,
) -> None:
try:
assert_campaign_uses_mail_profile_reference(raw_json, require_profile=True)
assert_campaign_uses_mail_profile_reference(
raw_json,
require_profile=require_profile,
)
except CampaignMailProfileBoundaryError as exc:
raise ExecutionSnapshotError(str(exc)) from exc
def _policy_fingerprint(raw_json: dict[str, Any], delivery: DeliveryConfig) -> str:
def _policy_fingerprint(
raw_json: dict[str, Any],
delivery: DeliveryConfig,
*,
snapshot_version: str = SNAPSHOT_VERSION,
) -> str:
delivery_payload = delivery.model_dump(mode="json")
if snapshot_version == "6":
delivery_payload.pop("channel_policy", None)
delivery_payload.pop("postbox", None)
return _sha256(
{
"validation_policy": raw_json.get("validation_policy"),
"policy": raw_json.get("policy"),
"delivery": delivery.model_dump(mode="json"),
"delivery": delivery_payload,
"attachment_defaults": (raw_json.get("attachments") or {}).get("defaults")
if isinstance(raw_json.get("attachments"), dict)
else None,
@@ -147,8 +174,12 @@ def _policy_fingerprint(raw_json: dict[str, Any], delivery: DeliveryConfig) -> s
)
def _job_execution_input_payload(job: CampaignJob) -> dict[str, Any]:
return {
def _job_execution_input_payload(
job: CampaignJob,
*,
snapshot_version: str = SNAPSHOT_VERSION,
) -> dict[str, Any]:
payload = {
"job_id": job.id,
"entry_index": job.entry_index,
"entry_id": job.entry_id,
@@ -163,17 +194,47 @@ def _job_execution_input_payload(job: CampaignJob) -> dict[str, Any]:
"resolved_attachments_sha256": _sha256(job.resolved_attachments or []),
"issues_sha256": _sha256(job.issues_snapshot or []),
}
if snapshot_version != "6":
payload.update(
{
"delivery_channel_policy": getattr(
job,
"delivery_channel_policy",
DeliveryChannelPolicy.MAIL.value,
),
"resolved_postbox_targets_sha256": _sha256(
getattr(job, "resolved_postbox_targets", None) or []
),
}
)
return payload
def job_execution_input_hash(job: CampaignJob) -> str:
return _sha256(_job_execution_input_payload(job))
def job_execution_input_hash(
job: CampaignJob,
*,
snapshot_version: str = SNAPSHOT_VERSION,
) -> str:
return _sha256(
_job_execution_input_payload(
job,
snapshot_version=snapshot_version,
)
)
def job_manifest_hash(jobs: Iterable[CampaignJob]) -> str:
def job_manifest_hash(
jobs: Iterable[CampaignJob],
*,
snapshot_version: str = SNAPSHOT_VERSION,
) -> str:
"""Hash the immutable per-message execution records in stable order."""
payload = [
_job_execution_input_payload(job)
_job_execution_input_payload(
job,
snapshot_version=snapshot_version,
)
for job in sorted(jobs, key=lambda item: (item.entry_index, item.id))
]
return _sha256(payload)
@@ -182,8 +243,8 @@ def job_manifest_hash(jobs: Iterable[CampaignJob]) -> str:
def create_execution_snapshot(
version: CampaignVersion,
*,
mail_profile_id: str,
smtp_transport_revision: str,
mail_profile_id: str | None,
smtp_transport_revision: str | None,
imap_transport_revision: str | None,
delivery: DeliveryConfig,
smtp_server_id: str | None = None,
@@ -195,8 +256,23 @@ def create_execution_snapshot(
) -> tuple[dict[str, Any], str]:
raw_json = version.raw_json if isinstance(version.raw_json, dict) else {}
job_list = list(jobs)
channel_policies = {
DeliveryChannelPolicy(
getattr(
job,
"delivery_channel_policy",
DeliveryChannelPolicy.MAIL.value,
)
)
for job in job_list
}
uses_mail = any(policy.uses_mail for policy in channel_policies)
uses_postbox = any(policy.uses_postbox for policy in channel_policies)
for job in job_list:
job.execution_input_sha256 = job_execution_input_hash(job)
job.execution_input_sha256 = job_execution_input_hash(
job,
snapshot_version=SNAPSHOT_VERSION,
)
summary = build_summary if isinstance(build_summary, dict) else {}
queueable_statuses = {JobValidationStatus.READY.value, JobValidationStatus.WARNING.value}
payload = ExecutionSnapshot(
@@ -211,10 +287,23 @@ def create_execution_snapshot(
built_at=str(summary.get("built_at") or "") or None,
job_count=len(job_list),
queueable_job_count=sum(1 for job in job_list if job.validation_status in queueable_statuses),
job_manifest_sha256=job_manifest_hash(job_list) if job_list else None,
effective_policy_sha256=_policy_fingerprint(raw_json, delivery),
job_manifest_sha256=(
job_manifest_hash(
job_list,
snapshot_version=SNAPSHOT_VERSION,
)
if job_list
else None
),
effective_policy_sha256=_policy_fingerprint(
raw_json,
delivery,
snapshot_version=SNAPSHOT_VERSION,
),
smtp_transport_revision=smtp_transport_revision,
imap_transport_revision=imap_transport_revision,
uses_mail=uses_mail,
uses_postbox=uses_postbox,
created_at=datetime.now(timezone.utc).isoformat(),
delivery=delivery,
).model_dump(mode="json")
@@ -238,13 +327,17 @@ def _assert_snapshot_matches_persisted_inputs(
"Campaign inputs changed after this execution snapshot was built. "
"Revalidate and rebuild the campaign before delivery."
)
if not snapshot.smtp_transport_revision:
if snapshot.uses_mail and not snapshot.smtp_transport_revision:
raise ExecutionSnapshotError("Execution snapshot has no SMTP transport revision")
if not snapshot.job_manifest_sha256:
raise ExecutionSnapshotError("Execution snapshot has no built-job manifest checksum")
if not snapshot.effective_policy_sha256:
raise ExecutionSnapshotError("Execution snapshot has no effective-policy checksum")
if snapshot.effective_policy_sha256 != _policy_fingerprint(raw_json, snapshot.delivery):
if snapshot.effective_policy_sha256 != _policy_fingerprint(
raw_json,
snapshot.delivery,
snapshot_version=snapshot.snapshot_version,
):
raise ExecutionSnapshotError(
"Campaign delivery policy changed after the execution snapshot was created. "
"Revalidate and rebuild the campaign before delivery."
@@ -255,7 +348,10 @@ def _assert_snapshot_matches_persisted_inputs(
raise ExecutionSnapshotError("Campaign job does not belong to the snapshotted version")
if not getattr(effect_job, "execution_input_sha256", None):
raise ExecutionSnapshotError("Campaign job has no execution-input checksum; rebuild before delivery")
if effect_job.execution_input_sha256 != job_execution_input_hash(effect_job):
if effect_job.execution_input_sha256 != job_execution_input_hash(
effect_job,
snapshot_version=snapshot.snapshot_version,
):
raise ExecutionSnapshotError(
"Built campaign job inputs changed after the execution snapshot was created. "
"Revalidate and rebuild the campaign before delivery."
@@ -277,8 +373,19 @@ def _assert_snapshot_matches_persisted_inputs(
queueable_count = sum(1 for job in jobs if job.validation_status in queueable_statuses)
if (
snapshot.queueable_job_count != queueable_count
or snapshot.job_manifest_sha256 != job_manifest_hash(jobs)
or any(getattr(job, "execution_input_sha256", None) != job_execution_input_hash(job) for job in jobs)
or snapshot.job_manifest_sha256
!= job_manifest_hash(
jobs,
snapshot_version=snapshot.snapshot_version,
)
or any(
getattr(job, "execution_input_sha256", None)
!= job_execution_input_hash(
job,
snapshot_version=snapshot.snapshot_version,
)
for job in jobs
)
):
raise ExecutionSnapshotError(
"Built campaign job inputs changed after the execution snapshot was created. "
@@ -307,17 +414,20 @@ def ensure_execution_snapshot(
)
except CampaignPathSecurityError as exc:
raise ExecutionSnapshotError(str(exc)) from exc
_assert_version_mail_profile_boundary(raw_json)
_assert_version_mail_profile_boundary(raw_json, require_profile=False)
if isinstance(version.execution_snapshot, dict):
if str(version.execution_snapshot.get("snapshot_version") or "") != SNAPSHOT_VERSION:
stored_version = str(
version.execution_snapshot.get("snapshot_version") or ""
)
if stored_version not in SUPPORTED_SNAPSHOT_VERSIONS:
raise ExecutionSnapshotError(
"This campaign has a legacy execution snapshot that may contain campaign-owned transport data. "
"It is preserved for audit only and cannot be delivered; select a Mail profile, then revalidate "
"and rebuild a new campaign version."
)
snapshot = ExecutionSnapshot.model_validate(version.execution_snapshot)
expected = snapshot_hash(snapshot.model_dump(mode="json"))
expected = snapshot_hash(version.execution_snapshot)
if not version.execution_snapshot_hash:
raise ExecutionSnapshotError("Execution snapshot checksum is missing")
if version.execution_snapshot_hash != expected:
@@ -334,11 +444,6 @@ def ensure_execution_snapshot(
from govoplan_campaign.backend.persistence.campaigns import load_version_config
_, _, config = load_version_config(session, version.id)
profile_id = campaign_mail_profile_id(raw_json)
if not config.server.profile_capabilities.smtp_available:
raise ExecutionSnapshotError("The selected Mail profile has no SMTP configuration")
if profile_id is None:
raise ExecutionSnapshotError("Campaign has no Mail profile reference")
jobs = (
session.query(CampaignJob)
.filter(CampaignJob.campaign_version_id == version.id)
@@ -347,9 +452,24 @@ def ensure_execution_snapshot(
)
if not jobs:
raise ExecutionSnapshotError("Campaign version has no built jobs; rebuild it before delivery")
summary = profile_delivery_summary(session, version)
if not summary.get("smtp_transport_revision"):
raise ExecutionSnapshotError("The selected Mail profile has no SMTP transport revision")
uses_mail = any(
DeliveryChannelPolicy(job.delivery_channel_policy).uses_mail
for job in jobs
)
profile_id = campaign_mail_profile_id(raw_json)
summary: dict[str, Any] = {}
if uses_mail:
if not config.server.profile_capabilities.smtp_available:
raise ExecutionSnapshotError(
"The selected Mail profile has no SMTP configuration"
)
if profile_id is None:
raise ExecutionSnapshotError("Campaign has no Mail profile reference")
summary = profile_delivery_summary(session, version)
if not summary.get("smtp_transport_revision"):
raise ExecutionSnapshotError(
"The selected Mail profile has no SMTP transport revision"
)
payload, digest = create_execution_snapshot(
version,
mail_profile_id=profile_id,
@@ -357,7 +477,7 @@ def ensure_execution_snapshot(
smtp_credential_id=summary.get("smtp_credential_id"),
imap_server_id=summary.get("imap_server_id"),
imap_credential_id=summary.get("imap_credential_id"),
smtp_transport_revision=summary["smtp_transport_revision"],
smtp_transport_revision=summary.get("smtp_transport_revision"),
imap_transport_revision=summary.get("imap_transport_revision"),
delivery=config.delivery,
jobs=jobs,

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

View File

@@ -0,0 +1,521 @@
from __future__ import annotations
import hashlib
import json
from dataclasses import dataclass, field
from email import policy
from email.parser import BytesParser
from typing import Any
from sqlalchemy import func
from sqlalchemy.orm import Session
from govoplan_core.core.postbox import (
PostboxAttachmentRef,
PostboxDeliveryOutcomeUnknown,
PostboxDeliveryRejected,
PostboxDeliveryRequest,
PostboxParticipantRef,
PostboxTargetRef,
)
from govoplan_campaign.backend.db.models import (
CampaignJob,
JobPostboxStatus,
PostboxDeliveryAttempt,
)
from govoplan_campaign.backend.integrations import (
PostboxDeliveryUnavailable,
postbox_integration,
)
from govoplan_core.security.time import utc_now
ACCEPTED_POSTBOX_ATTEMPT_STATUSES = {
JobPostboxStatus.ACCEPTED.value,
JobPostboxStatus.ACCEPTED_VACANT.value,
}
@dataclass(slots=True)
class PostboxChannelOutcome:
accepted: int = 0
accepted_vacant: int = 0
rejected_temporary: int = 0
rejected_permanent: int = 0
outcome_unknown: int = 0
messages: list[str] = field(default_factory=list)
@property
def accepted_count(self) -> int:
return self.accepted + self.accepted_vacant
@property
def rejected_count(self) -> int:
return self.rejected_temporary + self.rejected_permanent
@property
def all_rejected_before_acceptance(self) -> bool:
return (
self.accepted_count == 0
and self.outcome_unknown == 0
and self.rejected_count > 0
)
@property
def status(self) -> str:
if self.outcome_unknown:
return JobPostboxStatus.OUTCOME_UNKNOWN.value
if self.accepted_count and self.rejected_count:
return JobPostboxStatus.PARTIALLY_ACCEPTED.value
if self.accepted_vacant and not self.accepted:
return JobPostboxStatus.ACCEPTED_VACANT.value
if self.accepted_count:
return JobPostboxStatus.ACCEPTED.value
if self.rejected_temporary:
return JobPostboxStatus.REJECTED_TEMPORARY.value
return JobPostboxStatus.REJECTED_PERMANENT.value
def _target_key(target: dict[str, Any]) -> str:
payload = json.dumps(
target,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
default=str,
).encode("utf-8")
return hashlib.sha256(payload).hexdigest()
def _attempt_number(
session: Session,
*,
job_id: str,
target_key: str,
) -> int:
current = (
session.query(func.max(PostboxDeliveryAttempt.attempt_number))
.filter(
PostboxDeliveryAttempt.job_id == job_id,
PostboxDeliveryAttempt.target_key == target_key,
)
.scalar()
)
return int(current or 0) + 1
def _accepted_attempt(
session: Session,
*,
job_id: str,
target_key: str,
) -> PostboxDeliveryAttempt | None:
return (
session.query(PostboxDeliveryAttempt)
.filter(
PostboxDeliveryAttempt.job_id == job_id,
PostboxDeliveryAttempt.target_key == target_key,
PostboxDeliveryAttempt.status.in_(
list(ACCEPTED_POSTBOX_ATTEMPT_STATUSES)
),
)
.order_by(PostboxDeliveryAttempt.attempt_number.desc())
.first()
)
def _message_body(message_bytes: bytes) -> str | None:
message = BytesParser(policy=policy.default).parsebytes(message_bytes)
body = message.get_body(preferencelist=("plain", "html"))
if body is None:
payload = message.get_payload(decode=True)
if not isinstance(payload, bytes):
return None
return payload.decode(message.get_content_charset() or "utf-8", "replace")
try:
content = body.get_content()
except (LookupError, UnicodeError):
payload = body.get_payload(decode=True)
if not isinstance(payload, bytes):
return None
return payload.decode(body.get_content_charset() or "utf-8", "replace")
return str(content)
def _participants(job: CampaignJob) -> tuple[PostboxParticipantRef, ...]:
recipients = job.resolved_recipients or {}
values: list[PostboxParticipantRef] = []
for key in (
"from_all",
"to",
"cc",
"bcc",
"reply_to",
"bounce_to",
"disposition_notification_to",
):
for item in recipients.get(key) or []:
if not isinstance(item, dict):
continue
address = str(item.get("email") or "").strip() or None
label = str(item.get("name") or "").strip() or None
if address is None and label is None:
continue
values.append(
PostboxParticipantRef(
kind="sender" if key == "from_all" else key,
reference_type="mail_address",
label=label,
address=address,
)
)
return tuple(values)
def _attachments(job: CampaignJob) -> tuple[PostboxAttachmentRef, ...]:
values = [
PostboxAttachmentRef(
reference_type="campaign_eml",
reference_id=job.id,
name=f"{job.entry_id or job.entry_index}.eml",
media_type="message/rfc822",
size_bytes=job.eml_size_bytes,
digest=job.eml_sha256,
metadata={
"campaign_id": job.campaign_id,
"campaign_version_id": job.campaign_version_id,
},
)
]
for rule_index, rule in enumerate(job.resolved_attachments or []):
if not isinstance(rule, dict):
continue
managed_matches = rule.get("managed_matches")
if isinstance(managed_matches, list) and managed_matches:
for match in managed_matches:
if not isinstance(match, dict):
continue
reference_id = str(
match.get("version_id")
or match.get("asset_id")
or match.get("blob_id")
or ""
).strip()
if not reference_id:
continue
values.append(
PostboxAttachmentRef(
reference_type=(
"file_version"
if match.get("version_id")
else "file_asset"
),
reference_id=reference_id,
name=str(match.get("filename") or "").strip() or None,
media_type=(
str(match.get("content_type"))
if match.get("content_type")
else None
),
size_bytes=(
int(match["size_bytes"])
if match.get("size_bytes") is not None
else None
),
digest=(
str(match.get("checksum_sha256"))
if match.get("checksum_sha256")
else None
),
metadata={
key: value
for key, value in match.items()
if key
in {
"asset_id",
"version_id",
"blob_id",
"display_path",
"relative_path",
"owner_type",
"owner_id",
"source_revision",
}
},
)
)
continue
matches = rule.get("matches")
if not isinstance(matches, list):
continue
for match_index, match in enumerate(matches):
values.append(
PostboxAttachmentRef(
reference_type="campaign_attachment",
reference_id=f"{job.id}:{rule_index}:{match_index}",
name=str(match).rsplit("/", 1)[-1] or None,
metadata={
"campaign_id": job.campaign_id,
"campaign_version_id": job.campaign_version_id,
"job_id": job.id,
},
)
)
return tuple(values)
def _sender_label(job: CampaignJob) -> str | None:
recipients = job.resolved_recipients or {}
sender = recipients.get("from")
if not isinstance(sender, dict):
return None
name = str(sender.get("name") or "").strip()
address = str(sender.get("email") or "").strip()
if name and address:
return f"{name} <{address}>"
return address or name or None
def _request(
job: CampaignJob,
target: dict[str, Any],
*,
target_key: str,
body_text: str | None,
classification: str,
) -> PostboxDeliveryRequest:
return PostboxDeliveryRequest(
tenant_id=job.tenant_id,
target=PostboxTargetRef(postbox_id=str(target["postbox_id"])),
producer_module="campaigns",
producer_resource_type="campaign_job",
producer_resource_id=job.id,
idempotency_key=(
f"campaign:{job.campaign_version_id}:{job.id}:postbox:"
f"{target_key}"
),
subject=(job.subject or "").strip() or "(No subject)",
body_text=body_text,
sender_label=_sender_label(job),
classification=classification,
participants=_participants(job),
attachments=_attachments(job),
metadata={
"campaign_id": job.campaign_id,
"campaign_version_id": job.campaign_version_id,
"campaign_job_id": job.id,
"entry_id": job.entry_id,
"entry_index": job.entry_index,
"delivery_channel_policy": job.delivery_channel_policy,
"target_snapshot": target,
},
)
def _record_rejection(
session: Session,
*,
job_id: str,
attempt_id: str,
exc: Exception,
temporary: bool,
) -> None:
session.rollback()
attempt = session.get(PostboxDeliveryAttempt, attempt_id)
job = session.get(CampaignJob, job_id)
if attempt is None or job is None:
raise RuntimeError(
"Postbox rejection could not be written to Campaign evidence."
) from exc
attempt.status = (
JobPostboxStatus.REJECTED_TEMPORARY.value
if temporary
else JobPostboxStatus.REJECTED_PERMANENT.value
)
attempt.error_type = exc.__class__.__name__
attempt.error_code = str(getattr(exc, "code", "") or "") or None
attempt.error_message = str(exc)
attempt.finished_at = utc_now()
session.add(attempt)
session.add(job)
session.commit()
def _record_unknown(
session: Session,
*,
job_id: str,
attempt_id: str,
exc: Exception,
) -> None:
session.rollback()
attempt = session.get(PostboxDeliveryAttempt, attempt_id)
job = session.get(CampaignJob, job_id)
if attempt is None or job is None:
raise RuntimeError(
"Unknown Postbox outcome could not be written to Campaign evidence."
) from exc
attempt.status = JobPostboxStatus.OUTCOME_UNKNOWN.value
attempt.error_type = exc.__class__.__name__
attempt.error_code = str(getattr(exc, "code", "") or "") or None
attempt.error_message = str(exc)
attempt.finished_at = utc_now()
job.postbox_status = JobPostboxStatus.OUTCOME_UNKNOWN.value
session.add(attempt)
session.add(job)
session.commit()
def deliver_campaign_job_to_postboxes(
session: Session,
*,
job: CampaignJob,
message_bytes: bytes,
classification: str,
) -> PostboxChannelOutcome:
outcome = PostboxChannelOutcome()
targets = [
target
for target in (job.resolved_postbox_targets or [])
if isinstance(target, dict) and target.get("postbox_id")
]
if not targets:
outcome.rejected_permanent = 1
outcome.messages.append("No frozen Postbox target is available.")
job.postbox_status = JobPostboxStatus.REJECTED_PERMANENT.value
session.add(job)
session.commit()
return outcome
body_text = _message_body(message_bytes)
for target_index, target in enumerate(targets):
key = _target_key(target)
accepted = _accepted_attempt(
session,
job_id=job.id,
target_key=key,
)
if accepted is not None:
if accepted.vacant:
outcome.accepted_vacant += 1
else:
outcome.accepted += 1
continue
attempt_number = _attempt_number(
session,
job_id=job.id,
target_key=key,
)
request = _request(
job,
target,
target_key=key,
body_text=body_text,
classification=classification,
)
attempt = PostboxDeliveryAttempt(
tenant_id=job.tenant_id,
job_id=job.id,
target_key=key,
target_index=target_index,
attempt_number=attempt_number,
idempotency_key=request.idempotency_key,
status=JobPostboxStatus.DELIVERING.value,
target_snapshot=target,
evidence={},
started_at=utc_now(),
)
job.postbox_attempt_count += 1
job.postbox_status = JobPostboxStatus.DELIVERING.value
session.add(attempt)
session.add(job)
session.commit()
attempt_id = attempt.id
try:
result = postbox_integration().deliver(session, request)
current_attempt = session.get(PostboxDeliveryAttempt, attempt_id)
current_job = session.get(CampaignJob, job.id)
if current_attempt is None or current_job is None:
raise RuntimeError(
"Campaign Postbox attempt disappeared before acceptance."
)
current_attempt.status = (
JobPostboxStatus.ACCEPTED_VACANT.value
if result.vacant
else JobPostboxStatus.ACCEPTED.value
)
current_attempt.provider_delivery_id = result.delivery_id
current_attempt.provider_message_id = result.message_id
current_attempt.postbox_id = result.postbox_id
current_attempt.address = result.address
current_attempt.holder_count = result.holder_count
current_attempt.vacant = result.vacant
current_attempt.duplicate = result.duplicate
current_attempt.evidence = dict(result.evidence)
current_attempt.finished_at = utc_now()
session.add(current_attempt)
session.add(current_job)
session.commit()
if result.vacant:
outcome.accepted_vacant += 1
else:
outcome.accepted += 1
except PostboxDeliveryOutcomeUnknown as exc:
_record_unknown(
session,
job_id=job.id,
attempt_id=attempt_id,
exc=exc,
)
outcome.outcome_unknown += 1
outcome.messages.append(str(exc))
except PostboxDeliveryRejected as exc:
if exc.code == "idempotency_conflict":
_record_unknown(
session,
job_id=job.id,
attempt_id=attempt_id,
exc=exc,
)
outcome.outcome_unknown += 1
else:
_record_rejection(
session,
job_id=job.id,
attempt_id=attempt_id,
exc=exc,
temporary=exc.temporary,
)
if exc.temporary:
outcome.rejected_temporary += 1
else:
outcome.rejected_permanent += 1
outcome.messages.append(str(exc))
except PostboxDeliveryUnavailable as exc:
_record_rejection(
session,
job_id=job.id,
attempt_id=attempt_id,
exc=exc,
temporary=True,
)
outcome.rejected_temporary += 1
outcome.messages.append(str(exc))
except Exception as exc:
_record_unknown(
session,
job_id=job.id,
attempt_id=attempt_id,
exc=exc,
)
outcome.outcome_unknown += 1
outcome.messages.append(str(exc))
current_job = session.get(CampaignJob, job.id)
if current_job is not None:
current_job.postbox_status = outcome.status
session.add(current_job)
session.commit()
return outcome