security: harden Campaign delivery effects and reconciliation
This commit is contained in:
@@ -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",
|
||||
]
|
||||
|
||||
@@ -9,11 +9,12 @@ from typing import Any
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_campaign.backend.db.models import Campaign, CampaignVersion
|
||||
from govoplan_campaign.backend.campaign.loader import load_campaign_config
|
||||
from govoplan_campaign.backend.campaign.models import CampaignConfig, SmtpConfig
|
||||
from govoplan_campaign.backend.persistence.campaigns import _write_campaign_snapshot
|
||||
from govoplan_campaign.backend.campaign.models import CampaignConfig
|
||||
from govoplan_campaign.backend.campaign.mail_profile_boundary import campaign_mail_profile_id
|
||||
from govoplan_campaign.backend.persistence.campaigns import load_version_config
|
||||
from govoplan_campaign.backend.reports.campaigns import CampaignReportError, generate_campaign_report, generate_jobs_csv
|
||||
from govoplan_campaign.backend.integrations import SmtpConfigurationError, mail_integration
|
||||
from govoplan_campaign.backend.integrations import SmtpConfigurationError
|
||||
from govoplan_campaign.backend.sending.execution import ExecutionSnapshotError, ensure_execution_snapshot
|
||||
|
||||
|
||||
class CampaignReportEmailError(RuntimeError):
|
||||
@@ -30,8 +31,6 @@ class CampaignReportEmailResult:
|
||||
sent: bool
|
||||
attached_jobs_csv: bool
|
||||
attached_report_json: bool
|
||||
smtp_host: str | None = None
|
||||
smtp_port: int | None = None
|
||||
accepted_count: int | None = None
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
@@ -44,8 +43,6 @@ class CampaignReportEmailResult:
|
||||
"sent": self.sent,
|
||||
"attached_jobs_csv": self.attached_jobs_csv,
|
||||
"attached_report_json": self.attached_report_json,
|
||||
"smtp_host": self.smtp_host,
|
||||
"smtp_port": self.smtp_port,
|
||||
"accepted_count": self.accepted_count,
|
||||
}
|
||||
|
||||
@@ -62,18 +59,15 @@ def _selected_version(
|
||||
return version
|
||||
|
||||
|
||||
def _load_config(version: CampaignVersion) -> CampaignConfig:
|
||||
snapshot_path = _write_campaign_snapshot(version)
|
||||
return load_campaign_config(snapshot_path)
|
||||
def _load_config(session: Session, version: CampaignVersion) -> CampaignConfig:
|
||||
_campaign, _version, config = load_version_config(session, version.id)
|
||||
return config
|
||||
|
||||
|
||||
def _effective_from(config: CampaignConfig) -> tuple[str, str | None]:
|
||||
if config.recipients.from_:
|
||||
return config.recipients.from_[0].email, config.recipients.from_[0].name
|
||||
smtp_config = config.server.runtime_smtp_config()
|
||||
if smtp_config and smtp_config.username and "@" in smtp_config.username:
|
||||
return smtp_config.username, None
|
||||
raise SmtpConfigurationError("Report email requires a recipients.from address or an SMTP username that is an email address")
|
||||
raise SmtpConfigurationError("Report email requires a Campaign-owned recipients.from address")
|
||||
|
||||
|
||||
def _text_summary(report: dict[str, Any]) -> str:
|
||||
@@ -150,7 +144,7 @@ def send_campaign_report_email(
|
||||
version_id: str | None = None,
|
||||
to: list[str],
|
||||
include_jobs: bool = False,
|
||||
attach_jobs_csv: bool = True,
|
||||
attach_jobs_csv: bool = False,
|
||||
attach_report_json: bool = False,
|
||||
dry_run: bool = False,
|
||||
) -> CampaignReportEmailResult:
|
||||
@@ -161,10 +155,28 @@ def send_campaign_report_email(
|
||||
raise CampaignReportEmailError("At least one report recipient is required")
|
||||
|
||||
version = _selected_version(session, campaign, version_id)
|
||||
config = _load_config(version)
|
||||
smtp_config: SmtpConfig | None = config.server.runtime_smtp_config()
|
||||
if smtp_config is None:
|
||||
config = _load_config(session, version)
|
||||
if not config.server.profile_capabilities.smtp_available:
|
||||
raise SmtpConfigurationError("Campaign has no SMTP configuration")
|
||||
profile_id = campaign_mail_profile_id(version.raw_json if isinstance(version.raw_json, dict) else {})
|
||||
if not profile_id:
|
||||
raise SmtpConfigurationError("Campaign has no Mail profile reference")
|
||||
if not isinstance(version.execution_snapshot, dict):
|
||||
raise CampaignReportEmailError(
|
||||
"Report email requires a validated and built campaign version with stored Mail-profile evidence."
|
||||
)
|
||||
try:
|
||||
snapshot = ensure_execution_snapshot(session, version)
|
||||
except ExecutionSnapshotError as exc:
|
||||
raise CampaignReportEmailError(
|
||||
"Report email requires a validated and built campaign version with current Mail-profile evidence."
|
||||
) from exc
|
||||
if not snapshot.smtp_transport_revision:
|
||||
raise CampaignReportEmailError("Campaign build evidence has no SMTP transport revision")
|
||||
if not dry_run:
|
||||
raise CampaignReportEmailError(
|
||||
"Report email delivery is disabled until it uses a durable, idempotent Mail-owned outbox with unknown-outcome reconciliation."
|
||||
)
|
||||
|
||||
report = generate_campaign_report(
|
||||
session,
|
||||
@@ -172,6 +184,7 @@ def send_campaign_report_email(
|
||||
campaign_id=campaign_id,
|
||||
version_id=version.id,
|
||||
include_jobs=include_jobs,
|
||||
include_recent_failures=include_jobs,
|
||||
)
|
||||
jobs_csv = (
|
||||
generate_jobs_csv(session, tenant_id=tenant_id, campaign_id=campaign_id, version_id=version.id)
|
||||
@@ -187,38 +200,13 @@ def send_campaign_report_email(
|
||||
jobs_csv=jobs_csv,
|
||||
report_json=report_json,
|
||||
)
|
||||
envelope_from, _ = _effective_from(config)
|
||||
|
||||
if dry_run:
|
||||
return CampaignReportEmailResult(
|
||||
campaign_id=campaign.id,
|
||||
version_id=version.id,
|
||||
to=to,
|
||||
subject=str(message["Subject"]),
|
||||
dry_run=True,
|
||||
sent=False,
|
||||
attached_jobs_csv=jobs_csv is not None,
|
||||
attached_report_json=report_json is not None,
|
||||
smtp_host=smtp_config.host,
|
||||
smtp_port=smtp_config.port,
|
||||
)
|
||||
|
||||
result = mail_integration().send_email_message(
|
||||
message,
|
||||
smtp_config=smtp_config,
|
||||
envelope_from=envelope_from,
|
||||
envelope_recipients=to,
|
||||
)
|
||||
return CampaignReportEmailResult(
|
||||
campaign_id=campaign.id,
|
||||
version_id=version.id,
|
||||
to=to,
|
||||
subject=str(message["Subject"]),
|
||||
dry_run=False,
|
||||
sent=True,
|
||||
dry_run=True,
|
||||
sent=False,
|
||||
attached_jobs_csv=jobs_csv is not None,
|
||||
attached_report_json=report_json is not None,
|
||||
smtp_host=result.host,
|
||||
smtp_port=result.port,
|
||||
accepted_count=result.accepted_count,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user