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

@@ -8,6 +8,7 @@ from typing import Iterable
from pydantic import BaseModel, ConfigDict, Field
from .addressing import effective_address_lists
from .field_values import ignored_entry_field_overrides
from .models import AttachmentConfig, CampaignConfig, EntryConfig, FieldType, SourceType, ZipArchiveConfig, ZipPasswordMode, ZipPasswordScope, ZipRuleMode
from ..attachments.resolver import resolve_campaign_attachments
@@ -338,52 +339,72 @@ def _global_value_issues(config: CampaignConfig, declared_names: set[str]) -> li
def _delivery_issues(config: CampaignConfig) -> list[SemanticIssue]:
issues: list[SemanticIssue] = []
runtime_imap = config.server.runtime_imap_config()
if config.delivery.imap_append_sent.enabled:
issues.extend(_imap_delivery_issues(runtime_imap))
runtime_smtp = config.server.runtime_smtp_config()
if config.campaign.mode == "send" and not runtime_smtp:
profile_id = (config.server.mail_profile_id or "").strip()
if (config.campaign.mode == "send" or config.delivery.imap_append_sent.enabled) and not profile_id:
issues.append(
_issue(
Severity.ERROR,
"missing_smtp_config",
"campaign mode is 'send', but no server.smtp configuration is present",
"/server/smtp",
"missing_mail_profile",
"Select an authorized Mail-module profile; campaigns cannot store SMTP/IMAP settings or credentials.",
"/server/mail_profile_id",
)
)
if runtime_smtp:
missing = [name for name in ["host", "port"] if getattr(runtime_smtp, name) in (None, "")]
if missing:
issues.append(
_issue(
Severity.WARNING,
"incomplete_smtp_config",
"SMTP settings are present, but these settings are missing: " + ", ".join(missing),
"/server/smtp",
)
capabilities = config.server.profile_capabilities
if config.campaign.mode == "send" and profile_id and not capabilities.smtp_available:
issues.append(
_issue(
Severity.ERROR,
"mail_profile_without_smtp",
"campaign mode is 'send', but the selected Mail profile has no SMTP configuration",
"/server/mail_profile_id",
)
)
if config.delivery.imap_append_sent.enabled and profile_id and not capabilities.imap_available:
issues.append(
_issue(
Severity.ERROR,
"mail_profile_without_imap",
"IMAP append is enabled, but the selected Mail profile has no IMAP configuration",
"/server/mail_profile_id",
)
)
return issues
def _imap_delivery_issues(runtime_imap: object | None) -> list[SemanticIssue]:
if runtime_imap is None:
def _sender_issues(config: CampaignConfig) -> list[SemanticIssue]:
"""Require Campaign-owned sender data before a send-mode build."""
if config.campaign.mode != "send":
return []
if config.entries.is_inline:
return [
_issue(
Severity.WARNING,
"delivery_imap_enabled_without_server_imap",
"delivery.imap_append_sent is enabled, but no server.imap configuration is present",
"/delivery/imap_append_sent/enabled",
Severity.ERROR,
"missing_sender",
"No effective From address is configured; Campaign must resolve the sender before building.",
f"/entries/inline/{index}/from",
)
for index, entry in enumerate(config.entries.inline or [])
if entry.active and not effective_address_lists(config, entry)["from"]
]
missing = [name for name in ["host", "port", "username", "password"] if getattr(runtime_imap, name) in (None, "")]
if not missing:
if config.recipients.from_:
return []
defaults = config.entries.defaults
mapping_can_supply_sender = bool(
config.recipients.allow_individual_from
and (
(defaults is not None and defaults.from_)
or "from.email" in (config.entries.mapping or {})
)
)
if mapping_can_supply_sender:
return []
return [
_issue(
Severity.ERROR,
"incomplete_imap_config",
"IMAP append is enabled, but these IMAP settings are missing: " + ", ".join(missing),
"/server/imap",
"missing_sender",
"Configure recipients.from, or allow and map an individual From address, before building a send-mode campaign.",
"/recipients/from",
)
]
@@ -602,6 +623,7 @@ def validate_campaign_config(
issues.extend(_attachment_path_issues(config))
issues.extend(_zip_configuration_issues(config))
issues.extend(_delivery_issues(config))
issues.extend(_sender_issues(config))
entries = _entries_validation(
config,