security: harden Campaign delivery effects and reconciliation
This commit is contained in:
@@ -7,6 +7,7 @@ from typing import Any
|
||||
|
||||
from jsonschema import Draft202012Validator, FormatChecker
|
||||
|
||||
from .mail_profile_boundary import assert_campaign_uses_mail_profile_reference
|
||||
from .models import CampaignConfig
|
||||
|
||||
|
||||
@@ -83,6 +84,7 @@ def load_campaign_config(
|
||||
schema_path: str | Path | None = None,
|
||||
) -> CampaignConfig:
|
||||
data = load_campaign_json(path)
|
||||
assert_campaign_uses_mail_profile_reference(data)
|
||||
if validate_schema:
|
||||
validate_against_schema(data, schema_path=schema_path)
|
||||
return CampaignConfig.model_validate(data)
|
||||
|
||||
@@ -6,15 +6,6 @@ from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
from govoplan_core.mail.config import (
|
||||
ImapConfig,
|
||||
ImapServerConfig,
|
||||
SmtpConfig,
|
||||
SmtpServerConfig,
|
||||
TransportCredentials,
|
||||
normalize_split_transport_credentials,
|
||||
)
|
||||
|
||||
|
||||
class StrictModel(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
||||
@@ -115,37 +106,14 @@ class FieldDefinition(StrictModel):
|
||||
can_override: bool = True
|
||||
|
||||
|
||||
class MailServerCredentials(StrictModel):
|
||||
smtp: TransportCredentials = Field(default_factory=TransportCredentials)
|
||||
imap: TransportCredentials = Field(default_factory=TransportCredentials)
|
||||
class MailProfileCapabilities(StrictModel):
|
||||
smtp_available: bool = False
|
||||
imap_available: bool = False
|
||||
|
||||
|
||||
class ServerConfig(StrictModel):
|
||||
mail_profile_id: str | None = None
|
||||
inherit_smtp_credentials: bool = True
|
||||
inherit_imap_credentials: bool = True
|
||||
smtp: SmtpServerConfig | None = None
|
||||
imap: ImapServerConfig | None = None
|
||||
credentials: MailServerCredentials = Field(default_factory=MailServerCredentials)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def normalize_legacy_credentials(cls, value: Any) -> Any:
|
||||
return normalize_split_transport_credentials(value)
|
||||
|
||||
def runtime_smtp_config(self) -> SmtpConfig | None:
|
||||
if self.smtp is None:
|
||||
return None
|
||||
payload = self.smtp.model_dump(mode="json")
|
||||
payload.update(self.credentials.smtp.model_dump(mode="json", exclude_none=True))
|
||||
return SmtpConfig.model_validate(payload)
|
||||
|
||||
def runtime_imap_config(self) -> ImapConfig | None:
|
||||
if self.imap is None:
|
||||
return None
|
||||
payload = self.imap.model_dump(mode="json")
|
||||
payload.update(self.credentials.imap.model_dump(mode="json", exclude_none=True))
|
||||
return ImapConfig.model_validate(payload)
|
||||
profile_capabilities: MailProfileCapabilities = Field(default_factory=MailProfileCapabilities)
|
||||
|
||||
|
||||
class RecipientConfig(StrictModel):
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user