initial commit after split

This commit is contained in:
2026-06-24 01:43:27 +02:00
parent 2406e70597
commit 7f25a3ccdf
125 changed files with 25551 additions and 0 deletions
@@ -0,0 +1,14 @@
"""Campaign JSON model, loading and validation helpers."""
from .models import CampaignConfig
from .loader import load_campaign_config, load_campaign_json
from .validation import validate_campaign_config, SemanticIssue, SemanticReport
__all__ = [
"CampaignConfig",
"load_campaign_config",
"load_campaign_json",
"validate_campaign_config",
"SemanticIssue",
"SemanticReport",
]
@@ -0,0 +1,153 @@
from __future__ import annotations
from email.utils import formataddr
from typing import Iterable
from .models import CampaignConfig, EntryConfig, RecipientConfig
ADDRESS_TEMPLATE_FIELDS = ("from", "to", "reply_to", "cc", "bcc")
def _deduplicate(recipients: Iterable[RecipientConfig]) -> list[RecipientConfig]:
unique: list[RecipientConfig] = []
seen: set[str] = set()
for recipient in recipients:
key = recipient.email.strip().casefold()
if not key or key in seen:
continue
seen.add(key)
unique.append(recipient)
return unique
def _effective_list(
*,
global_recipients: list[RecipientConfig],
local_recipients: list[RecipientConfig],
allow_individual: bool,
merge: bool,
) -> list[RecipientConfig]:
if not allow_individual:
return _deduplicate(global_recipients)
if not local_recipients:
return _deduplicate(global_recipients)
if merge:
return _deduplicate([*global_recipients, *local_recipients])
return _deduplicate(local_recipients)
def _effective_single(
*,
global_recipients: list[RecipientConfig],
local_recipients: list[RecipientConfig],
allow_individual: bool,
) -> list[RecipientConfig]:
"""Resolve a single-mailbox header such as From.
A recipient-specific value overrides the campaign value when allowed. Any
additional legacy values are ignored deliberately; the canonical schema and
WebUI prevent creating them.
"""
candidates = local_recipients if allow_individual and local_recipients else global_recipients
unique = _deduplicate(candidates)
return unique[:1]
def effective_address_lists(config: CampaignConfig, entry: EntryConfig) -> dict[str, list[RecipientConfig]]:
"""Return the exact address lists used by a built message.
``merge_*`` is the canonical configuration. Older ``combine_*`` values are
normalized by EntryConfig before this function is called.
"""
return {
"from": _effective_single(
global_recipients=config.recipients.from_,
local_recipients=entry.from_,
allow_individual=config.recipients.allow_individual_from,
),
"to": _effective_list(
global_recipients=config.recipients.to,
local_recipients=entry.to,
allow_individual=config.recipients.allow_individual_to,
merge=entry.merge_to,
),
"cc": _effective_list(
global_recipients=config.recipients.cc,
local_recipients=entry.cc,
allow_individual=config.recipients.allow_individual_cc,
merge=entry.merge_cc,
),
"bcc": _effective_list(
global_recipients=config.recipients.bcc,
local_recipients=entry.bcc,
allow_individual=config.recipients.allow_individual_bcc,
merge=entry.merge_bcc,
),
"reply_to": _effective_list(
global_recipients=config.recipients.reply_to,
local_recipients=entry.reply_to,
allow_individual=config.recipients.allow_individual_reply_to,
merge=entry.merge_reply_to,
),
"bounce_to": _effective_list(
global_recipients=config.recipients.bounce_to,
local_recipients=entry.bounce_to,
allow_individual=config.recipients.allow_individual_bounce_to,
merge=entry.merge_bounce_to,
),
"disposition_notification_to": _effective_list(
global_recipients=config.recipients.disposition_notification_to,
local_recipients=entry.disposition_notification_to,
allow_individual=config.recipients.allow_individual_disposition_notification_to,
merge=entry.merge_disposition_notification_to,
),
}
def formatted_recipient(recipient: RecipientConfig) -> str:
return formataddr((recipient.name or "", recipient.email))
def recipient_template_values(addresses: dict[str, list[RecipientConfig]]) -> dict[str, str]:
"""Expose effective address lists as local template values.
Common forms:
local:to first formatted address
local:all_to all formatted addresses, comma separated
local:to.email first address only
local:all_to.email all email addresses, comma separated
local:to[2] second formatted address (one-based)
local:to[2].email second email address
Legacy zero-based keys such as ``local:to.0.email`` remain available.
"""
values: dict[str, str] = {}
for field_name in ADDRESS_TEMPLATE_FIELDS:
recipients = addresses.get(field_name, [])
formatted = [formatted_recipient(recipient) for recipient in recipients]
emails = [recipient.email for recipient in recipients]
names = [recipient.name or "" for recipient in recipients]
first_formatted = formatted[0] if formatted else ""
first_email = emails[0] if emails else ""
first_name = names[0] if names else ""
values[f"local::{field_name}"] = first_formatted
values[f"local::{field_name}.email"] = first_email
values[f"local::{field_name}.name"] = first_name
values[f"local::all_{field_name}"] = ", ".join(formatted)
values[f"local::all_{field_name}.email"] = ", ".join(emails)
values[f"local::all_{field_name}.name"] = ", ".join(name for name in names if name)
for index, recipient in enumerate(recipients):
one_based = index + 1
values[f"local::{field_name}[{one_based}]"] = formatted[index]
values[f"local::{field_name}[{one_based}].email"] = recipient.email
values[f"local::{field_name}[{one_based}].name"] = recipient.name or ""
# Preserve the original zero-based recipient field syntax.
values[f"local::{field_name}.{index}.email"] = recipient.email
values[f"local::{field_name}.{index}.name"] = recipient.name or ""
values[f"local::{field_name}.{index}.type"] = recipient.recipient_type.value
return values
@@ -0,0 +1,222 @@
from __future__ import annotations
import copy
import csv
import json
from pathlib import Path
from typing import Any
from .models import CampaignConfig, EntryConfig, SourceType
class EntryLoadError(ValueError):
"""Raised when campaign entries cannot be loaded from inline or external sources."""
def _resolve(campaign_file: str | Path, raw_path: str) -> Path:
campaign_path = Path(campaign_file).resolve()
path = Path(raw_path).expanduser()
if path.is_absolute():
return path
return (campaign_path.parent / path).resolve()
def _parse_bool(value: Any) -> bool:
if isinstance(value, bool):
return value
if value is None:
return False
text = str(value).strip().lower()
if text in {"1", "true", "yes", "y", "ja", "j", "x", "active", "aktiv"}:
return True
if text in {"0", "false", "no", "n", "nein", "", "inactive", "inaktiv"}:
return False
raise EntryLoadError(f"cannot parse boolean value: {value!r}")
def _parse_scalar_for_target(target: str, value: Any) -> Any:
bool_targets = {
"active",
"merge_from",
"merge_to",
"merge_cc",
"merge_bcc",
"merge_reply_to",
"merge_bounce_to",
"merge_disposition_notification_to",
"combine_to",
"combine_cc",
"combine_bcc",
"combine_reply_to",
"combine_bounce_to",
"combine_disposition_notification_to",
"combine_attachments",
}
if target in bool_targets:
return _parse_bool(value)
if target.endswith(".include_subdirs") or target.endswith(".required") or target.endswith(".allow_multiple"):
return _parse_bool(value)
if target.endswith(".zip.enabled"):
return _parse_bool(value)
return value
def _ensure_list_length(values: list[Any], index: int, factory: Any) -> None:
while len(values) <= index:
values.append(factory())
def _set_recipient_value(entry_data: dict[str, Any], target: str, value: Any) -> bool:
# Examples: from.email, to.0.email, cc.0.name
if target.startswith("from."):
entry_data.setdefault("from", {})
_, field = target.split(".", 1)
if field == "type":
field = "type"
entry_data["from"][field] = value
return True
for recipient_list_name in ["to", "cc", "bcc", "reply_to", "bounce_to", "disposition_notification_to"]:
prefix = recipient_list_name + "."
if not target.startswith(prefix):
continue
parts = target.split(".")
if len(parts) != 3 or not parts[1].isdigit():
raise EntryLoadError(f"invalid recipient mapping target: {target}")
index = int(parts[1])
field = parts[2]
recipients = entry_data.setdefault(recipient_list_name, [])
_ensure_list_length(recipients, index, dict)
recipients[index][field] = value
return True
return False
def _set_attachment_value(entry_data: dict[str, Any], target: str, value: Any) -> bool:
if not target.startswith("attachments."):
return False
parts = target.split(".")
if len(parts) < 3 or not parts[1].isdigit():
raise EntryLoadError(f"invalid attachment mapping target: {target}")
index = int(parts[1])
attachments = entry_data.setdefault("attachments", [])
_ensure_list_length(attachments, index, dict)
attachment = attachments[index]
if parts[2] == "zip":
if len(parts) != 4:
raise EntryLoadError(f"invalid zip attachment mapping target: {target}")
attachment.setdefault("zip", {})[parts[3]] = value
return True
if len(parts) != 3:
raise EntryLoadError(f"invalid attachment mapping target: {target}")
attachment[parts[2]] = value
return True
def _set_entry_value(entry_data: dict[str, Any], target: str, value: Any) -> None:
value = _parse_scalar_for_target(target, value)
if value is None:
return
if isinstance(value, str) and value == "":
return
if target.startswith("fields."):
_, field_name = target.split(".", 1)
entry_data.setdefault("fields", {})[field_name] = value
return
if _set_recipient_value(entry_data, target, value):
return
if _set_attachment_value(entry_data, target, value):
return
entry_data[target] = value
def _entry_defaults_data(config: CampaignConfig) -> dict[str, Any]:
if config.entries.defaults is None:
return {}
return config.entries.defaults.model_dump(mode="json", by_alias=True, exclude_none=True)
def _load_csv_rows(path: Path, *, delimiter: str, encoding: str) -> list[dict[str, Any]]:
try:
with path.open("r", encoding=encoding, newline="") as handle:
reader = csv.DictReader(handle, delimiter=delimiter)
return [dict(row) for row in reader]
except OSError as exc:
raise EntryLoadError(f"could not read CSV entries source {path}: {exc}") from exc
def _load_json_rows(path: Path, *, encoding: str) -> list[dict[str, Any]]:
try:
with path.open("r", encoding=encoding) as handle:
data = json.load(handle)
except OSError as exc:
raise EntryLoadError(f"could not read JSON entries source {path}: {exc}") from exc
except json.JSONDecodeError as exc:
raise EntryLoadError(f"invalid JSON entries source {path}: {exc}") from exc
if isinstance(data, list):
rows = data
elif isinstance(data, dict) and isinstance(data.get("entries"), list):
rows = data["entries"]
else:
raise EntryLoadError("JSON entries source must be a list or an object with an 'entries' list")
if not all(isinstance(row, dict) for row in rows):
raise EntryLoadError("JSON entries source rows must be objects")
return [dict(row) for row in rows]
def _row_to_entry(defaults_data: dict[str, Any], mapping: dict[str, str], row: dict[str, Any], row_number: int) -> EntryConfig:
entry_data = copy.deepcopy(defaults_data)
for target, source_name in mapping.items():
if source_name not in row:
# Detailed missing-column validation is handled in semantic validation.
continue
try:
_set_entry_value(entry_data, target, row[source_name])
except EntryLoadError:
raise
except Exception as exc:
raise EntryLoadError(f"row {row_number}: could not map {source_name!r} to {target!r}: {exc}") from exc
try:
return EntryConfig.model_validate(entry_data)
except Exception as exc:
raise EntryLoadError(f"row {row_number}: mapped entry is invalid: {exc}") from exc
def load_campaign_entries(config: CampaignConfig, *, campaign_file: str | Path) -> list[EntryConfig]:
"""Load and normalize campaign entries from inline data or external CSV/JSON source.
The normalized output is always a list of EntryConfig instances. This is intentionally
UI/API friendly: a future web interface can generate the same JSON structure and use the
same resolver without code changes.
"""
if config.entries.inline is not None:
return list(config.entries.inline)
if config.entries.source is None or config.entries.mapping is None:
raise EntryLoadError("external entries require source and mapping")
source = config.entries.source
path = _resolve(campaign_file, source.path)
if not path.exists():
raise EntryLoadError(f"entries source file does not exist: {path}")
if source.type == SourceType.CSV:
if not source.has_header:
raise EntryLoadError("CSV entries currently require has_header=true")
rows = _load_csv_rows(path, delimiter=source.delimiter, encoding=source.encoding)
elif source.type == SourceType.JSON:
rows = _load_json_rows(path, encoding=source.encoding)
else: # pragma: no cover - defensive; Pydantic constrains this already.
raise EntryLoadError(f"unsupported entries source type: {source.type}")
defaults_data = _entry_defaults_data(config)
return [_row_to_entry(defaults_data, config.entries.mapping, row, index + 2) for index, row in enumerate(rows)]
@@ -0,0 +1,62 @@
from __future__ import annotations
from typing import Any
from .models import CampaignConfig, EntryConfig, FieldDefinition
def field_definitions_by_name(config: CampaignConfig) -> dict[str, FieldDefinition]:
"""Return campaign field definitions keyed by field id/name."""
return {field.name: field for field in config.fields}
def field_can_override(config: CampaignConfig, field_name: str) -> bool:
"""Return whether a recipient/entry value may override the global value.
Unknown fields remain overridable for backwards compatibility with older
campaigns and ad-hoc external mappings. Semantic validation reports unknown
field usage separately when a field list is configured.
"""
field = field_definitions_by_name(config).get(field_name)
if field is None:
return True
return field.can_override
def ignored_entry_field_overrides(config: CampaignConfig, entry: EntryConfig) -> list[str]:
"""Return recipient field keys that are ignored by the override policy."""
return sorted(name for name in entry.fields if not field_can_override(config, name))
def effective_entry_field_values(config: CampaignConfig, entry: EntryConfig) -> dict[str, Any]:
"""Return the local/effective field value map for one message entry.
Global values act as defaults for local template placeholders. Recipient
values replace those defaults only when the corresponding field allows
overrides. Fields that are unknown to the campaign definition keep the old
permissive behavior and remain usable as local values.
"""
values: dict[str, Any] = dict(config.global_values)
for key, value in entry.fields.items():
if field_can_override(config, key) and entry_field_has_override_value(value):
values[key] = value
return values
def entry_field_has_override_value(value: Any) -> bool:
"""Return whether an entry field should override a global default.
Empty recipient values are treated as "not set" so global_values remain the
effective local defaults. Numeric zero and boolean false are valid explicit
overrides.
"""
if value is None:
return False
if isinstance(value, str):
return value.strip() != ""
return True
@@ -0,0 +1,79 @@
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from jsonschema import Draft202012Validator, FormatChecker
from .models import CampaignConfig
class CampaignLoadError(ValueError):
"""Raised when the campaign JSON cannot be loaded or parsed."""
@dataclass(frozen=True)
class SchemaValidationError:
path: str
message: str
class CampaignSchemaError(CampaignLoadError):
def __init__(self, errors: list[SchemaValidationError]) -> None:
self.errors = errors
details = "; ".join(f"{error.path}: {error.message}" for error in errors[:5])
if len(errors) > 5:
details += f"; ... and {len(errors) - 5} more"
super().__init__(f"campaign schema validation failed: {details}")
def load_campaign_json(path: str | Path) -> dict[str, Any]:
campaign_path = Path(path)
try:
with campaign_path.open("r", encoding="utf-8") as handle:
data = json.load(handle)
except OSError as exc:
raise CampaignLoadError(f"could not read campaign JSON {campaign_path}: {exc}") from exc
except json.JSONDecodeError as exc:
raise CampaignLoadError(f"invalid campaign JSON {campaign_path}: {exc}") from exc
if not isinstance(data, dict):
raise CampaignLoadError("campaign JSON root must be an object")
return data
def _default_schema_path() -> Path:
return Path(__file__).resolve().parents[1] / "schema" / "campaign.schema.json"
def load_campaign_schema(schema_path: str | Path | None = None) -> dict[str, Any]:
path = Path(schema_path) if schema_path else _default_schema_path()
return load_campaign_json(path)
def validate_against_schema(data: dict[str, Any], schema_path: str | Path | None = None) -> None:
schema = load_campaign_schema(schema_path)
validator = Draft202012Validator(schema, format_checker=FormatChecker())
errors = sorted(validator.iter_errors(data), key=lambda error: list(error.path))
if errors:
normalized = [
SchemaValidationError(
path="/" + "/".join(str(part) for part in error.absolute_path),
message=error.message,
)
for error in errors
]
raise CampaignSchemaError(normalized)
def load_campaign_config(
path: str | Path,
*,
validate_schema: bool = True,
schema_path: str | Path | None = None,
) -> CampaignConfig:
data = load_campaign_json(path)
if validate_schema:
validate_against_schema(data, schema_path=schema_path)
return CampaignConfig.model_validate(data)
@@ -0,0 +1,551 @@
from __future__ import annotations
from enum import StrEnum
from pathlib import Path
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from govoplan_mail.backend.config import ImapConfig, SmtpConfig, TransportSecurity
class StrictModel(BaseModel):
model_config = ConfigDict(extra="forbid", populate_by_name=True)
class CampaignMode(StrEnum):
DRAFT = "draft"
TEST = "test"
SEND = "send"
class FieldType(StrEnum):
STRING = "string"
INTEGER = "integer"
DOUBLE = "double"
DATE = "date"
PASSWORD = "password"
class RecipientType(StrEnum):
TO = "to"
CC = "cc"
BCC = "bcc"
REPLY_TO = "reply_to"
BOUNCE_TO = "bounce_to"
DISPOSITION_NOTIFICATION_TO = "disposition_notification_to"
class Behavior(StrEnum):
BLOCK = "block"
ASK = "ask"
DROP = "drop"
CONTINUE = "continue"
WARN = "warn"
class MissingAddressBehavior(StrEnum):
BLOCK = "block"
DROP = "drop"
class InactiveEntryBehavior(StrEnum):
DROP = "drop"
BLOCK = "block"
WARN = "warn"
class SourceType(StrEnum):
CSV = "csv"
JSON = "json"
class ZipMethod(StrEnum):
ZIP_STANDARD = "zip_standard"
AES = "aes"
class ZipRuleMode(StrEnum):
INHERIT = "inherit"
INCLUDE = "include"
EXCLUDE = "exclude"
class ZipPasswordScope(StrEnum):
LOCAL = "local"
GLOBAL = "global"
class ZipPasswordMode(StrEnum):
NONE = "none"
DIRECT = "direct"
FIELD = "field"
TEMPLATE = "template"
class BuildStatus(StrEnum):
BUILT = "built"
BUILD_FAILED = "build_failed"
class SendStatus(StrEnum):
DRAFT = "draft"
QUEUED = "queued"
class CampaignMeta(StrictModel):
id: str
name: str
description: str | None = None
mode: CampaignMode = CampaignMode.DRAFT
class FieldDefinition(StrictModel):
name: str
type: FieldType = FieldType.STRING
label: str | None = None
required: bool = False
can_override: bool = True
class ServerConfig(StrictModel):
mail_profile_id: str | None = None
inherit_smtp_credentials: bool = True
inherit_imap_credentials: bool = True
smtp: SmtpConfig | None = None
imap: ImapConfig | None = None
class RecipientConfig(StrictModel):
email: str
name: str | None = None
recipient_type: RecipientType = Field(default=RecipientType.TO, alias="type")
@field_validator("email")
@classmethod
def email_should_look_like_address(cls, value: str) -> str:
# JSON Schema's format=email remains the stricter validation layer.
# Keep this deliberately lightweight to avoid an extra email-validator dependency.
if "@" not in value:
raise ValueError("email must contain '@'")
return value
class RecipientsConfig(StrictModel):
from_: list[RecipientConfig] = Field(default_factory=list, alias="from", max_length=1)
@field_validator("from_", mode="before")
@classmethod
def normalize_from_list(cls, value: Any) -> Any:
# Older campaign files stored From as one object. The canonical model
# is now an array so every address header has the same list semantics.
if value is None:
return []
if isinstance(value, dict):
return [] if not any(value.values()) else [value]
return value
allow_individual_from: bool = False
to: list[RecipientConfig] = Field(default_factory=list)
allow_individual_to: bool = False
cc: list[RecipientConfig] = Field(default_factory=list)
allow_individual_cc: bool = False
bcc: list[RecipientConfig] = Field(default_factory=list)
allow_individual_bcc: bool = False
reply_to: list[RecipientConfig] = Field(default_factory=list)
allow_individual_reply_to: bool = False
bounce_to: list[RecipientConfig] = Field(default_factory=list)
allow_individual_bounce_to: bool = False
disposition_notification_to: list[RecipientConfig] = Field(default_factory=list)
allow_individual_disposition_notification_to: bool = False
class TemplateSourceConfig(StrictModel):
type: Literal["files"] = "files"
subject_path: str | None = None
text_path: str | None = None
html_path: str | None = None
encoding: str = "utf-8"
@model_validator(mode="after")
def at_least_one_path(self) -> "TemplateSourceConfig":
if not any([self.subject_path, self.text_path, self.html_path]):
raise ValueError("template.source must define subject_path, text_path or html_path")
return self
class TemplateConfig(StrictModel):
subject: str | None = None
text: str | None = None
html: str | None = None
source: TemplateSourceConfig | None = None
@model_validator(mode="after")
def inline_or_source(self) -> "TemplateConfig":
inline_values = any(value is not None for value in [self.subject, self.text, self.html])
if self.source and inline_values:
raise ValueError("template must be either inline or source-based, not both")
if self.source:
return self
if not self.subject:
raise ValueError("inline template requires subject")
return self
@property
def is_external(self) -> bool:
return self.source is not None
class ZipArchiveConfig(StrictModel):
id: str
name: str = "attachments.zip"
standard: bool = False
password_enabled: bool = False
password_field: str | None = None
password_scope: ZipPasswordScope = ZipPasswordScope.LOCAL
method: ZipMethod = ZipMethod.AES
# Compatibility fields for campaigns created by the first single-archive
# implementation. New WebUI campaigns use password_enabled/field/scope.
password_mode: ZipPasswordMode | None = None
password: str | None = None
password_template: str | None = None
@model_validator(mode="before")
@classmethod
def normalize_legacy_archive(cls, value: Any) -> Any:
if not isinstance(value, dict):
return value
normalized = dict(value)
if "name" not in normalized and normalized.get("filename_template"):
normalized["name"] = normalized.get("filename_template")
normalized.pop("filename_template", None)
mode = normalized.get("password_mode")
if "password_enabled" not in normalized and mode in {
ZipPasswordMode.DIRECT.value,
ZipPasswordMode.FIELD.value,
ZipPasswordMode.TEMPLATE.value,
}:
normalized["password_enabled"] = True
if mode == ZipPasswordMode.FIELD.value and "password_scope" not in normalized:
normalized["password_scope"] = ZipPasswordScope.LOCAL.value
return normalized
class ZipCollectionConfig(StrictModel):
enabled: bool = False
archives: list[ZipArchiveConfig] = Field(default_factory=list)
@model_validator(mode="before")
@classmethod
def normalize_single_archive_config(cls, value: Any) -> Any:
"""Upgrade the original single-recipient-ZIP object in memory.
The campaign JSON remains portable: old filename/password settings are
converted to one standard archive, while the new format is passed
through unchanged.
"""
if not isinstance(value, dict) or "archives" in value:
return value
legacy = dict(value)
enabled = bool(legacy.get("enabled"))
meaningful = enabled or any(
legacy.get(key) not in (None, "", False, "none", "inherit")
for key in ("filename_template", "password_mode", "password", "password_field", "password_template")
)
if not meaningful:
return {"enabled": False, "archives": []}
archive = {
"id": "default",
"name": legacy.get("filename_template") or "attachments.zip",
"standard": True,
"method": legacy.get("method", ZipMethod.AES.value),
"password_mode": legacy.get("password_mode"),
"password": legacy.get("password"),
"password_field": legacy.get("password_field"),
"password_template": legacy.get("password_template"),
}
mode = legacy.get("password_mode")
if not mode and legacy.get("password_template"):
mode = ZipPasswordMode.TEMPLATE.value
archive["password_mode"] = mode
archive["password_enabled"] = mode in {
ZipPasswordMode.DIRECT.value,
ZipPasswordMode.FIELD.value,
ZipPasswordMode.TEMPLATE.value,
}
archive["password_scope"] = ZipPasswordScope.LOCAL.value
return {"enabled": enabled, "archives": [archive]}
@property
def standard_archive(self) -> ZipArchiveConfig | None:
return next((archive for archive in self.archives if archive.standard), self.archives[0] if self.archives else None)
def archive_by_id(self, archive_id: str | None) -> ZipArchiveConfig | None:
if not archive_id:
return None
return next((archive for archive in self.archives if archive.id == archive_id), None)
class ZipRuleConfig(StrictModel):
# New format: inherit the campaign standard, select an archive id, or use
# the reserved value "exclude" to send the file outside every archive.
archive_id: str = ZipRuleMode.INHERIT.value
# Original per-rule fields remain accepted for imported campaign JSON.
enabled: bool = False
mode: ZipRuleMode = ZipRuleMode.INHERIT
filename_template: str | None = None
password_mode: ZipPasswordMode = ZipPasswordMode.NONE
password: str | None = None
password_field: str | None = None
password_template: str | None = None
method: ZipMethod = ZipMethod.AES
@model_validator(mode="before")
@classmethod
def normalize_legacy_password_mode(cls, value: Any) -> Any:
if isinstance(value, dict) and value.get("password_template") and "password_mode" not in value:
return {**value, "password_mode": ZipPasswordMode.TEMPLATE.value}
return value
class AttachmentBasePathConfig(StrictModel):
id: str | None = None
name: str
path: str = "."
allow_individual: bool = False
unsent_warning: bool = False
# Legacy UI builds briefly wrote a source value. Keep accepting it so older
# drafts do not become invalid merely because the current UI no longer shows
# or edits that column.
source: str | None = None
class AttachmentConfig(StrictModel):
id: str | None = None
label: str | None = None
base_path_id: str | None = None
# Legacy UI helper. Current attachment resolution ignores this value and
# treats direct files as plain file_filter patterns without wildcards.
# Keep accepting it so existing drafts with {"type": ""}, "direct"
# or "pattern" remain valid.
type_: str | None = Field(default=None, alias="type")
base_dir: str
file_filter: str
include_subdirs: bool = False
required: bool = True
allow_multiple: bool = False
@field_validator("type_", mode="before")
@classmethod
def empty_type_means_unset(cls, value: Any) -> Any:
if value == "":
return None
return value
# None means: inherit from validation_policy. Explicit values remain
# supported for backwards compatibility and per-rule overrides.
missing_behavior: Behavior | None = None
ambiguous_behavior: Behavior | None = None
zip: ZipRuleConfig = Field(default_factory=ZipRuleConfig)
class AttachmentsConfig(StrictModel):
base_path: str = "."
base_paths: list[AttachmentBasePathConfig] = Field(default_factory=list)
allow_individual: bool = False
send_without_attachments: bool = True
zip: ZipCollectionConfig = Field(default_factory=ZipCollectionConfig)
global_: list[AttachmentConfig] = Field(default_factory=list, alias="global")
missing_behavior: Behavior = Behavior.ASK
ambiguous_behavior: Behavior = Behavior.ASK
@property
def individual_base_path_values(self) -> set[str]:
return {base_path.path for base_path in self.base_paths if base_path.allow_individual}
class EntryConfig(StrictModel):
id: str | None = None
active: bool = True
@model_validator(mode="before")
@classmethod
def normalize_legacy_combine_flags(cls, value: Any) -> Any:
if not isinstance(value, dict):
return value
normalized = dict(value)
for address_field in ("from", "to", "cc", "bcc", "reply_to", "bounce_to", "disposition_notification_to"):
merge_key = f"merge_{address_field}"
combine_key = f"combine_{address_field}"
if merge_key not in normalized and combine_key in normalized:
normalized[merge_key] = normalized[combine_key]
normalized.pop(combine_key, None)
return normalized
# Compatibility fields written by older/current WebUI recipient rows.
# Address routing uses the explicit to/cc/bcc/reply_to/from fields below;
# these values are retained for round-tripping but are not used for sending.
name: str | None = None
email: str | None = None
from_: list[RecipientConfig] = Field(default_factory=list, alias="from", max_length=1)
merge_from: bool = False # Deprecated compatibility field; From never merges.
@field_validator("from_", mode="before")
@classmethod
def normalize_from_list(cls, value: Any) -> Any:
if value is None:
return []
if isinstance(value, dict):
return [] if not any(value.values()) else [value]
return value
to: list[RecipientConfig] = Field(default_factory=list)
merge_to: bool = True
cc: list[RecipientConfig] = Field(default_factory=list)
merge_cc: bool = True
bcc: list[RecipientConfig] = Field(default_factory=list)
merge_bcc: bool = True
reply_to: list[RecipientConfig] = Field(default_factory=list)
merge_reply_to: bool = True
bounce_to: list[RecipientConfig] = Field(default_factory=list)
merge_bounce_to: bool = True
disposition_notification_to: list[RecipientConfig] = Field(default_factory=list)
merge_disposition_notification_to: bool = True
attachments: list[AttachmentConfig] = Field(default_factory=list)
combine_attachments: bool = True
fields: dict[str, Any] = Field(default_factory=dict)
last_sent: str | None = None
class SourceConfig(StrictModel):
type: SourceType
path: str
delimiter: str = ";"
encoding: str = "utf-8"
has_header: bool = True
class EntriesConfig(StrictModel):
inline: list[EntryConfig] | None = None
source: SourceConfig | None = None
mapping: dict[str, str] | None = None
defaults: EntryConfig | None = None
@model_validator(mode="after")
def inline_or_external(self) -> "EntriesConfig":
has_inline = self.inline is not None
has_external_source = self.source is not None or self.mapping is not None
# defaults are compatible with both inline and external entries. The
# WebUI stores the current per-entry combination defaults here even for
# inline campaigns, so treating defaults as an external-source marker
# made valid UI drafts fail backend validation.
if has_inline and has_external_source:
raise ValueError("entries must be either inline or source-based, not both")
if has_inline:
return self
if self.source is None or self.mapping is None:
raise ValueError("external entries require source and mapping")
return self
@property
def is_inline(self) -> bool:
return self.inline is not None
@property
def is_external(self) -> bool:
return self.source is not None
class ValidationPolicy(StrictModel):
missing_required_attachment: Behavior = Behavior.ASK
missing_optional_attachment: Behavior = Behavior.WARN
ambiguous_attachment_match: Behavior = Behavior.ASK
ignore_empty_fields: bool = False
unsent_attachment_files: Behavior = Behavior.WARN
missing_email: MissingAddressBehavior = MissingAddressBehavior.BLOCK
template_error: MissingAddressBehavior = MissingAddressBehavior.BLOCK
inactive_entry: InactiveEntryBehavior = InactiveEntryBehavior.DROP
class RateLimitConfig(StrictModel):
messages_per_minute: int = Field(default=5, ge=1)
concurrency: int = Field(default=1, ge=1)
class ImapAppendSentConfig(StrictModel):
enabled: bool = False
folder: str = "auto"
class RetryConfig(StrictModel):
max_attempts: int = Field(default=3, ge=1)
backoff_seconds: list[int] = Field(default_factory=lambda: [60, 300, 900])
@field_validator("backoff_seconds")
@classmethod
def backoff_values_must_be_positive(cls, values: list[int]) -> list[int]:
if any(value < 1 for value in values):
raise ValueError("backoff_seconds values must be >= 1")
return values
class DeliveryConfig(StrictModel):
rate_limit: RateLimitConfig = Field(default_factory=RateLimitConfig)
imap_append_sent: ImapAppendSentConfig = Field(default_factory=ImapAppendSentConfig)
retry: RetryConfig = Field(default_factory=RetryConfig)
class StatusTrackingConfig(StrictModel):
enabled: bool = True
initial_build_status: BuildStatus = BuildStatus.BUILT
initial_send_status: SendStatus = SendStatus.DRAFT
class CampaignConfig(StrictModel):
version: Literal["1.0"]
campaign: CampaignMeta
fields: list[FieldDefinition] = Field(default_factory=list)
global_values: dict[str, Any] = Field(default_factory=dict)
server: ServerConfig = Field(default_factory=ServerConfig)
recipients: RecipientsConfig = Field(default_factory=RecipientsConfig)
template: TemplateConfig
attachments: AttachmentsConfig = Field(default_factory=AttachmentsConfig)
entries: EntriesConfig
validation_policy: ValidationPolicy = Field(default_factory=ValidationPolicy)
delivery: DeliveryConfig = Field(default_factory=DeliveryConfig)
status_tracking: StatusTrackingConfig = Field(default_factory=StatusTrackingConfig)
@model_validator(mode="after")
def field_names_must_be_unique(self) -> "CampaignConfig":
names = [field.name for field in self.fields]
duplicates = sorted({name for name in names if names.count(name) > 1})
if duplicates:
raise ValueError(f"duplicate field definitions: {', '.join(duplicates)}")
return self
@property
def field_names(self) -> set[str]:
return {field.name for field in self.fields}
def resolve_relative_path(self, campaign_file: Path, raw_path: str) -> Path:
path = Path(raw_path).expanduser()
if path.is_absolute():
return path
return (campaign_file.parent / path).resolve()
@@ -0,0 +1,25 @@
from __future__ import annotations
from typing import Any
from .addressing import effective_address_lists, recipient_template_values
from .field_values import effective_entry_field_values
from .models import CampaignConfig, EntryConfig
def build_template_values(config: CampaignConfig, entry: EntryConfig) -> dict[str, Any]:
values: dict[str, Any] = {}
for field in config.fields:
values.setdefault(field.name, "")
values.setdefault(f"global::{field.name}", "")
values.setdefault(f"local::{field.name}", "")
for key, value in config.global_values.items():
values[f"global::{key}"] = value
for key, value in effective_entry_field_values(config, entry).items():
values[key] = value
values[f"local::{key}"] = value
if entry.id:
values["local::id"] = entry.id
values["local::active"] = entry.active
values.update(recipient_template_values(effective_address_lists(config, entry)))
return values
@@ -0,0 +1,437 @@
from __future__ import annotations
import csv
from enum import StrEnum
from pathlib import Path
from typing import Iterable
from pydantic import BaseModel, ConfigDict, Field
from .field_values import ignored_entry_field_overrides
from .models import AttachmentConfig, CampaignConfig, EntryConfig, FieldType, SourceType, ZipPasswordMode, ZipPasswordScope, ZipRuleMode
class Severity(StrEnum):
INFO = "info"
WARNING = "warning"
ERROR = "error"
class SemanticIssue(BaseModel):
model_config = ConfigDict(extra="forbid")
severity: Severity
code: str
message: str
path: str | None = None
class SemanticReport(BaseModel):
model_config = ConfigDict(extra="forbid")
campaign_id: str
campaign_name: str
issues: list[SemanticIssue] = Field(default_factory=list)
entries_mode: str
entries_count: int | None = None
attachments_base_path: str
rate_limit: str
imap_append_enabled: bool
@property
def error_count(self) -> int:
return sum(1 for issue in self.issues if issue.severity == Severity.ERROR)
@property
def warning_count(self) -> int:
return sum(1 for issue in self.issues if issue.severity == Severity.WARNING)
@property
def ok(self) -> bool:
return self.error_count == 0
def _issue(severity: Severity, code: str, message: str, path: str | None = None) -> SemanticIssue:
return SemanticIssue(severity=severity, code=code, message=message, path=path)
def _resolve(campaign_file: Path, raw_path: str) -> Path:
path = Path(raw_path).expanduser()
if path.is_absolute():
return path
return (campaign_file.parent / path).resolve()
def _mapping_target_field_name(target: str) -> str | None:
if target.startswith("fields."):
return target.split(".", 1)[1]
return None
def _mapping_target_known(target: str, field_names: set[str]) -> bool:
direct_targets = {
"id",
"active",
"last_sent",
"merge_from",
"merge_to",
"merge_cc",
"merge_bcc",
"merge_reply_to",
"merge_bounce_to",
"merge_disposition_notification_to",
"combine_to",
"combine_cc",
"combine_bcc",
"combine_reply_to",
"combine_bounce_to",
"combine_disposition_notification_to",
"combine_attachments",
}
if target in direct_targets:
return True
if target.startswith("fields."):
name = target.split(".", 1)[1]
return not field_names or name in field_names
if target.startswith("from."):
return target in {"from.email", "from.name", "from.type"}
for prefix in ["to", "cc", "bcc", "reply_to", "bounce_to", "disposition_notification_to"]:
if target.startswith(prefix + "."):
parts = target.split(".")
return len(parts) == 3 and parts[1].isdigit() and parts[2] in {"email", "name", "type"}
if target.startswith("attachments."):
parts = target.split(".")
# attachments.0.zip.filename_template etc.
if len(parts) >= 3 and parts[1].isdigit():
if parts[2] in {
"id",
"label",
"base_dir",
"file_filter",
"include_subdirs",
"required",
"allow_multiple",
"missing_behavior",
"ambiguous_behavior",
}:
return len(parts) == 3
if parts[2] == "zip" and len(parts) == 4:
return parts[3] in {"enabled", "mode", "filename_template", "password_mode", "password", "password_field", "password_template", "method"}
return False
def _csv_header(path: Path, delimiter: str, encoding: str) -> list[str] | None:
with path.open("r", encoding=encoding, newline="") as handle:
reader = csv.reader(handle, delimiter=delimiter)
try:
return next(reader)
except StopIteration:
return []
def _iter_template_source_paths(config: CampaignConfig) -> Iterable[tuple[str, str]]:
if not config.template.source:
return []
source = config.template.source
paths: list[tuple[str, str]] = []
if source.subject_path:
paths.append(("/template/source/subject_path", source.subject_path))
if source.text_path:
paths.append(("/template/source/text_path", source.text_path))
if source.html_path:
paths.append(("/template/source/html_path", source.html_path))
return paths
def _attachment_base_path_report_value(config: CampaignConfig) -> str:
if config.attachments.base_paths:
return ", ".join(f"{base_path.name}: {base_path.path}" for base_path in config.attachments.base_paths)
return config.attachments.base_path
def _iter_attachment_rules(config: CampaignConfig) -> Iterable[tuple[str, AttachmentConfig, bool]]:
for index, attachment_config in enumerate(config.attachments.global_):
yield f"/attachments/global/{index}", attachment_config, False
inline_entries = config.entries.inline or [] if config.entries.is_inline else []
for entry_index, entry in enumerate(inline_entries):
if not entry.active:
continue
for attachment_index, attachment_config in enumerate(entry.attachments):
yield f"/entries/inline/{entry_index}/attachments/{attachment_index}", attachment_config, True
if config.entries.defaults:
for attachment_index, attachment_config in enumerate(config.entries.defaults.attachments):
yield f"/entries/defaults/attachments/{attachment_index}", attachment_config, True
def _attachment_path_issues(config: CampaignConfig) -> list[SemanticIssue]:
issues: list[SemanticIssue] = []
configured_paths = {base_path.path for base_path in config.attachments.base_paths}
individual_paths = config.attachments.individual_base_path_values
if config.attachments.base_paths:
for index, base_path in enumerate(config.attachments.base_paths):
if not base_path.name.strip():
issues.append(_issue(Severity.WARNING, "attachment_base_path_missing_name", "attachment base path has no display name", f"/attachments/base_paths/{index}/name"))
if not base_path.path.strip():
issues.append(_issue(Severity.ERROR, "attachment_base_path_missing_path", "attachment base path has no path", f"/attachments/base_paths/{index}/path"))
elif not config.attachments.base_path:
issues.append(_issue(Severity.INFO, "missing_attachment_base_path", "Attachment base path is not configured yet.", "/attachments/base_path"))
if configured_paths:
for path, attachment_config, is_individual in _iter_attachment_rules(config):
if attachment_config.base_dir and attachment_config.base_dir not in configured_paths:
issues.append(_issue(
Severity.WARNING,
"unknown_attachment_base_path",
f"attachment rule refers to base path {attachment_config.base_dir!r}, but it is not listed in attachments.base_paths",
f"{path}/base_dir",
))
if is_individual and individual_paths and attachment_config.base_dir not in individual_paths:
issues.append(_issue(
Severity.WARNING,
"individual_attachment_base_path_not_allowed",
f"individual attachment rule uses base path {attachment_config.base_dir!r}, but that base path does not allow individual attachments",
f"{path}/base_dir",
))
return issues
def _zip_configuration_issues(config: CampaignConfig) -> list[SemanticIssue]:
collection = config.attachments.zip
issues: list[SemanticIssue] = []
if not collection.enabled:
return issues
if not collection.archives:
return [_issue(Severity.ERROR, "zip_archive_missing", "Attachment zipping is enabled, but no ZIP archive is configured", "/attachments/zip/archives")]
archive_ids: set[str] = set()
archive_names: set[str] = set()
standard_count = 0
field_definitions = {field.name: field for field in config.fields}
for index, archive in enumerate(collection.archives):
path = f"/attachments/zip/archives/{index}"
if not archive.id.strip():
issues.append(_issue(Severity.ERROR, "zip_archive_id_missing", "ZIP archive has no identifier", f"{path}/id"))
elif archive.id in archive_ids:
issues.append(_issue(Severity.ERROR, "zip_archive_id_duplicate", f"ZIP archive id {archive.id!r} is used more than once", f"{path}/id"))
archive_ids.add(archive.id)
normalized_archive_name = _normalized_zip_archive_name(archive.name)
if not normalized_archive_name:
issues.append(_issue(Severity.ERROR, "zip_archive_name_missing", "ZIP archive has no filename", f"{path}/name"))
elif normalized_archive_name in archive_names:
issues.append(_issue(Severity.ERROR, "zip_archive_name_duplicate", f"ZIP archive filename {archive.name!r} is used more than once; archive filenames must be unique", f"{path}/name"))
archive_names.add(normalized_archive_name)
if archive.standard:
standard_count += 1
if not archive.password_enabled:
continue
if archive.password_mode == ZipPasswordMode.DIRECT:
if not (archive.password or ""):
issues.append(_issue(Severity.ERROR, "zip_password_missing", "A legacy fixed ZIP password is enabled, but no password is configured", f"{path}/password"))
continue
if archive.password_mode == ZipPasswordMode.TEMPLATE:
if not (archive.password_template or ""):
issues.append(_issue(Severity.ERROR, "zip_password_template_missing", "A legacy ZIP password template is enabled, but no template is configured", f"{path}/password_template"))
continue
field_name = (archive.password_field or "").strip()
field = field_definitions.get(field_name)
if not field_name:
issues.append(_issue(Severity.ERROR, "zip_password_field_missing", f"ZIP archive {archive.name!r} has password protection enabled, but no field is selected", f"{path}/password_field"))
elif field is None:
issues.append(_issue(Severity.ERROR, "zip_password_field_unknown", f"ZIP password field {field_name!r} is not declared in campaign fields", f"{path}/password_field"))
elif field.type != FieldType.PASSWORD:
issues.append(_issue(Severity.WARNING, "zip_password_field_not_password_type", f"ZIP password field {field_name!r} is not configured with field type 'password'", f"{path}/password_field"))
elif archive.password_scope == ZipPasswordScope.GLOBAL and config.global_values.get(field_name) in (None, ""):
issues.append(_issue(Severity.ERROR, "zip_global_password_value_missing", f"Global ZIP password field {field_name!r} has no campaign-wide value", f"/global_values/{field_name}"))
if standard_count != 1:
issues.append(_issue(Severity.ERROR, "zip_standard_archive_invalid", "Exactly one ZIP archive must be selected as the campaign standard", "/attachments/zip/archives"))
for path, rule, _is_individual in _iter_attachment_rules(config):
selection = (rule.zip.archive_id or ZipRuleMode.INHERIT.value).strip()
if selection not in {"", ZipRuleMode.INHERIT.value, ZipRuleMode.INCLUDE.value, ZipRuleMode.EXCLUDE.value} and selection not in archive_ids:
issues.append(_issue(Severity.ERROR, "zip_archive_unknown", f"Attachment rule selects unknown ZIP archive {selection!r}", f"{path}/zip/archive_id"))
return issues
def _normalized_zip_archive_name(value: str) -> str:
normalized = value.strip().casefold()
if not normalized:
return ""
return normalized if normalized.endswith(".zip") else f"{normalized}.zip"
def _ignored_override_issues(config: CampaignConfig, entry: EntryConfig, path_prefix: str) -> list[SemanticIssue]:
return [
_issue(
Severity.WARNING,
"field_override_not_allowed",
f"recipient value for field {field_name!r} will be ignored because the field does not allow overrides",
f"{path_prefix}/fields/{field_name}",
)
for field_name in ignored_entry_field_overrides(config, entry)
]
def validate_campaign_config(
config: CampaignConfig,
*,
campaign_file: str | Path | None = None,
check_files: bool = False,
) -> SemanticReport:
campaign_path = Path(campaign_file).resolve() if campaign_file else Path.cwd() / "campaign.json"
issues: list[SemanticIssue] = []
field_names = config.field_names
field_definitions = {field.name: field for field in config.fields}
declared_names = set(field_definitions)
for key in config.global_values:
if declared_names and key not in declared_names:
issues.append(_issue(
Severity.WARNING,
"unknown_global_value",
f"global_values contains {key!r}, but it is not declared in fields",
f"/global_values/{key}",
))
issues.extend(_attachment_path_issues(config))
issues.extend(_zip_configuration_issues(config))
if config.server.imap and config.server.imap.enabled:
missing = [name for name in ["host", "port", "username", "password"] if getattr(config.server.imap, name) in (None, "")]
if missing:
issues.append(_issue(
Severity.ERROR,
"incomplete_imap_config",
"IMAP append is enabled, but these IMAP settings are missing: " + ", ".join(missing),
"/server/imap",
))
if config.delivery.imap_append_sent.enabled and not (config.server.imap and config.server.imap.enabled):
issues.append(_issue(
Severity.WARNING,
"delivery_imap_enabled_without_server_imap",
"delivery.imap_append_sent is enabled, but server.imap.enabled is not true",
"/delivery/imap_append_sent/enabled",
))
if config.campaign.mode == "send" and not config.server.smtp:
issues.append(_issue(
Severity.ERROR,
"missing_smtp_config",
"campaign mode is 'send', but no server.smtp configuration is present",
"/server/smtp",
))
if config.server.smtp:
missing = [name for name in ["host", "port"] if getattr(config.server.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",
))
if config.entries.is_inline:
inline_entries = config.entries.inline or []
entries_count = len(inline_entries)
entries_mode = "inline"
if entries_count == 0:
issues.append(_issue(Severity.WARNING, "no_inline_entries", "entries.inline is empty", "/entries/inline"))
for index, entry in enumerate(inline_entries):
if entry.active:
issues.extend(_ignored_override_issues(config, entry, f"/entries/inline/{index}"))
else:
entries_count = None
entries_mode = f"external:{config.entries.source.type.value if config.entries.source else 'unknown'}"
mapping = config.entries.mapping or {}
if not mapping:
issues.append(_issue(Severity.ERROR, "empty_mapping", "external entries require a non-empty mapping", "/entries/mapping"))
for target in mapping:
if not _mapping_target_known(target, field_names):
issues.append(_issue(
Severity.WARNING,
"unknown_mapping_target",
f"mapping target {target!r} is not recognized by the current campaign model",
f"/entries/mapping/{target}",
))
field_name = _mapping_target_field_name(target)
if field_name and field_name in field_definitions and not field_definitions[field_name].can_override:
issues.append(_issue(
Severity.WARNING,
"mapping_target_not_overridable",
f"mapping target {target!r} points to a field that does not allow recipient overrides; mapped values will be ignored",
f"/entries/mapping/{target}",
))
if config.entries.defaults:
issues.extend(_ignored_override_issues(config, config.entries.defaults, "/entries/defaults"))
if check_files and config.entries.source:
source_path = _resolve(campaign_path, config.entries.source.path)
if not source_path.exists():
issues.append(_issue(
Severity.ERROR,
"entries_source_not_found",
f"entries source file does not exist: {source_path}",
"/entries/source/path",
))
elif config.entries.source.type == SourceType.CSV and config.entries.source.has_header:
try:
header = _csv_header(source_path, config.entries.source.delimiter, config.entries.source.encoding)
header_set = set(header or [])
missing_columns = sorted({source_name for source_name in mapping.values() if source_name not in header_set})
if missing_columns:
issues.append(_issue(
Severity.ERROR,
"mapping_columns_missing",
"CSV mapping refers to missing columns: " + ", ".join(missing_columns),
"/entries/mapping",
))
except OSError as exc:
issues.append(_issue(Severity.ERROR, "entries_source_read_error", str(exc), "/entries/source/path"))
if check_files:
if config.attachments.base_paths:
for index, base_path_config in enumerate(config.attachments.base_paths):
attachments_base_path = _resolve(campaign_path, base_path_config.path)
if not attachments_base_path.exists():
issues.append(_issue(
Severity.WARNING,
"attachments_base_path_not_found",
f"attachment base path {base_path_config.name!r} does not exist: {attachments_base_path}",
f"/attachments/base_paths/{index}/path",
))
else:
attachments_base_path = _resolve(campaign_path, config.attachments.base_path)
if not attachments_base_path.exists():
issues.append(_issue(
Severity.WARNING,
"attachments_base_path_not_found",
f"attachments.base_path does not exist: {attachments_base_path}",
"/attachments/base_path",
))
for schema_path, raw_path in _iter_template_source_paths(config):
path = _resolve(campaign_path, raw_path)
if not path.exists():
issues.append(_issue(
Severity.ERROR,
"template_source_not_found",
f"template source file does not exist: {path}",
schema_path,
))
report = SemanticReport(
campaign_id=config.campaign.id,
campaign_name=config.campaign.name,
issues=issues,
entries_mode=entries_mode,
entries_count=entries_count,
attachments_base_path=_attachment_base_path_report_value(config),
rate_limit=f"{config.delivery.rate_limit.messages_per_minute}/min, concurrency {config.delivery.rate_limit.concurrency}",
imap_append_enabled=config.delivery.imap_append_sent.enabled,
)
return report