feat: add governed postbox delivery and report hardening
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
521
src/govoplan_campaign/backend/sending/postbox_delivery.py
Normal file
521
src/govoplan_campaign/backend/sending/postbox_delivery.py
Normal 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
|
||||
Reference in New Issue
Block a user