Implement governed hybrid campaign delivery

This commit is contained in:
2026-08-02 13:58:37 +02:00
parent b38597f2be
commit 7733265cc8
40 changed files with 2531 additions and 122 deletions
@@ -2,7 +2,8 @@ from __future__ import annotations
import copy
import hashlib
from dataclasses import dataclass
import json
from dataclasses import asdict, dataclass
from datetime import UTC, datetime
from email import policy
from email.parser import BytesParser
@@ -14,11 +15,13 @@ from uuid import uuid4
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,
@@ -29,6 +32,7 @@ from govoplan_campaign.backend.db.models import (
CampaignVersionWorkflowState,
JobImapStatus,
JobPostboxStatus,
JobPrintStatus,
JobQueueStatus,
JobSendStatus,
JobValidationStatus,
@@ -42,13 +46,23 @@ from govoplan_campaign.backend.campaign.mail_profile_boundary import (
campaign_mail_profile_id,
campaign_mail_resource_ids,
)
from govoplan_campaign.backend.campaign.validation import validate_campaign_config
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.messages.builder import build_campaign_messages
from govoplan_campaign.backend.messages.models import MessageDraft
from govoplan_campaign.backend.messages.models import (
MessageDraft,
MessageValidationStatus,
)
from govoplan_campaign.backend.sending.execution import (
create_execution_snapshot,
profile_delivery_summary,
@@ -62,6 +76,7 @@ from govoplan_campaign.backend.integrations import (
files_integration,
mail_integration,
postbox_integration,
templates_integration,
)
from govoplan_campaign.backend.path_security import assert_server_safe_campaign_paths
from govoplan_campaign.backend.runtime import get_settings
@@ -426,6 +441,129 @@ def load_version_config(session: Session, version_id: str):
)
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 validate_campaign_version(
session: Session,
*,
@@ -433,6 +571,7 @@ def validate_campaign_version(
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)
@@ -483,6 +622,7 @@ def validate_campaign_version(
campaign_file=prepared.path,
check_files=True,
postbox_available=postbox_integration().available,
templates_available=templates_integration().available,
)
else:
report = validate_campaign_config(
@@ -490,7 +630,15 @@ def validate_campaign_version(
campaign_file=snapshot_path,
check_files=False,
postbox_available=postbox_integration().available,
templates_available=templates_integration().available,
)
report.issues.extend(
_print_template_compatibility_issues(
session,
principal=principal,
config=config,
)
)
report_json = report.model_dump(mode="json")
report_json.update(
{
@@ -569,6 +717,8 @@ def _job_from_message(
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
@@ -576,6 +726,7 @@ def _job_from_message(
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,
@@ -602,9 +753,16 @@ def _job_from_message(
delivery_channel_policy=message.delivery_channel_policy,
postbox_status=(
JobPostboxStatus.PENDING.value
if DeliveryChannelPolicy(message.delivery_channel_policy).uses_postbox
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,
@@ -621,7 +779,9 @@ def _job_from_message(
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
@@ -665,6 +825,192 @@ def _resolve_built_postbox_targets(
return resolved_by_index
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(
@@ -701,18 +1047,26 @@ def _replace_version_jobs(
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 = [
str(key)
for (key,) in session.query(CampaignJob.eml_storage_key)
.filter(
CampaignJob.campaign_version_id == version_id,
CampaignJob.eml_storage_key.is_not(None),
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()
if key
]
)
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),
@@ -732,6 +1086,13 @@ def _replace_version_jobs(
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)
@@ -851,6 +1212,7 @@ def build_campaign_version(
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)
@@ -911,6 +1273,10 @@ def build_campaign_version(
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,
@@ -918,18 +1284,67 @@ def build_campaign_version(
built_messages=result.built_messages,
entries_by_index=entries_by_index,
)
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,
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,
)
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")
}
)
new_storage_keys = [item.storage_key for item in stored_eml_by_index.values()]
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)
@@ -943,6 +1358,8 @@ def build_campaign_version(
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]