Implement governed hybrid campaign delivery

This commit is contained in:
2026-08-02 13:58:37 +02:00
parent b38597f2be
commit 7733265cc8
40 changed files with 2531 additions and 122 deletions
@@ -22,8 +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 = "7"
SUPPORTED_SNAPSHOT_VERSIONS = {"6", SNAPSHOT_VERSION}
SNAPSHOT_VERSION = "8"
SUPPORTED_SNAPSHOT_VERSIONS = {"6", "7", SNAPSHOT_VERSION}
class ExecutionSnapshotError(RuntimeError):
@@ -61,6 +61,7 @@ class ExecutionSnapshot(BaseModel):
imap_transport_revision: str | None = None
uses_mail: bool = True
uses_postbox: bool = False
uses_print: bool = False
delivery: DeliveryConfig
@@ -162,6 +163,8 @@ def _policy_fingerprint(
if snapshot_version == "6":
delivery_payload.pop("channel_policy", None)
delivery_payload.pop("postbox", None)
if snapshot_version in {"6", "7"}:
delivery_payload.pop("print", None)
return _sha256(
{
"validation_policy": raw_json.get("validation_policy"),
@@ -207,6 +210,13 @@ def _job_execution_input_payload(
),
}
)
if snapshot_version not in {"6", "7"}:
payload["delivery_provenance_sha256"] = _sha256(
getattr(job, "delivery_provenance", None) or {}
)
payload["resolved_print_output_sha256"] = _sha256(
getattr(job, "resolved_print_output", None) or {}
)
return payload
@@ -268,6 +278,7 @@ def create_execution_snapshot(
}
uses_mail = any(policy.uses_mail for policy in channel_policies)
uses_postbox = any(policy.uses_postbox for policy in channel_policies)
uses_print = any(policy.uses_print for policy in channel_policies)
for job in job_list:
job.execution_input_sha256 = job_execution_input_hash(
job,
@@ -304,6 +315,7 @@ def create_execution_snapshot(
imap_transport_revision=imap_transport_revision,
uses_mail=uses_mail,
uses_postbox=uses_postbox,
uses_print=uses_print,
created_at=datetime.now(timezone.utc).isoformat(),
delivery=delivery,
).model_dump(mode="json")
+215 -12
View File
@@ -37,11 +37,13 @@ from govoplan_campaign.backend.db.models import (
JobBuildStatus,
JobImapStatus,
JobPostboxStatus,
JobPrintStatus,
JobQueueStatus,
JobSendStatus,
JobValidationStatus,
ImapAppendAttempt,
PostboxDeliveryAttempt,
PrintOutputAttempt,
SendAttempt,
)
from govoplan_campaign.backend.campaign.models import DeliveryChannelPolicy
@@ -241,17 +243,26 @@ class _MailChannelOutcome:
)
@dataclass(frozen=True, slots=True)
class _PrintChannelOutcome:
accepted: bool = False
rejected_permanent: bool = False
message: str | None = None
@dataclass(frozen=True, slots=True)
class _DeliveryOutcomeSummary:
mail_accepted: bool
postbox_accepted: int
postbox_rejected: int
print_accepted: bool
print_rejected: bool
outcome_unknown: bool
temporary_rejection: bool
@property
def accepted_count(self) -> int:
return int(self.mail_accepted) + self.postbox_accepted
return int(self.mail_accepted) + self.postbox_accepted + int(self.print_accepted)
@dataclass(frozen=True, slots=True)
@@ -275,11 +286,13 @@ QUEUEABLE_VALIDATION_STATUSES = {
SMTP_ACCEPTED_STATUSES = {JobSendStatus.SMTP_ACCEPTED.value, JobSendStatus.SENT.value}
DELIVERY_ACCEPTED_STATUSES = SMTP_ACCEPTED_STATUSES | {
JobSendStatus.POSTBOX_ACCEPTED.value,
JobSendStatus.PRINT_ACCEPTED.value,
JobSendStatus.DELIVERED.value,
JobSendStatus.PARTIALLY_ACCEPTED.value,
}
FULLY_ACCEPTED_STATUSES = SMTP_ACCEPTED_STATUSES | {
JobSendStatus.POSTBOX_ACCEPTED.value,
JobSendStatus.PRINT_ACCEPTED.value,
JobSendStatus.DELIVERED.value,
}
DELIVERY_MODE_SYNCHRONOUS = "synchronous"
@@ -2890,6 +2903,11 @@ def send_campaign_job(
descriptions.append(
f"Postbox to {len(job.resolved_postbox_targets or [])} target(s)"
)
if policy.uses_print:
output = job.resolved_print_output or {}
descriptions.append(
f"Printable output item {int(output.get('item_index') or 0) + 1}"
)
return SendJobResult(
job_id=job.id,
status="dry_run",
@@ -2985,10 +3003,14 @@ def _send_job_delivery_context(
except ExecutionSnapshotError as exc:
raise SendJobError(str(exc)) from exc
message_bytes = _load_eml_bytes_for_job(job)
channel_policy = DeliveryChannelPolicy(
getattr(job, "delivery_channel_policy", DeliveryChannelPolicy.MAIL.value)
)
message_bytes = (
_load_eml_bytes_for_job(job)
if channel_policy.uses_mail or channel_policy.uses_postbox
else b""
)
envelope_from: str | None = None
envelope_recipients: list[str] = []
if channel_policy.uses_mail:
@@ -3058,6 +3080,16 @@ def _send_claimed_campaign_job(
use_rate_limit=use_rate_limit,
enqueue_imap_task=enqueue_imap_task,
)
if channel_policy == DeliveryChannelPolicy.PRINT:
print_outcome = _deliver_print_channel(session, job=job)
return _finalize_multichannel_job(
session,
job_id=job.id,
channel_policy=channel_policy,
mail=None,
postbox=None,
print_output=print_outcome,
)
return _send_claimed_multichannel_job(
session,
job=job,
@@ -3262,13 +3294,115 @@ def _empty_postbox_outcome() -> PostboxChannelOutcome:
return PostboxChannelOutcome()
def _deliver_print_channel(
session: Session,
*,
job: CampaignJob,
) -> _PrintChannelOutcome:
current = session.get(CampaignJob, job.id)
if current is None:
raise SendJobError("Campaign job disappeared before printable output acceptance.")
if current.print_status == JobPrintStatus.ACCEPTED.value:
return _PrintChannelOutcome(accepted=True, message="Output was already accepted.")
output = (
current.resolved_print_output
if isinstance(current.resolved_print_output, dict)
else {}
)
artifact = output.get("artifact") if isinstance(output.get("artifact"), dict) else {}
output_sha256 = str(output.get("output_sha256") or artifact.get("sha256") or "")
render_id = str(output.get("render_id") or "")
if (
current.print_status != JobPrintStatus.READY.value
or not render_id
or not output_sha256
):
current.print_status = JobPrintStatus.FAILED.value
current.last_error = "The frozen printable output artifact is missing or incomplete."
session.add(current)
session.commit()
return _PrintChannelOutcome(
rejected_permanent=True,
message=current.last_error,
)
attempt_number = current.print_attempt_count + 1
idempotency_key = (
f"campaign:{current.campaign_version_id}:{current.id}:print:"
f"{render_id}:{int(output.get('item_index') or 0)}"
)
existing = (
session.query(PrintOutputAttempt)
.filter(
PrintOutputAttempt.tenant_id == current.tenant_id,
PrintOutputAttempt.idempotency_key == idempotency_key,
)
.one_or_none()
)
if existing is not None and existing.status == JobPrintStatus.ACCEPTED.value:
current.print_status = JobPrintStatus.ACCEPTED.value
current.print_attempt_count = max(
current.print_attempt_count,
existing.attempt_number,
)
session.add(current)
session.commit()
return _PrintChannelOutcome(accepted=True, message="Output was already accepted.")
now = _utcnow()
attempt = PrintOutputAttempt(
tenant_id=current.tenant_id,
job_id=current.id,
attempt_number=attempt_number,
idempotency_key=idempotency_key,
status=JobPrintStatus.ACCEPTED.value,
render_id=render_id,
artifact_sha256=output_sha256,
evidence={
"template_id": output.get("template_id"),
"template_revision_id": output.get("template_revision_id"),
"template_hash": output.get("template_hash"),
"input_hash": output.get("input_hash"),
"output_sha256": output_sha256,
"artifact": artifact,
"route": output.get("route"),
"recipient_key": output.get("recipient_key"),
"item_index": output.get("item_index"),
"actor_account_id": output.get("actor_account_id"),
},
started_at=now,
finished_at=now,
)
current.print_status = JobPrintStatus.ACCEPTED.value
current.print_attempt_count = attempt_number
session.add(attempt)
session.add(current)
session.commit()
return _PrintChannelOutcome(
accepted=True,
message="Frozen printable output was accepted for distribution.",
)
def _skip_print_channel(session: Session, job_id: str) -> None:
current = session.get(CampaignJob, job_id)
if current is None or current.print_status == JobPrintStatus.ACCEPTED.value:
return
current.print_status = JobPrintStatus.SKIPPED.value
session.add(current)
session.commit()
def _final_multichannel_status(
*,
channel_policy: DeliveryChannelPolicy,
mail: _MailChannelOutcome | None,
postbox: PostboxChannelOutcome | None,
print_output: _PrintChannelOutcome | None = None,
) -> str:
outcome = _delivery_outcome_summary(mail=mail, postbox=postbox)
outcome = _delivery_outcome_summary(
mail=mail,
postbox=postbox,
print_output=print_output,
)
if outcome.outcome_unknown:
return JobSendStatus.OUTCOME_UNKNOWN.value
if not outcome.accepted_count:
@@ -3287,11 +3421,14 @@ def _delivery_outcome_summary(
*,
mail: _MailChannelOutcome | None,
postbox: PostboxChannelOutcome | None,
print_output: _PrintChannelOutcome | None = None,
) -> _DeliveryOutcomeSummary:
return _DeliveryOutcomeSummary(
mail_accepted=bool(mail and mail.accepted),
postbox_accepted=int(postbox.accepted_count if postbox else 0),
postbox_rejected=int(postbox.rejected_count if postbox else 0),
print_accepted=bool(print_output and print_output.accepted),
print_rejected=bool(print_output and print_output.rejected_permanent),
outcome_unknown=bool(
(mail and mail.outcome_unknown) or (postbox and postbox.outcome_unknown)
),
@@ -3310,6 +3447,10 @@ def _classify_postbox_delivery(outcome: _DeliveryOutcomeSummary) -> str:
)
def _classify_print_delivery(_outcome: _DeliveryOutcomeSummary) -> str:
return JobSendStatus.PRINT_ACCEPTED.value
def _classify_dual_delivery(outcome: _DeliveryOutcomeSummary) -> str:
fully_delivered = (
outcome.mail_accepted
@@ -3326,15 +3467,16 @@ def _classify_dual_delivery(outcome: _DeliveryOutcomeSummary) -> str:
def _classify_fallback_delivery(outcome: _DeliveryOutcomeSummary) -> str:
if outcome.postbox_rejected:
return JobSendStatus.PARTIALLY_ACCEPTED.value
return (
JobSendStatus.SMTP_ACCEPTED.value
if outcome.mail_accepted
else JobSendStatus.POSTBOX_ACCEPTED.value
)
if outcome.mail_accepted:
return JobSendStatus.SMTP_ACCEPTED.value
if outcome.postbox_accepted:
return JobSendStatus.POSTBOX_ACCEPTED.value
return JobSendStatus.PRINT_ACCEPTED.value
_ACCEPTED_DELIVERY_CLASSIFIERS = {
DeliveryChannelPolicy.POSTBOX: _classify_postbox_delivery,
DeliveryChannelPolicy.PRINT: _classify_print_delivery,
DeliveryChannelPolicy.MAIL_AND_POSTBOX: _classify_dual_delivery,
}
@@ -3342,12 +3484,15 @@ _ACCEPTED_DELIVERY_CLASSIFIERS = {
def _multichannel_messages(
mail: _MailChannelOutcome | None,
postbox: PostboxChannelOutcome | None,
print_output: _PrintChannelOutcome | None = 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)
if print_output and print_output.message:
values.append(f"Print: {print_output.message}")
return values
@@ -3358,6 +3503,7 @@ def _finalize_multichannel_job(
channel_policy: DeliveryChannelPolicy,
mail: _MailChannelOutcome | None,
postbox: PostboxChannelOutcome | None,
print_output: _PrintChannelOutcome | None = None,
commit: bool = True,
) -> SendJobResult:
job = session.get(CampaignJob, job_id)
@@ -3367,10 +3513,11 @@ def _finalize_multichannel_job(
channel_policy=channel_policy,
mail=mail,
postbox=postbox,
print_output=print_output,
)
accepted = status in DELIVERY_ACCEPTED_STATUSES
unknown = status == JobSendStatus.OUTCOME_UNKNOWN.value
messages = _multichannel_messages(mail, postbox)
messages = _multichannel_messages(mail, postbox, print_output)
job.queue_status = JobQueueStatus.DRAFT.value
job.send_status = status
job.claim_token = None
@@ -3378,7 +3525,11 @@ def _finalize_multichannel_job(
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))
and bool(
(mail and mail.accepted)
or (postbox and postbox.accepted_count)
or (print_output and print_output.accepted)
)
):
job.sent_at = job.sent_at or _utcnow()
files_integration().mark_job_attachment_uses_sent(session, job)
@@ -3397,7 +3548,11 @@ def _finalize_multichannel_job(
return SendJobResult(
job_id=job.id,
status=status,
attempt_number=job.attempt_count + job.postbox_attempt_count,
attempt_number=(
job.attempt_count
+ job.postbox_attempt_count
+ getattr(job, "print_attempt_count", 0)
),
message=job.last_error,
)
@@ -3414,8 +3569,55 @@ def _send_claimed_multichannel_job(
) -> SendJobResult:
mail_outcome: _MailChannelOutcome | None = None
postbox_outcome: PostboxChannelOutcome | None = None
print_outcome: _PrintChannelOutcome | None = None
if channel_policy == DeliveryChannelPolicy.MAIL_THEN_POSTBOX:
if channel_policy == DeliveryChannelPolicy.MAIL_THEN_PRINT:
if job.print_status == JobPrintStatus.ACCEPTED.value:
print_outcome = _PrintChannelOutcome(
accepted=True,
message="Output was already accepted.",
)
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 printable fallback."
)
print_outcome = _deliver_print_channel(session, job=current)
elif mail_outcome.accepted:
_skip_print_channel(session, job.id)
elif channel_policy == DeliveryChannelPolicy.POSTBOX_THEN_PRINT:
if job.print_status == JobPrintStatus.ACCEPTED.value:
print_outcome = _PrintChannelOutcome(
accepted=True,
message="Output was already accepted.",
)
else:
postbox_outcome = deliver_campaign_job_to_postboxes(
session,
job=job,
message_bytes=context.message_bytes,
classification=context.snapshot.delivery.postbox.classification,
)
if postbox_outcome.all_rejected_before_acceptance:
current = session.get(CampaignJob, job.id)
if current is None:
raise SendJobError(
"Campaign job disappeared before printable fallback."
)
print_outcome = _deliver_print_channel(session, job=current)
elif postbox_outcome.accepted_count:
_skip_print_channel(session, job.id)
elif 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
@@ -3486,6 +3688,7 @@ def _send_claimed_multichannel_job(
channel_policy=channel_policy,
mail=mail_outcome,
postbox=postbox_outcome,
print_output=print_outcome,
)