security: harden Campaign delivery effects and reconciliation

This commit is contained in:
2026-07-21 17:14:33 +02:00
parent 057e660b17
commit 09c63de813
25 changed files with 2079 additions and 708 deletions

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
import csv
import io
import logging
import math
from collections import Counter
from datetime import datetime, timezone
@@ -20,13 +21,20 @@ from govoplan_campaign.backend.db.models import (
SendAttempt,
)
from govoplan_campaign.backend.sending.execution import ExecutionSnapshot
from govoplan_campaign.backend.response_security import public_campaign_payload, public_source_filename
from govoplan_campaign.backend.response_security import (
public_campaign_payload,
public_delivery_result_message,
public_source_filename,
)
class CampaignReportError(RuntimeError):
pass
logger = logging.getLogger(__name__)
def _utcnow_iso() -> str:
return datetime.now(timezone.utc).isoformat()
@@ -88,8 +96,8 @@ def _load_delivery_info(version: CampaignVersion | None, jobs: list[CampaignJob]
"job_count": 0,
"queueable_job_count": 0,
"effective_policy_sha256": None,
"smtp_config_fingerprint": None,
"imap_config_fingerprint": None,
"smtp_transport_revision": None,
"imap_transport_revision": None,
"estimated_remaining_send_seconds": None,
"estimated_remaining_send_human": None,
"background_workers_enabled": bool(core_settings.celery_enabled),
@@ -99,8 +107,9 @@ def _load_delivery_info(version: CampaignVersion | None, jobs: list[CampaignJob]
return default
try:
snapshot = ExecutionSnapshot.model_validate(version.execution_snapshot)
except Exception as exc: # pragma: no cover - reporting should remain available
default["load_error"] = str(exc)
except Exception: # pragma: no cover - reporting should remain available
logger.warning("Campaign execution snapshot could not be loaded for a report")
default["load_error"] = "Execution snapshot is invalid; rebuild the selected version before delivery."
return default
messages_per_minute = snapshot.delivery.rate_limit.messages_per_minute
@@ -131,8 +140,8 @@ def _load_delivery_info(version: CampaignVersion | None, jobs: list[CampaignJob]
"job_count": snapshot.job_count,
"queueable_job_count": snapshot.queueable_job_count,
"effective_policy_sha256": snapshot.effective_policy_sha256,
"smtp_config_fingerprint": snapshot.smtp_config_fingerprint,
"imap_config_fingerprint": snapshot.imap_config_fingerprint,
"smtp_transport_revision": snapshot.smtp_transport_revision,
"imap_transport_revision": snapshot.imap_transport_revision,
"estimated_remaining_send_seconds": estimated_seconds,
"estimated_remaining_send_human": _human_duration(estimated_seconds),
"background_workers_enabled": bool(core_settings.celery_enabled),
@@ -223,7 +232,11 @@ def _recent_failures(jobs: list[CampaignJob], *, limit: int = 20) -> list[dict[s
"send_status": job.send_status,
"imap_status": job.imap_status,
"attempt_count": job.attempt_count,
"last_error": job.last_error,
"last_error": public_delivery_result_message(
last_error=job.last_error,
send_status=job.send_status,
imap_status=job.imap_status,
),
"updated_at": job.updated_at.isoformat() if job.updated_at else None,
}
for job in failed[:limit]
@@ -248,7 +261,11 @@ def _job_row(job: CampaignJob) -> dict[str, Any]:
"smtp_started_at": job.smtp_started_at.isoformat() if job.smtp_started_at else None,
"outcome_unknown_at": job.outcome_unknown_at.isoformat() if job.outcome_unknown_at else None,
"sent_at": job.sent_at.isoformat() if job.sent_at else None,
"last_error": job.last_error,
"last_error": public_delivery_result_message(
last_error=job.last_error,
send_status=job.send_status,
imap_status=job.imap_status,
),
"eml_size_bytes": job.eml_size_bytes,
"eml_sha256": job.eml_sha256,
"issues_count": len(job.issues_snapshot or []),
@@ -333,15 +350,11 @@ def _job_evidence_row(
"latest_smtp_attempt_number": latest_smtp.attempt_number if latest_smtp else None,
"latest_smtp_status": latest_smtp.status if latest_smtp else None,
"latest_smtp_status_code": latest_smtp.smtp_status_code if latest_smtp else None,
"latest_smtp_response": latest_smtp.smtp_response if latest_smtp else None,
"latest_smtp_error_type": latest_smtp.error_type if latest_smtp else None,
"latest_smtp_error_message": latest_smtp.error_message if latest_smtp else None,
"latest_smtp_started_at": latest_smtp.started_at.isoformat() if latest_smtp and latest_smtp.started_at else None,
"latest_smtp_finished_at": latest_smtp.finished_at.isoformat() if latest_smtp and latest_smtp.finished_at else None,
"latest_imap_attempt_number": latest_imap.attempt_number if latest_imap else None,
"latest_imap_status": latest_imap.status if latest_imap else None,
"latest_imap_folder": latest_imap.folder if latest_imap else None,
"latest_imap_error_message": latest_imap.error_message if latest_imap else None,
"latest_imap_created_at": latest_imap.created_at.isoformat() if latest_imap and latest_imap.created_at else None,
"latest_imap_updated_at": latest_imap.updated_at.isoformat() if latest_imap and latest_imap.updated_at else None,
})
@@ -355,7 +368,7 @@ def generate_campaign_report(
campaign_id: str,
version_id: str | None = None,
include_jobs: bool = False,
include_recent_failures: bool = True,
include_recent_failures: bool = False,
) -> dict[str, Any]:
"""Generate a dashboard/report payload for one campaign.
@@ -609,15 +622,11 @@ def generate_jobs_csv(
"latest_smtp_attempt_number",
"latest_smtp_status",
"latest_smtp_status_code",
"latest_smtp_response",
"latest_smtp_error_type",
"latest_smtp_error_message",
"latest_smtp_started_at",
"latest_smtp_finished_at",
"latest_imap_attempt_number",
"latest_imap_status",
"latest_imap_folder",
"latest_imap_error_message",
"latest_imap_created_at",
"latest_imap_updated_at",
]