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

@@ -19,6 +19,12 @@ from govoplan_campaign.backend.db.models import (
JobSendStatus,
)
from govoplan_campaign.backend.sending.execution import clear_execution_snapshot
from govoplan_campaign.backend.campaign.mail_profile_boundary import (
campaign_mail_profile_boundary_violations,
campaign_mail_profile_id,
assert_campaign_uses_mail_profile_reference,
public_campaign_mail_server,
)
from govoplan_campaign.backend.integrations import files_integration, mail_integration
from govoplan_campaign.backend.persistence.campaigns import (
CampaignPersistenceError,
@@ -106,25 +112,7 @@ def minimal_campaign_json(*, external_id: str, name: str, description: str | Non
},
"fields": [],
"global_values": {},
"server": {
"inherit_smtp_credentials": True,
"inherit_imap_credentials": True,
"smtp": {
"host": "",
"port": 587,
"security": "starttls",
},
"imap": {
"host": "",
"port": 993,
"security": "tls",
"sent_folder": "auto",
},
"credentials": {
"smtp": {"username": "", "password": ""}, # nosec B105 - empty draft placeholder.
"imap": {"username": "", "password": ""}, # nosec B105 - empty draft placeholder.
},
},
"server": {},
"recipients": {
"from": [],
"allow_individual_from": False,
@@ -347,6 +335,7 @@ def fork_campaign_version_for_edit(
source_filename: str | None = None,
source_base_path: str | None = None,
autosave: bool = True,
migrate_legacy_mail_settings: bool = False,
) -> CampaignVersion:
"""Create the next sole working version from immutable campaign history.
@@ -371,7 +360,16 @@ def fork_campaign_version_for_edit(
"Create the next working copy from the campaign's current immutable version."
)
base_json = raw_json if raw_json is not None else copy.deepcopy(source.raw_json)
source_json = source.raw_json if isinstance(source.raw_json, dict) else {}
source_requires_mail_migration = bool(campaign_mail_profile_boundary_violations(source_json))
if source_requires_mail_migration and not migrate_legacy_mail_settings:
raise CampaignPersistenceError(
"This version contains legacy campaign-local SMTP/IMAP settings. Create the editable copy from "
"the Mail settings migration action so the audit record is preserved and the copy uses a Mail profile."
)
base_json = raw_json if raw_json is not None else copy.deepcopy(source_json)
if source_requires_mail_migration and raw_json is None:
base_json["server"] = public_campaign_mail_server(source_json)
assert_server_safe_campaign_paths(
base_json,
source_filename=source_filename,
@@ -379,6 +377,7 @@ def fork_campaign_version_for_edit(
managed_files_available=files_integration().available,
)
runtime_json = normalize_campaign_paths(base_json, source_base_path) if source_base_path else copy.deepcopy(base_json)
assert_campaign_uses_mail_profile_reference(runtime_json)
mail_integration().assert_campaign_mail_policy_allows_json(session, tenant_id=tenant_id, raw_json=runtime_json, campaign_id=campaign.id)
new_version = CampaignVersion(
@@ -528,6 +527,7 @@ def update_campaign_version(
source_filename: str | None = None,
source_base_path: str | None = None,
autosave: bool = False,
migrate_legacy_mail_settings: bool = False,
) -> CampaignVersion:
if raw_json is not None or source_filename is not None or source_base_path is not None:
assert_server_safe_campaign_paths(
@@ -547,6 +547,18 @@ def update_campaign_version(
if raw_json is not None:
runtime_json = normalize_campaign_paths(raw_json, source_base_path) if source_base_path else copy.deepcopy(raw_json)
if campaign_mail_profile_boundary_violations(version.raw_json) and not migrate_legacy_mail_settings:
raise CampaignPersistenceError(
"This version contains legacy campaign-local SMTP/IMAP settings. Select an authorized Mail "
"profile on the Mail settings page and explicitly save the migration; the stored legacy version "
"will not be changed automatically."
)
assert_campaign_uses_mail_profile_reference(runtime_json)
if campaign_mail_profile_boundary_violations(version.raw_json) and campaign_mail_profile_id(runtime_json) is None:
raise CampaignPersistenceError(
"Migrating legacy campaign mail settings requires an authorized server.mail_profile_id. "
"Select a Mail profile before saving."
)
mail_integration().assert_campaign_mail_policy_allows_json(session, tenant_id=tenant_id, raw_json=runtime_json, campaign_id=campaign.id)
version.raw_json = runtime_json
version.schema_version = str(runtime_json.get("version", version.schema_version or "1.0"))
@@ -839,6 +851,7 @@ def validate_campaign_partial(raw_json: dict[str, Any], *, section: str | None =
_validate_partial_basics(collector, _dict_value(raw_json, "campaign"))
recipients = _dict_value(raw_json, "recipients")
_validate_partial_sender(collector, recipients)
_validate_partial_mail_profile(collector, raw_json)
_validate_partial_recipients(collector, _dict_value(raw_json, "entries"))
_validate_partial_template(collector, _dict_value(raw_json, "template"))
_validate_partial_attachments(collector, _dict_value(raw_json, "attachments"))
@@ -864,6 +877,26 @@ def _validate_partial_sender(collector: _PartialValidationCollector, recipients:
collector.issue("warning", "sender", "recipients.from.email", "missing_sender_email", "Sender email is not configured yet.")
def _validate_partial_mail_profile(collector: _PartialValidationCollector, raw_json: dict[str, Any]) -> None:
violations = campaign_mail_profile_boundary_violations(raw_json)
if violations:
collector.issue(
"error",
"sender",
"server",
"campaign_local_mail_transport_forbidden",
"Campaign-local SMTP/IMAP settings are not supported. Select or migrate to an authorized Mail-module profile.",
)
elif campaign_mail_profile_id(raw_json) is None:
collector.issue(
"warning",
"sender",
"server.mail_profile_id",
"missing_mail_profile",
"Select an authorized Mail-module profile before validating or delivering this campaign.",
)
def _validate_partial_recipients(collector: _PartialValidationCollector, entries: dict[str, Any]) -> None:
has_inline = bool(entries.get("inline"))
has_source = isinstance(entries.get("source"), dict)