security: harden Campaign delivery effects and reconciliation

This commit is contained in:
2026-07-21 17:14:33 +02:00
parent 057e660b17
commit 09c63de813
25 changed files with 2079 additions and 708 deletions

View File

@@ -27,10 +27,14 @@ from govoplan_campaign.backend.db.models import (
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,
)
from govoplan_campaign.backend.campaign.validation import validate_campaign_config
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
from govoplan_campaign.backend.sending.execution import create_execution_snapshot, profile_transport_revisions
from govoplan_campaign.backend.campaign.models import CampaignConfig
from govoplan_campaign.backend.integrations import files_integration, mail_integration
from govoplan_campaign.backend.path_security import assert_server_safe_campaign_paths
@@ -61,15 +65,26 @@ def load_campaign_config_from_json(
owner_user_id: str | None = None,
owner_group_id: str | None = None,
) -> CampaignConfig:
materialized = mail_integration().materialize_campaign_mail_profile_config(
session,
tenant_id=tenant_id,
raw_json=raw_json,
campaign_id=campaign_id,
owner_user_id=owner_user_id,
owner_group_id=owner_group_id,
)
validate_against_schema(materialized)
# 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:
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,
)
materialized.setdefault("server", {})["profile_capabilities"] = {
"smtp_available": bool(summary.get("smtp_available")),
"imap_available": bool(summary.get("imap_available")),
}
return CampaignConfig.model_validate(materialized)
@@ -485,13 +500,19 @@ def build_campaign_version(
[job for job, _message in job_build_pairs],
stage="built",
)
runtime_smtp = managed_config.server.runtime_smtp_config()
if not runtime_smtp:
raise CampaignPersistenceError("Campaign has no SMTP configuration; an execution snapshot cannot be created")
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")
revisions = profile_transport_revisions(session, version)
if not revisions["smtp"]:
raise CampaignPersistenceError("The selected Mail profile has no SMTP transport revision")
execution_snapshot, execution_snapshot_hash = create_execution_snapshot(
version,
smtp=runtime_smtp,
imap=managed_config.server.runtime_imap_config(),
mail_profile_id=profile_id,
smtp_transport_revision=revisions["smtp"],
imap_transport_revision=revisions["imap"],
delivery=managed_config.delivery,
jobs=[job for job, _message in job_build_pairs],
build_summary=report_json,