initial commit after split
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user