inital commit
This commit is contained in:
14
server/app/mailer/campaign/__init__.py
Normal file
14
server/app/mailer/campaign/__init__.py
Normal file
@@ -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",
|
||||
]
|
||||
215
server/app/mailer/campaign/entries.py
Normal file
215
server/app/mailer/campaign/entries.py
Normal file
@@ -0,0 +1,215 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import csv
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
from .models import AttachmentConfig, CampaignConfig, EntryConfig, RecipientConfig, 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",
|
||||
"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)]
|
||||
79
server/app/mailer/campaign/loader.py
Normal file
79
server/app/mailer/campaign/loader.py
Normal file
@@ -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)
|
||||
363
server/app/mailer/campaign/models.py
Normal file
363
server/app/mailer/campaign/models.py
Normal file
@@ -0,0 +1,363 @@
|
||||
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
|
||||
|
||||
|
||||
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 TransportSecurity(StrEnum):
|
||||
PLAIN = "plain"
|
||||
TLS = "tls"
|
||||
STARTTLS = "starttls"
|
||||
|
||||
|
||||
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 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
|
||||
|
||||
|
||||
class SmtpConfig(StrictModel):
|
||||
host: str | None = None
|
||||
port: int | None = Field(default=None, ge=1, le=65535)
|
||||
username: str | None = None
|
||||
password: str | None = None
|
||||
security: TransportSecurity = TransportSecurity.STARTTLS
|
||||
timeout_seconds: int = Field(default=30, ge=1)
|
||||
|
||||
|
||||
class ImapConfig(StrictModel):
|
||||
enabled: bool = False
|
||||
host: str | None = None
|
||||
port: int | None = Field(default=None, ge=1, le=65535)
|
||||
username: str | None = None
|
||||
password: str | None = None
|
||||
security: TransportSecurity = TransportSecurity.TLS
|
||||
sent_folder: str = "auto"
|
||||
timeout_seconds: int = Field(default=30, ge=1)
|
||||
|
||||
|
||||
class ServerConfig(StrictModel):
|
||||
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_: RecipientConfig | None = Field(default=None, alias="from")
|
||||
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 ZipConfig(StrictModel):
|
||||
enabled: bool = False
|
||||
filename_template: str | None = None
|
||||
password_template: str | None = None
|
||||
method: ZipMethod = ZipMethod.AES
|
||||
|
||||
|
||||
class AttachmentConfig(StrictModel):
|
||||
id: str | None = None
|
||||
label: str | None = None
|
||||
base_dir: str
|
||||
file_filter: str
|
||||
include_subdirs: bool = False
|
||||
required: bool = True
|
||||
allow_multiple: bool = False
|
||||
missing_behavior: Behavior = Behavior.ASK
|
||||
ambiguous_behavior: Behavior = Behavior.ASK
|
||||
zip: ZipConfig = Field(default_factory=ZipConfig)
|
||||
|
||||
|
||||
class AttachmentsConfig(StrictModel):
|
||||
base_path: str = "."
|
||||
allow_individual: bool = False
|
||||
send_without_attachments: bool = True
|
||||
global_: list[AttachmentConfig] = Field(default_factory=list, alias="global")
|
||||
missing_behavior: Behavior = Behavior.ASK
|
||||
ambiguous_behavior: Behavior = Behavior.ASK
|
||||
|
||||
|
||||
class EntryConfig(StrictModel):
|
||||
id: str | None = None
|
||||
active: bool = True
|
||||
|
||||
from_: RecipientConfig | None = Field(default=None, alias="from")
|
||||
|
||||
to: list[RecipientConfig] = Field(default_factory=list)
|
||||
combine_to: bool = True
|
||||
|
||||
cc: list[RecipientConfig] = Field(default_factory=list)
|
||||
combine_cc: bool = True
|
||||
|
||||
bcc: list[RecipientConfig] = Field(default_factory=list)
|
||||
combine_bcc: bool = True
|
||||
|
||||
reply_to: list[RecipientConfig] = Field(default_factory=list)
|
||||
combine_reply_to: bool = True
|
||||
|
||||
bounce_to: list[RecipientConfig] = Field(default_factory=list)
|
||||
combine_bounce_to: bool = True
|
||||
|
||||
disposition_notification_to: list[RecipientConfig] = Field(default_factory=list)
|
||||
combine_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 = self.source is not None or self.mapping is not None or self.defaults is not None
|
||||
if has_inline and has_external:
|
||||
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
|
||||
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()
|
||||
261
server/app/mailer/campaign/validation.py
Normal file
261
server/app/mailer/campaign/validation.py
Normal file
@@ -0,0 +1,261 @@
|
||||
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 .models import CampaignConfig, SourceType
|
||||
|
||||
|
||||
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_known(target: str, field_names: set[str]) -> bool:
|
||||
direct_targets = {
|
||||
"id",
|
||||
"active",
|
||||
"last_sent",
|
||||
"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", "filename_template", "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 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
|
||||
declared_names = {field.name for field in config.fields}
|
||||
|
||||
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}",
|
||||
))
|
||||
|
||||
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:
|
||||
entries_count = len(config.entries.inline or [])
|
||||
entries_mode = "inline"
|
||||
if entries_count == 0:
|
||||
issues.append(_issue(Severity.WARNING, "no_inline_entries", "entries.inline is empty", "/entries/inline"))
|
||||
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}",
|
||||
))
|
||||
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:
|
||||
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=config.attachments.base_path,
|
||||
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