Files
govoplan-campaign/src/govoplan_campaign/backend/persistence/campaigns.py
T

1676 lines
59 KiB
Python

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