from __future__ import annotations import hashlib import json from datetime import datetime, timezone from typing import Any, Iterable from pydantic import BaseModel, ConfigDict from sqlalchemy.orm import Session from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignVersion, JobValidationStatus from govoplan_campaign.backend.campaign.models import ( DeliveryChannelPolicy, DeliveryConfig, ) from govoplan_campaign.backend.campaign.mail_profile_boundary import ( CampaignMailProfileBoundaryError, assert_campaign_uses_mail_profile_reference, campaign_mail_profile_id, campaign_mail_resource_ids, ) 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 = "8" SUPPORTED_SNAPSHOT_VERSIONS = {"6", "7", SNAPSHOT_VERSION} class ExecutionSnapshotError(RuntimeError): pass class ExecutionSnapshot(BaseModel): """Immutable delivery inputs for one built campaign version. Rendered messages and attachment evidence remain normalized in ``CampaignJob`` and ``CampaignAttachmentUse``. This record freezes the Mail-profile reference, delivery policy, and opaque transport revisions and cryptographically binds them to the build and exact persisted jobs. Mail owns the resolved transport configuration and credentials. """ model_config = ConfigDict(extra="forbid") snapshot_version: str = SNAPSHOT_VERSION campaign_version_id: str campaign_json_sha256: str mail_profile_id: str | None = None smtp_server_id: str | None = None smtp_credential_id: str | None = None imap_server_id: str | None = None imap_credential_id: str | None = None created_at: str build_token: str | None = None built_at: str | None = None job_count: int = 0 queueable_job_count: int = 0 job_manifest_sha256: str | None = None effective_policy_sha256: str | None = None smtp_transport_revision: str | None = None imap_transport_revision: str | None = None uses_mail: bool = True uses_postbox: bool = False uses_print: bool = False delivery: DeliveryConfig def _canonical_json(value: Any) -> bytes: return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=str).encode("utf-8") def _sha256(value: Any) -> str: return hashlib.sha256(_canonical_json(value)).hexdigest() def snapshot_hash(payload: dict[str, Any]) -> str: return _sha256(payload) def profile_delivery_summary(session: Session, version: CampaignVersion) -> dict[str, Any]: raw_json = version.raw_json if isinstance(version.raw_json, dict) else {} _assert_version_mail_profile_boundary(raw_json, require_profile=True) mail = mail_integration() profile_id = campaign_mail_profile_id(raw_json) if profile_id is None: # Kept explicit for static typing; the assertion above requires it. raise ExecutionSnapshotError("Campaign has no Mail profile reference") campaign = session.get(Campaign, version.campaign_id) if campaign is None: raise ExecutionSnapshotError("Campaign not found for mail-server profile resolution") references = campaign_mail_resource_ids(raw_json) try: return mail.campaign_profile_delivery_summary( session, tenant_id=campaign.tenant_id, campaign_id=campaign.id, profile_id=profile_id, smtp_server_id=references["smtp_server_id"], smtp_credential_id=references["smtp_credential_id"], imap_server_id=references["imap_server_id"], imap_credential_id=references["imap_credential_id"], ) except MailProfileError as exc: raise ExecutionSnapshotError(str(exc)) from exc def profile_transport_revisions(session: Session, version: CampaignVersion) -> dict[str, str | None]: summary = profile_delivery_summary(session, version) return { "smtp": summary.get("smtp_transport_revision"), "imap": summary.get("imap_transport_revision"), } def _assert_snapshot_profile_matches_version(version: CampaignVersion, snapshot: ExecutionSnapshot) -> None: raw_json = version.raw_json if isinstance(version.raw_json, dict) else {} _assert_version_mail_profile_boundary( raw_json, require_profile=snapshot.uses_mail, ) if not snapshot.uses_mail: return if campaign_mail_profile_id(raw_json) != snapshot.mail_profile_id: raise ExecutionSnapshotError( "The campaign's Mail profile reference differs from the built execution snapshot. " "Revalidate and rebuild the campaign before delivery." ) references = campaign_mail_resource_ids(raw_json) for key in ( "smtp_server_id", "smtp_credential_id", "imap_server_id", "imap_credential_id", ): configured = references[key] if configured and configured != getattr(snapshot, key): raise ExecutionSnapshotError( "The campaign's Mail server or credential selection differs from the built " "execution snapshot. Revalidate and rebuild before delivery." ) def _assert_version_mail_profile_boundary( raw_json: dict[str, Any], *, require_profile: bool, ) -> None: try: assert_campaign_uses_mail_profile_reference( raw_json, require_profile=require_profile, ) except CampaignMailProfileBoundaryError as exc: raise ExecutionSnapshotError(str(exc)) from exc def _policy_fingerprint( raw_json: dict[str, Any], delivery: DeliveryConfig, *, snapshot_version: str = SNAPSHOT_VERSION, ) -> str: delivery_payload = delivery.model_dump(mode="json") 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"), "policy": raw_json.get("policy"), "delivery": delivery_payload, "attachment_defaults": (raw_json.get("attachments") or {}).get("defaults") if isinstance(raw_json.get("attachments"), dict) else None, } ) def _job_execution_input_payload( job: CampaignJob, *, snapshot_version: str = SNAPSHOT_VERSION, ) -> dict[str, Any]: payload = { "job_id": job.id, "entry_index": job.entry_index, "entry_id": job.entry_id, "recipient_email": job.recipient_email, "subject": job.subject, "message_id_header": job.message_id_header, "eml_size_bytes": job.eml_size_bytes, "eml_sha256": job.eml_sha256, "build_status": job.build_status, "validation_status": job.validation_status, "resolved_recipients_sha256": _sha256(job.resolved_recipients or {}), "resolved_attachments_sha256": _sha256(job.resolved_attachments or []), "issues_sha256": _sha256(job.issues_snapshot or []), } if snapshot_version != "6": payload.update( { "delivery_channel_policy": getattr( job, "delivery_channel_policy", DeliveryChannelPolicy.MAIL.value, ), "resolved_postbox_targets_sha256": _sha256( getattr(job, "resolved_postbox_targets", None) or [] ), } ) 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 def job_execution_input_hash( job: CampaignJob, *, snapshot_version: str = SNAPSHOT_VERSION, ) -> str: return _sha256( _job_execution_input_payload( job, snapshot_version=snapshot_version, ) ) def job_manifest_hash( jobs: Iterable[CampaignJob], *, snapshot_version: str = SNAPSHOT_VERSION, ) -> str: """Hash the immutable per-message execution records in stable order.""" payload = [ _job_execution_input_payload( job, snapshot_version=snapshot_version, ) for job in sorted(jobs, key=lambda item: (item.entry_index, item.id)) ] return _sha256(payload) def create_execution_snapshot( version: CampaignVersion, *, mail_profile_id: str | None, smtp_transport_revision: str | None, imap_transport_revision: str | None, delivery: DeliveryConfig, smtp_server_id: str | None = None, smtp_credential_id: str | None = None, imap_server_id: str | None = None, imap_credential_id: str | None = None, jobs: Iterable[CampaignJob] = (), build_summary: dict[str, Any] | None = None, ) -> tuple[dict[str, Any], str]: raw_json = version.raw_json if isinstance(version.raw_json, dict) else {} job_list = list(jobs) channel_policies = { DeliveryChannelPolicy( getattr( job, "delivery_channel_policy", DeliveryChannelPolicy.MAIL.value, ) ) for job in job_list } 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, snapshot_version=SNAPSHOT_VERSION, ) summary = build_summary if isinstance(build_summary, dict) else {} queueable_statuses = {JobValidationStatus.READY.value, JobValidationStatus.WARNING.value} payload = ExecutionSnapshot( campaign_version_id=version.id, campaign_json_sha256=_sha256(raw_json), mail_profile_id=mail_profile_id, smtp_server_id=smtp_server_id, smtp_credential_id=smtp_credential_id, imap_server_id=imap_server_id, imap_credential_id=imap_credential_id, build_token=str(summary.get("build_token") or "") or None, built_at=str(summary.get("built_at") or "") or None, job_count=len(job_list), queueable_job_count=sum(1 for job in job_list if job.validation_status in queueable_statuses), job_manifest_sha256=( job_manifest_hash( job_list, snapshot_version=SNAPSHOT_VERSION, ) if job_list else None ), effective_policy_sha256=_policy_fingerprint( raw_json, delivery, snapshot_version=SNAPSHOT_VERSION, ), smtp_transport_revision=smtp_transport_revision, 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") return payload, snapshot_hash(payload) def _assert_snapshot_matches_persisted_inputs( session: Session, version: CampaignVersion, snapshot: ExecutionSnapshot, *, effect_job: CampaignJob | None = None, ) -> None: """Fail closed when any build-bound input drifted after snapshot creation.""" raw_json = version.raw_json if isinstance(version.raw_json, dict) else {} if snapshot.campaign_version_id != version.id: raise ExecutionSnapshotError("Execution snapshot campaign version mismatch") if snapshot.campaign_json_sha256 != _sha256(raw_json): raise ExecutionSnapshotError( "Campaign inputs changed after this execution snapshot was built. " "Revalidate and rebuild the campaign before delivery." ) if snapshot.uses_mail and not snapshot.smtp_transport_revision: raise ExecutionSnapshotError("Execution snapshot has no SMTP transport revision") if not snapshot.job_manifest_sha256: raise ExecutionSnapshotError("Execution snapshot has no built-job manifest checksum") if not snapshot.effective_policy_sha256: raise ExecutionSnapshotError("Execution snapshot has no effective-policy checksum") if snapshot.effective_policy_sha256 != _policy_fingerprint( raw_json, snapshot.delivery, snapshot_version=snapshot.snapshot_version, ): raise ExecutionSnapshotError( "Campaign delivery policy changed after the execution snapshot was created. " "Revalidate and rebuild the campaign before delivery." ) if effect_job is not None: if effect_job.campaign_version_id != version.id: raise ExecutionSnapshotError("Campaign job does not belong to the snapshotted version") if not getattr(effect_job, "execution_input_sha256", None): raise ExecutionSnapshotError("Campaign job has no execution-input checksum; rebuild before delivery") if effect_job.execution_input_sha256 != job_execution_input_hash( effect_job, snapshot_version=snapshot.snapshot_version, ): raise ExecutionSnapshotError( "Built campaign job inputs changed after the execution snapshot was created. " "Revalidate and rebuild the campaign before delivery." ) return jobs = ( session.query(CampaignJob) .filter(CampaignJob.campaign_version_id == version.id) .order_by(CampaignJob.entry_index.asc(), CampaignJob.id.asc()) .all() ) queueable_statuses = {JobValidationStatus.READY.value, JobValidationStatus.WARNING.value} if snapshot.job_count != len(jobs): raise ExecutionSnapshotError( "Built campaign jobs changed after the execution snapshot was created. " "Revalidate and rebuild the campaign before delivery." ) queueable_count = sum(1 for job in jobs if job.validation_status in queueable_statuses) if ( snapshot.queueable_job_count != queueable_count or snapshot.job_manifest_sha256 != job_manifest_hash( jobs, snapshot_version=snapshot.snapshot_version, ) or any( getattr(job, "execution_input_sha256", None) != job_execution_input_hash( job, snapshot_version=snapshot.snapshot_version, ) for job in jobs ) ): raise ExecutionSnapshotError( "Built campaign job inputs changed after the execution snapshot was created. " "Revalidate and rebuild the campaign before delivery." ) def ensure_execution_snapshot( session: Session, version: CampaignVersion, *, effect_job: CampaignJob | None = None, ) -> ExecutionSnapshot: """Return a validated snapshot, creating one for pre-migration builds. New builds create the snapshot after persisting their jobs. The fallback is intentionally limited to legacy built versions so they can be operated without a manual data migration. """ raw_json = version.raw_json if isinstance(version.raw_json, dict) else {} try: assert_server_safe_campaign_paths( raw_json, managed_files_available=files_integration().available, ) except CampaignPathSecurityError as exc: raise ExecutionSnapshotError(str(exc)) from exc _assert_version_mail_profile_boundary(raw_json, require_profile=False) if isinstance(version.execution_snapshot, dict): stored_version = str( version.execution_snapshot.get("snapshot_version") or "" ) if stored_version not in SUPPORTED_SNAPSHOT_VERSIONS: raise ExecutionSnapshotError( "This campaign has a legacy execution snapshot that may contain campaign-owned transport data. " "It is preserved for audit only and cannot be delivered; select a Mail profile, then revalidate " "and rebuild a new campaign version." ) snapshot = ExecutionSnapshot.model_validate(version.execution_snapshot) expected = snapshot_hash(version.execution_snapshot) if not version.execution_snapshot_hash: raise ExecutionSnapshotError("Execution snapshot checksum is missing") if version.execution_snapshot_hash != expected: raise ExecutionSnapshotError("Execution snapshot checksum mismatch") _assert_snapshot_profile_matches_version(version, snapshot) _assert_snapshot_matches_persisted_inputs( session, version, snapshot, effect_job=effect_job, ) return snapshot from govoplan_campaign.backend.persistence.campaigns import load_version_config _, _, config = load_version_config(session, version.id) jobs = ( session.query(CampaignJob) .filter(CampaignJob.campaign_version_id == version.id) .order_by(CampaignJob.entry_index.asc(), CampaignJob.id.asc()) .all() ) if not jobs: raise ExecutionSnapshotError("Campaign version has no built jobs; rebuild it before delivery") uses_mail = any( DeliveryChannelPolicy(job.delivery_channel_policy).uses_mail for job in jobs ) profile_id = campaign_mail_profile_id(raw_json) summary: dict[str, Any] = {} if uses_mail: if not config.server.profile_capabilities.smtp_available: raise ExecutionSnapshotError( "The selected Mail profile has no SMTP configuration" ) if profile_id is None: raise ExecutionSnapshotError("Campaign has no Mail profile reference") summary = profile_delivery_summary(session, version) if not summary.get("smtp_transport_revision"): raise ExecutionSnapshotError( "The selected Mail profile has no SMTP transport revision" ) payload, digest = create_execution_snapshot( version, mail_profile_id=profile_id, smtp_server_id=summary.get("smtp_server_id"), smtp_credential_id=summary.get("smtp_credential_id"), imap_server_id=summary.get("imap_server_id"), imap_credential_id=summary.get("imap_credential_id"), smtp_transport_revision=summary.get("smtp_transport_revision"), imap_transport_revision=summary.get("imap_transport_revision"), delivery=config.delivery, jobs=jobs, build_summary=version.build_summary if isinstance(version.build_summary, dict) else {}, ) version.execution_snapshot = payload version.execution_snapshot_hash = digest version.execution_snapshot_at = datetime.now(timezone.utc) session.add(version) session.flush() return ExecutionSnapshot.model_validate(payload) def clear_execution_snapshot(version: CampaignVersion) -> None: version.execution_snapshot = None version.execution_snapshot_hash = None version.execution_snapshot_at = None