Implement governed hybrid campaign delivery
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user