feat: add governed postbox delivery and report hardening

This commit is contained in:
2026-07-29 14:16:28 +02:00
parent f11c56e890
commit 5240749ae1
47 changed files with 5538 additions and 288 deletions

View File

@@ -22,6 +22,7 @@ from govoplan_campaign.backend.db.models import (
CampaignVersion,
CampaignVersionWorkflowState,
JobImapStatus,
JobPostboxStatus,
JobQueueStatus,
JobSendStatus,
JobValidationStatus,
@@ -33,11 +34,23 @@ from govoplan_campaign.backend.campaign.mail_profile_boundary import (
campaign_mail_resource_ids,
)
from govoplan_campaign.backend.campaign.validation import 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.messages.builder import build_campaign_messages
from govoplan_campaign.backend.messages.models import MessageDraft
from govoplan_campaign.backend.sending.execution import create_execution_snapshot, profile_delivery_summary
from govoplan_campaign.backend.campaign.models import CampaignConfig, SendStatus
from govoplan_campaign.backend.integrations import files_integration, mail_integration
from govoplan_campaign.backend.campaign.models import (
CampaignConfig,
DeliveryChannelPolicy,
SendStatus,
)
from govoplan_campaign.backend.integrations import (
files_integration,
mail_integration,
postbox_integration,
)
from govoplan_campaign.backend.path_security import assert_server_safe_campaign_paths
RUNTIME_DIR = Path(__file__).resolve().parents[3] / "runtime"
@@ -329,9 +342,19 @@ def validate_campaign_version(
) 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)
report = validate_campaign_config(
managed_config,
campaign_file=prepared.path,
check_files=True,
postbox_available=postbox_integration().available,
)
else:
report = validate_campaign_config(config, campaign_file=snapshot_path, check_files=False)
report = validate_campaign_config(
config,
campaign_file=snapshot_path,
check_files=False,
postbox_available=postbox_integration().available,
)
report_json = report.model_dump(mode="json")
report_json.update({"ok": report.ok, "error_count": report.error_count, "warning_count": report.warning_count})
version.validation_summary = report_json
@@ -394,6 +417,7 @@ def _job_from_message(
campaign_id: str,
version_id: str,
message: MessageDraft,
resolved_postbox_targets: list[dict[str, Any]] | None = None,
) -> CampaignJob:
recipient_email = message.to[0].email if message.to else None
eml_sha256, message_id_header = _eml_evidence(message.eml_path)
@@ -417,6 +441,12 @@ def _job_from_message(
if message.send_status == SendStatus.SKIPPED
else JobSendStatus.NOT_QUEUED.value
),
delivery_channel_policy=message.delivery_channel_policy,
postbox_status=(
JobPostboxStatus.PENDING.value
if DeliveryChannelPolicy(message.delivery_channel_policy).uses_postbox
else JobPostboxStatus.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,
@@ -428,6 +458,7 @@ def _job_from_message(
"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],
},
resolved_postbox_targets=resolved_postbox_targets or [],
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,
@@ -467,6 +498,46 @@ def build_campaign_version(
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=output_dir, 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,
)
}
resolved_postbox_targets_by_index: dict[
int,
list[dict[str, Any]],
] = {}
for built in result.built_messages:
policy = DeliveryChannelPolicy(
built.draft.delivery_channel_policy
)
if not 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_targets, postbox_issues, validation_status = (
resolve_entry_postbox_targets(
session,
tenant_id=tenant_id,
config=managed_config,
entry=entry,
validation_status=built.draft.validation_status,
materialize=True,
)
)
built.draft.issues.extend(postbox_issues)
built.draft.validation_status = validation_status
resolved_postbox_targets_by_index[built.draft.entry_index] = (
resolved_targets
)
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):
@@ -501,6 +572,10 @@ def build_campaign_version(
campaign_id=campaign.id,
version_id=version.id,
message=built.draft,
resolved_postbox_targets=resolved_postbox_targets_by_index.get(
built.draft.entry_index,
[],
),
)
session.add(job)
job_build_pairs.append((job, built.draft))
@@ -514,14 +589,31 @@ def build_campaign_version(
[job for job, _message in job_build_pairs],
stage="built",
)
if not managed_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")
profile_summary = profile_delivery_summary(session, version)
if not profile_summary.get("smtp_transport_revision"):
raise CampaignPersistenceError("The selected Mail profile has no SMTP transport revision")
uses_mail = any(
DeliveryChannelPolicy(job.delivery_channel_policy).uses_mail
for job, _message in job_build_pairs
)
profile_id: str | None = None
profile_summary: dict[str, Any] = {}
if uses_mail:
if not managed_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."
)
profile_summary = profile_delivery_summary(session, version)
if not profile_summary.get("smtp_transport_revision"):
raise CampaignPersistenceError(
"The selected Mail profile has no SMTP transport revision."
)
execution_snapshot, execution_snapshot_hash = create_execution_snapshot(
version,
mail_profile_id=profile_id,
@@ -529,7 +621,9 @@ def build_campaign_version(
smtp_credential_id=profile_summary.get("smtp_credential_id"),
imap_server_id=profile_summary.get("imap_server_id"),
imap_credential_id=profile_summary.get("imap_credential_id"),
smtp_transport_revision=profile_summary["smtp_transport_revision"],
smtp_transport_revision=profile_summary.get(
"smtp_transport_revision"
),
imap_transport_revision=profile_summary.get("imap_transport_revision"),
delivery=managed_config.delivery,
jobs=[job for job, _message in job_build_pairs],