feat: add governed postbox delivery and report hardening
This commit is contained in:
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user