inital commit

This commit is contained in:
2026-06-08 15:57:11 +02:00
parent aaf8729663
commit d9ca48addc
114 changed files with 12172 additions and 1 deletions

View 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",
]

View 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)]

View 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)

View 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()

View 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