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