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
@@ -9,6 +9,7 @@ from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
from sqlalchemy import func
from sqlalchemy.orm import Session
from govoplan_core.settings import settings as core_settings
@@ -17,11 +18,14 @@ from govoplan_campaign.backend.db.models import (
Campaign,
CampaignIssue,
CampaignJob,
CampaignMessageAction,
CampaignMessageActionAttempt,
CampaignVersion,
ImapAppendAttempt,
PostboxDeliveryAttempt,
SendAttempt,
)
from govoplan_campaign.backend.integrations import postbox_integration
from govoplan_campaign.backend.sending.execution import ExecutionSnapshot
from govoplan_campaign.backend.response_security import (
public_campaign_payload,
@@ -586,6 +590,7 @@ def _job_evidence_row(
*,
latest_smtp: SendAttempt | None = None,
latest_imap: ImapAppendAttempt | None = None,
latest_message_action: CampaignMessageAction | None = None,
include_diagnostics: bool = False,
) -> dict[str, Any]:
row = _job_row(job, include_diagnostics=include_diagnostics)
@@ -598,6 +603,20 @@ def _job_evidence_row(
"postbox_targets": _job_postbox_target_summary(job),
"attachment_names": _attachment_names(job.resolved_attachments),
**_job_attempt_evidence(latest_smtp=latest_smtp, latest_imap=latest_imap),
"latest_message_action_kind": (
latest_message_action.kind if latest_message_action else None
),
"latest_message_action_status": (
latest_message_action.status if latest_message_action else None
),
"latest_message_action_id": (
latest_message_action.id if latest_message_action else None
),
"latest_message_action_at": (
_iso_timestamp(latest_message_action.completed_at)
if latest_message_action
else None
),
})
return row
@@ -762,6 +781,18 @@ def _campaign_report_payload(
campaign_id=campaign.id,
version=version,
),
"postbox_receipts": _campaign_postbox_receipt_summary(
session,
tenant_id=tenant_id,
campaign_id=campaign.id,
version=version,
),
"message_actions": _campaign_message_action_summary(
session,
tenant_id=tenant_id,
campaign_id=campaign.id,
version=version,
),
"delivery": _load_delivery_info(
version,
pending_job_count=aggregate.pending,
@@ -775,6 +806,85 @@ def _campaign_report_payload(
return report
def _campaign_postbox_receipt_summary(
session: Session,
*,
tenant_id: str,
campaign_id: str,
version: CampaignVersion | None,
) -> dict[str, object]:
if version is None:
return {"status": "not_applicable", "delivery_count": 0}
rows = (
session.query(PostboxDeliveryAttempt.provider_delivery_id)
.join(
CampaignJob,
PostboxDeliveryAttempt.job_id == CampaignJob.id,
)
.filter(
CampaignJob.tenant_id == tenant_id,
CampaignJob.campaign_id == campaign_id,
CampaignJob.campaign_version_id == version.id,
PostboxDeliveryAttempt.tenant_id == tenant_id,
PostboxDeliveryAttempt.provider_delivery_id.is_not(None),
)
.all()
)
delivery_ids = tuple(
dict.fromkeys(
str(row[0])
for row in rows
if row[0]
)
)
if not delivery_ids:
return {"status": "not_applicable", "delivery_count": 0}
integration = postbox_integration()
if not integration.receipt_evidence_available:
return {
"status": "unavailable",
"delivery_count": len(delivery_ids),
"missing_delivery_count": len(delivery_ids),
}
summaries = integration.delivery_receipt_summaries(
session,
tenant_id=tenant_id,
delivery_ids=delivery_ids,
)
values = tuple(summaries.values())
return {
"status": "available",
"delivery_count": len(delivery_ids),
"reported_delivery_count": len(values),
"missing_delivery_count": len(delivery_ids) - len(values),
"currently_readable_delivery_count": sum(
summary.currently_readable for summary in values
),
"read_delivery_count": sum(
summary.read_receipt_count > 0 for summary in values
),
"acknowledged_delivery_count": sum(
summary.acknowledged_receipt_count > 0 for summary in values
),
"read_receipt_count": sum(
summary.read_receipt_count for summary in values
),
"acknowledged_receipt_count": sum(
summary.acknowledged_receipt_count for summary in values
),
"routed_message_count": sum(
summary.routed_message_count for summary in values
),
"withdrawn_message_count": sum(
summary.withdrawn_message_count for summary in values
),
"expired_message_count": sum(
summary.expired_message_count for summary in values
),
}
def _campaign_report_campaign_payload(campaign: Campaign) -> dict[str, Any]:
return {
"id": campaign.id,
@@ -815,6 +925,99 @@ def _campaign_report_attempt_counts(
"send_attempts": count(SendAttempt),
"imap_append_attempts": count(ImapAppendAttempt),
"postbox_delivery_attempts": count(PostboxDeliveryAttempt),
"message_actions": count(CampaignMessageAction),
"message_action_attempts": int(
session.query(CampaignMessageActionAttempt)
.join(
CampaignMessageAction,
CampaignMessageAction.id
== CampaignMessageActionAttempt.action_id,
)
.filter(
CampaignMessageAction.tenant_id == tenant_id,
CampaignMessageAction.campaign_id == campaign_id,
*(
[CampaignMessageAction.campaign_version_id == version.id]
if version is not None
else [False]
),
)
.count()
),
}
def _campaign_message_action_summary(
session: Session,
*,
tenant_id: str,
campaign_id: str,
version: CampaignVersion | None,
) -> dict[str, Any]:
base = [
CampaignMessageAction.tenant_id == tenant_id,
CampaignMessageAction.campaign_id == campaign_id,
]
if version is not None:
base.append(CampaignMessageAction.campaign_version_id == version.id)
else:
base.append(False)
by_kind = {
str(kind): int(count)
for kind, count in (
session.query(
CampaignMessageAction.kind,
func.count(CampaignMessageAction.id),
)
.filter(*base)
.group_by(CampaignMessageAction.kind)
.all()
)
}
by_status = {
str(action_status): int(count)
for action_status, count in (
session.query(
CampaignMessageAction.status,
func.count(CampaignMessageAction.id),
)
.filter(*base)
.group_by(CampaignMessageAction.status)
.all()
)
}
recent = (
session.query(CampaignMessageAction)
.filter(*base)
.order_by(CampaignMessageAction.created_at.desc())
.limit(100)
.all()
)
return {
"total": sum(by_kind.values()),
"by_kind": by_kind,
"by_status": by_status,
"recent": [
{
"id": action.id,
"job_id": action.job_id,
"kind": action.kind,
"status": action.status,
"reason": action.reason,
"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,
"linked_send_attempt_id": action.linked_send_attempt_id,
"actor_user_id": action.actor_user_id,
"created_at": _iso_timestamp(action.created_at),
"completed_at": _iso_timestamp(action.completed_at),
}
for action in recent
],
}
@@ -992,11 +1195,16 @@ def generate_jobs_csv(
ImapAppendAttempt,
job_ids,
)
latest_message_actions = _latest_message_actions_for_jobs(
session,
job_ids,
)
rows = [
_job_evidence_row(
job,
latest_smtp=latest_smtp.get(job.id),
latest_imap=latest_imap.get(job.id),
latest_message_action=latest_message_actions.get(job.id),
include_diagnostics=include_diagnostics,
)
for job in jobs
@@ -1046,6 +1254,10 @@ def generate_jobs_csv(
"latest_imap_folder",
"latest_imap_created_at",
"latest_imap_updated_at",
"latest_message_action_kind",
"latest_message_action_status",
"latest_message_action_id",
"latest_message_action_at",
]
if include_diagnostics:
sent_at_index = fieldnames.index("outcome_unknown_at")
@@ -1082,3 +1294,26 @@ def _latest_attempts_for_jobs(
):
latest[attempt.job_id] = attempt
return latest
def _latest_message_actions_for_jobs(
session: Session,
job_ids: list[str],
*,
batch_size: int = 500,
) -> dict[str, CampaignMessageAction]:
latest: dict[str, CampaignMessageAction] = {}
for offset in range(0, len(job_ids), batch_size):
batch = job_ids[offset : offset + batch_size]
actions = (
session.query(CampaignMessageAction)
.filter(CampaignMessageAction.job_id.in_(batch))
.order_by(
CampaignMessageAction.job_id.asc(),
CampaignMessageAction.created_at.asc(),
)
.yield_per(batch_size)
)
for action in actions:
latest[action.job_id] = action
return latest