feat: harden campaign delivery and editing

This commit is contained in:
2026-07-30 14:27:04 +02:00
parent 4a120e8009
commit cd223cbb95
34 changed files with 3370 additions and 165 deletions
@@ -1,7 +1,7 @@
from __future__ import annotations
import json
from collections.abc import Sequence
from collections.abc import Mapping, Sequence
from typing import Literal
from fastapi import HTTPException, Query, status
@@ -13,6 +13,7 @@ from govoplan_campaign.backend.schemas import (
CampaignJobDiagnosticsResponse,
)
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.postbox import PostboxDeliveryReceiptSummaryRef
from govoplan_core.core.change_sequence import (
encode_sequence_watermark,
max_sequence_id,
@@ -30,6 +31,8 @@ from govoplan_campaign.backend.change_tracking import (
from govoplan_campaign.backend.db.models import (
Campaign,
CampaignJob,
CampaignMessageAction,
CampaignMessageActionAttempt,
CampaignVersion,
ImapAppendAttempt,
JobImapStatus,
@@ -128,7 +131,12 @@ def _job_attempts_payload(
send_attempts: list[SendAttempt],
imap_attempts: list[ImapAppendAttempt],
postbox_attempts: Sequence[PostboxDeliveryAttempt] = (),
message_actions: Sequence[CampaignMessageAction] = (),
message_action_attempts: Sequence[CampaignMessageActionAttempt] = (),
*,
postbox_receipts: (
Mapping[str, PostboxDeliveryReceiptSummaryRef] | None
) = None,
include_diagnostics: bool = False,
) -> dict[str, list[dict[str, object]]]:
smtp_payloads: list[dict[str, object]] = []
@@ -186,11 +194,109 @@ def _job_attempts_payload(
payload["evidence"] = attempt.evidence or {}
payload["error_type"] = attempt.error_type
payload["error_message"] = attempt.error_message
if attempt.provider_delivery_id:
receipt_summary = (
postbox_receipts.get(attempt.provider_delivery_id)
if postbox_receipts is not None
else None
)
payload["receipt_summary_status"] = (
"available"
if receipt_summary is not None
else "not_found"
if postbox_receipts is not None
else "unavailable"
)
if receipt_summary is not None:
payload["receipt_summary"] = _postbox_receipt_summary_payload(
receipt_summary
)
postbox_payloads.append(payload)
action_attempts_by_action = {
attempt.action_id: attempt
for attempt in message_action_attempts
}
message_action_payloads: list[dict[str, object]] = []
for action in message_actions:
action_attempt = action_attempts_by_action.get(action.id)
payload = {
"id": action.id,
"kind": action.kind,
"status": action.status,
"reason": action.reason,
"actor_user_id": action.actor_user_id,
"actor_api_key_id": action.actor_api_key_id,
"campaign_version_id": action.campaign_version_id,
"message_sha256": action.message_sha256,
"recipient_manifest_sha256": action.recipient_manifest_sha256,
"recipient_count": action.recipient_count,
"prior_send_status": action.prior_send_status,
"final_send_status": action.final_send_status,
"accepted_count": action.accepted_count,
"refused_count": action.refused_count,
"refusal_summary": action.refusal_summary or {},
"linked_send_attempt_id": action.linked_send_attempt_id,
"created_at": action.created_at,
"effect_started_at": action.effect_started_at,
"completed_at": action.completed_at,
"attempt": (
{
"id": action_attempt.id,
"status": action_attempt.status,
"started_at": action_attempt.started_at,
"effect_started_at": action_attempt.effect_started_at,
"completed_at": action_attempt.completed_at,
"accepted_count": action_attempt.accepted_count,
"refused_count": action_attempt.refused_count,
}
if action_attempt is not None
else None
),
}
if include_diagnostics:
payload["idempotency_key"] = action.idempotency_key
payload["canonical_request_hash"] = action.canonical_request_hash
payload["context"] = action.context or {}
payload["error_type"] = action.error_type
payload["error_message"] = action.error_message
if action_attempt is not None:
payload["attempt"] = {
**dict(payload["attempt"] or {}),
"outcome_code": action_attempt.outcome_code,
"diagnostic_summary": action_attempt.diagnostic_summary,
}
message_action_payloads.append(payload)
return {
"smtp": smtp_payloads,
"imap": imap_payloads,
"postbox": postbox_payloads,
"message_actions": message_action_payloads,
}
def _postbox_receipt_summary_payload(
summary: PostboxDeliveryReceiptSummaryRef,
) -> dict[str, object]:
return {
"delivery_id": summary.delivery_id,
"message_id": summary.message_id,
"postbox_id": summary.postbox_id,
"delivery_status": summary.delivery_status,
"accepted_at": summary.accepted_at,
"current_holder_count": summary.current_holder_count,
"currently_readable": summary.currently_readable,
"message_count": summary.message_count,
"routed_message_count": summary.routed_message_count,
"readable_message_count": summary.readable_message_count,
"read_receipt_count": summary.read_receipt_count,
"acknowledged_receipt_count": summary.acknowledged_receipt_count,
"withdrawn_message_count": summary.withdrawn_message_count,
"expired_message_count": summary.expired_message_count,
"first_read_at": summary.first_read_at,
"last_read_at": summary.last_read_at,
"first_acknowledged_at": summary.first_acknowledged_at,
"last_acknowledged_at": summary.last_acknowledged_at,
"route_status_counts": dict(summary.route_status_counts),
}
@@ -199,6 +305,12 @@ def _job_diagnostics_payload(
send_attempts: list[SendAttempt],
imap_attempts: list[ImapAppendAttempt],
postbox_attempts: Sequence[PostboxDeliveryAttempt] = (),
message_actions: Sequence[CampaignMessageAction] = (),
message_action_attempts: Sequence[CampaignMessageActionAttempt] = (),
*,
postbox_receipts: (
Mapping[str, PostboxDeliveryReceiptSummaryRef] | None
) = None,
) -> CampaignJobDiagnosticsResponse:
return CampaignJobDiagnosticsResponse(
job_id=job.id,
@@ -221,6 +333,9 @@ def _job_diagnostics_payload(
send_attempts,
imap_attempts,
postbox_attempts,
message_actions,
message_action_attempts,
postbox_receipts=postbox_receipts,
include_diagnostics=True,
),
)