from __future__ import annotations import copy import hashlib import json from dataclasses import asdict, dataclass from datetime import UTC, datetime from email import policy from email.parser import BytesParser from pathlib import Path from tempfile import TemporaryDirectory from typing import Any from uuid import uuid4 from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from sqlalchemy import func from sqlalchemy.orm import Session from govoplan_core.auth import ApiPrincipal from govoplan_core.core.object_storage import ( StorageBackend, StorageBackendError, configured_storage_backend, ) from govoplan_core.core.templates import TemplateRenderRequest from govoplan_core.settings import settings as core_settings from govoplan_campaign.backend.db.models import ( Campaign, CampaignIssue, CampaignJob, CampaignStatus, CampaignVersion, CampaignVersionWorkflowState, JobImapStatus, JobPostboxStatus, JobPrintStatus, JobQueueStatus, JobSendStatus, JobValidationStatus, ) from govoplan_campaign.backend.campaign.loader import ( load_campaign_json, validate_against_schema, ) from govoplan_campaign.backend.campaign.mail_profile_boundary import ( assert_campaign_uses_mail_profile_reference, campaign_mail_profile_id, campaign_mail_resource_ids, ) from govoplan_campaign.backend.campaign.validation import ( SemanticIssue, Severity, validate_campaign_config, ) from govoplan_campaign.backend.campaign.entries import load_campaign_entries from govoplan_campaign.backend.campaign.postbox_targets import ( resolve_entry_postbox_targets, ) from govoplan_campaign.backend.campaign.field_values import ( effective_entry_field_values, ) from govoplan_campaign.backend.campaign.template_values import build_template_values from govoplan_campaign.backend.messages.builder import build_campaign_messages from govoplan_campaign.backend.messages.models import ( MessageDraft, MessageValidationStatus, ) from govoplan_campaign.backend.sending.execution import ( create_execution_snapshot, profile_delivery_summary, ) from govoplan_campaign.backend.campaign.models import ( CampaignConfig, DeliveryChannelPolicy, SendStatus, ) from govoplan_campaign.backend.integrations import ( CalendarInvitationUnavailable, calendar_integration, files_integration, mail_integration, postbox_integration, templates_integration, ) from govoplan_campaign.backend.template_rendering import render_template from govoplan_campaign.backend.path_security import assert_server_safe_campaign_paths from govoplan_campaign.backend.runtime import get_settings class CampaignPersistenceError(RuntimeError): pass @dataclass(frozen=True, slots=True) class _StoredEmlArtifact: storage_key: str size_bytes: int sha256: str message_id_header: str | None def load_campaign_config_from_json( session: Session, *, tenant_id: str, raw_json: dict[str, Any], campaign_id: str | None = None, owner_user_id: str | None = None, owner_group_id: str | None = None, ) -> CampaignConfig: # Validate the persisted Campaign-to-Mail contract before asking Mail for a # non-secret capability summary. Campaign never receives resolved transport # settings, account identities, or credentials. assert_campaign_uses_mail_profile_reference(raw_json) validate_against_schema(raw_json) materialized = copy.deepcopy(raw_json) profile_id = campaign_mail_profile_id(raw_json) if profile_id: references = campaign_mail_resource_ids(raw_json) summary = mail_integration().campaign_profile_delivery_summary( session, tenant_id=tenant_id, campaign_id=campaign_id, profile_id=profile_id, owner_user_id=owner_user_id, owner_group_id=owner_group_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"], ) materialized.setdefault("server", {})["profile_capabilities"] = { "smtp_available": bool(summary.get("smtp_available")), "imap_available": bool(summary.get("imap_available")), } return CampaignConfig.model_validate(materialized) def _campaign_reference_path(version: CampaignVersion) -> Path: """Return a path anchor without persisting a second configuration copy.""" source_base_path = str(version.source_base_path or "").strip() base = ( Path(source_base_path).expanduser().resolve() if source_base_path else Path.cwd() ) return base / "campaign.json" def _object_storage() -> StorageBackend: return configured_storage_backend(get_settings() or core_settings) def _persist_built_eml_artifacts( *, storage: StorageBackend, tenant_id: str, campaign_id: str, version_id: str, build_id: str, built_messages: list[Any], ) -> dict[int, _StoredEmlArtifact]: artifacts: dict[int, _StoredEmlArtifact] = {} try: for built in built_messages: message = built.draft if not message.eml_path: continue path = Path(message.eml_path) if not path.is_file(): raise CampaignPersistenceError( f"Generated EML evidence is missing for entry {message.entry_index}" ) payload = path.read_bytes() digest = hashlib.sha256(payload).hexdigest() parsed = BytesParser(policy=policy.default).parsebytes(payload) message_id = parsed.get("Message-ID") storage_key = ( f"campaign-artifacts/{tenant_id}/{campaign_id}/{version_id}/" f"{build_id}/{message.entry_index:08d}-{digest}.eml" ) try: storage.put_bytes( storage_key, payload, content_type="message/rfc822", ) except StorageBackendError as exc: raise CampaignPersistenceError( f"Generated EML evidence could not be persisted: {exc}" ) from exc artifacts[message.entry_index] = _StoredEmlArtifact( storage_key=storage_key, size_bytes=len(payload), sha256=digest, message_id_header=str(message_id) if message_id else None, ) message.eml_path = None message.eml_size_bytes = len(payload) except Exception: _delete_storage_keys( storage, [artifact.storage_key for artifact in artifacts.values()], ) raise return artifacts def _delete_storage_keys(storage: StorageBackend, keys: list[str]) -> None: for key in keys: try: storage.delete(key) except StorageBackendError: # A committed build remains authoritative. Reconciliation can # remove an orphaned superseded object later. continue def _next_version_number(session: Session, campaign_id: str) -> int: current = ( session.query(func.max(CampaignVersion.version_number)) .filter(CampaignVersion.campaign_id == campaign_id) .scalar() ) return int(current or 0) + 1 def _resolve_runtime_path(base_path: Path | None, value: str | None) -> str | None: if not value or base_path is None: return value path = Path(value).expanduser() if path.is_absolute(): return str(path) return str((base_path / path).resolve()) def normalize_campaign_paths( raw_json: dict[str, Any], source_base_path: str | Path | None ) -> dict[str, Any]: """Resolve paths for an explicitly trusted, file-oriented import. The CLI naturally resolves relative paths against the campaign.json file. Once the campaign is stored in the database, its JSON is authoritative. To keep existing file-based campaigns working, relative file paths are normalized to absolute paths at import time when a source_base_path is known. HTTP/API callers are rejected by ``assert_server_safe_campaign_paths`` before they can reach this helper. """ base = Path(source_base_path).expanduser().resolve() if source_base_path else None data = copy.deepcopy(raw_json) template_source = ( data.get("template", {}).get("source") if isinstance(data.get("template"), dict) else None ) if isinstance(template_source, dict): for key in ("subject_path", "text_path", "html_path"): template_source[key] = _resolve_runtime_path(base, template_source.get(key)) entries_source = ( data.get("entries", {}).get("source") if isinstance(data.get("entries"), dict) else None ) if isinstance(entries_source, dict): entries_source["path"] = _resolve_runtime_path(base, entries_source.get("path")) attachments = data.get("attachments") if isinstance(attachments, dict): attachments["base_path"] = ( _resolve_runtime_path(base, attachments.get("base_path")) or "." ) return data def create_campaign_version_from_json( session: Session, *, tenant_id: str, user_id: str | None, raw_json: dict[str, Any], source_filename: str | None = None, source_base_path: str | None = None, commit: bool = True, ) -> tuple[Campaign, CampaignVersion]: assert_server_safe_campaign_paths( raw_json, source_filename=source_filename, source_base_path=source_base_path, managed_files_available=files_integration().available, ) if source_base_path is None and source_filename: source_path = Path(source_filename).expanduser() source_base_path = str( source_path.parent if source_path.suffix else source_path ) runtime_json = normalize_campaign_paths(raw_json, source_base_path) config = load_campaign_config_from_json( session, tenant_id=tenant_id, raw_json=runtime_json, owner_user_id=user_id ) campaign = ( session.query(Campaign) .filter( Campaign.tenant_id == tenant_id, Campaign.external_id == config.campaign.id ) .one_or_none() ) if campaign is None: campaign = Campaign( tenant_id=tenant_id, created_by_user_id=user_id, owner_user_id=user_id, external_id=config.campaign.id, name=config.campaign.name, description=config.campaign.description, status=CampaignStatus.DRAFT.value, ) session.add(campaign) session.flush() else: current = ( session.get(CampaignVersion, campaign.current_version_id) if campaign.current_version_id else None ) if current and not _version_is_audit_safe_snapshot(current): raise CampaignPersistenceError( f"Campaign already has active working version #{current.version_number}. " "Continue editing or unlock that version instead of importing a parallel draft." ) campaign.name = config.campaign.name campaign.description = config.campaign.description version = CampaignVersion( campaign_id=campaign.id, version_number=_next_version_number(session, campaign.id), raw_json=runtime_json, schema_version=raw_json.get("version", "1.0"), source_filename=source_filename, source_base_path=source_base_path, ) session.add(version) session.flush() campaign.current_version_id = version.id session.add(campaign) if commit: session.commit() else: session.flush() return campaign, version def _version_user_lock_state(version: CampaignVersion) -> str | None: state = getattr(version, "user_lock_state", None) if state in {"temporary", "permanent"}: return state return "permanent" if version.published_at else None def _version_is_user_locked(version: CampaignVersion) -> bool: return _version_user_lock_state(version) is not None def _version_is_audit_safe_snapshot(version: CampaignVersion) -> bool: return _version_user_lock_state( version ) == "permanent" or version.workflow_state in { CampaignVersionWorkflowState.QUEUED.value, CampaignVersionWorkflowState.SENDING.value, CampaignVersionWorkflowState.COMPLETED.value, CampaignVersionWorkflowState.PARTIALLY_COMPLETED.value, CampaignVersionWorkflowState.OUTCOME_UNKNOWN.value, CampaignVersionWorkflowState.FAILED.value, CampaignVersionWorkflowState.CANCELLED.value, CampaignVersionWorkflowState.ARCHIVED.value, } def _ensure_current_campaign_version( campaign: Campaign, version: CampaignVersion, *, action: str ) -> None: if campaign.current_version_id != version.id: raise CampaignPersistenceError( f"Historical campaign versions are read-only and cannot be used to {action}. " "Open the current working version instead." ) def _version_is_validated_and_locked(version: CampaignVersion) -> bool: validation_summary = ( version.validation_summary if isinstance(version.validation_summary, dict) else {} ) return bool( version.locked_at and validation_summary.get("ok") is True and not _version_is_user_locked(version) ) def _ensure_version_validated_and_locked(version: CampaignVersion) -> None: state = _version_user_lock_state(version) if state == "temporary": raise CampaignPersistenceError( "This version has a temporary user lock. Unlock it before building, queueing, dry-run or sending." ) if state == "permanent": raise CampaignPersistenceError( "This version is permanently user-locked. Create an editable copy instead." ) if not _version_is_validated_and_locked(version): raise CampaignPersistenceError( "Campaign version must be validated and locked before building, queueing, dry-run or sending." ) def load_version_config(session: Session, version_id: str): version = session.get(CampaignVersion, version_id) if not version: raise CampaignPersistenceError(f"Campaign version not found: {version_id}") campaign = session.get(Campaign, version.campaign_id) if not campaign: raise CampaignPersistenceError(f"Campaign not found for version: {version_id}") raw_json = version.raw_json if isinstance(version.raw_json, dict) else {} assert_server_safe_campaign_paths( raw_json, managed_files_available=files_integration().available, ) path = _campaign_reference_path(version) return ( version, path, load_campaign_config_from_json( session, tenant_id=campaign.tenant_id, raw_json=raw_json, campaign_id=campaign.id, ), ) def _campaign_uses_print(config: CampaignConfig) -> bool: entries = ( config.entries.inline if config.entries.is_inline else [config.entries.defaults] ) return any( entry is not None and entry.active and (entry.channel_policy or config.delivery.channel_policy).uses_print for entry in entries or [] ) def _print_template_available_fields(config: CampaignConfig) -> dict[str, str]: type_map = { "double": "number", "organization_unit": "string", "organization_function": "string", "password": "string", } fields: dict[str, str] = { "campaign.id": "string", "campaign.name": "string", "recipient_key": "string", "display_name": "string", "print_target.channel": "string", "print_target.target": "string", "print_target.target_key": "string", "distribution.list_id": "string", "distribution.list_revision": "integer", "distribution.expansion_hash": "string", } for field in config.fields: value_type = type_map.get(field.type.value, field.type.value) fields[field.name] = value_type fields[f"fields.{field.name}"] = value_type return fields def _print_template_compatibility_issues( session: Session, *, principal: ApiPrincipal | None, config: CampaignConfig, ) -> list[SemanticIssue]: if not _campaign_uses_print(config) or not config.delivery.print.template_id: return [] integration = templates_integration() if not integration.available: return [] # The semantic availability check already explains this. if principal is None: return [ SemanticIssue( severity=Severity.ERROR, code="print_template_principal_missing", message="Printable output must be validated by an authenticated Campaign actor.", path="/delivery/print/template_id", ) ] if not ( principal.has("templates:template:render") or principal.has("templates:template:admin") ): return [ SemanticIssue( severity=Severity.ERROR, code="print_template_render_forbidden", message="You may select this template but are not permitted to render printable output.", path="/delivery/print/template_id", ) ] if config.delivery.print.template_revision is None: return [] # The semantic validation report already requires an exact revision. try: template = integration.get_template( session, principal, template_id=config.delivery.print.template_id, revision=config.delivery.print.template_revision, ) if template is None or template.revision is None: raise ValueError("Template revision not found.") if template.revision.published_at is None: raise ValueError("The selected template revision is not published.") result = integration.check_compatibility( session, principal, template_id=config.delivery.print.template_id, revision=config.delivery.print.template_revision, output_format=config.delivery.print.output_format, available_fields=_print_template_available_fields(config), ) except (PermissionError, RuntimeError, ValueError) as exc: return [ SemanticIssue( severity=Severity.ERROR, code="print_template_unavailable", message=f"The selected printable template cannot be used: {exc}", path="/delivery/print/template_id", ) ] if result.compatible: return [] details = [*result.missing_fields, *result.incompatible_fields] suffix = ( f" Missing or incompatible fields: {', '.join(details)}." if details else "" ) return [ SemanticIssue( severity=Severity.ERROR, code="print_template_incompatible", message=( "The selected printable template is not compatible with this Campaign." f"{suffix}" ), path="/delivery/print/template_id", ) ] def _calendar_invitation_compatibility_issues( session: Session, *, tenant_id: str, principal: ApiPrincipal | None, config: CampaignConfig, ) -> list[SemanticIssue]: invitation = config.delivery.calendar_invitation if not invitation.enabled or not invitation.calendar_id: return [] integration = calendar_integration() if not integration.available: return [] if principal is None: return [ SemanticIssue( severity=Severity.ERROR, code="calendar_invitation_principal_missing", message="Calendar invitations must be validated by an authenticated Campaign actor.", path="/delivery/calendar_invitation/calendar_id", ) ] if not any( principal.has(scope) for scope in ( "calendar:calendar:read", "calendar:calendar:write", "calendar:calendar:admin", ) ): return [ SemanticIssue( severity=Severity.ERROR, code="calendar_invitation_calendar_forbidden", message="You are not permitted to select a calendar for invitation tracking.", path="/delivery/calendar_invitation/calendar_id", ) ] calendars = integration.list_calendars( session, tenant_id=tenant_id, user_id=str(getattr(principal.user, "id", "")) or None, group_ids=tuple(principal.group_ids), can_admin=principal.has("calendar:calendar:admin"), ) selected = next( (item for item in calendars if item.id == invitation.calendar_id), None, ) if selected is None: message = "The selected invitation calendar is not visible or no longer exists." elif not selected.writable: message = "The selected invitation calendar is read-only." else: return [] return [ SemanticIssue( severity=Severity.ERROR, code="calendar_invitation_calendar_unavailable", message=message, path="/delivery/calendar_invitation/calendar_id", ) ] def validate_campaign_version( session: Session, *, tenant_id: str, version_id: str, check_files: bool = False, user_id: str | None = None, principal: ApiPrincipal | None = None, lock_on_success: bool = True, ) -> dict[str, Any]: version, snapshot_path, config = load_version_config(session, version_id) campaign = session.get(Campaign, version.campaign_id) if not campaign or campaign.tenant_id != tenant_id: raise CampaignPersistenceError( "Campaign version is not accessible for this tenant" ) _ensure_current_campaign_version(campaign, version, action="validate") if _version_is_user_locked(version) or version.workflow_state in { CampaignVersionWorkflowState.QUEUED.value, CampaignVersionWorkflowState.SENDING.value, CampaignVersionWorkflowState.COMPLETED.value, CampaignVersionWorkflowState.PARTIALLY_COMPLETED.value, CampaignVersionWorkflowState.OUTCOME_UNKNOWN.value, CampaignVersionWorkflowState.FAILED.value, CampaignVersionWorkflowState.CANCELLED.value, CampaignVersionWorkflowState.ARCHIVED.value, }: lock_label = ( "temporarily user-locked" if _version_user_lock_state(version) == "temporary" else "permanently locked/final" ) raise CampaignPersistenceError( f"{lock_label.capitalize()} campaign versions cannot be validated. Unlock or create an editable copy instead." ) if check_files: files = files_integration() with files.prepared_campaign_snapshot( session, tenant_id=tenant_id, campaign_id=campaign.id, raw_json=version.raw_json if isinstance(version.raw_json, dict) else {}, include_bytes=False, prefix="govoplan-managed-validate-", ) as prepared: managed_raw = load_campaign_json(prepared.path) managed_config = load_campaign_config_from_json( session, tenant_id=tenant_id, raw_json=managed_raw, campaign_id=campaign.id, ) report = validate_campaign_config( managed_config, campaign_file=prepared.path, check_files=True, postbox_available=postbox_integration().available, templates_available=templates_integration().available, calendar_available=calendar_integration().available, ) else: report = validate_campaign_config( config, campaign_file=snapshot_path, check_files=False, postbox_available=postbox_integration().available, templates_available=templates_integration().available, calendar_available=calendar_integration().available, ) report.issues.extend( _print_template_compatibility_issues( session, principal=principal, config=config, ) ) report.issues.extend( _calendar_invitation_compatibility_issues( session, tenant_id=tenant_id, principal=principal, config=config, ) ) report_json = report.model_dump(mode="json") report_json.update( { "ok": report.ok, "error_count": report.error_count, "warning_count": report.warning_count, "validated_at": datetime.now(UTC).isoformat(), "validated_by_user_id": user_id, } ) version.validation_summary = report_json # Replace version-level semantic issues from previous validations. ( session.query(CampaignIssue) .filter( CampaignIssue.campaign_version_id == version.id, CampaignIssue.job_id.is_(None), ) .delete(synchronize_session=False) ) for issue in report.issues: session.add( CampaignIssue( tenant_id=tenant_id, campaign_id=campaign.id, campaign_version_id=version.id, severity=issue.severity.value, code=issue.code, message=issue.message, source=issue.path, ) ) campaign.status = ( CampaignStatus.VALIDATED.value if report.ok else CampaignStatus.NEEDS_REVIEW.value ) if report.ok: version.workflow_state = CampaignVersionWorkflowState.APPROVED.value version.is_complete = True if lock_on_success and version.locked_at is None: version.locked_at = datetime.now(UTC) version.locked_by_user_id = user_id else: version.workflow_state = CampaignVersionWorkflowState.EDITING.value session.add(version) session.add(campaign) session.commit() return report_json def _job_validation_status(value: str) -> str: allowed = {item.value for item in JobValidationStatus} return value if value in allowed else JobValidationStatus.NEEDS_REVIEW.value def _eml_evidence(eml_path: str | None) -> tuple[str | None, str | None]: if not eml_path: return None, None path = Path(eml_path) if not path.exists(): return None, None payload = path.read_bytes() message_id = ( BytesParser(policy=policy.default).parsebytes(payload).get("Message-ID") ) return hashlib.sha256(payload).hexdigest(), str(message_id) if message_id else None def _job_from_message( *, tenant_id: str, campaign_id: str, version_id: str, message: MessageDraft, resolved_postbox_targets: list[dict[str, Any]] | None = None, resolved_print_output: dict[str, Any] | None = None, delivery_provenance: dict[str, Any] | None = None, stored_eml: _StoredEmlArtifact | None = None, ) -> CampaignJob: recipient_email = message.to[0].email if message.to else None eml_sha256, message_id_header = _eml_evidence(message.eml_path) if stored_eml is not None: eml_sha256 = stored_eml.sha256 message_id_header = stored_eml.message_id_header channel_policy = DeliveryChannelPolicy(message.delivery_channel_policy) return CampaignJob( tenant_id=tenant_id, campaign_id=campaign_id, campaign_version_id=version_id, entry_index=message.entry_index, entry_id=message.entry_id, recipient_email=recipient_email, subject=message.subject, message_id_header=message_id_header, eml_storage_key=stored_eml.storage_key if stored_eml else None, eml_local_path=message.eml_path if stored_eml is None else None, eml_size_bytes=stored_eml.size_bytes if stored_eml else message.eml_size_bytes, eml_sha256=eml_sha256, build_status=message.build_status.value if hasattr(message.build_status, "value") else str(message.build_status), validation_status=_job_validation_status(message.validation_status.value), queue_status=JobQueueStatus.DRAFT.value, send_status=( JobSendStatus.SKIPPED.value if message.send_status == SendStatus.SKIPPED else JobSendStatus.NOT_QUEUED.value ), delivery_channel_policy=message.delivery_channel_policy, postbox_status=( JobPostboxStatus.PENDING.value if channel_policy.uses_postbox else JobPostboxStatus.NOT_REQUESTED.value ), print_status=( JobPrintStatus.READY.value if channel_policy.uses_print and resolved_print_output else JobPrintStatus.FAILED.value if channel_policy.uses_print else JobPrintStatus.NOT_REQUESTED.value ), imap_status=message.imap_status.value if hasattr(message.imap_status, "value") else JobImapStatus.NOT_REQUESTED.value, resolved_recipients={ "from": message.from_.model_dump(mode="json") if message.from_ else None, "from_all": [item.model_dump(mode="json") for item in message.from_all], "to": [item.model_dump(mode="json") for item in message.to], "cc": [item.model_dump(mode="json") for item in message.cc], "bcc": [item.model_dump(mode="json") for item in message.bcc], "reply_to": [item.model_dump(mode="json") for item in message.reply_to], "bounce_to": [item.model_dump(mode="json") for item in message.bounce_to], "disposition_notification_to": [ item.model_dump(mode="json") for item in message.disposition_notification_to ], }, delivery_provenance=delivery_provenance or {}, resolved_postbox_targets=resolved_postbox_targets or [], resolved_print_output=resolved_print_output, resolved_attachments=[ files_integration().public_attachment_summary_payload(item) for item in message.attachments ], issues_snapshot=[item.model_dump(mode="json") for item in message.issues], last_error="; ".join( issue.message for issue in message.issues if issue.severity == "error" ) or None, ) def _resolve_built_postbox_targets( session: Session, *, tenant_id: str, config: CampaignConfig, built_messages: list[Any], entries_by_index: dict[int, Any], ) -> dict[int, list[dict[str, Any]]]: resolved_by_index: dict[int, list[dict[str, Any]]] = {} for built in built_messages: if not DeliveryChannelPolicy(built.draft.delivery_channel_policy).uses_postbox: continue entry = entries_by_index.get(built.draft.entry_index) if entry is None: raise CampaignPersistenceError( "Built recipient row is missing from the campaign input." ) resolved, issues, validation_status = resolve_entry_postbox_targets( session, tenant_id=tenant_id, config=config, entry=entry, validation_status=built.draft.validation_status, materialize=True, ) built.draft.issues.extend(issues) built.draft.validation_status = validation_status resolved_by_index[built.draft.entry_index] = resolved return resolved_by_index def _render_invitation_value( template: str | None, values: dict[str, Any], *, fallback: str | None = None, ) -> str | None: if template is None: return fallback rendered = render_template(template, values, keep_missing=False).strip() return rendered or fallback def _invitation_datetime(value: str, *, timezone_id: str | None) -> datetime: try: parsed = datetime.fromisoformat(value.strip().replace("Z", "+00:00")) except ValueError as exc: raise CampaignPersistenceError( f"Calendar invitation date is not valid ISO 8601: {value!r}" ) from exc if parsed.tzinfo is not None: return parsed try: return parsed.replace(tzinfo=ZoneInfo(timezone_id or "UTC")) except ZoneInfoNotFoundError as exc: raise CampaignPersistenceError( f"Calendar invitation timezone is unknown: {timezone_id}" ) from exc def _calendar_invitation_request_payload( *, version: CampaignVersion, config: CampaignConfig, built: Any, entry: Any, user_id: str | None, ) -> dict[str, Any]: invitation = config.delivery.calendar_invitation values = build_template_values(config, entry) start_text = _render_invitation_value( invitation.start_at_template, values, ) if not start_text: raise CampaignPersistenceError( "Calendar invitation start resolved to an empty value." ) end_text = _render_invitation_value(invitation.end_at_template, values) start_at = _invitation_datetime(start_text, timezone_id=invitation.timezone) end_at = ( _invitation_datetime(end_text, timezone_id=invitation.timezone) if end_text else None ) if end_at is not None and end_at <= start_at: raise CampaignPersistenceError( "Calendar invitation end must be after its start." ) attendee_by_address = { item.email.strip().casefold(): item for item in built.draft.to if item.email.strip() } if not attendee_by_address: raise CampaignPersistenceError( "Calendar invitation has no effective To recipient." ) sender = built.draft.from_ organizer = None if sender is not None and sender.email.strip(): params: dict[str, list[str]] = {} if sender.name: params["CN"] = [sender.name] organizer = { "value": f"mailto:{sender.email.strip()}", "params": params, } entry_key = str(built.draft.entry_id or built.draft.entry_index) correlation_digest = hashlib.sha256( f"{version.id}:{entry_key}".encode("utf-8") ).hexdigest()[:32] return { "correlation_id": f"campaign:{version.id}:{correlation_digest}", "source_resource_id": version.id, "calendar_id": invitation.calendar_id, "summary": _render_invitation_value( invitation.summary_template, values, fallback=built.draft.subject or config.campaign.name, ), "description": _render_invitation_value( invitation.description_template, values, ), "location": _render_invitation_value( invitation.location_template, values, ), "start_at": start_at.isoformat(), "end_at": end_at.isoformat() if end_at else None, "timezone": invitation.timezone, "organizer": organizer, "attendees": [ { "address": item.email.strip(), "name": item.name, "role": "REQ-PARTICIPANT", "participation_status": "NEEDS-ACTION", "rsvp": True, } for item in attendee_by_address.values() ], "classification": invitation.classification, "categories": list(invitation.categories), "metadata": { "campaign_id": version.campaign_id, "campaign_version_id": version.id, "entry_id": built.draft.entry_id, "entry_index": built.draft.entry_index, "prepared_by_user_id": user_id, }, } def _prepare_built_calendar_invitations( *, version: CampaignVersion, config: CampaignConfig, built_messages: list[Any], entries_by_index: dict[int, Any], delivery_provenance_by_index: dict[int, dict[str, Any]], user_id: str | None, ) -> None: if not config.delivery.calendar_invitation.enabled: return integration = calendar_integration() if not integration.available: raise CampaignPersistenceError( "Calendar invitation capability became unavailable after validation." ) for built in built_messages: if built.mime is None or not built.draft.is_queueable: continue entry = entries_by_index.get(built.draft.entry_index) if entry is None: raise CampaignPersistenceError( "Built invitation recipient is missing from the campaign input." ) request_payload = _calendar_invitation_request_payload( version=version, config=config, built=built, entry=entry, user_id=user_id, ) request = integration.request_from_payload(request_payload) try: icalendar = integration.render_invitation(request) except (CalendarInvitationUnavailable, ValueError) as exc: raise CampaignPersistenceError(str(exc)) from exc built.mime.add_attachment( icalendar, subtype="calendar", charset="utf-8", filename="invitation.ics", params={"method": "REQUEST"}, ) if built.draft.eml_path: path = Path(built.draft.eml_path) path.write_bytes(bytes(built.mime)) built.draft.eml_size_bytes = path.stat().st_size built.draft.attachment_count += 1 provenance = delivery_provenance_by_index.setdefault( built.draft.entry_index, {}, ) provenance["calendar_invitation"] = { "state": "prepared", "request": request_payload, "icalendar_sha256": hashlib.sha256( icalendar.encode("utf-8") ).hexdigest(), "prepared_at": datetime.now(UTC).isoformat(), } def _print_render_item( config: CampaignConfig, entry: Any, ) -> dict[str, Any]: fields = effective_entry_field_values(config, entry) distribution = dict(entry.distribution_source or {}) target = entry.print_target.model_dump(mode="json") if entry.print_target else {} return { **fields, "fields": fields, "campaign": { "id": config.campaign.id, "name": config.campaign.name, "description": config.campaign.description, }, "recipient_key": str(distribution.get("recipient_key") or entry.id or ""), "display_name": entry.name or "", "print_target": target, "distribution": distribution, } def _canonical_sha256(value: object) -> str: payload = json.dumps( value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=str, ).encode("utf-8") return hashlib.sha256(payload).hexdigest() def _resolve_built_print_outputs( session: Session, *, storage: StorageBackend, tenant_id: str, build_id: str, version: CampaignVersion, principal: ApiPrincipal | None, config: CampaignConfig, built_messages: list[Any], entries_by_index: dict[int, Any], ) -> dict[int, dict[str, Any]]: printable: list[tuple[Any, Any, dict[str, Any]]] = [] for built in built_messages: policy = DeliveryChannelPolicy(built.draft.delivery_channel_policy) if not policy.uses_print: continue entry = entries_by_index.get(built.draft.entry_index) if entry is None: raise CampaignPersistenceError( "A printable recipient row is missing from the Campaign input." ) if built.draft.validation_status not in { MessageValidationStatus.READY, MessageValidationStatus.WARNING, }: continue printable.append((built, entry, _print_render_item(config, entry))) if not printable: return {} if principal is None: raise CampaignPersistenceError( "Printable output requires the authenticated actor that validated the Campaign." ) print_config = config.delivery.print if not print_config.template_id: raise CampaignPersistenceError( "Printable output requires a selected template." ) items = tuple(item for _built, _entry, item in printable) routes = [ { "entry_index": built.draft.entry_index, "entry_id": built.draft.entry_id, "channel_policy": built.draft.delivery_channel_policy, "print_target": item["print_target"], "distribution": item["distribution"], } for built, _entry, item in printable ] input_snapshot = { "producer_module": "campaigns", "campaign_id": version.campaign_id, "campaign_version_id": version.id, "campaign_version_number": version.version_number, "actor_account_id": principal.account_id, "routes": routes, } render_key = _canonical_sha256( { "template_id": print_config.template_id, "template_revision": print_config.template_revision, "usage": print_config.usage, "output_format": print_config.output_format, "profile_id": print_config.profile_id, "items": items, "input_snapshot": input_snapshot, } ) result = templates_integration().render( session, principal, request=TemplateRenderRequest( template_id=print_config.template_id, revision=print_config.template_revision, usage=print_config.usage, output_format=print_config.output_format, profile_id=print_config.profile_id, items=items, input_snapshot=input_snapshot, mode="final", idempotency_key=f"campaign:{version.id}:print:{render_key}", persist_to_files=print_config.persist_to_files, ), ) artifact = asdict(result.artifact) if result.artifact is not None else None if artifact and artifact.get("kind") == "bounded_download": if result.payload is None: raise CampaignPersistenceError( "Templates returned a bounded print artifact without its payload." ) storage_key = ( f"campaign-artifacts/{tenant_id}/{version.campaign_id}/{version.id}/" f"{build_id}/print-{result.output_sha256}" ) try: storage.put_bytes( storage_key, result.payload, content_type=result.content_type, ) except StorageBackendError as exc: raise CampaignPersistenceError( f"Printable Campaign output could not be persisted: {exc}" ) from exc artifact["storage_key"] = storage_key artifact["download_path"] = ( f"/api/v1/campaigns/{version.campaign_id}/versions/{version.id}/" "print-output/download" ) artifact["provenance"] = { **dict(artifact.get("provenance") or {}), "module": "campaigns", "source_module": "templates", "source_render_id": result.render_id, "bounded": True, } common = { "status": "ready", "render_id": result.render_id, "template_id": result.template_id, "template_revision_id": result.revision_id, "template_revision": result.revision, "template_hash": result.template_hash, "input_hash": result.input_hash, "renderer_version": result.renderer_version, "output_format": result.output_format, "output_sha256": result.output_sha256, "output_size_bytes": result.output_size_bytes, "item_count": result.item_count, "page_count": result.page_count, "artifact": artifact, "diagnostics": [dict(item) for item in result.diagnostics], "actor_account_id": principal.account_id, "render_idempotency_key": f"campaign:{version.id}:print:{render_key}", } resolved: dict[int, dict[str, Any]] = {} for item_index, (built, entry, _item) in enumerate(printable): resolved[built.draft.entry_index] = { **common, "item_index": item_index, "recipient_key": str( entry.distribution_source.get("recipient_key") or entry.id or built.draft.entry_index ), "route": entry.print_target.model_dump(mode="json") if entry.print_target else None, } return resolved def _campaign_build_report(result: Any, files: Any) -> dict[str, Any]: report_json = result.report.model_dump(mode="json", by_alias=True) for message_payload, message in zip( report_json.get("messages", []), result.report.messages, strict=False ): if isinstance(message_payload, dict): message_payload["attachments"] = [ files.public_attachment_summary_payload(item) for item in message.attachments ] report_json.update( { "built_at": datetime.now(UTC).isoformat(), "build_token": uuid4().hex, "built_count": result.report.built_count, "build_failed_count": result.report.build_failed_count, "ready_count": result.report.ready_count, "warning_count": result.report.warning_count, "needs_review_count": result.report.needs_review_count, "blocked_count": result.report.blocked_count, "excluded_count": result.report.excluded_count, "inactive_count": result.report.inactive_count, "queueable_count": result.report.queueable_count, } ) return report_json def _replace_version_jobs( session: Session, *, tenant_id: str, campaign_id: str, version_id: str, built_messages: list[Any], postbox_targets_by_index: dict[int, list[dict[str, Any]]], print_outputs_by_index: dict[int, dict[str, Any]], delivery_provenance_by_index: dict[int, dict[str, Any]], stored_eml_by_index: dict[int, _StoredEmlArtifact], ) -> tuple[list[tuple[CampaignJob, MessageDraft]], list[str]]: old_storage_keys: list[str] = [] old_job_artifacts = ( session.query( CampaignJob.eml_storage_key, CampaignJob.resolved_print_output, ) .filter(CampaignJob.campaign_version_id == version_id) .all() ) for eml_storage_key, print_output in old_job_artifacts: if eml_storage_key: old_storage_keys.append(str(eml_storage_key)) if isinstance(print_output, dict): artifact = print_output.get("artifact") if isinstance(artifact, dict) and artifact.get("storage_key"): old_storage_keys.append(str(artifact["storage_key"])) session.query(CampaignIssue).filter( CampaignIssue.campaign_version_id == version_id, CampaignIssue.job_id.is_not(None), ).delete(synchronize_session=False) session.query(CampaignJob).filter( CampaignJob.campaign_version_id == version_id ).delete(synchronize_session=False) session.flush() pairs: list[tuple[CampaignJob, MessageDraft]] = [] for built in built_messages: job = _job_from_message( tenant_id=tenant_id, campaign_id=campaign_id, version_id=version_id, message=built.draft, resolved_postbox_targets=postbox_targets_by_index.get( built.draft.entry_index, [] ), resolved_print_output=print_outputs_by_index.get( built.draft.entry_index ), delivery_provenance=delivery_provenance_by_index.get( built.draft.entry_index, {}, ), stored_eml=stored_eml_by_index.get(built.draft.entry_index), ) session.add(job) pairs.append((job, built.draft)) session.flush() return pairs, old_storage_keys def _mail_execution_profile( session: Session, *, version: CampaignVersion, config: CampaignConfig, jobs: list[CampaignJob], ) -> tuple[str | None, dict[str, Any]]: if not any( DeliveryChannelPolicy(job.delivery_channel_policy).uses_mail for job in jobs ): return None, {} if not config.server.profile_capabilities.smtp_available: raise CampaignPersistenceError( "The selected Mail profile has no SMTP configuration; an execution snapshot cannot be created." ) profile_id = campaign_mail_profile_id( version.raw_json if isinstance(version.raw_json, dict) else {} ) if profile_id is None: raise CampaignPersistenceError( "Select an authorized Mail profile before building campaign messages that use Mail." ) summary = profile_delivery_summary(session, version) if not summary.get("smtp_transport_revision"): raise CampaignPersistenceError( "The selected Mail profile has no SMTP transport revision." ) return profile_id, summary def _store_execution_snapshot( session: Session, *, version: CampaignVersion, config: CampaignConfig, jobs: list[CampaignJob], build_summary: dict[str, Any], ) -> None: profile_id, profile = _mail_execution_profile( session, version=version, config=config, jobs=jobs ) snapshot, snapshot_hash = create_execution_snapshot( version, mail_profile_id=profile_id, smtp_server_id=profile.get("smtp_server_id"), smtp_credential_id=profile.get("smtp_credential_id"), imap_server_id=profile.get("imap_server_id"), imap_credential_id=profile.get("imap_credential_id"), smtp_transport_revision=profile.get("smtp_transport_revision"), imap_transport_revision=profile.get("imap_transport_revision"), delivery=config.delivery, jobs=jobs, build_summary=build_summary, ) version.execution_snapshot = snapshot version.execution_snapshot_hash = snapshot_hash version.execution_snapshot_at = datetime.now(UTC) def _store_job_issues( session: Session, *, tenant_id: str, campaign_id: str, version_id: str, job_build_pairs: list[tuple[CampaignJob, MessageDraft]], ) -> None: for job, message in job_build_pairs: session.add_all( [ CampaignIssue( tenant_id=tenant_id, campaign_id=campaign_id, campaign_version_id=version_id, job_id=job.id, severity=issue.severity, code=issue.code, message=issue.message, source=issue.source, behavior=issue.behavior, ) for issue in message.issues ] ) def _apply_campaign_build_state( campaign: Campaign, version: CampaignVersion, *, needs_review_count: int, blocked_count: int, queueable_count: int, ) -> None: if needs_review_count or blocked_count: campaign.status = CampaignStatus.NEEDS_REVIEW.value version.workflow_state = CampaignVersionWorkflowState.APPROVED.value elif queueable_count > 0: campaign.status = CampaignStatus.READY_TO_QUEUE.value version.workflow_state = CampaignVersionWorkflowState.BUILT.value else: campaign.status = CampaignStatus.VALIDATED.value def build_campaign_version( session: Session, *, tenant_id: str, version_id: str, write_eml: bool = True, user_id: str | None = None, principal: ApiPrincipal | None = None, ) -> dict[str, Any]: version, snapshot_path, config = load_version_config(session, version_id) campaign = session.get(Campaign, version.campaign_id) if not campaign or campaign.tenant_id != tenant_id: raise CampaignPersistenceError( "Campaign version is not accessible for this tenant" ) _ensure_current_campaign_version(campaign, version, action="build") if version.workflow_state == CampaignVersionWorkflowState.COMPLETED.value: raise CampaignPersistenceError("Sent campaign versions cannot be rebuilt") validation_summary = ( version.validation_summary if isinstance(version.validation_summary, dict) else {} ) if not validation_summary.get("ok"): raise CampaignPersistenceError( "Campaign version must be successfully validated before messages are built" ) _ensure_version_validated_and_locked(version) files = files_integration() storage = _object_storage() build_id = uuid4().hex with TemporaryDirectory(prefix="govoplan-campaign-build-") as output_directory: with files.prepared_campaign_snapshot( session, tenant_id=tenant_id, campaign_id=campaign.id, raw_json=version.raw_json if isinstance(version.raw_json, dict) else {}, include_bytes=True, prefix="govoplan-managed-build-", ) as prepared: managed_raw = load_campaign_json(prepared.path) managed_config = load_campaign_config_from_json( session, tenant_id=tenant_id, raw_json=managed_raw, campaign_id=campaign.id, ) result = build_campaign_messages( managed_config, campaign_file=prepared.path, output_dir=Path(output_directory), write_eml=write_eml, ) files.annotate_built_messages_with_managed_files( result.built_messages, prepared.managed_files_by_local_path, ) entries_by_index = { index: entry for index, entry in enumerate( load_campaign_entries( managed_config, campaign_file=prepared.path, ), start=1, ) } delivery_provenance_by_index = { index: dict(entry.distribution_source or {}) for index, entry in entries_by_index.items() } resolved_postbox_targets_by_index = _resolve_built_postbox_targets( session, tenant_id=tenant_id, config=managed_config, built_messages=result.built_messages, entries_by_index=entries_by_index, ) resolved_print_outputs_by_index = _resolve_built_print_outputs( session, storage=storage, tenant_id=tenant_id, build_id=build_id, version=version, principal=principal, config=managed_config, built_messages=result.built_messages, entries_by_index=entries_by_index, ) _prepare_built_calendar_invitations( version=version, config=managed_config, built_messages=result.built_messages, entries_by_index=entries_by_index, delivery_provenance_by_index=delivery_provenance_by_index, user_id=user_id, ) new_print_storage_keys = sorted( { str(artifact["storage_key"]) for output in resolved_print_outputs_by_index.values() if isinstance(output, dict) for artifact in [output.get("artifact")] if isinstance(artifact, dict) and artifact.get("storage_key") } ) try: stored_eml_by_index = _persist_built_eml_artifacts( storage=storage, tenant_id=tenant_id, campaign_id=campaign.id, version_id=version.id, build_id=build_id, built_messages=result.built_messages, ) except Exception: _delete_storage_keys(storage, new_print_storage_keys) raise new_storage_keys = [ *new_print_storage_keys, *(item.storage_key for item in stored_eml_by_index.values()), ] try: report_json = _campaign_build_report(result, files) report_json["built_by_user_id"] = user_id if resolved_print_outputs_by_index: first_output = next(iter(resolved_print_outputs_by_index.values())) report_json["print_output"] = { key: first_output.get(key) for key in ( "render_id", "template_id", "template_revision_id", "template_revision", "template_hash", "input_hash", "renderer_version", "output_format", "output_sha256", "output_size_bytes", "item_count", "page_count", "artifact", "diagnostics", "actor_account_id", ) } version.build_summary = report_json editor_state = copy.deepcopy(version.editor_state or {}) editor_state.pop("review_send", None) editor_state.pop("approval_gate", None) version.editor_state = editor_state job_build_pairs, old_storage_keys = _replace_version_jobs( session, tenant_id=tenant_id, campaign_id=campaign.id, version_id=version.id, built_messages=result.built_messages, postbox_targets_by_index=resolved_postbox_targets_by_index, print_outputs_by_index=resolved_print_outputs_by_index, delivery_provenance_by_index=delivery_provenance_by_index, stored_eml_by_index=stored_eml_by_index, ) jobs = [job for job, _message in job_build_pairs] files.record_campaign_attachment_uses_for_jobs( session, jobs, stage="built", ) _store_execution_snapshot( session, version=version, config=managed_config, jobs=jobs, build_summary=report_json, ) _store_job_issues( session, tenant_id=tenant_id, campaign_id=campaign.id, version_id=version.id, job_build_pairs=job_build_pairs, ) _apply_campaign_build_state( campaign, version, needs_review_count=result.report.needs_review_count, blocked_count=result.report.blocked_count, queueable_count=result.report.queueable_count, ) session.add(version) session.add(campaign) session.commit() except Exception: session.rollback() _delete_storage_keys(storage, new_storage_keys) raise _delete_storage_keys(storage, old_storage_keys) return report_json