inital commit
This commit is contained in:
0
server/app/mailer/__init__.py
Normal file
0
server/app/mailer/__init__.py
Normal file
0
server/app/mailer/attachments/__init__.py
Normal file
0
server/app/mailer/attachments/__init__.py
Normal file
318
server/app/mailer/attachments/resolver.py
Normal file
318
server/app/mailer/attachments/resolver.py
Normal file
@@ -0,0 +1,318 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import fnmatch
|
||||
from enum import StrEnum
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.mailer.campaign.entries import load_campaign_entries
|
||||
from app.mailer.campaign.models import AttachmentConfig, Behavior, CampaignConfig, EntryConfig
|
||||
|
||||
|
||||
class AttachmentScope(StrEnum):
|
||||
GLOBAL = "global"
|
||||
ENTRY = "entry"
|
||||
|
||||
|
||||
class AttachmentMatchStatus(StrEnum):
|
||||
OK = "ok"
|
||||
MISSING = "missing"
|
||||
AMBIGUOUS = "ambiguous"
|
||||
|
||||
|
||||
class MessageAttachmentStatus(StrEnum):
|
||||
READY = "ready"
|
||||
WARNING = "warning"
|
||||
NEEDS_REVIEW = "needs_review"
|
||||
BLOCKED = "blocked"
|
||||
EXCLUDED = "excluded"
|
||||
INACTIVE = "inactive"
|
||||
|
||||
|
||||
class ResolutionSeverity(StrEnum):
|
||||
INFO = "info"
|
||||
WARNING = "warning"
|
||||
ERROR = "error"
|
||||
|
||||
|
||||
class AttachmentIssue(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
severity: ResolutionSeverity
|
||||
code: str
|
||||
message: str
|
||||
behavior: Behavior | None = None
|
||||
|
||||
|
||||
class ResolvedAttachment(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
scope: AttachmentScope
|
||||
index: int
|
||||
attachment_id: str | None = None
|
||||
label: str | None = None
|
||||
base_dir_template: str
|
||||
file_filter_template: str
|
||||
base_dir: str
|
||||
file_filter: str
|
||||
directory: str
|
||||
include_subdirs: bool
|
||||
required: bool
|
||||
allow_multiple: bool
|
||||
zip_enabled: bool
|
||||
status: AttachmentMatchStatus
|
||||
behavior: Behavior | None = None
|
||||
matches: list[str] = Field(default_factory=list)
|
||||
issues: list[AttachmentIssue] = Field(default_factory=list)
|
||||
|
||||
|
||||
class EntryAttachmentResolution(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
entry_index: int
|
||||
entry_id: str | None = None
|
||||
active: bool
|
||||
status: MessageAttachmentStatus
|
||||
attachments: list[ResolvedAttachment] = Field(default_factory=list)
|
||||
issues: list[AttachmentIssue] = Field(default_factory=list)
|
||||
|
||||
@property
|
||||
def match_count(self) -> int:
|
||||
return sum(len(item.matches) for item in self.attachments)
|
||||
|
||||
|
||||
class AttachmentResolutionReport(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
campaign_id: str
|
||||
campaign_name: str
|
||||
campaign_file: str
|
||||
attachments_base_path: str
|
||||
entries_count: int
|
||||
entries: list[EntryAttachmentResolution] = Field(default_factory=list)
|
||||
|
||||
@property
|
||||
def ready_count(self) -> int:
|
||||
return sum(1 for entry in self.entries if entry.status == MessageAttachmentStatus.READY)
|
||||
|
||||
@property
|
||||
def warning_count(self) -> int:
|
||||
return sum(1 for entry in self.entries if entry.status == MessageAttachmentStatus.WARNING)
|
||||
|
||||
@property
|
||||
def needs_review_count(self) -> int:
|
||||
return sum(1 for entry in self.entries if entry.status == MessageAttachmentStatus.NEEDS_REVIEW)
|
||||
|
||||
@property
|
||||
def blocked_count(self) -> int:
|
||||
return sum(1 for entry in self.entries if entry.status == MessageAttachmentStatus.BLOCKED)
|
||||
|
||||
@property
|
||||
def excluded_count(self) -> int:
|
||||
return sum(1 for entry in self.entries if entry.status == MessageAttachmentStatus.EXCLUDED)
|
||||
|
||||
@property
|
||||
def inactive_count(self) -> int:
|
||||
return sum(1 for entry in self.entries if entry.status == MessageAttachmentStatus.INACTIVE)
|
||||
|
||||
|
||||
def _resolve_path(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 _render_template(template: str, values: dict[str, Any]) -> str:
|
||||
rendered = template
|
||||
for key, value in values.items():
|
||||
rendered = rendered.replace("${" + key + "}", "" if value is None else str(value))
|
||||
return rendered
|
||||
|
||||
|
||||
def _recipient_values(entry: EntryConfig) -> dict[str, str]:
|
||||
values: dict[str, str] = {}
|
||||
for list_name in ["to", "cc", "bcc", "reply_to", "bounce_to", "disposition_notification_to"]:
|
||||
recipients = getattr(entry, list_name)
|
||||
for index, recipient in enumerate(recipients):
|
||||
prefix = f"{list_name}.{index}"
|
||||
values[f"local::{prefix}.email"] = recipient.email
|
||||
values[f"local::{prefix}.name"] = recipient.name or ""
|
||||
values[f"local::{prefix}.type"] = recipient.recipient_type.value
|
||||
if entry.from_:
|
||||
values["local::from.email"] = entry.from_.email
|
||||
values["local::from.name"] = entry.from_.name or ""
|
||||
values["local::from.type"] = entry.from_.recipient_type.value
|
||||
return values
|
||||
|
||||
|
||||
def _template_values(config: CampaignConfig, entry: EntryConfig) -> dict[str, Any]:
|
||||
values: dict[str, Any] = {}
|
||||
for key, value in config.global_values.items():
|
||||
values[f"global::{key}"] = value
|
||||
for key, value in entry.fields.items():
|
||||
values[f"local::{key}"] = value
|
||||
if entry.id:
|
||||
values["local::id"] = entry.id
|
||||
values["local::active"] = entry.active
|
||||
values.update(_recipient_values(entry))
|
||||
return values
|
||||
|
||||
|
||||
def _iter_effective_attachment_configs(config: CampaignConfig, entry: EntryConfig) -> Iterable[tuple[AttachmentScope, int, AttachmentConfig]]:
|
||||
if entry.combine_attachments:
|
||||
for index, attachment_config in enumerate(config.attachments.global_):
|
||||
yield AttachmentScope.GLOBAL, index, attachment_config
|
||||
if config.attachments.allow_individual:
|
||||
for index, attachment_config in enumerate(entry.attachments):
|
||||
yield AttachmentScope.ENTRY, index, attachment_config
|
||||
|
||||
|
||||
def _match_files(directory: Path, file_filter: str, include_subdirs: bool) -> list[Path]:
|
||||
if not directory.exists() or not directory.is_dir():
|
||||
return []
|
||||
if include_subdirs:
|
||||
# pathlib.rglob accepts glob patterns, but fnmatch keeps behavior predictable
|
||||
# when file_filter is supplied as the Java-style filter portion only.
|
||||
return sorted(path for path in directory.rglob("*") if path.is_file() and fnmatch.fnmatch(path.name, file_filter))
|
||||
return sorted(path for path in directory.glob(file_filter) if path.is_file())
|
||||
|
||||
|
||||
def _issue_for_missing(config: AttachmentConfig, behavior: Behavior) -> AttachmentIssue:
|
||||
code = "missing_required_attachment" if config.required else "missing_optional_attachment"
|
||||
severity = ResolutionSeverity.ERROR if config.required and behavior == Behavior.BLOCK else ResolutionSeverity.WARNING
|
||||
return AttachmentIssue(
|
||||
severity=severity,
|
||||
code=code,
|
||||
message=f"No file matched attachment filter {config.file_filter!r}",
|
||||
behavior=behavior,
|
||||
)
|
||||
|
||||
|
||||
def _issue_for_ambiguous(config: AttachmentConfig, behavior: Behavior, match_count: int) -> AttachmentIssue:
|
||||
severity = ResolutionSeverity.ERROR if behavior == Behavior.BLOCK else ResolutionSeverity.WARNING
|
||||
return AttachmentIssue(
|
||||
severity=severity,
|
||||
code="ambiguous_attachment_match",
|
||||
message=f"Attachment filter {config.file_filter!r} matched {match_count} files, but allow_multiple is false",
|
||||
behavior=behavior,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_one_config(
|
||||
*,
|
||||
campaign_file: str | Path,
|
||||
attachments_base_path: Path,
|
||||
values: dict[str, Any],
|
||||
scope: AttachmentScope,
|
||||
index: int,
|
||||
config: AttachmentConfig,
|
||||
) -> ResolvedAttachment:
|
||||
rendered_base_dir = _render_template(config.base_dir, values)
|
||||
rendered_file_filter = _render_template(config.file_filter, values)
|
||||
directory = (attachments_base_path / rendered_base_dir).resolve()
|
||||
matches = _match_files(directory, rendered_file_filter, config.include_subdirs)
|
||||
|
||||
issues: list[AttachmentIssue] = []
|
||||
behavior: Behavior | None = None
|
||||
|
||||
if not matches:
|
||||
status = AttachmentMatchStatus.MISSING
|
||||
behavior = config.missing_behavior
|
||||
issues.append(_issue_for_missing(config, behavior))
|
||||
elif len(matches) > 1 and not config.allow_multiple:
|
||||
status = AttachmentMatchStatus.AMBIGUOUS
|
||||
behavior = config.ambiguous_behavior
|
||||
issues.append(_issue_for_ambiguous(config, behavior, len(matches)))
|
||||
else:
|
||||
status = AttachmentMatchStatus.OK
|
||||
|
||||
return ResolvedAttachment(
|
||||
scope=scope,
|
||||
index=index,
|
||||
attachment_id=config.id,
|
||||
label=config.label,
|
||||
base_dir_template=config.base_dir,
|
||||
file_filter_template=config.file_filter,
|
||||
base_dir=rendered_base_dir,
|
||||
file_filter=rendered_file_filter,
|
||||
directory=str(directory),
|
||||
include_subdirs=config.include_subdirs,
|
||||
required=config.required,
|
||||
allow_multiple=config.allow_multiple,
|
||||
zip_enabled=config.zip.enabled,
|
||||
status=status,
|
||||
behavior=behavior,
|
||||
matches=[str(path) for path in matches],
|
||||
issues=issues,
|
||||
)
|
||||
|
||||
|
||||
def _status_from_issues(active: bool, issues: list[AttachmentIssue]) -> MessageAttachmentStatus:
|
||||
if not active:
|
||||
return MessageAttachmentStatus.INACTIVE
|
||||
behaviors = {issue.behavior for issue in issues if issue.behavior is not None}
|
||||
if Behavior.BLOCK in behaviors:
|
||||
return MessageAttachmentStatus.BLOCKED
|
||||
if Behavior.DROP in behaviors:
|
||||
return MessageAttachmentStatus.EXCLUDED
|
||||
if Behavior.ASK in behaviors:
|
||||
return MessageAttachmentStatus.NEEDS_REVIEW
|
||||
if Behavior.WARN in behaviors:
|
||||
return MessageAttachmentStatus.WARNING
|
||||
return MessageAttachmentStatus.READY
|
||||
|
||||
|
||||
def resolve_entry_attachments(
|
||||
*,
|
||||
config: CampaignConfig,
|
||||
campaign_file: str | Path,
|
||||
entry: EntryConfig,
|
||||
entry_index: int,
|
||||
) -> EntryAttachmentResolution:
|
||||
attachments_base_path = _resolve_path(campaign_file, config.attachments.base_path)
|
||||
values = _template_values(config, entry)
|
||||
resolved: list[ResolvedAttachment] = []
|
||||
|
||||
if entry.active:
|
||||
for scope, index, attachment_config in _iter_effective_attachment_configs(config, entry):
|
||||
resolved.append(
|
||||
_resolve_one_config(
|
||||
campaign_file=campaign_file,
|
||||
attachments_base_path=attachments_base_path,
|
||||
values=values,
|
||||
scope=scope,
|
||||
index=index,
|
||||
config=attachment_config,
|
||||
)
|
||||
)
|
||||
|
||||
issues = [issue for item in resolved for issue in item.issues]
|
||||
return EntryAttachmentResolution(
|
||||
entry_index=entry_index,
|
||||
entry_id=entry.id,
|
||||
active=entry.active,
|
||||
status=_status_from_issues(entry.active, issues),
|
||||
attachments=resolved,
|
||||
issues=issues,
|
||||
)
|
||||
|
||||
|
||||
def resolve_campaign_attachments(config: CampaignConfig, *, campaign_file: str | Path) -> AttachmentResolutionReport:
|
||||
entries = load_campaign_entries(config, campaign_file=campaign_file)
|
||||
base_path = _resolve_path(campaign_file, config.attachments.base_path)
|
||||
resolved_entries = [
|
||||
resolve_entry_attachments(config=config, campaign_file=campaign_file, entry=entry, entry_index=index)
|
||||
for index, entry in enumerate(entries, start=1)
|
||||
]
|
||||
return AttachmentResolutionReport(
|
||||
campaign_id=config.campaign.id,
|
||||
campaign_name=config.campaign.name,
|
||||
campaign_file=str(Path(campaign_file).resolve()),
|
||||
attachments_base_path=str(base_path),
|
||||
entries_count=len(entries),
|
||||
entries=resolved_entries,
|
||||
)
|
||||
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
|
||||
1
server/app/mailer/commands/__init__.py
Normal file
1
server/app/mailer/commands/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""CLI commands for MultiMailer development workflows."""
|
||||
69
server/app/mailer/commands/append_pending_sent.py
Normal file
69
server/app/mailer/commands/append_pending_sent.py
Normal file
@@ -0,0 +1,69 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from time import sleep
|
||||
|
||||
from app.db.bootstrap import create_all_tables
|
||||
from app.db.models import CampaignJob, JobImapStatus, JobSendStatus
|
||||
from app.db.session import SessionLocal
|
||||
from app.mailer.sending.jobs import append_sent_for_job
|
||||
from app.security.api_keys import authenticate_api_key
|
||||
from app.settings import settings
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Append sent campaign messages to the configured IMAP Sent folder.")
|
||||
parser.add_argument("--campaign-id", required=True, help="Database campaign UUID")
|
||||
parser.add_argument("--api-key", default=settings.dev_bootstrap_api_key)
|
||||
parser.add_argument("--limit", type=int, default=0, help="Maximum jobs to process; 0 means all pending/failed IMAP appends")
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
parser.add_argument("--include-failed", action="store_true", help="Also retry jobs with imap_status=failed")
|
||||
parser.add_argument("--json", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
create_all_tables()
|
||||
results = []
|
||||
with SessionLocal() as session:
|
||||
api_key = authenticate_api_key(session, args.api_key)
|
||||
if not api_key:
|
||||
raise SystemExit("Invalid API key")
|
||||
|
||||
statuses = [JobImapStatus.PENDING.value]
|
||||
if args.include_failed:
|
||||
statuses.append(JobImapStatus.FAILED.value)
|
||||
|
||||
query = (
|
||||
session.query(CampaignJob)
|
||||
.filter(
|
||||
CampaignJob.tenant_id == api_key.tenant_id,
|
||||
CampaignJob.campaign_id == args.campaign_id,
|
||||
CampaignJob.send_status == JobSendStatus.SENT.value,
|
||||
CampaignJob.imap_status.in_(statuses),
|
||||
)
|
||||
.order_by(CampaignJob.entry_index.asc())
|
||||
)
|
||||
if args.limit > 0:
|
||||
query = query.limit(args.limit)
|
||||
|
||||
jobs = query.all()
|
||||
for job in jobs:
|
||||
try:
|
||||
result = append_sent_for_job(session, job_id=job.id, dry_run=args.dry_run)
|
||||
results.append(result.as_dict())
|
||||
if not args.json:
|
||||
print(f"{job.entry_index}: {result.status} ({job.recipient_email}) folder={result.folder or '-'}")
|
||||
except Exception as exc:
|
||||
results.append({"job_id": job.id, "status": "error", "error": str(exc)})
|
||||
if not args.json:
|
||||
print(f"{job.entry_index}: ERROR {exc} ({job.recipient_email})")
|
||||
sleep(0.1)
|
||||
|
||||
if args.json:
|
||||
print(json.dumps({"processed": len(results), "results": results}, indent=2))
|
||||
elif not jobs:
|
||||
print("No pending IMAP append jobs found")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
65
server/app/mailer/commands/audit_log.py
Normal file
65
server/app/mailer/commands/audit_log.py
Normal file
@@ -0,0 +1,65 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
|
||||
from app.db.bootstrap import create_all_tables
|
||||
from app.db.models import AuditLog
|
||||
from app.db.session import SessionLocal
|
||||
from app.security.api_keys import authenticate_api_key
|
||||
from app.settings import settings
|
||||
|
||||
|
||||
def _row(item: AuditLog) -> dict:
|
||||
return {
|
||||
"id": item.id,
|
||||
"created_at": item.created_at.isoformat() if item.created_at else None,
|
||||
"tenant_id": item.tenant_id,
|
||||
"user_id": item.user_id,
|
||||
"api_key_id": item.api_key_id,
|
||||
"action": item.action,
|
||||
"object_type": item.object_type,
|
||||
"object_id": item.object_id,
|
||||
"details": item.details,
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="List audit log entries.")
|
||||
parser.add_argument("--api-key", default=settings.dev_bootstrap_api_key)
|
||||
parser.add_argument("--limit", type=int, default=50)
|
||||
parser.add_argument("--offset", type=int, default=0)
|
||||
parser.add_argument("--action")
|
||||
parser.add_argument("--object-type")
|
||||
parser.add_argument("--object-id")
|
||||
parser.add_argument("--json", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
create_all_tables()
|
||||
with SessionLocal() as session:
|
||||
api_key = authenticate_api_key(session, args.api_key)
|
||||
if not api_key:
|
||||
raise SystemExit("Invalid API key")
|
||||
query = session.query(AuditLog).filter(AuditLog.tenant_id == api_key.tenant_id)
|
||||
if args.action:
|
||||
query = query.filter(AuditLog.action == args.action)
|
||||
if args.object_type:
|
||||
query = query.filter(AuditLog.object_type == args.object_type)
|
||||
if args.object_id:
|
||||
query = query.filter(AuditLog.object_id == args.object_id)
|
||||
items = query.order_by(AuditLog.created_at.desc()).offset(args.offset).limit(args.limit).all()
|
||||
rows = [_row(item) for item in items]
|
||||
|
||||
if args.json:
|
||||
print(json.dumps({"items": rows}, indent=2, ensure_ascii=False, default=str))
|
||||
return
|
||||
|
||||
for row in rows:
|
||||
print(
|
||||
f"{row['created_at']} | {row['action']} | "
|
||||
f"{row['object_type'] or '-'}:{row['object_id'] or '-'} | {row['details'] or {}}"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
99
server/app/mailer/commands/build_messages.py
Normal file
99
server/app/mailer/commands/build_messages.py
Normal file
@@ -0,0 +1,99 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from app.mailer.campaign.entries import EntryLoadError
|
||||
from app.mailer.campaign.loader import CampaignLoadError, load_campaign_config
|
||||
from app.mailer.messages.builder import build_campaign_messages
|
||||
from app.mailer.messages.models import CampaignBuildReport
|
||||
|
||||
|
||||
def _print_report(report: CampaignBuildReport, *, verbose: bool = False) -> None:
|
||||
print(f"Campaign: {report.campaign_name} ({report.campaign_id})")
|
||||
print(f"Campaign file: {report.campaign_file}")
|
||||
print(f"Entries: {report.entries_count}")
|
||||
print(
|
||||
"Build: "
|
||||
f"built={report.built_count}, "
|
||||
f"failed={report.build_failed_count}, "
|
||||
f"queueable={report.queueable_count}"
|
||||
)
|
||||
print(
|
||||
"Validation: "
|
||||
f"ready={report.ready_count}, "
|
||||
f"warning={report.warning_count}, "
|
||||
f"needs_review={report.needs_review_count}, "
|
||||
f"blocked={report.blocked_count}, "
|
||||
f"excluded={report.excluded_count}, "
|
||||
f"inactive={report.inactive_count}"
|
||||
)
|
||||
|
||||
for message in report.messages:
|
||||
print("---")
|
||||
label = message.entry_id or f"#{message.entry_index}"
|
||||
eml = f", eml={message.eml_path}" if message.eml_path else ""
|
||||
print(
|
||||
f"Entry {label}: "
|
||||
f"build={message.build_status.value}, "
|
||||
f"validation={message.validation_status.value}, "
|
||||
f"send={message.send_status.value}, "
|
||||
f"imap={message.imap_status.value}, "
|
||||
f"attachments={message.attachment_count}{eml}"
|
||||
)
|
||||
if message.subject:
|
||||
print(f" Subject: {message.subject}")
|
||||
if message.to:
|
||||
print(" To: " + ", ".join(item.email for item in message.to))
|
||||
for issue in message.issues:
|
||||
behavior = f", behavior={issue.behavior}" if issue.behavior else ""
|
||||
source = f", source={issue.source}" if issue.source else ""
|
||||
print(f" [{issue.severity}] {issue.code}{behavior}{source}: {issue.message}")
|
||||
if verbose:
|
||||
for attachment in message.attachments:
|
||||
print(
|
||||
f" - attachment {attachment.attachment_id or ''}: "
|
||||
f"{attachment.status}, matches={len(attachment.matches)}, "
|
||||
f"zip={attachment.zip_enabled}, filter={attachment.directory}/{attachment.file_filter}"
|
||||
)
|
||||
for match in attachment.matches:
|
||||
print(f" {match}")
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="Build campaign message drafts and review statuses without sending.")
|
||||
parser.add_argument("--campaign", required=True, help="Path to campaign.json")
|
||||
parser.add_argument("--output-dir", default=None, help="Optional directory for generated .eml files")
|
||||
parser.add_argument("--write-eml", action="store_true", help="Write generated messages as .eml files")
|
||||
parser.add_argument("--json", action="store_true", help="Output machine-readable JSON report")
|
||||
parser.add_argument("--verbose", "-v", action="store_true", help="Print attachment details")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
campaign_path = Path(args.campaign).resolve()
|
||||
output_dir = Path(args.output_dir).resolve() if args.output_dir else None
|
||||
write_eml = args.write_eml or output_dir is not None
|
||||
|
||||
try:
|
||||
config = load_campaign_config(campaign_path)
|
||||
result = build_campaign_messages(
|
||||
config,
|
||||
campaign_file=campaign_path,
|
||||
output_dir=output_dir,
|
||||
write_eml=write_eml,
|
||||
)
|
||||
except (CampaignLoadError, EntryLoadError, ValueError, OSError) as exc:
|
||||
print(f"Error: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(result.report.model_dump(mode="json", by_alias=True), ensure_ascii=False, indent=2))
|
||||
else:
|
||||
_print_report(result.report, verbose=args.verbose)
|
||||
|
||||
return 0 if result.report.build_failed_count == 0 else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
78
server/app/mailer/commands/campaign_report.py
Normal file
78
server/app/mailer/commands/campaign_report.py
Normal file
@@ -0,0 +1,78 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from app.db.bootstrap import create_all_tables
|
||||
from app.db.session import SessionLocal
|
||||
from app.mailer.reports.campaigns import CampaignReportError, generate_campaign_report, generate_jobs_csv
|
||||
from app.security.api_keys import authenticate_api_key
|
||||
from app.settings import settings
|
||||
|
||||
|
||||
def _print_text_report(report: dict) -> None:
|
||||
campaign = report["campaign"]
|
||||
cards = report["cards"]
|
||||
delivery = report["delivery"]
|
||||
print(f"Campaign: {campaign['name']} ({campaign['id']})")
|
||||
print(f"Status: {campaign['status']}")
|
||||
print(f"Jobs: {cards['jobs_total']} total | {cards['queueable']} queueable | {cards['needs_attention']} need attention")
|
||||
print(f"Sending: {cards['sent']} sent | {cards['failed']} failed")
|
||||
print(f"IMAP: {cards['imap_appended']} appended | {cards['imap_failed']} failed")
|
||||
if delivery.get("rate_limit", {}).get("messages_per_minute"):
|
||||
print(
|
||||
"Rate: "
|
||||
f"{delivery['rate_limit']['messages_per_minute']}/min, concurrency {delivery['rate_limit']['concurrency']}"
|
||||
)
|
||||
if delivery.get("estimated_remaining_send_human"):
|
||||
print(f"ETA: {delivery['estimated_remaining_send_human']}")
|
||||
print("Validation counts:", report["status_counts"]["validation"])
|
||||
print("Send counts: ", report["status_counts"]["send"])
|
||||
print("Issue codes: ", report["issues"]["by_code"])
|
||||
print("Attachments: ", report["attachments"])
|
||||
failures = report.get("recent_failures") or []
|
||||
if failures:
|
||||
print("Recent failures:")
|
||||
for failure in failures[:10]:
|
||||
print(
|
||||
f" - entry={failure['entry_index']} recipient={failure['recipient_email']} "
|
||||
f"send={failure['send_status']} imap={failure['imap_status']} error={failure['last_error']}"
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Generate a campaign status/report payload.")
|
||||
parser.add_argument("--campaign-id", required=True, help="Database campaign UUID")
|
||||
parser.add_argument("--api-key", default=settings.dev_bootstrap_api_key)
|
||||
parser.add_argument("--json", action="store_true", help="Print machine-readable JSON")
|
||||
parser.add_argument("--include-jobs", action="store_true", help="Include per-job rows in JSON output")
|
||||
parser.add_argument("--jobs-csv", help="Write per-job report CSV to this path")
|
||||
args = parser.parse_args()
|
||||
|
||||
create_all_tables()
|
||||
with SessionLocal() as session:
|
||||
api_key = authenticate_api_key(session, args.api_key)
|
||||
if not api_key:
|
||||
raise SystemExit("Invalid API key")
|
||||
try:
|
||||
report = generate_campaign_report(
|
||||
session,
|
||||
tenant_id=api_key.tenant_id,
|
||||
campaign_id=args.campaign_id,
|
||||
include_jobs=args.include_jobs,
|
||||
)
|
||||
if args.jobs_csv:
|
||||
csv_text = generate_jobs_csv(session, tenant_id=api_key.tenant_id, campaign_id=args.campaign_id)
|
||||
Path(args.jobs_csv).write_text(csv_text, encoding="utf-8")
|
||||
print(f"Wrote {args.jobs_csv}")
|
||||
if args.json:
|
||||
print(json.dumps(report, indent=2, ensure_ascii=False, default=str))
|
||||
else:
|
||||
_print_text_report(report)
|
||||
except CampaignReportError as exc:
|
||||
raise SystemExit(str(exc)) from exc
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
74
server/app/mailer/commands/email_campaign_report.py
Normal file
74
server/app/mailer/commands/email_campaign_report.py
Normal file
@@ -0,0 +1,74 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
|
||||
from app.audit.logging import audit_event
|
||||
from app.db.bootstrap import create_all_tables
|
||||
from app.db.session import SessionLocal
|
||||
from app.mailer.reports.emailing import CampaignReportEmailError, send_campaign_report_email
|
||||
from app.mailer.reports.campaigns import CampaignReportError
|
||||
from app.security.api_keys import authenticate_api_key
|
||||
from app.settings import settings
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Email a campaign report to one or more recipients.")
|
||||
parser.add_argument("--campaign-id", required=True, help="Database campaign UUID")
|
||||
parser.add_argument("--to", action="append", required=True, help="Report recipient. Repeat for multiple recipients.")
|
||||
parser.add_argument("--api-key", default=settings.dev_bootstrap_api_key)
|
||||
parser.add_argument("--include-jobs", action="store_true", help="Include per-job rows in the JSON report payload before rendering")
|
||||
parser.add_argument("--no-jobs-csv", action="store_true", help="Do not attach the per-job CSV report")
|
||||
parser.add_argument("--attach-report-json", action="store_true", help="Attach the complete report JSON")
|
||||
parser.add_argument("--dry-run", action="store_true", help="Build and validate the report email without SMTP sending")
|
||||
parser.add_argument("--json", action="store_true", help="Print machine-readable JSON")
|
||||
args = parser.parse_args()
|
||||
|
||||
create_all_tables()
|
||||
with SessionLocal() as session:
|
||||
api_key = authenticate_api_key(session, args.api_key)
|
||||
if not api_key:
|
||||
raise SystemExit("Invalid API key")
|
||||
try:
|
||||
result = send_campaign_report_email(
|
||||
session,
|
||||
tenant_id=api_key.tenant_id,
|
||||
campaign_id=args.campaign_id,
|
||||
to=args.to,
|
||||
include_jobs=args.include_jobs,
|
||||
attach_jobs_csv=not args.no_jobs_csv,
|
||||
attach_report_json=args.attach_report_json,
|
||||
dry_run=args.dry_run,
|
||||
)
|
||||
audit_event(
|
||||
session,
|
||||
tenant_id=api_key.tenant_id,
|
||||
user_id=api_key.user_id,
|
||||
api_key_id=api_key.id,
|
||||
action="report.email_sent" if not args.dry_run else "report.email_dry_run",
|
||||
object_type="campaign",
|
||||
object_id=args.campaign_id,
|
||||
details=result.as_dict(),
|
||||
commit=True,
|
||||
)
|
||||
except (CampaignReportError, CampaignReportEmailError) as exc:
|
||||
raise SystemExit(str(exc)) from exc
|
||||
except Exception as exc:
|
||||
raise SystemExit(f"Could not email campaign report: {exc}") from exc
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(result.as_dict(), indent=2, ensure_ascii=False))
|
||||
else:
|
||||
print(f"Campaign: {result.campaign_id}")
|
||||
print(f"To: {', '.join(result.to)}")
|
||||
print(f"Subject: {result.subject}")
|
||||
print(f"Dry run: {result.dry_run}")
|
||||
print(f"Sent: {result.sent}")
|
||||
print(f"CSV: {result.attached_jobs_csv}")
|
||||
print(f"JSON: {result.attached_report_json}")
|
||||
if result.smtp_host:
|
||||
print(f"SMTP: {result.smtp_host}:{result.smtp_port}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
67
server/app/mailer/commands/import_campaign.py
Normal file
67
server/app/mailer/commands/import_campaign.py
Normal file
@@ -0,0 +1,67 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from app.audit.logging import audit_event
|
||||
from app.db.bootstrap import create_all_tables
|
||||
from app.db.session import SessionLocal
|
||||
from app.db.models import User
|
||||
from app.security.api_keys import authenticate_api_key
|
||||
from app.settings import settings
|
||||
from app.mailer.persistence.campaigns import create_campaign_version_from_json, validate_campaign_version, build_campaign_version
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Import a campaign JSON into the database and optionally validate/build it.")
|
||||
parser.add_argument("--campaign", required=True, help="Path to campaign.json")
|
||||
parser.add_argument("--api-key", default=settings.dev_bootstrap_api_key, help="API key used as the importing principal")
|
||||
parser.add_argument("--validate", action="store_true", help="Run semantic validation after import")
|
||||
parser.add_argument("--build", action="store_true", help="Build message jobs after import")
|
||||
parser.add_argument("--no-eml", action="store_true", help="Do not write generated .eml files during build")
|
||||
args = parser.parse_args()
|
||||
|
||||
create_all_tables()
|
||||
campaign_path = Path(args.campaign).resolve()
|
||||
raw_json = json.loads(campaign_path.read_text(encoding="utf-8"))
|
||||
|
||||
with SessionLocal() as session:
|
||||
api_key = authenticate_api_key(session, args.api_key)
|
||||
if not api_key:
|
||||
raise SystemExit("Invalid API key. Run init_db --with-dev-data first or pass --api-key.")
|
||||
user = session.get(User, api_key.user_id)
|
||||
campaign, version = create_campaign_version_from_json(
|
||||
session,
|
||||
tenant_id=api_key.tenant_id,
|
||||
user_id=user.id if user else None,
|
||||
raw_json=raw_json,
|
||||
source_filename=str(campaign_path),
|
||||
)
|
||||
audit_event(
|
||||
session,
|
||||
tenant_id=api_key.tenant_id,
|
||||
user_id=api_key.user_id,
|
||||
api_key_id=api_key.id,
|
||||
action="campaign.imported",
|
||||
object_type="campaign",
|
||||
object_id=campaign.id,
|
||||
details={"version_id": version.id, "source_filename": str(campaign_path)},
|
||||
commit=True,
|
||||
)
|
||||
print(f"Campaign: {campaign.name} ({campaign.id})")
|
||||
print(f"Version: {version.version_number} ({version.id})")
|
||||
|
||||
if args.validate:
|
||||
report = validate_campaign_version(session, tenant_id=api_key.tenant_id, version_id=version.id)
|
||||
audit_event(session, tenant_id=api_key.tenant_id, user_id=api_key.user_id, api_key_id=api_key.id, action="campaign.validated", object_type="campaign_version", object_id=version.id, details={"ok": report.get("ok")}, commit=True)
|
||||
print(f"Validation: ok={report['ok']}, errors={report['error_count']}, warnings={report['warning_count']}")
|
||||
|
||||
if args.build:
|
||||
report = build_campaign_version(session, tenant_id=api_key.tenant_id, version_id=version.id, write_eml=not args.no_eml)
|
||||
audit_event(session, tenant_id=api_key.tenant_id, user_id=api_key.user_id, api_key_id=api_key.id, action="campaign.messages_built", object_type="campaign_version", object_id=version.id, details={"built_count": report.get("built_count"), "write_eml": not args.no_eml}, commit=True)
|
||||
print(f"Build: built={report['built_count']}, queueable={report['queueable_count']}, needs_review={report['needs_review_count']}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
32
server/app/mailer/commands/init_db.py
Normal file
32
server/app/mailer/commands/init_db.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
|
||||
from app.db.bootstrap import bootstrap_dev_data, create_all_tables
|
||||
from app.db.session import SessionLocal
|
||||
from app.settings import settings
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Initialize the MultiMailer database")
|
||||
parser.add_argument("--with-dev-data", action="store_true", help="Create default tenant/user/roles and a development API key")
|
||||
parser.add_argument("--dev-api-key", default=settings.dev_bootstrap_api_key, help="Development API key secret to create")
|
||||
args = parser.parse_args()
|
||||
|
||||
create_all_tables()
|
||||
print("Database tables ensured.")
|
||||
|
||||
if args.with_dev_data:
|
||||
with SessionLocal() as session:
|
||||
result = bootstrap_dev_data(session, api_key_secret=args.dev_api_key)
|
||||
print(f"Tenant: {result.tenant.slug} ({result.tenant.id})")
|
||||
print(f"User: {result.user.email} ({result.user.id})")
|
||||
if result.created_api_key:
|
||||
print("Development API key created:")
|
||||
print(result.created_api_key.secret)
|
||||
else:
|
||||
print("Development API key already exists or was not requested.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
29
server/app/mailer/commands/list_db_campaigns.py
Normal file
29
server/app/mailer/commands/list_db_campaigns.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
|
||||
from app.db.bootstrap import create_all_tables
|
||||
from app.db.models import Campaign, CampaignJob
|
||||
from app.db.session import SessionLocal
|
||||
from app.security.api_keys import authenticate_api_key
|
||||
from app.settings import settings
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="List persisted campaigns and job counts.")
|
||||
parser.add_argument("--api-key", default=settings.dev_bootstrap_api_key)
|
||||
args = parser.parse_args()
|
||||
|
||||
create_all_tables()
|
||||
with SessionLocal() as session:
|
||||
api_key = authenticate_api_key(session, args.api_key)
|
||||
if not api_key:
|
||||
raise SystemExit("Invalid API key")
|
||||
campaigns = session.query(Campaign).filter(Campaign.tenant_id == api_key.tenant_id).order_by(Campaign.updated_at.desc()).all()
|
||||
for campaign in campaigns:
|
||||
jobs = session.query(CampaignJob).filter(CampaignJob.campaign_id == campaign.id).count()
|
||||
print(f"{campaign.id} | {campaign.status:15s} | jobs={jobs:4d} | {campaign.name}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
54
server/app/mailer/commands/queue_campaign.py
Normal file
54
server/app/mailer/commands/queue_campaign.py
Normal file
@@ -0,0 +1,54 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
|
||||
from app.db.bootstrap import create_all_tables
|
||||
from app.db.models import Campaign
|
||||
from app.db.session import SessionLocal
|
||||
from app.mailer.sending.jobs import queue_campaign_jobs
|
||||
from app.security.api_keys import authenticate_api_key
|
||||
from app.settings import settings
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Queue built campaign jobs for sending.")
|
||||
parser.add_argument("--campaign-id", required=True, help="Database campaign UUID, not external campaign id")
|
||||
parser.add_argument("--version-id", default=None, help="Optional campaign version UUID; defaults to current version")
|
||||
parser.add_argument("--api-key", default=settings.dev_bootstrap_api_key)
|
||||
parser.add_argument("--no-celery", action="store_true", help="Only mark jobs as queued; do not enqueue Celery tasks")
|
||||
parser.add_argument("--exclude-warnings", action="store_true", help="Queue only validation_status=ready, not warnings")
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
parser.add_argument("--json", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
create_all_tables()
|
||||
with SessionLocal() as session:
|
||||
api_key = authenticate_api_key(session, args.api_key)
|
||||
if not api_key:
|
||||
raise SystemExit("Invalid API key")
|
||||
result = queue_campaign_jobs(
|
||||
session,
|
||||
tenant_id=api_key.tenant_id,
|
||||
campaign_id=args.campaign_id,
|
||||
version_id=args.version_id,
|
||||
enqueue_celery=not args.no_celery,
|
||||
include_warnings=not args.exclude_warnings,
|
||||
dry_run=args.dry_run,
|
||||
)
|
||||
if args.json:
|
||||
print(json.dumps(result.as_dict(), indent=2))
|
||||
return
|
||||
campaign = session.get(Campaign, args.campaign_id)
|
||||
print(f"Campaign: {campaign.name if campaign else args.campaign_id}")
|
||||
print(f"Version: {result.version_id}")
|
||||
print(f"Queued: {result.queued_count}")
|
||||
print(f"Skipped: {result.skipped_count}")
|
||||
print(f"Blocked: {result.blocked_count}")
|
||||
print(f"Enqueued Celery tasks: {result.enqueued_count}")
|
||||
if result.dry_run:
|
||||
print("Dry run: no database changes were committed")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
69
server/app/mailer/commands/resolve_attachments.py
Normal file
69
server/app/mailer/commands/resolve_attachments.py
Normal file
@@ -0,0 +1,69 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from app.mailer.attachments.resolver import AttachmentResolutionReport, resolve_campaign_attachments
|
||||
from app.mailer.campaign.loader import CampaignLoadError, load_campaign_config
|
||||
from app.mailer.campaign.entries import EntryLoadError
|
||||
|
||||
|
||||
def _print_report(report: AttachmentResolutionReport, *, verbose: bool = False) -> None:
|
||||
print(f"Campaign: {report.campaign_name} ({report.campaign_id})")
|
||||
print(f"Campaign file: {report.campaign_file}")
|
||||
print(f"Attachments base path: {report.attachments_base_path}")
|
||||
print(f"Entries: {report.entries_count}")
|
||||
print(
|
||||
"Status: "
|
||||
f"ready={report.ready_count}, "
|
||||
f"warning={report.warning_count}, "
|
||||
f"needs_review={report.needs_review_count}, "
|
||||
f"blocked={report.blocked_count}, "
|
||||
f"excluded={report.excluded_count}, "
|
||||
f"inactive={report.inactive_count}"
|
||||
)
|
||||
|
||||
for entry in report.entries:
|
||||
print("---")
|
||||
label = entry.entry_id or f"#{entry.entry_index}"
|
||||
print(f"Entry {label}: {entry.status.value}, matches={entry.match_count}")
|
||||
for issue in entry.issues:
|
||||
behavior = f", behavior={issue.behavior.value}" if issue.behavior else ""
|
||||
print(f" [{issue.severity.value}] {issue.code}{behavior}: {issue.message}")
|
||||
if verbose:
|
||||
for attachment in entry.attachments:
|
||||
print(
|
||||
f" - {attachment.scope.value}[{attachment.index}] "
|
||||
f"{attachment.attachment_id or ''} "
|
||||
f"{attachment.status.value}: {attachment.directory}/{attachment.file_filter}"
|
||||
)
|
||||
for match in attachment.matches:
|
||||
print(f" {match}")
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="Resolve campaign attachment patterns and report missing/ambiguous matches.")
|
||||
parser.add_argument("--campaign", required=True, help="Path to campaign.json")
|
||||
parser.add_argument("--json", action="store_true", help="Output machine-readable JSON")
|
||||
parser.add_argument("--verbose", "-v", action="store_true", help="Print resolved configs and matched files")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
campaign_path = Path(args.campaign)
|
||||
try:
|
||||
config = load_campaign_config(campaign_path)
|
||||
report = resolve_campaign_attachments(config, campaign_file=campaign_path)
|
||||
except (CampaignLoadError, EntryLoadError, ValueError) as exc:
|
||||
print(f"Error: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(report.model_dump(mode="json"), ensure_ascii=False, indent=2))
|
||||
else:
|
||||
_print_report(report, verbose=args.verbose)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
71
server/app/mailer/commands/send_queued_jobs.py
Normal file
71
server/app/mailer/commands/send_queued_jobs.py
Normal file
@@ -0,0 +1,71 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from time import sleep
|
||||
|
||||
from app.db.bootstrap import create_all_tables
|
||||
from app.db.models import CampaignJob, JobQueueStatus, JobSendStatus
|
||||
from app.db.session import SessionLocal
|
||||
from app.mailer.sending.jobs import append_sent_for_job, send_campaign_job
|
||||
from app.security.api_keys import authenticate_api_key
|
||||
from app.settings import settings
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Process queued campaign jobs directly, without a Celery worker.")
|
||||
parser.add_argument("--campaign-id", required=True)
|
||||
parser.add_argument("--api-key", default=settings.dev_bootstrap_api_key)
|
||||
parser.add_argument("--limit", type=int, default=0, help="Maximum jobs to process; 0 means all queued jobs")
|
||||
parser.add_argument("--dry-run", action="store_true", help="Validate/send path without SMTP delivery or status mutation to SENT")
|
||||
parser.add_argument("--no-rate-limit", action="store_true")
|
||||
parser.add_argument("--append-sent", action="store_true", help="After successful SMTP delivery, immediately run IMAP append-to-Sent in this CLI process")
|
||||
parser.add_argument("--json", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
create_all_tables()
|
||||
results = []
|
||||
with SessionLocal() as session:
|
||||
api_key = authenticate_api_key(session, args.api_key)
|
||||
if not api_key:
|
||||
raise SystemExit("Invalid API key")
|
||||
query = (
|
||||
session.query(CampaignJob)
|
||||
.filter(
|
||||
CampaignJob.tenant_id == api_key.tenant_id,
|
||||
CampaignJob.campaign_id == args.campaign_id,
|
||||
CampaignJob.queue_status == JobQueueStatus.QUEUED.value,
|
||||
CampaignJob.send_status.in_([JobSendStatus.QUEUED.value, JobSendStatus.FAILED_TEMPORARY.value]),
|
||||
)
|
||||
.order_by(CampaignJob.entry_index.asc())
|
||||
)
|
||||
if args.limit > 0:
|
||||
query = query.limit(args.limit)
|
||||
jobs = query.all()
|
||||
for job in jobs:
|
||||
try:
|
||||
result = send_campaign_job(session, job_id=job.id, dry_run=args.dry_run, use_rate_limit=not args.no_rate_limit)
|
||||
result_dict = result.as_dict()
|
||||
if args.append_sent and result.status == "sent":
|
||||
append_result = append_sent_for_job(session, job_id=job.id, dry_run=args.dry_run)
|
||||
result_dict["imap_append"] = append_result.as_dict()
|
||||
results.append(result_dict)
|
||||
if not args.json:
|
||||
line = f"{job.entry_index}: {result.status} ({job.recipient_email})"
|
||||
if "imap_append" in result_dict:
|
||||
line += f"; IMAP: {result_dict['imap_append']['status']}"
|
||||
print(line)
|
||||
except Exception as exc:
|
||||
results.append({"job_id": job.id, "status": "error", "error": str(exc)})
|
||||
if not args.json:
|
||||
print(f"{job.entry_index}: ERROR {exc} ({job.recipient_email})")
|
||||
# Continue with the next job; individual attempts/statuses are recorded.
|
||||
sleep(0.1)
|
||||
if args.json:
|
||||
print(json.dumps({"processed": len(results), "results": results}, indent=2))
|
||||
elif not jobs:
|
||||
print("No queued jobs found")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
226
server/app/mailer/commands/send_test_message.py
Normal file
226
server/app/mailer/commands/send_test_message.py
Normal file
@@ -0,0 +1,226 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import getpass
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from app.mailer.campaign.entries import EntryLoadError
|
||||
from app.mailer.campaign.loader import CampaignLoadError, load_campaign_config
|
||||
from app.mailer.campaign.models import SmtpConfig, TransportSecurity
|
||||
from app.mailer.messages.builder import BuiltMessage, build_campaign_messages
|
||||
from app.mailer.messages.models import MessageDraft
|
||||
from app.mailer.sending.smtp import (
|
||||
SmtpConfigurationError,
|
||||
SmtpSendError,
|
||||
prepare_test_message,
|
||||
send_email_message,
|
||||
)
|
||||
|
||||
|
||||
def _env(name: str) -> str | None:
|
||||
value = os.getenv(name)
|
||||
return value if value not in {None, ""} else None
|
||||
|
||||
|
||||
def _parse_security(value: str | None, fallback: TransportSecurity) -> TransportSecurity:
|
||||
if not value:
|
||||
return fallback
|
||||
try:
|
||||
return TransportSecurity(value.lower())
|
||||
except ValueError as exc:
|
||||
allowed = ", ".join(item.value for item in TransportSecurity)
|
||||
raise ValueError(f"invalid SMTP security '{value}', expected one of: {allowed}") from exc
|
||||
|
||||
|
||||
def _parse_port(value: str | None, fallback: int | None) -> int | None:
|
||||
if not value:
|
||||
return fallback
|
||||
try:
|
||||
return int(value)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"invalid SMTP port '{value}'") from exc
|
||||
|
||||
|
||||
def _smtp_config_with_overrides(args: argparse.Namespace, base: SmtpConfig | None) -> SmtpConfig:
|
||||
config = base or SmtpConfig()
|
||||
|
||||
password = args.smtp_password
|
||||
if args.smtp_password_env:
|
||||
password = _env(args.smtp_password_env)
|
||||
if password is None:
|
||||
raise ValueError(f"environment variable {args.smtp_password_env} is empty or not set")
|
||||
if args.ask_password:
|
||||
password = getpass.getpass("SMTP password: ")
|
||||
|
||||
return SmtpConfig(
|
||||
host=args.smtp_host or _env("MULTIMAILER_SMTP_HOST") or config.host,
|
||||
port=_parse_port(args.smtp_port or _env("MULTIMAILER_SMTP_PORT"), config.port),
|
||||
username=args.smtp_username or _env("MULTIMAILER_SMTP_USERNAME") or config.username,
|
||||
password=password or _env("MULTIMAILER_SMTP_PASSWORD") or config.password,
|
||||
security=_parse_security(args.smtp_security or _env("MULTIMAILER_SMTP_SECURITY"), config.security),
|
||||
timeout_seconds=args.smtp_timeout or config.timeout_seconds,
|
||||
)
|
||||
|
||||
|
||||
def _select_message(
|
||||
messages: list[BuiltMessage],
|
||||
*,
|
||||
entry_id: str | None,
|
||||
entry_index: int | None,
|
||||
allow_non_queueable: bool,
|
||||
) -> BuiltMessage:
|
||||
candidates = messages
|
||||
if entry_id is not None:
|
||||
candidates = [item for item in candidates if item.draft.entry_id == entry_id]
|
||||
if not candidates:
|
||||
raise ValueError(f"no generated message found for entry id '{entry_id}'")
|
||||
if entry_index is not None:
|
||||
candidates = [item for item in candidates if item.draft.entry_index == entry_index]
|
||||
if not candidates:
|
||||
raise ValueError(f"no generated message found for entry index {entry_index}")
|
||||
|
||||
if not allow_non_queueable:
|
||||
queueable = [item for item in candidates if item.draft.is_queueable and item.mime is not None]
|
||||
if queueable:
|
||||
return queueable[0]
|
||||
raise ValueError(
|
||||
"no queueable built message found. Fix validation issues or pass --allow-non-queueable for a deliberate test send."
|
||||
)
|
||||
|
||||
built = [item for item in candidates if item.mime is not None]
|
||||
if not built:
|
||||
raise ValueError("no built MIME message found")
|
||||
return built[0]
|
||||
|
||||
|
||||
def _envelope_from(draft: MessageDraft) -> str:
|
||||
if draft.bounce_to:
|
||||
return draft.bounce_to[0].email
|
||||
if draft.from_:
|
||||
return draft.from_.email
|
||||
raise SmtpConfigurationError("message has no sender; cannot determine SMTP envelope sender")
|
||||
|
||||
|
||||
def _write_test_eml(path: Path, message) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(bytes(message))
|
||||
|
||||
|
||||
def _print_summary(*, draft: MessageDraft, test_to: str, smtp_config: SmtpConfig, envelope_from: str) -> None:
|
||||
print(f"Entry: {draft.entry_id or '#' + str(draft.entry_index)}")
|
||||
print(f"Subject: {draft.subject or ''}")
|
||||
print(f"Original validation: {draft.validation_status.value}")
|
||||
print(f"Original send status: {draft.send_status.value}")
|
||||
print(f"Test recipient: {test_to}")
|
||||
print(f"Envelope sender: {envelope_from}")
|
||||
print(f"SMTP: {smtp_config.host}:{smtp_config.port} ({smtp_config.security.value})")
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Send one generated campaign message to a test recipient. Does not mutate campaign/job status."
|
||||
)
|
||||
parser.add_argument("--campaign", required=True, help="Path to campaign.json")
|
||||
parser.add_argument("--to", required=True, help="Test recipient email address. Real campaign recipients are never used.")
|
||||
parser.add_argument("--to-name", default=None, help="Optional display name for the test recipient")
|
||||
parser.add_argument("--entry-id", default=None, help="Select a specific entry by id")
|
||||
parser.add_argument("--entry-index", type=int, default=None, help="Select a specific entry by 1-based index")
|
||||
parser.add_argument(
|
||||
"--allow-non-queueable",
|
||||
action="store_true",
|
||||
help="Allow test-send of a built message whose validation status is needs_review/warning/blocked/excluded",
|
||||
)
|
||||
parser.add_argument("--write-eml", default=None, help="Write the prepared test .eml to this path")
|
||||
parser.add_argument("--dry-run", action="store_true", help="Build and prepare the test message but do not connect to SMTP")
|
||||
parser.add_argument("--json", action="store_true", help="Output machine-readable JSON")
|
||||
|
||||
parser.add_argument("--smtp-host", default=None, help="Override SMTP host")
|
||||
parser.add_argument("--smtp-port", default=None, help="Override SMTP port")
|
||||
parser.add_argument("--smtp-security", default=None, choices=[item.value for item in TransportSecurity], help="Override SMTP security")
|
||||
parser.add_argument("--smtp-username", default=None, help="Override SMTP username")
|
||||
parser.add_argument("--smtp-password", default=None, help="Override SMTP password (prefer --smtp-password-env or --ask-password)")
|
||||
parser.add_argument("--smtp-password-env", default=None, help="Read SMTP password from this environment variable")
|
||||
parser.add_argument("--ask-password", action="store_true", help="Prompt for SMTP password")
|
||||
parser.add_argument("--smtp-timeout", type=int, default=None, help="Override SMTP timeout in seconds")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
campaign_path = Path(args.campaign).resolve()
|
||||
|
||||
try:
|
||||
config = load_campaign_config(campaign_path)
|
||||
smtp_config = _smtp_config_with_overrides(args, config.server.smtp)
|
||||
result = build_campaign_messages(config, campaign_file=campaign_path)
|
||||
selected = _select_message(
|
||||
result.built_messages,
|
||||
entry_id=args.entry_id,
|
||||
entry_index=args.entry_index,
|
||||
allow_non_queueable=args.allow_non_queueable,
|
||||
)
|
||||
assert selected.mime is not None
|
||||
|
||||
envelope_from = _envelope_from(selected.draft)
|
||||
test_message = prepare_test_message(selected.mime, test_recipient=args.to, test_recipient_name=args.to_name)
|
||||
|
||||
if args.write_eml:
|
||||
_write_test_eml(Path(args.write_eml).resolve(), test_message)
|
||||
|
||||
if not args.json:
|
||||
_print_summary(draft=selected.draft, test_to=args.to, smtp_config=smtp_config, envelope_from=envelope_from)
|
||||
|
||||
send_result = None
|
||||
if not args.dry_run:
|
||||
send_result = send_email_message(
|
||||
test_message,
|
||||
smtp_config=smtp_config,
|
||||
envelope_from=envelope_from,
|
||||
envelope_recipients=[args.to],
|
||||
)
|
||||
|
||||
except (CampaignLoadError, EntryLoadError, ValueError, SmtpConfigurationError, SmtpSendError, OSError) as exc:
|
||||
if args.json:
|
||||
print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False, indent=2))
|
||||
else:
|
||||
print(f"Error: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
if args.json:
|
||||
payload = {
|
||||
"ok": True,
|
||||
"dry_run": args.dry_run,
|
||||
"campaign_id": config.campaign.id,
|
||||
"entry_id": selected.draft.entry_id,
|
||||
"entry_index": selected.draft.entry_index,
|
||||
"test_recipient": args.to,
|
||||
"validation_status": selected.draft.validation_status.value,
|
||||
"smtp": {
|
||||
"host": smtp_config.host,
|
||||
"port": smtp_config.port,
|
||||
"security": smtp_config.security.value,
|
||||
},
|
||||
"send_result": None
|
||||
if send_result is None
|
||||
else {
|
||||
"accepted_count": send_result.accepted_count,
|
||||
"refused_recipients": send_result.refused_recipients,
|
||||
},
|
||||
}
|
||||
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||
else:
|
||||
if args.dry_run:
|
||||
print("Dry run only; no SMTP connection attempted.")
|
||||
else:
|
||||
assert send_result is not None
|
||||
print(f"SMTP accepted recipients: {send_result.accepted_count}/{len(send_result.envelope_recipients)}")
|
||||
if send_result.refused_recipients:
|
||||
print(f"SMTP refused recipients: {send_result.refused_recipients}")
|
||||
else:
|
||||
print("Test message sent.")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
71
server/app/mailer/commands/validate_campaign.py
Normal file
71
server/app/mailer/commands/validate_campaign.py
Normal file
@@ -0,0 +1,71 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from app.mailer.campaign.loader import CampaignLoadError, CampaignSchemaError, load_campaign_config
|
||||
from app.mailer.campaign.validation import Severity, validate_campaign_config
|
||||
|
||||
|
||||
def _default_campaign_path() -> Path:
|
||||
return Path(__file__).resolve().parents[1] / "examples" / "campaign.json"
|
||||
|
||||
|
||||
def _print_text_report(report) -> None:
|
||||
print(f"Campaign: {report.campaign_name} ({report.campaign_id})")
|
||||
print(f"Entries: {report.entries_mode}" + (f", {report.entries_count} item(s)" if report.entries_count is not None else ""))
|
||||
print(f"Attachments base path: {report.attachments_base_path}")
|
||||
print(f"Rate limit: {report.rate_limit}")
|
||||
print(f"IMAP append: {'enabled' if report.imap_append_enabled else 'disabled'}")
|
||||
print(f"Issues: {report.error_count} error(s), {report.warning_count} warning(s)")
|
||||
if report.issues:
|
||||
print()
|
||||
for issue in report.issues:
|
||||
location = f" [{issue.path}]" if issue.path else ""
|
||||
print(f"- {issue.severity.upper()} {issue.code}{location}: {issue.message}")
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="Validate a MultiMailer campaign JSON file.")
|
||||
parser.add_argument("--campaign", default=str(_default_campaign_path()), help="Path to campaign JSON file")
|
||||
parser.add_argument("--schema", default=None, help="Optional path to campaign.schema.json")
|
||||
parser.add_argument("--no-schema", action="store_true", help="Skip JSON Schema validation")
|
||||
parser.add_argument("--check-files", action="store_true", help="Check referenced local files and CSV headers")
|
||||
parser.add_argument("--json", action="store_true", help="Print machine-readable validation report")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
campaign_path = Path(args.campaign).resolve()
|
||||
try:
|
||||
config = load_campaign_config(
|
||||
campaign_path,
|
||||
validate_schema=not args.no_schema,
|
||||
schema_path=args.schema,
|
||||
)
|
||||
report = validate_campaign_config(config, campaign_file=campaign_path, check_files=args.check_files)
|
||||
except CampaignSchemaError as exc:
|
||||
if args.json:
|
||||
print(json.dumps({"ok": False, "schema_errors": [error.__dict__ for error in exc.errors]}, indent=2), file=sys.stdout)
|
||||
else:
|
||||
print(str(exc), file=sys.stderr)
|
||||
for error in exc.errors:
|
||||
print(f"- {error.path}: {error.message}", file=sys.stderr)
|
||||
return 2
|
||||
except CampaignLoadError as exc:
|
||||
print(str(exc), file=sys.stderr)
|
||||
return 2
|
||||
except Exception as exc:
|
||||
print(f"campaign validation failed: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
if args.json:
|
||||
print(report.model_dump_json(indent=2))
|
||||
else:
|
||||
_print_text_report(report)
|
||||
|
||||
return 0 if report.ok else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
0
server/app/mailer/domain/__init__.py
Normal file
0
server/app/mailer/domain/__init__.py
Normal file
210
server/app/mailer/domain/campaign.py
Normal file
210
server/app/mailer/domain/campaign.py
Normal file
@@ -0,0 +1,210 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import StrEnum
|
||||
from pathlib import Path
|
||||
|
||||
from .fields import Field, FieldConfiguration, FieldContents
|
||||
from .recipients import Recipient, RecipientList, RecipientType
|
||||
from .template import MailTemplate
|
||||
|
||||
|
||||
class TransportSecurity(StrEnum):
|
||||
PLAIN = "plain"
|
||||
TLS = "tls"
|
||||
STARTTLS = "starttls"
|
||||
|
||||
@property
|
||||
def standard_port(self) -> int:
|
||||
return 465 if self == TransportSecurity.TLS else 587
|
||||
|
||||
|
||||
@dataclass
|
||||
class MailServerSettings:
|
||||
server: str = ""
|
||||
port: int | None = None
|
||||
username: str = ""
|
||||
password: str = ""
|
||||
transport_security: TransportSecurity = TransportSecurity.PLAIN
|
||||
|
||||
def use_plain(self) -> "MailServerSettings":
|
||||
self.transport_security = TransportSecurity.PLAIN
|
||||
self.port = self.port or self.transport_security.standard_port
|
||||
return self
|
||||
|
||||
def use_tls(self) -> "MailServerSettings":
|
||||
self.transport_security = TransportSecurity.TLS
|
||||
self.port = self.port or self.transport_security.standard_port
|
||||
return self
|
||||
|
||||
def use_starttls(self) -> "MailServerSettings":
|
||||
self.transport_security = TransportSecurity.STARTTLS
|
||||
self.port = self.port or self.transport_security.standard_port
|
||||
return self
|
||||
|
||||
def resolved_port(self) -> int:
|
||||
return self.port or self.transport_security.standard_port
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MailAttachmentConfig:
|
||||
base_dir: Path
|
||||
file_filter: str = "*"
|
||||
include_subdirs: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class MailEntry:
|
||||
field_config: FieldConfiguration
|
||||
is_active: bool = True
|
||||
from_recipient: Recipient | None = None
|
||||
to: RecipientList = field(default_factory=RecipientList)
|
||||
cc: RecipientList = field(default_factory=RecipientList)
|
||||
bcc: RecipientList = field(default_factory=RecipientList)
|
||||
combine_to: bool = True
|
||||
combine_cc: bool = True
|
||||
combine_bcc: bool = True
|
||||
attachment_configs: list[MailAttachmentConfig] = field(default_factory=list)
|
||||
combine_attachments: bool = True
|
||||
field_contents: FieldContents = field(init=False)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.field_contents = FieldContents(self.field_config)
|
||||
|
||||
def add_to(self, recipient: Recipient) -> "MailEntry":
|
||||
self.to.add_recipient(recipient)
|
||||
return self
|
||||
|
||||
def add_cc(self, recipient: Recipient) -> "MailEntry":
|
||||
self.cc.add_recipient(recipient)
|
||||
return self
|
||||
|
||||
def add_bcc(self, recipient: Recipient) -> "MailEntry":
|
||||
self.bcc.add_recipient(recipient)
|
||||
return self
|
||||
|
||||
def no_combine_to(self) -> "MailEntry":
|
||||
self.combine_to = False
|
||||
return self
|
||||
|
||||
def combine_to_recipients(self) -> "MailEntry":
|
||||
self.combine_to = True
|
||||
return self
|
||||
|
||||
def no_combine_attachments(self) -> "MailEntry":
|
||||
self.combine_attachments = False
|
||||
return self
|
||||
|
||||
def combine_attachments_with_global(self) -> "MailEntry":
|
||||
self.combine_attachments = True
|
||||
return self
|
||||
|
||||
def add_mail_attachment_config(self, config: MailAttachmentConfig) -> "MailEntry":
|
||||
self.attachment_configs.append(config)
|
||||
return self
|
||||
|
||||
def set_field_content_for_name(self, name: str, value: Field | object) -> "MailEntry":
|
||||
if not self.field_contents.set_field_content_for_name(name, value):
|
||||
raise KeyError(f"unknown field: {name}")
|
||||
return self
|
||||
|
||||
def get_field_content_from_name(self, name: str) -> Field:
|
||||
return self.field_contents.get_field_content_from_name(name)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MailCampaign:
|
||||
mail_server_settings: MailServerSettings | None = None
|
||||
global_from: Recipient | None = None
|
||||
global_to: RecipientList = field(default_factory=RecipientList)
|
||||
global_cc: RecipientList = field(default_factory=RecipientList)
|
||||
global_bcc: RecipientList = field(default_factory=RecipientList)
|
||||
individual_from: bool = False
|
||||
individual_to: bool = False
|
||||
individual_cc: bool = False
|
||||
individual_bcc: bool = False
|
||||
base_attachment_path: Path = Path(".")
|
||||
global_attachment_configs: list[MailAttachmentConfig] = field(default_factory=list)
|
||||
individual_attachments: bool = False
|
||||
send_without_attachments: bool = True
|
||||
field_config: FieldConfiguration = field(default_factory=FieldConfiguration)
|
||||
field_contents: FieldContents = field(init=False)
|
||||
subject_template: MailTemplate = field(default_factory=MailTemplate)
|
||||
mail_template: MailTemplate = field(default_factory=MailTemplate)
|
||||
mail_entries: list[MailEntry] = field(default_factory=list)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.field_contents = FieldContents(self.field_config)
|
||||
|
||||
@classmethod
|
||||
def with_server_settings(cls, settings: MailServerSettings) -> "MailCampaign":
|
||||
return cls(mail_server_settings=settings)
|
||||
|
||||
def add_field(self, name: str, field_type) -> "MailCampaign":
|
||||
from .fields import FieldDescription
|
||||
self.field_config.add_field_at_end(FieldDescription(name, field_type))
|
||||
self.field_contents.ensure_field(self.field_config.get_field_description(name)) # type: ignore[arg-type]
|
||||
for entry in self.mail_entries:
|
||||
entry.field_contents.ensure_field(self.field_config.get_field_description(name)) # type: ignore[arg-type]
|
||||
return self
|
||||
|
||||
def set_from(self, recipient: Recipient) -> "MailCampaign":
|
||||
self.global_from = recipient
|
||||
return self
|
||||
|
||||
def add_to(self, recipient: Recipient) -> "MailCampaign":
|
||||
self.global_to.add_recipient(recipient)
|
||||
return self
|
||||
|
||||
def allow_individual_to(self) -> "MailCampaign":
|
||||
self.individual_to = True
|
||||
return self
|
||||
|
||||
def disallow_individual_to(self) -> "MailCampaign":
|
||||
self.individual_to = False
|
||||
return self
|
||||
|
||||
def allow_individual_attachments(self) -> "MailCampaign":
|
||||
self.individual_attachments = True
|
||||
return self
|
||||
|
||||
def disallow_individual_attachments(self) -> "MailCampaign":
|
||||
self.individual_attachments = False
|
||||
return self
|
||||
|
||||
def dont_send_without_attachments(self) -> "MailCampaign":
|
||||
self.send_without_attachments = False
|
||||
return self
|
||||
|
||||
def send_without_attachments_allowed(self) -> "MailCampaign":
|
||||
self.send_without_attachments = True
|
||||
return self
|
||||
|
||||
def add_new_mail_entry(self) -> MailEntry:
|
||||
entry = MailEntry(self.field_config)
|
||||
self.mail_entries.append(entry)
|
||||
return entry
|
||||
|
||||
def set_field_content_for_name(self, name: str, value: Field | object) -> "MailCampaign":
|
||||
if not self.field_contents.set_field_content_for_name(name, value):
|
||||
raise KeyError(f"unknown field: {name}")
|
||||
return self
|
||||
|
||||
def get_field_content_from_name(self, name: str) -> Field:
|
||||
return self.field_contents.get_field_content_from_name(name)
|
||||
|
||||
def all_recipients_for(self, entry: MailEntry) -> list[Recipient]:
|
||||
recipients: list[Recipient] = []
|
||||
if not self.individual_to or entry.combine_to:
|
||||
recipients.extend(self.global_to.recipients)
|
||||
if not self.individual_cc or entry.combine_cc:
|
||||
recipients.extend(self.global_cc.recipients)
|
||||
if not self.individual_bcc or entry.combine_bcc:
|
||||
recipients.extend(self.global_bcc.recipients)
|
||||
if self.individual_to:
|
||||
recipients.extend(entry.to.recipients)
|
||||
if self.individual_cc:
|
||||
recipients.extend(entry.cc.recipients)
|
||||
if self.individual_bcc:
|
||||
recipients.extend(entry.bcc.recipients)
|
||||
return recipients
|
||||
125
server/app/mailer/domain/fields.py
Normal file
125
server/app/mailer/domain/fields.py
Normal file
@@ -0,0 +1,125 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date, datetime
|
||||
from enum import StrEnum
|
||||
from typing import Any
|
||||
|
||||
|
||||
class FieldType(StrEnum):
|
||||
STRING = "string"
|
||||
INTEGER = "integer"
|
||||
DOUBLE = "double"
|
||||
DATE = "date"
|
||||
PASSWORD = "password"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class FieldDescription:
|
||||
name: str
|
||||
type: FieldType = FieldType.STRING
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Field:
|
||||
content: Any
|
||||
|
||||
@classmethod
|
||||
def with_content(cls, content: Any) -> "Field":
|
||||
if content is None:
|
||||
raise ValueError("content must not be None")
|
||||
return cls(content=content)
|
||||
|
||||
@property
|
||||
def type(self) -> FieldType:
|
||||
if isinstance(self.content, bool):
|
||||
return FieldType.STRING
|
||||
if isinstance(self.content, int):
|
||||
return FieldType.INTEGER
|
||||
if isinstance(self.content, float):
|
||||
return FieldType.DOUBLE
|
||||
if isinstance(self.content, (date, datetime)):
|
||||
return FieldType.DATE
|
||||
if isinstance(self.content, (bytes, bytearray)):
|
||||
return FieldType.PASSWORD
|
||||
return FieldType.STRING
|
||||
|
||||
def as_string(self) -> str:
|
||||
if isinstance(self.content, (bytes, bytearray)):
|
||||
return self.content.decode("utf-8")
|
||||
if isinstance(self.content, (date, datetime)):
|
||||
return self.content.isoformat()
|
||||
return str(self.content)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FieldConfiguration:
|
||||
fields: list[FieldDescription] = field(default_factory=list)
|
||||
|
||||
def add_field_at_end(self, field_description: FieldDescription) -> "FieldConfiguration":
|
||||
return self.add_field_at_position(len(self.fields), field_description)
|
||||
|
||||
def add_field_at_start(self, field_description: FieldDescription) -> "FieldConfiguration":
|
||||
return self.add_field_at_position(0, field_description)
|
||||
|
||||
def add_field_at_position(self, position: int, field_description: FieldDescription) -> "FieldConfiguration":
|
||||
if self.has_field(field_description.name):
|
||||
raise ValueError(f"field already exists: {field_description.name}")
|
||||
position = max(0, min(position, len(self.fields)))
|
||||
self.fields.insert(position, field_description)
|
||||
return self
|
||||
|
||||
def has_field(self, name: str) -> bool:
|
||||
return any(f.name == name for f in self.fields)
|
||||
|
||||
def get_field_description(self, name: str) -> FieldDescription | None:
|
||||
return next((f for f in self.fields if f.name == name), None)
|
||||
|
||||
def get_field_names(self) -> list[str]:
|
||||
return [f.name for f in self.fields]
|
||||
|
||||
|
||||
@dataclass
|
||||
class FieldContents:
|
||||
field_config: FieldConfiguration
|
||||
field_map: dict[str, Field] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for field_description in self.field_config.fields:
|
||||
self.ensure_field(field_description)
|
||||
|
||||
def ensure_field(self, field_description: FieldDescription) -> None:
|
||||
if field_description.name in self.field_map:
|
||||
return
|
||||
match field_description.type:
|
||||
case FieldType.INTEGER:
|
||||
value = 0
|
||||
case FieldType.DOUBLE:
|
||||
value = 0.0
|
||||
case FieldType.DATE:
|
||||
value = date.today()
|
||||
case FieldType.PASSWORD:
|
||||
value = b""
|
||||
case _:
|
||||
value = ""
|
||||
self.field_map[field_description.name] = Field.with_content(value)
|
||||
|
||||
def get_field_content_from_name(self, name: str) -> Field:
|
||||
try:
|
||||
return self.field_map[name]
|
||||
except KeyError as exc:
|
||||
raise KeyError(f"unknown field: {name}") from exc
|
||||
|
||||
def set_field_content_for_name(self, name: str, value: Field | Any) -> bool:
|
||||
if name not in self.field_map:
|
||||
return False
|
||||
if not isinstance(value, Field):
|
||||
value = Field.with_content(value)
|
||||
expected = self.field_map[name].type
|
||||
if expected != value.type and expected != FieldType.PASSWORD:
|
||||
raise TypeError(f"field {name!r} expects {expected}, got {value.type}")
|
||||
self.field_map[name] = value
|
||||
return True
|
||||
|
||||
def as_value_map(self, prefix: str) -> dict[str, str]:
|
||||
return {f"{prefix}::{name}": field.as_string() for name, field in self.field_map.items()}
|
||||
29
server/app/mailer/domain/queue.py
Normal file
29
server/app/mailer/domain/queue.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from email.message import EmailMessage
|
||||
from typing import Iterator
|
||||
|
||||
|
||||
@dataclass
|
||||
class MailQueue:
|
||||
messages: list[EmailMessage] = field(default_factory=list)
|
||||
|
||||
def add_mail(self, message: EmailMessage) -> None:
|
||||
self.messages.append(message)
|
||||
|
||||
def remove_mail(self, message: EmailMessage) -> bool:
|
||||
if message in self.messages:
|
||||
self.messages.remove(message)
|
||||
return True
|
||||
return False
|
||||
|
||||
@property
|
||||
def mail_count(self) -> int:
|
||||
return len(self.messages)
|
||||
|
||||
def is_empty(self) -> bool:
|
||||
return not self.messages
|
||||
|
||||
def __iter__(self) -> Iterator[EmailMessage]:
|
||||
return iter(self.messages)
|
||||
43
server/app/mailer/domain/recipients.py
Normal file
43
server/app/mailer/domain/recipients.py
Normal file
@@ -0,0 +1,43 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from email.utils import formataddr
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class RecipientType(StrEnum):
|
||||
TO = "to"
|
||||
CC = "cc"
|
||||
BCC = "bcc"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Recipient:
|
||||
address: str
|
||||
name: str | None = None
|
||||
type: RecipientType = RecipientType.TO
|
||||
|
||||
def formatted(self) -> str:
|
||||
return formataddr((self.name or self.address, self.address))
|
||||
|
||||
|
||||
@dataclass
|
||||
class RecipientList:
|
||||
recipients: list[Recipient] = field(default_factory=list)
|
||||
|
||||
def add_recipient(self, recipient: Recipient) -> "RecipientList":
|
||||
if recipient not in self.recipients:
|
||||
self.recipients.append(recipient)
|
||||
return self
|
||||
|
||||
def add_multiple_recipients(self, recipients: list[Recipient] | tuple[Recipient, ...]) -> "RecipientList":
|
||||
for recipient in recipients:
|
||||
self.add_recipient(recipient)
|
||||
return self
|
||||
|
||||
def clear_all_recipients(self) -> "RecipientList":
|
||||
self.recipients.clear()
|
||||
return self
|
||||
|
||||
def by_type(self, recipient_type: RecipientType) -> list[Recipient]:
|
||||
return [r for r in self.recipients if r.type == recipient_type]
|
||||
28
server/app/mailer/domain/template.py
Normal file
28
server/app/mailer/domain/template.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
_FIELD_PATTERN = re.compile(r"(?<!\\)\$\{(.*?)(?<!\\)\}")
|
||||
|
||||
|
||||
@dataclass
|
||||
class MailTemplate:
|
||||
template_string: str = ""
|
||||
|
||||
def set_template_string(self, template: str) -> "MailTemplate":
|
||||
self.template_string = template
|
||||
return self
|
||||
|
||||
def get_used_fields(self) -> set[str]:
|
||||
return set(_FIELD_PATTERN.findall(self.template_string))
|
||||
|
||||
def apply_values(self, values: dict[str, str], *, keep_missing: bool = True) -> str:
|
||||
def replace(match: re.Match[str]) -> str:
|
||||
key = match.group(1)
|
||||
if key in values:
|
||||
return values[key]
|
||||
return match.group(0) if keep_missing else ""
|
||||
|
||||
rendered = _FIELD_PATTERN.sub(replace, self.template_string)
|
||||
return rendered.replace(r"\${", "${").replace(r"\}", "}")
|
||||
0
server/app/mailer/examples/__init__.py
Normal file
0
server/app/mailer/examples/__init__.py
Normal file
180
server/app/mailer/examples/campaign.json
Normal file
180
server/app/mailer/examples/campaign.json
Normal file
@@ -0,0 +1,180 @@
|
||||
{
|
||||
"version": "1.0",
|
||||
"campaign": {
|
||||
"id": "rechnungslegung-2026-05",
|
||||
"name": "Rechnungslegung 2026-05",
|
||||
"mode": "draft",
|
||||
"description": "Example campaign migrated from the Java object model."
|
||||
},
|
||||
"fields": [
|
||||
{
|
||||
"name": "monthyear",
|
||||
"type": "string",
|
||||
"label": "Month/year",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "number",
|
||||
"type": "string",
|
||||
"label": "Dienststelle",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "anrede",
|
||||
"type": "string",
|
||||
"label": "Salutation"
|
||||
},
|
||||
{
|
||||
"name": "zip_password",
|
||||
"type": "password",
|
||||
"label": "ZIP password",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"global_values": {
|
||||
"monthyear": "05 / 2026"
|
||||
},
|
||||
"server": {
|
||||
"smtp": {
|
||||
"host": "smtp.example.org",
|
||||
"port": 587,
|
||||
"username": "user@example.org",
|
||||
"password": "CHANGE_ME_OR_REFERENCE_SECRET",
|
||||
"security": "starttls"
|
||||
},
|
||||
"imap": {
|
||||
"enabled": true,
|
||||
"host": "imap.example.org",
|
||||
"port": 993,
|
||||
"username": "user@example.org",
|
||||
"password": "CHANGE_ME_OR_REFERENCE_SECRET",
|
||||
"security": "tls",
|
||||
"sent_folder": "auto"
|
||||
}
|
||||
},
|
||||
"recipients": {
|
||||
"from": {
|
||||
"name": "Rechnungslegung D5",
|
||||
"email": "d5-rechnungslegung@example.org",
|
||||
"type": "to"
|
||||
},
|
||||
"allow_individual_from": false,
|
||||
"to": [],
|
||||
"allow_individual_to": true,
|
||||
"cc": [],
|
||||
"allow_individual_cc": false,
|
||||
"bcc": [],
|
||||
"allow_individual_bcc": false,
|
||||
"reply_to": [
|
||||
{
|
||||
"name": "Rechnungslegung D5",
|
||||
"email": "d5-rechnungslegung@example.org",
|
||||
"type": "reply_to"
|
||||
}
|
||||
],
|
||||
"allow_individual_reply_to": false,
|
||||
"bounce_to": [],
|
||||
"allow_individual_bounce_to": false,
|
||||
"disposition_notification_to": [],
|
||||
"allow_individual_disposition_notification_to": false
|
||||
},
|
||||
"template": {
|
||||
"subject": "Rechnungslegungslisten für ${global::monthyear} und Dienststelle ${local::number}",
|
||||
"text": "${local::anrede},\n\nbeigefügt erhalten Sie die Rechnungslegungslisten für ${global::monthyear}.\n\nMit freundlichen Grüßen"
|
||||
},
|
||||
"attachments": {
|
||||
"base_path": "./data/attachments",
|
||||
"allow_individual": true,
|
||||
"send_without_attachments": false,
|
||||
"global": [],
|
||||
"missing_behavior": "ask",
|
||||
"ambiguous_behavior": "ask"
|
||||
},
|
||||
"entries": {
|
||||
"source": {
|
||||
"type": "csv",
|
||||
"path": "./data/recipients.csv",
|
||||
"delimiter": ";",
|
||||
"encoding": "utf-8",
|
||||
"has_header": true
|
||||
},
|
||||
"mapping": {
|
||||
"id": "ID",
|
||||
"active": "Aktiv",
|
||||
"to.0.email": "E-Mail",
|
||||
"to.0.name": "Name",
|
||||
"fields.number": "Dienststelle",
|
||||
"fields.anrede": "Anrede",
|
||||
"fields.zip_password": "ZIP-Passwort",
|
||||
"attachments.0.base_dir": "Unterordner",
|
||||
"attachments.0.file_filter": "Dateimuster"
|
||||
},
|
||||
"defaults": {
|
||||
"active": true,
|
||||
"to": [],
|
||||
"combine_to": false,
|
||||
"cc": [],
|
||||
"combine_cc": true,
|
||||
"bcc": [],
|
||||
"combine_bcc": true,
|
||||
"reply_to": [],
|
||||
"combine_reply_to": true,
|
||||
"bounce_to": [],
|
||||
"combine_bounce_to": true,
|
||||
"disposition_notification_to": [],
|
||||
"combine_disposition_notification_to": true,
|
||||
"attachments": [
|
||||
{
|
||||
"id": "individual-documents",
|
||||
"label": "Personalized PDF bundle",
|
||||
"base_dir": ".",
|
||||
"file_filter": "${local::number}_*.pdf",
|
||||
"include_subdirs": false,
|
||||
"required": true,
|
||||
"allow_multiple": true,
|
||||
"missing_behavior": "ask",
|
||||
"ambiguous_behavior": "continue",
|
||||
"zip": {
|
||||
"enabled": true,
|
||||
"filename_template": "Rechnungslegung_${local::number}.zip",
|
||||
"password_template": "${local::zip_password}",
|
||||
"method": "aes"
|
||||
}
|
||||
}
|
||||
],
|
||||
"combine_attachments": true,
|
||||
"fields": {}
|
||||
}
|
||||
},
|
||||
"validation_policy": {
|
||||
"missing_required_attachment": "ask",
|
||||
"missing_optional_attachment": "warn",
|
||||
"ambiguous_attachment_match": "ask",
|
||||
"missing_email": "block",
|
||||
"template_error": "block",
|
||||
"inactive_entry": "drop"
|
||||
},
|
||||
"delivery": {
|
||||
"rate_limit": {
|
||||
"messages_per_minute": 5,
|
||||
"concurrency": 1
|
||||
},
|
||||
"imap_append_sent": {
|
||||
"enabled": true,
|
||||
"folder": "auto"
|
||||
},
|
||||
"retry": {
|
||||
"max_attempts": 3,
|
||||
"backoff_seconds": [
|
||||
60,
|
||||
300,
|
||||
900
|
||||
]
|
||||
}
|
||||
},
|
||||
"status_tracking": {
|
||||
"enabled": true,
|
||||
"initial_build_status": "built",
|
||||
"initial_send_status": "draft"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
dummy example attachment for resolver smoke tests
|
||||
2
server/app/mailer/examples/data/recipients.csv
Normal file
2
server/app/mailer/examples/data/recipients.csv
Normal file
@@ -0,0 +1,2 @@
|
||||
ID;Aktiv;E-Mail;Name;Dienststelle;Anrede;ZIP-Passwort;Unterordner;Dateimuster
|
||||
entry-001;true;mail@example.com;Example Recipient;ab0000;Sehr geehrte Damen und Herren;secret-demo;xls;ab????-123456-*.XLSX
|
||||
|
73
server/app/mailer/examples/rechnungslegung_2026_05.py
Normal file
73
server/app/mailer/examples/rechnungslegung_2026_05.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""Python port of the provided Java MultiMailerSettings example.
|
||||
|
||||
This is intentionally safe: credentials and real recipients are placeholders.
|
||||
Run from server/ with:
|
||||
python -m app.mailer.examples.rechnungslegung_2026_05
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
from app.mailer.domain.campaign import MailAttachmentConfig, MailCampaign, MailServerSettings
|
||||
from app.mailer.domain.fields import FieldType
|
||||
from app.mailer.domain.recipients import Recipient
|
||||
from app.mailer.services.campaign_executor import build_mail_queue
|
||||
|
||||
|
||||
def build_campaign() -> MailCampaign:
|
||||
mail_settings = MailServerSettings(
|
||||
server="smtp.example.org",
|
||||
username="user@example.org",
|
||||
password="change-me",
|
||||
).use_starttls()
|
||||
|
||||
campaign = MailCampaign.with_server_settings(mail_settings)
|
||||
campaign.set_from(Recipient(address="d5-rechnungslegung@example.org", name="Rechnungslegung D5"))
|
||||
campaign.allow_individual_to()
|
||||
campaign.allow_individual_attachments()
|
||||
campaign.dont_send_without_attachments()
|
||||
campaign.base_attachment_path = Path("/mnt/FLASH/rele/202606")
|
||||
|
||||
campaign.add_field("monthyear", FieldType.STRING)
|
||||
campaign.add_field("number", FieldType.STRING)
|
||||
campaign.add_field("password", FieldType.PASSWORD)
|
||||
campaign.add_field("anrede", FieldType.STRING)
|
||||
|
||||
campaign.set_field_content_for_name("monthyear", "05 / 2026")
|
||||
campaign.subject_template.set_template_string(
|
||||
"Rechnungslegungslisten für ${global::monthyear} und Dienststelle ${local::number}"
|
||||
)
|
||||
campaign.mail_template.set_template_string(
|
||||
"${local::anrede},\r\n\r\n"
|
||||
"in der Anlage erhalten Sie die Rechnungslegungslisten für die Dienststelle "
|
||||
"${local::number} für den Abrechnungsmonat ${global::monthyear} im Excel-Format. "
|
||||
"Bitte verwenden Sie zum öffnen das dauerhafte Passwort, das Ihnen bereits in der Vergangenheit zugeschickt wurde.\r\n"
|
||||
"Die Rechnungslegungslisten liefern den Nachweis (inkl. Brutto-/Netto-Darstellung) "
|
||||
"der auf Ihren dezentral bewirtschafteten Fonds gebuchten Personalkosten. Sie dienen der "
|
||||
"Überwachung und Kontrolle und ggf. als Nachweis gegenüber Drittmittelgebern.\r\n"
|
||||
"Die Listen erhalten vertrauliche personenbezogene Daten, daher sind diese nur berechtigten "
|
||||
"Personen zugänglich zu machen und nur für einen unbedingt notwendigen Zeitraum aufzubewahren.\r\n"
|
||||
"Falls Sie Rechnungslegungslisten erhalten haben sollten, die nicht zu Ihrer Einrichtung gehören, "
|
||||
"bitten wir Sie um entsprechende Rückmeldung.\r\n\r\n"
|
||||
"Mit freundlichen Grüßen\r\n\r\n"
|
||||
"Rechnungslegungsteam Dezernat 5"
|
||||
)
|
||||
|
||||
campaign.add_new_mail_entry() \
|
||||
.add_to(Recipient(address="mail@example.com", name="mail@example.com")) \
|
||||
.no_combine_to() \
|
||||
.combine_attachments_with_global() \
|
||||
.add_mail_attachment_config(MailAttachmentConfig(Path("xls/"), "ab????-123456-*.XLSX", False)) \
|
||||
.set_field_content_for_name("number", "ab0000") \
|
||||
.set_field_content_for_name("password", b"..........") \
|
||||
.set_field_content_for_name("anrede", "Sehr geehrte Damen und Herren")
|
||||
|
||||
return campaign
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
mc = build_campaign()
|
||||
queue = build_mail_queue(mc, zip_attachments=False)
|
||||
print(f"Built {queue.mail_count} message(s).")
|
||||
for message in queue:
|
||||
print("---")
|
||||
print("To:", message.get("To"))
|
||||
print("Subject:", message.get("Subject"))
|
||||
12
server/app/mailer/messages/__init__.py
Normal file
12
server/app/mailer/messages/__init__.py
Normal file
@@ -0,0 +1,12 @@
|
||||
"""Message building and review helpers."""
|
||||
|
||||
from .builder import build_campaign_messages
|
||||
from .models import CampaignBuildReport, MessageDraft, MessageIssue, MessageValidationStatus
|
||||
|
||||
__all__ = [
|
||||
"build_campaign_messages",
|
||||
"CampaignBuildReport",
|
||||
"MessageDraft",
|
||||
"MessageIssue",
|
||||
"MessageValidationStatus",
|
||||
]
|
||||
547
server/app/mailer/messages/builder.py
Normal file
547
server/app/mailer/messages/builder.py
Normal file
@@ -0,0 +1,547 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import mimetypes
|
||||
import re
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from email.message import EmailMessage
|
||||
from email.utils import formataddr, make_msgid, formatdate
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
from app.mailer.attachments.resolver import (
|
||||
AttachmentMatchStatus,
|
||||
EntryAttachmentResolution,
|
||||
MessageAttachmentStatus,
|
||||
ResolvedAttachment,
|
||||
resolve_entry_attachments,
|
||||
)
|
||||
from app.mailer.campaign.entries import load_campaign_entries
|
||||
from app.mailer.campaign.models import (
|
||||
Behavior,
|
||||
BuildStatus,
|
||||
CampaignConfig,
|
||||
EntryConfig,
|
||||
MissingAddressBehavior,
|
||||
RecipientConfig,
|
||||
SendStatus,
|
||||
)
|
||||
from app.mailer.services.zip_service import create_encrypted_zip
|
||||
|
||||
from .models import (
|
||||
CampaignBuildReport,
|
||||
ImapStatus,
|
||||
MessageAddress,
|
||||
MessageAttachmentSummary,
|
||||
MessageDraft,
|
||||
MessageIssue,
|
||||
MessageValidationStatus,
|
||||
)
|
||||
|
||||
_FIELD_PATTERN = re.compile(r"(?<!\\)\$\{(.*?)(?<!\\)\}")
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class BuiltMessage:
|
||||
draft: MessageDraft
|
||||
mime: EmailMessage | None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class CampaignBuildResult:
|
||||
report: CampaignBuildReport
|
||||
built_messages: list[BuiltMessage]
|
||||
|
||||
|
||||
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 _read_text(campaign_file: str | Path, raw_path: str | None, encoding: str = "utf-8") -> str | None:
|
||||
if not raw_path:
|
||||
return None
|
||||
path = _resolve(campaign_file, raw_path)
|
||||
return path.read_text(encoding=encoding)
|
||||
|
||||
|
||||
def _render_template(template: str, values: dict[str, Any], *, keep_missing: bool = True) -> str:
|
||||
def replace(match: re.Match[str]) -> str:
|
||||
key = match.group(1)
|
||||
if key in values:
|
||||
value = values[key]
|
||||
return "" if value is None else str(value)
|
||||
return match.group(0) if keep_missing else ""
|
||||
|
||||
rendered = _FIELD_PATTERN.sub(replace, template)
|
||||
return rendered.replace(r"\${", "${").replace(r"\}", "}")
|
||||
|
||||
|
||||
def _find_unresolved_placeholders(text: str | None) -> set[str]:
|
||||
if not text:
|
||||
return set()
|
||||
return set(_FIELD_PATTERN.findall(text))
|
||||
|
||||
|
||||
def _recipient_values(entry: EntryConfig) -> dict[str, str]:
|
||||
values: dict[str, str] = {}
|
||||
for list_name in ["to", "cc", "bcc", "reply_to", "bounce_to", "disposition_notification_to"]:
|
||||
recipients = getattr(entry, list_name)
|
||||
for index, recipient in enumerate(recipients):
|
||||
prefix = f"{list_name}.{index}"
|
||||
values[f"local::{prefix}.email"] = recipient.email
|
||||
values[f"local::{prefix}.name"] = recipient.name or ""
|
||||
values[f"local::{prefix}.type"] = recipient.recipient_type.value
|
||||
if entry.from_:
|
||||
values["local::from.email"] = entry.from_.email
|
||||
values["local::from.name"] = entry.from_.name or ""
|
||||
values["local::from.type"] = entry.from_.recipient_type.value
|
||||
return values
|
||||
|
||||
|
||||
def _template_values(config: CampaignConfig, entry: EntryConfig) -> dict[str, Any]:
|
||||
values: dict[str, Any] = {}
|
||||
for key, value in config.global_values.items():
|
||||
values[f"global::{key}"] = value
|
||||
for key, value in entry.fields.items():
|
||||
values[f"local::{key}"] = value
|
||||
if entry.id:
|
||||
values["local::id"] = entry.id
|
||||
values["local::active"] = entry.active
|
||||
values.update(_recipient_values(entry))
|
||||
return values
|
||||
|
||||
|
||||
def _message_address(recipient: RecipientConfig | None) -> MessageAddress | None:
|
||||
if recipient is None:
|
||||
return None
|
||||
return MessageAddress(email=recipient.email, name=recipient.name)
|
||||
|
||||
|
||||
def _message_addresses(recipients: Iterable[RecipientConfig]) -> list[MessageAddress]:
|
||||
return [MessageAddress(email=recipient.email, name=recipient.name) for recipient in recipients]
|
||||
|
||||
|
||||
def _format_recipient(recipient: RecipientConfig) -> str:
|
||||
return formataddr((recipient.name or recipient.email, recipient.email))
|
||||
|
||||
|
||||
def _format_recipient_header(recipients: Iterable[RecipientConfig]) -> str:
|
||||
return ", ".join(_format_recipient(recipient) for recipient in recipients)
|
||||
|
||||
|
||||
def _effective_sender(config: CampaignConfig, entry: EntryConfig) -> RecipientConfig | None:
|
||||
if config.recipients.allow_individual_from and entry.from_:
|
||||
return entry.from_
|
||||
return config.recipients.from_
|
||||
|
||||
|
||||
def _combine_recipients(
|
||||
*,
|
||||
allow_individual: bool,
|
||||
combine: bool,
|
||||
global_recipients: list[RecipientConfig],
|
||||
entry_recipients: list[RecipientConfig],
|
||||
) -> list[RecipientConfig]:
|
||||
recipients: list[RecipientConfig] = []
|
||||
if not allow_individual or combine:
|
||||
recipients.extend(global_recipients)
|
||||
if allow_individual:
|
||||
recipients.extend(entry_recipients)
|
||||
# keep order while avoiding exact duplicate email/type pairs
|
||||
seen: set[tuple[str, str]] = set()
|
||||
unique: list[RecipientConfig] = []
|
||||
for recipient in recipients:
|
||||
key = (recipient.email.lower(), recipient.recipient_type.value)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
unique.append(recipient)
|
||||
return unique
|
||||
|
||||
|
||||
def _effective_recipients(config: CampaignConfig, entry: EntryConfig) -> dict[str, list[RecipientConfig]]:
|
||||
return {
|
||||
"to": _combine_recipients(
|
||||
allow_individual=config.recipients.allow_individual_to,
|
||||
combine=entry.combine_to,
|
||||
global_recipients=config.recipients.to,
|
||||
entry_recipients=entry.to,
|
||||
),
|
||||
"cc": _combine_recipients(
|
||||
allow_individual=config.recipients.allow_individual_cc,
|
||||
combine=entry.combine_cc,
|
||||
global_recipients=config.recipients.cc,
|
||||
entry_recipients=entry.cc,
|
||||
),
|
||||
"bcc": _combine_recipients(
|
||||
allow_individual=config.recipients.allow_individual_bcc,
|
||||
combine=entry.combine_bcc,
|
||||
global_recipients=config.recipients.bcc,
|
||||
entry_recipients=entry.bcc,
|
||||
),
|
||||
"reply_to": _combine_recipients(
|
||||
allow_individual=config.recipients.allow_individual_reply_to,
|
||||
combine=entry.combine_reply_to,
|
||||
global_recipients=config.recipients.reply_to,
|
||||
entry_recipients=entry.reply_to,
|
||||
),
|
||||
"bounce_to": _combine_recipients(
|
||||
allow_individual=config.recipients.allow_individual_bounce_to,
|
||||
combine=entry.combine_bounce_to,
|
||||
global_recipients=config.recipients.bounce_to,
|
||||
entry_recipients=entry.bounce_to,
|
||||
),
|
||||
"disposition_notification_to": _combine_recipients(
|
||||
allow_individual=config.recipients.allow_individual_disposition_notification_to,
|
||||
combine=entry.combine_disposition_notification_to,
|
||||
global_recipients=config.recipients.disposition_notification_to,
|
||||
entry_recipients=entry.disposition_notification_to,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _load_template_parts(config: CampaignConfig, campaign_file: str | Path) -> tuple[str, str | None, str | None]:
|
||||
template = config.template
|
||||
if template.source:
|
||||
subject = _read_text(campaign_file, template.source.subject_path, template.source.encoding)
|
||||
text = _read_text(campaign_file, template.source.text_path, template.source.encoding)
|
||||
html = _read_text(campaign_file, template.source.html_path, template.source.encoding)
|
||||
return subject or "", text, html
|
||||
return template.subject or "", template.text, template.html
|
||||
|
||||
|
||||
def _issue_from_behavior(*, code: str, message: str, behavior: str, source: str) -> MessageIssue:
|
||||
severity = "error" if behavior == "block" else "warning"
|
||||
return MessageIssue(severity=severity, code=code, message=message, behavior=behavior, source=source)
|
||||
|
||||
|
||||
def _apply_behavior(current: MessageValidationStatus, behavior: str) -> MessageValidationStatus:
|
||||
if behavior == Behavior.BLOCK.value:
|
||||
return MessageValidationStatus.BLOCKED
|
||||
if behavior == Behavior.DROP.value:
|
||||
return MessageValidationStatus.EXCLUDED
|
||||
if behavior == Behavior.ASK.value:
|
||||
if current not in {MessageValidationStatus.BLOCKED, MessageValidationStatus.EXCLUDED}:
|
||||
return MessageValidationStatus.NEEDS_REVIEW
|
||||
if behavior == Behavior.WARN.value:
|
||||
if current == MessageValidationStatus.READY:
|
||||
return MessageValidationStatus.WARNING
|
||||
# continue leaves status as-is
|
||||
return current
|
||||
|
||||
|
||||
def _validation_status_from_attachment_status(status: MessageAttachmentStatus) -> MessageValidationStatus:
|
||||
return MessageValidationStatus(status.value)
|
||||
|
||||
|
||||
def _attachment_summaries(resolution: EntryAttachmentResolution) -> list[MessageAttachmentSummary]:
|
||||
return [
|
||||
MessageAttachmentSummary(
|
||||
attachment_id=attachment.attachment_id,
|
||||
label=attachment.label,
|
||||
status=attachment.status.value,
|
||||
behavior=attachment.behavior.value if attachment.behavior else None,
|
||||
required=attachment.required,
|
||||
allow_multiple=attachment.allow_multiple,
|
||||
zip_enabled=attachment.zip_enabled,
|
||||
file_filter=attachment.file_filter,
|
||||
directory=attachment.directory,
|
||||
matches=attachment.matches,
|
||||
)
|
||||
for attachment in resolution.attachments
|
||||
]
|
||||
|
||||
|
||||
def _message_issues_from_attachment_resolution(resolution: EntryAttachmentResolution) -> list[MessageIssue]:
|
||||
return [
|
||||
MessageIssue(
|
||||
severity=issue.severity.value,
|
||||
code=issue.code,
|
||||
message=issue.message,
|
||||
behavior=issue.behavior.value if issue.behavior else None,
|
||||
source="attachments",
|
||||
)
|
||||
for issue in resolution.issues
|
||||
]
|
||||
|
||||
|
||||
def _safe_filename(value: str | None, fallback: str) -> str:
|
||||
raw = value or fallback
|
||||
safe = re.sub(r"[^A-Za-z0-9_.-]+", "_", raw).strip("._")
|
||||
return safe or fallback
|
||||
|
||||
|
||||
def _attachment_bytes(path: Path) -> tuple[bytes, str, str]:
|
||||
data = path.read_bytes()
|
||||
mime_type, _ = mimetypes.guess_type(str(path))
|
||||
if not mime_type:
|
||||
return data, "application", "octet-stream"
|
||||
maintype, subtype = mime_type.split("/", 1)
|
||||
return data, maintype, subtype
|
||||
|
||||
|
||||
def _render_zip_filename(
|
||||
*,
|
||||
attachment: ResolvedAttachment,
|
||||
values: dict[str, Any],
|
||||
entry: EntryConfig,
|
||||
default_index: int,
|
||||
) -> str:
|
||||
template = attachment.attachment_id or attachment.label or f"attachments-{default_index}"
|
||||
# The resolver summary does not carry the full ZipConfig, so the build step receives
|
||||
# filename/password through the resolved attachment's original config by re-resolving
|
||||
# via a private companion in _zip_config_for_attachment.
|
||||
rendered = _render_template(template, values, keep_missing=False)
|
||||
if not rendered.lower().endswith(".zip"):
|
||||
rendered += ".zip"
|
||||
return _safe_filename(rendered, f"entry-{entry.id or default_index}.zip")
|
||||
|
||||
|
||||
def _iter_attachment_configs_for_resolution(config: CampaignConfig, entry: EntryConfig):
|
||||
if entry.combine_attachments:
|
||||
for index, attachment_config in enumerate(config.attachments.global_):
|
||||
yield "global", index, attachment_config
|
||||
if config.attachments.allow_individual:
|
||||
for index, attachment_config in enumerate(entry.attachments):
|
||||
yield "entry", index, attachment_config
|
||||
|
||||
|
||||
def _zip_config_for_attachment(config: CampaignConfig, entry: EntryConfig, resolved: ResolvedAttachment):
|
||||
for scope, index, attachment_config in _iter_attachment_configs_for_resolution(config, entry):
|
||||
if scope == resolved.scope.value and index == resolved.index:
|
||||
return attachment_config.zip
|
||||
return None
|
||||
|
||||
|
||||
def _attach_files(
|
||||
*,
|
||||
message: EmailMessage,
|
||||
config: CampaignConfig,
|
||||
entry: EntryConfig,
|
||||
resolution: EntryAttachmentResolution,
|
||||
values: dict[str, Any],
|
||||
work_dir: Path,
|
||||
) -> int:
|
||||
attached_count = 0
|
||||
zip_dir = work_dir / "_zip"
|
||||
zip_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for index, attachment in enumerate(resolution.attachments, start=1):
|
||||
# Missing/ambiguous configs still keep the message draft. They simply do not add files.
|
||||
if attachment.status != AttachmentMatchStatus.OK:
|
||||
continue
|
||||
match_paths = [Path(match) for match in attachment.matches]
|
||||
if not match_paths:
|
||||
continue
|
||||
|
||||
zip_config = _zip_config_for_attachment(config, entry, attachment)
|
||||
if attachment.zip_enabled:
|
||||
filename_template = zip_config.filename_template if zip_config else None
|
||||
if filename_template:
|
||||
filename = _safe_filename(_render_template(filename_template, values, keep_missing=False), f"entry-{entry.entry_id if hasattr(entry, 'entry_id') else index}.zip")
|
||||
if not filename.lower().endswith(".zip"):
|
||||
filename += ".zip"
|
||||
else:
|
||||
filename = _render_zip_filename(attachment=attachment, values=values, entry=entry, default_index=index)
|
||||
password = _render_template(zip_config.password_template or "", values, keep_missing=False) if zip_config else ""
|
||||
zip_path = create_encrypted_zip(zip_dir / filename, match_paths, password)
|
||||
files_to_attach = [zip_path]
|
||||
else:
|
||||
files_to_attach = match_paths
|
||||
|
||||
for path in files_to_attach:
|
||||
data, maintype, subtype = _attachment_bytes(path)
|
||||
message.add_attachment(data, maintype=maintype, subtype=subtype, filename=path.name)
|
||||
attached_count += 1
|
||||
return attached_count
|
||||
|
||||
|
||||
def _imap_initial_status(config: CampaignConfig) -> ImapStatus:
|
||||
if config.delivery.imap_append_sent.enabled:
|
||||
return ImapStatus.PENDING
|
||||
return ImapStatus.NOT_REQUESTED
|
||||
|
||||
|
||||
def _write_eml(message: EmailMessage, output_dir: Path, entry: EntryConfig, entry_index: int) -> tuple[str, int]:
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
filename = _safe_filename(entry.id, f"entry-{entry_index:04d}") + ".eml"
|
||||
path = output_dir / filename
|
||||
path.write_bytes(bytes(message))
|
||||
return str(path), path.stat().st_size
|
||||
|
||||
|
||||
def build_entry_message(
|
||||
*,
|
||||
config: CampaignConfig,
|
||||
campaign_file: str | Path,
|
||||
entry: EntryConfig,
|
||||
entry_index: int,
|
||||
output_dir: Path | None = None,
|
||||
write_eml: bool = False,
|
||||
work_dir: Path | None = None,
|
||||
) -> BuiltMessage:
|
||||
resolution = resolve_entry_attachments(config=config, campaign_file=campaign_file, entry=entry, entry_index=entry_index)
|
||||
recipients = _effective_recipients(config, entry)
|
||||
sender = _effective_sender(config, entry)
|
||||
issues = _message_issues_from_attachment_resolution(resolution)
|
||||
validation_status = _validation_status_from_attachment_status(resolution.status)
|
||||
|
||||
if not entry.active:
|
||||
draft = MessageDraft(
|
||||
entry_index=entry_index,
|
||||
entry_id=entry.id,
|
||||
active=False,
|
||||
build_status=BuildStatus.BUILD_FAILED,
|
||||
validation_status=MessageValidationStatus.INACTIVE,
|
||||
send_status=SendStatus.DRAFT,
|
||||
imap_status=ImapStatus.SKIPPED,
|
||||
from_=_message_address(sender),
|
||||
to=_message_addresses(recipients["to"]),
|
||||
cc=_message_addresses(recipients["cc"]),
|
||||
bcc=_message_addresses(recipients["bcc"]),
|
||||
reply_to=_message_addresses(recipients["reply_to"]),
|
||||
bounce_to=_message_addresses(recipients["bounce_to"]),
|
||||
disposition_notification_to=_message_addresses(recipients["disposition_notification_to"]),
|
||||
attachments=_attachment_summaries(resolution),
|
||||
issues=[MessageIssue(severity="info", code="inactive_entry", message="Entry is inactive", behavior=config.validation_policy.inactive_entry.value, source="entry")],
|
||||
)
|
||||
return BuiltMessage(draft=draft, mime=None)
|
||||
|
||||
if not recipients["to"]:
|
||||
behavior = config.validation_policy.missing_email.value
|
||||
issues.append(_issue_from_behavior(code="missing_email", message="No effective To recipient is configured", behavior=behavior, source="recipients"))
|
||||
validation_status = MessageValidationStatus.BLOCKED if behavior == MissingAddressBehavior.BLOCK.value else MessageValidationStatus.EXCLUDED
|
||||
|
||||
subject_template, text_template, html_template = _load_template_parts(config, campaign_file)
|
||||
values = _template_values(config, entry)
|
||||
subject = _render_template(subject_template, values)
|
||||
text_body = _render_template(text_template or "", values) if text_template is not None else None
|
||||
html_body = _render_template(html_template or "", values) if html_template is not None else None
|
||||
|
||||
unresolved = sorted(
|
||||
_find_unresolved_placeholders(subject)
|
||||
| _find_unresolved_placeholders(text_body)
|
||||
| _find_unresolved_placeholders(html_body)
|
||||
)
|
||||
if unresolved:
|
||||
behavior = config.validation_policy.template_error.value
|
||||
issues.append(
|
||||
_issue_from_behavior(
|
||||
code="template_error",
|
||||
message="Unresolved template placeholder(s): " + ", ".join(unresolved),
|
||||
behavior=behavior,
|
||||
source="template",
|
||||
)
|
||||
)
|
||||
validation_status = MessageValidationStatus.BLOCKED if behavior == MissingAddressBehavior.BLOCK.value else MessageValidationStatus.EXCLUDED
|
||||
|
||||
message = EmailMessage()
|
||||
try:
|
||||
message["Date"] = formatdate(localtime=True)
|
||||
message["Message-ID"] = make_msgid()
|
||||
if sender:
|
||||
message["From"] = _format_recipient(sender)
|
||||
if recipients["to"]:
|
||||
message["To"] = _format_recipient_header(recipients["to"])
|
||||
if recipients["cc"]:
|
||||
message["Cc"] = _format_recipient_header(recipients["cc"])
|
||||
# Bcc deliberately remains envelope-only and is tracked in MessageDraft.
|
||||
if recipients["reply_to"]:
|
||||
message["Reply-To"] = _format_recipient_header(recipients["reply_to"])
|
||||
if recipients["disposition_notification_to"]:
|
||||
message["Disposition-Notification-To"] = _format_recipient_header(recipients["disposition_notification_to"])
|
||||
# bounce_to is tracked but not emitted as Return-Path. That should be the SMTP envelope sender.
|
||||
message["Subject"] = subject
|
||||
|
||||
if html_body is not None:
|
||||
message.set_content(text_body or "")
|
||||
message.add_alternative(html_body, subtype="html")
|
||||
else:
|
||||
message.set_content(text_body or "")
|
||||
|
||||
if work_dir is None:
|
||||
work_dir = output_dir or Path(tempfile.mkdtemp(prefix="multimailer-build-"))
|
||||
attachment_count = _attach_files(
|
||||
message=message,
|
||||
config=config,
|
||||
entry=entry,
|
||||
resolution=resolution,
|
||||
values=values,
|
||||
work_dir=work_dir,
|
||||
)
|
||||
build_status = BuildStatus.BUILT
|
||||
except Exception as exc:
|
||||
issues.append(MessageIssue(severity="error", code="build_failed", message=str(exc), behavior="block", source="builder"))
|
||||
validation_status = MessageValidationStatus.BLOCKED
|
||||
build_status = BuildStatus.BUILD_FAILED
|
||||
attachment_count = 0
|
||||
message = None # type: ignore[assignment]
|
||||
|
||||
eml_path: str | None = None
|
||||
eml_size: int | None = None
|
||||
if write_eml and output_dir is not None and message is not None:
|
||||
eml_path, eml_size = _write_eml(message, output_dir, entry, entry_index)
|
||||
|
||||
draft = MessageDraft(
|
||||
entry_index=entry_index,
|
||||
entry_id=entry.id,
|
||||
active=entry.active,
|
||||
build_status=build_status,
|
||||
validation_status=validation_status,
|
||||
send_status=SendStatus.DRAFT,
|
||||
imap_status=_imap_initial_status(config) if build_status == BuildStatus.BUILT else ImapStatus.SKIPPED,
|
||||
subject=subject,
|
||||
from_=_message_address(sender),
|
||||
to=_message_addresses(recipients["to"]),
|
||||
cc=_message_addresses(recipients["cc"]),
|
||||
bcc=_message_addresses(recipients["bcc"]),
|
||||
reply_to=_message_addresses(recipients["reply_to"]),
|
||||
bounce_to=_message_addresses(recipients["bounce_to"]),
|
||||
disposition_notification_to=_message_addresses(recipients["disposition_notification_to"]),
|
||||
attachment_count=attachment_count,
|
||||
attachments=_attachment_summaries(resolution),
|
||||
issues=issues,
|
||||
eml_path=eml_path,
|
||||
eml_size_bytes=eml_size,
|
||||
)
|
||||
return BuiltMessage(draft=draft, mime=message)
|
||||
|
||||
|
||||
def build_campaign_messages(
|
||||
config: CampaignConfig,
|
||||
*,
|
||||
campaign_file: str | Path,
|
||||
output_dir: str | Path | None = None,
|
||||
write_eml: bool = False,
|
||||
) -> CampaignBuildResult:
|
||||
campaign_path = Path(campaign_file).resolve()
|
||||
entries = load_campaign_entries(config, campaign_file=campaign_path)
|
||||
output_path = Path(output_dir).resolve() if output_dir is not None else None
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="multimailer-build-") as tmp:
|
||||
work_dir = output_path or Path(tmp)
|
||||
built_messages = [
|
||||
build_entry_message(
|
||||
config=config,
|
||||
campaign_file=campaign_path,
|
||||
entry=entry,
|
||||
entry_index=index,
|
||||
output_dir=output_path,
|
||||
write_eml=write_eml,
|
||||
work_dir=work_dir,
|
||||
)
|
||||
for index, entry in enumerate(entries, start=1)
|
||||
]
|
||||
|
||||
report = CampaignBuildReport(
|
||||
campaign_id=config.campaign.id,
|
||||
campaign_name=config.campaign.name,
|
||||
campaign_file=str(campaign_path),
|
||||
entries_count=len(entries),
|
||||
messages=[built.draft for built in built_messages],
|
||||
)
|
||||
return CampaignBuildResult(report=report, built_messages=built_messages)
|
||||
139
server/app/mailer/messages/models.py
Normal file
139
server/app/mailer/messages/models.py
Normal file
@@ -0,0 +1,139 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.mailer.campaign.models import BuildStatus, SendStatus
|
||||
|
||||
|
||||
class MessageValidationStatus(StrEnum):
|
||||
READY = "ready"
|
||||
WARNING = "warning"
|
||||
NEEDS_REVIEW = "needs_review"
|
||||
BLOCKED = "blocked"
|
||||
EXCLUDED = "excluded"
|
||||
INACTIVE = "inactive"
|
||||
|
||||
|
||||
class ImapStatus(StrEnum):
|
||||
NOT_REQUESTED = "not_requested"
|
||||
PENDING = "pending"
|
||||
APPENDED = "appended"
|
||||
FAILED = "failed"
|
||||
SKIPPED = "skipped"
|
||||
|
||||
|
||||
class MessageIssue(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
severity: Literal["info", "warning", "error"]
|
||||
code: str
|
||||
message: str
|
||||
behavior: str | None = None
|
||||
source: str | None = None
|
||||
|
||||
|
||||
class MessageAddress(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
email: str
|
||||
name: str | None = None
|
||||
|
||||
|
||||
class MessageAttachmentSummary(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
attachment_id: str | None = None
|
||||
label: str | None = None
|
||||
status: str
|
||||
behavior: str | None = None
|
||||
required: bool
|
||||
allow_multiple: bool
|
||||
zip_enabled: bool
|
||||
file_filter: str
|
||||
directory: str
|
||||
matches: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class MessageDraft(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
||||
|
||||
entry_index: int
|
||||
entry_id: str | None = None
|
||||
active: bool
|
||||
|
||||
build_status: BuildStatus
|
||||
validation_status: MessageValidationStatus
|
||||
send_status: SendStatus
|
||||
imap_status: ImapStatus
|
||||
|
||||
subject: str | None = None
|
||||
from_: MessageAddress | None = Field(default=None, alias="from")
|
||||
to: list[MessageAddress] = Field(default_factory=list)
|
||||
cc: list[MessageAddress] = Field(default_factory=list)
|
||||
bcc: list[MessageAddress] = Field(default_factory=list)
|
||||
reply_to: list[MessageAddress] = Field(default_factory=list)
|
||||
bounce_to: list[MessageAddress] = Field(default_factory=list)
|
||||
disposition_notification_to: list[MessageAddress] = Field(default_factory=list)
|
||||
|
||||
attachment_count: int = 0
|
||||
attachments: list[MessageAttachmentSummary] = Field(default_factory=list)
|
||||
issues: list[MessageIssue] = Field(default_factory=list)
|
||||
|
||||
eml_path: str | None = None
|
||||
eml_size_bytes: int | None = None
|
||||
|
||||
@property
|
||||
def is_queueable(self) -> bool:
|
||||
return self.active and self.build_status == BuildStatus.BUILT and self.validation_status in {
|
||||
MessageValidationStatus.READY,
|
||||
MessageValidationStatus.WARNING,
|
||||
}
|
||||
|
||||
|
||||
class CampaignBuildReport(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
campaign_id: str
|
||||
campaign_name: str
|
||||
campaign_file: str
|
||||
entries_count: int
|
||||
messages: list[MessageDraft] = Field(default_factory=list)
|
||||
|
||||
@property
|
||||
def built_count(self) -> int:
|
||||
return sum(1 for message in self.messages if message.build_status == BuildStatus.BUILT)
|
||||
|
||||
@property
|
||||
def build_failed_count(self) -> int:
|
||||
return sum(1 for message in self.messages if message.build_status == BuildStatus.BUILD_FAILED)
|
||||
|
||||
@property
|
||||
def ready_count(self) -> int:
|
||||
return sum(1 for message in self.messages if message.validation_status == MessageValidationStatus.READY)
|
||||
|
||||
@property
|
||||
def warning_count(self) -> int:
|
||||
return sum(1 for message in self.messages if message.validation_status == MessageValidationStatus.WARNING)
|
||||
|
||||
@property
|
||||
def needs_review_count(self) -> int:
|
||||
return sum(1 for message in self.messages if message.validation_status == MessageValidationStatus.NEEDS_REVIEW)
|
||||
|
||||
@property
|
||||
def blocked_count(self) -> int:
|
||||
return sum(1 for message in self.messages if message.validation_status == MessageValidationStatus.BLOCKED)
|
||||
|
||||
@property
|
||||
def excluded_count(self) -> int:
|
||||
return sum(1 for message in self.messages if message.validation_status == MessageValidationStatus.EXCLUDED)
|
||||
|
||||
@property
|
||||
def inactive_count(self) -> int:
|
||||
return sum(1 for message in self.messages if message.validation_status == MessageValidationStatus.INACTIVE)
|
||||
|
||||
@property
|
||||
def queueable_count(self) -> int:
|
||||
return sum(1 for message in self.messages if message.is_queueable)
|
||||
0
server/app/mailer/persistence/__init__.py
Normal file
0
server/app/mailer/persistence/__init__.py
Normal file
309
server/app/mailer/persistence/campaigns.py
Normal file
309
server/app/mailer/persistence/campaigns.py
Normal file
@@ -0,0 +1,309 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
import copy
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.models import (
|
||||
Campaign,
|
||||
CampaignIssue,
|
||||
CampaignJob,
|
||||
CampaignStatus,
|
||||
CampaignVersion,
|
||||
JobBuildStatus,
|
||||
JobImapStatus,
|
||||
JobQueueStatus,
|
||||
JobSendStatus,
|
||||
JobValidationStatus,
|
||||
)
|
||||
from app.mailer.campaign.loader import load_campaign_config
|
||||
from app.mailer.campaign.validation import Severity, validate_campaign_config
|
||||
from app.mailer.messages.builder import build_campaign_messages
|
||||
from app.mailer.messages.models import MessageDraft
|
||||
|
||||
RUNTIME_DIR = Path(__file__).resolve().parents[3] / "runtime"
|
||||
CAMPAIGN_SNAPSHOT_DIR = RUNTIME_DIR / "campaign_snapshots"
|
||||
BUILD_OUTPUT_DIR = RUNTIME_DIR / "generated_eml"
|
||||
|
||||
|
||||
class CampaignPersistenceError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _ensure_dirs() -> None:
|
||||
CAMPAIGN_SNAPSHOT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
BUILD_OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def _write_campaign_snapshot(version: CampaignVersion) -> Path:
|
||||
_ensure_dirs()
|
||||
path = CAMPAIGN_SNAPSHOT_DIR / f"{version.id}.json"
|
||||
path.write_text(json.dumps(version.raw_json, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def _next_version_number(session: Session, campaign_id: str) -> int:
|
||||
current = session.query(func.max(CampaignVersion.version_number)).filter(CampaignVersion.campaign_id == campaign_id).scalar()
|
||||
return int(current or 0) + 1
|
||||
|
||||
|
||||
def _resolve_runtime_path(base_path: Path | None, value: str | None) -> str | None:
|
||||
if not value or base_path is None:
|
||||
return value
|
||||
path = Path(value).expanduser()
|
||||
if path.is_absolute():
|
||||
return str(path)
|
||||
return str((base_path / path).resolve())
|
||||
|
||||
|
||||
def normalize_campaign_paths(raw_json: dict[str, Any], source_base_path: str | Path | None) -> dict[str, Any]:
|
||||
"""Return a DB/runtime-safe campaign JSON snapshot.
|
||||
|
||||
The CLI naturally resolves relative paths against the campaign.json file.
|
||||
Once the campaign is stored in the database, the JSON snapshot lives in
|
||||
app/mailer/runtime/campaign_snapshots. To keep existing file-based
|
||||
campaigns working, relative file paths are normalized to absolute paths at
|
||||
import time when a source_base_path is known.
|
||||
"""
|
||||
base = Path(source_base_path).expanduser().resolve() if source_base_path else None
|
||||
data = copy.deepcopy(raw_json)
|
||||
|
||||
template_source = data.get("template", {}).get("source") if isinstance(data.get("template"), dict) else None
|
||||
if isinstance(template_source, dict):
|
||||
for key in ("subject_path", "text_path", "html_path"):
|
||||
template_source[key] = _resolve_runtime_path(base, template_source.get(key))
|
||||
|
||||
entries_source = data.get("entries", {}).get("source") if isinstance(data.get("entries"), dict) else None
|
||||
if isinstance(entries_source, dict):
|
||||
entries_source["path"] = _resolve_runtime_path(base, entries_source.get("path"))
|
||||
|
||||
attachments = data.get("attachments")
|
||||
if isinstance(attachments, dict):
|
||||
attachments["base_path"] = _resolve_runtime_path(base, attachments.get("base_path")) or "."
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def create_campaign_version_from_json(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
user_id: str | None,
|
||||
raw_json: dict[str, Any],
|
||||
source_filename: str | None = None,
|
||||
source_base_path: str | None = None,
|
||||
) -> tuple[Campaign, CampaignVersion]:
|
||||
if source_base_path is None and source_filename:
|
||||
source_path = Path(source_filename).expanduser()
|
||||
source_base_path = str(source_path.parent if source_path.suffix else source_path)
|
||||
|
||||
runtime_json = normalize_campaign_paths(raw_json, source_base_path)
|
||||
|
||||
# load_campaign_config is file-oriented. Use a temporary snapshot for schema/Pydantic validation.
|
||||
_ensure_dirs()
|
||||
tmp_path = CAMPAIGN_SNAPSHOT_DIR / "_incoming_campaign.json"
|
||||
tmp_path.write_text(json.dumps(runtime_json, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
config = load_campaign_config(tmp_path)
|
||||
|
||||
campaign = (
|
||||
session.query(Campaign)
|
||||
.filter(Campaign.tenant_id == tenant_id, Campaign.external_id == config.campaign.id)
|
||||
.one_or_none()
|
||||
)
|
||||
if campaign is None:
|
||||
campaign = Campaign(
|
||||
tenant_id=tenant_id,
|
||||
created_by_user_id=user_id,
|
||||
external_id=config.campaign.id,
|
||||
name=config.campaign.name,
|
||||
description=config.campaign.description,
|
||||
status=CampaignStatus.DRAFT.value,
|
||||
)
|
||||
session.add(campaign)
|
||||
session.flush()
|
||||
else:
|
||||
campaign.name = config.campaign.name
|
||||
campaign.description = config.campaign.description
|
||||
|
||||
version = CampaignVersion(
|
||||
campaign_id=campaign.id,
|
||||
version_number=_next_version_number(session, campaign.id),
|
||||
raw_json=runtime_json,
|
||||
schema_version=raw_json.get("version", "1.0"),
|
||||
source_filename=source_filename,
|
||||
source_base_path=source_base_path,
|
||||
)
|
||||
session.add(version)
|
||||
session.flush()
|
||||
campaign.current_version_id = version.id
|
||||
session.add(campaign)
|
||||
_write_campaign_snapshot(version)
|
||||
session.commit()
|
||||
return campaign, version
|
||||
|
||||
|
||||
def load_version_config(session: Session, version_id: str):
|
||||
version = session.get(CampaignVersion, version_id)
|
||||
if not version:
|
||||
raise CampaignPersistenceError(f"Campaign version not found: {version_id}")
|
||||
path = _write_campaign_snapshot(version)
|
||||
return version, path, load_campaign_config(path)
|
||||
|
||||
|
||||
def validate_campaign_version(session: Session, *, tenant_id: str, version_id: str, check_files: bool = False) -> dict[str, Any]:
|
||||
version, snapshot_path, config = load_version_config(session, version_id)
|
||||
campaign = session.get(Campaign, version.campaign_id)
|
||||
if not campaign or campaign.tenant_id != tenant_id:
|
||||
raise CampaignPersistenceError("Campaign version is not accessible for this tenant")
|
||||
|
||||
report = validate_campaign_config(config, campaign_file=snapshot_path, check_files=check_files)
|
||||
report_json = report.model_dump(mode="json")
|
||||
report_json.update({"ok": report.ok, "error_count": report.error_count, "warning_count": report.warning_count})
|
||||
version.validation_summary = report_json
|
||||
|
||||
# Replace version-level semantic issues from previous validations.
|
||||
(
|
||||
session.query(CampaignIssue)
|
||||
.filter(CampaignIssue.campaign_version_id == version.id, CampaignIssue.job_id.is_(None))
|
||||
.delete(synchronize_session=False)
|
||||
)
|
||||
for issue in report.issues:
|
||||
session.add(
|
||||
CampaignIssue(
|
||||
tenant_id=tenant_id,
|
||||
campaign_id=campaign.id,
|
||||
campaign_version_id=version.id,
|
||||
severity=issue.severity.value,
|
||||
code=issue.code,
|
||||
message=issue.message,
|
||||
source=issue.path,
|
||||
)
|
||||
)
|
||||
|
||||
campaign.status = CampaignStatus.VALIDATED.value if report.ok else CampaignStatus.NEEDS_REVIEW.value
|
||||
if report.ok:
|
||||
version.workflow_state = "under_review"
|
||||
version.is_complete = True
|
||||
session.add(version)
|
||||
session.add(campaign)
|
||||
session.commit()
|
||||
return report_json
|
||||
|
||||
|
||||
def _job_validation_status(value: str) -> str:
|
||||
allowed = {item.value for item in JobValidationStatus}
|
||||
return value if value in allowed else JobValidationStatus.NEEDS_REVIEW.value
|
||||
|
||||
|
||||
def _job_from_message(
|
||||
*,
|
||||
tenant_id: str,
|
||||
campaign_id: str,
|
||||
version_id: str,
|
||||
message: MessageDraft,
|
||||
) -> CampaignJob:
|
||||
recipient_email = message.to[0].email if message.to else None
|
||||
return CampaignJob(
|
||||
tenant_id=tenant_id,
|
||||
campaign_id=campaign_id,
|
||||
campaign_version_id=version_id,
|
||||
entry_index=message.entry_index,
|
||||
entry_id=message.entry_id,
|
||||
recipient_email=recipient_email,
|
||||
subject=message.subject,
|
||||
eml_local_path=message.eml_path,
|
||||
eml_size_bytes=message.eml_size_bytes,
|
||||
build_status=message.build_status.value if hasattr(message.build_status, "value") else str(message.build_status),
|
||||
validation_status=_job_validation_status(message.validation_status.value),
|
||||
queue_status=JobQueueStatus.DRAFT.value,
|
||||
send_status=JobSendStatus.NOT_QUEUED.value,
|
||||
imap_status=message.imap_status.value if hasattr(message.imap_status, "value") else JobImapStatus.NOT_REQUESTED.value,
|
||||
resolved_recipients={
|
||||
"from": message.from_.model_dump(mode="json") if message.from_ else None,
|
||||
"to": [item.model_dump(mode="json") for item in message.to],
|
||||
"cc": [item.model_dump(mode="json") for item in message.cc],
|
||||
"bcc": [item.model_dump(mode="json") for item in message.bcc],
|
||||
"reply_to": [item.model_dump(mode="json") for item in message.reply_to],
|
||||
"bounce_to": [item.model_dump(mode="json") for item in message.bounce_to],
|
||||
"disposition_notification_to": [item.model_dump(mode="json") for item in message.disposition_notification_to],
|
||||
},
|
||||
resolved_attachments=[item.model_dump(mode="json") for item in message.attachments],
|
||||
issues_snapshot=[item.model_dump(mode="json") for item in message.issues],
|
||||
last_error="; ".join(issue.message for issue in message.issues if issue.severity == "error") or None,
|
||||
)
|
||||
|
||||
|
||||
def build_campaign_version(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
version_id: str,
|
||||
write_eml: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
version, snapshot_path, config = load_version_config(session, version_id)
|
||||
campaign = session.get(Campaign, version.campaign_id)
|
||||
if not campaign or campaign.tenant_id != tenant_id:
|
||||
raise CampaignPersistenceError("Campaign version is not accessible for this tenant")
|
||||
|
||||
output_dir = BUILD_OUTPUT_DIR / campaign.id / version.id
|
||||
result = build_campaign_messages(config, campaign_file=snapshot_path, output_dir=output_dir, write_eml=write_eml)
|
||||
report_json = result.report.model_dump(mode="json", by_alias=True)
|
||||
report_json.update({
|
||||
"built_count": result.report.built_count,
|
||||
"build_failed_count": result.report.build_failed_count,
|
||||
"ready_count": result.report.ready_count,
|
||||
"warning_count": result.report.warning_count,
|
||||
"needs_review_count": result.report.needs_review_count,
|
||||
"blocked_count": result.report.blocked_count,
|
||||
"excluded_count": result.report.excluded_count,
|
||||
"inactive_count": result.report.inactive_count,
|
||||
"queueable_count": result.report.queueable_count,
|
||||
})
|
||||
version.build_summary = report_json
|
||||
|
||||
# Rebuild jobs for the current version. Later, protect sent jobs from destructive rebuilds.
|
||||
session.query(CampaignIssue).filter(CampaignIssue.campaign_version_id == version.id, CampaignIssue.job_id.is_not(None)).delete(synchronize_session=False)
|
||||
session.query(CampaignJob).filter(CampaignJob.campaign_version_id == version.id).delete(synchronize_session=False)
|
||||
session.flush()
|
||||
|
||||
for built in result.built_messages:
|
||||
job = _job_from_message(
|
||||
tenant_id=tenant_id,
|
||||
campaign_id=campaign.id,
|
||||
version_id=version.id,
|
||||
message=built.draft,
|
||||
)
|
||||
session.add(job)
|
||||
session.flush()
|
||||
for issue in built.draft.issues:
|
||||
session.add(
|
||||
CampaignIssue(
|
||||
tenant_id=tenant_id,
|
||||
campaign_id=campaign.id,
|
||||
campaign_version_id=version.id,
|
||||
job_id=job.id,
|
||||
severity=issue.severity,
|
||||
code=issue.code,
|
||||
message=issue.message,
|
||||
source=issue.source,
|
||||
behavior=issue.behavior,
|
||||
)
|
||||
)
|
||||
|
||||
if result.report.needs_review_count or result.report.blocked_count:
|
||||
campaign.status = CampaignStatus.NEEDS_REVIEW.value
|
||||
version.workflow_state = "under_review"
|
||||
elif result.report.queueable_count > 0:
|
||||
campaign.status = CampaignStatus.READY_TO_QUEUE.value
|
||||
version.workflow_state = "built"
|
||||
else:
|
||||
campaign.status = CampaignStatus.VALIDATED.value
|
||||
|
||||
session.add(version)
|
||||
session.add(campaign)
|
||||
session.commit()
|
||||
return report_json
|
||||
346
server/app/mailer/persistence/versions.py
Normal file
346
server/app/mailer/persistence/versions.py
Normal file
@@ -0,0 +1,346 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.models import (
|
||||
Campaign,
|
||||
CampaignIssue,
|
||||
CampaignStatus,
|
||||
CampaignVersion,
|
||||
CampaignVersionFlow,
|
||||
CampaignVersionWorkflowState,
|
||||
)
|
||||
from app.mailer.campaign.loader import load_campaign_config
|
||||
from app.mailer.persistence.campaigns import (
|
||||
CAMPAIGN_SNAPSHOT_DIR,
|
||||
CampaignPersistenceError,
|
||||
_ensure_dirs,
|
||||
_next_version_number,
|
||||
_write_campaign_snapshot,
|
||||
normalize_campaign_paths,
|
||||
)
|
||||
|
||||
|
||||
def minimal_campaign_json(*, external_id: str, name: str, description: str | None = None) -> dict[str, Any]:
|
||||
"""Return a WebUI-friendly starter campaign JSON.
|
||||
|
||||
It is intentionally usable as an editable working copy. It contains the
|
||||
main sections the UI expects, but it may still be incomplete from the
|
||||
strict send/build perspective until the user configures recipients,
|
||||
template and sender details.
|
||||
"""
|
||||
|
||||
return {
|
||||
"version": "1.0",
|
||||
"campaign": {
|
||||
"id": external_id,
|
||||
"name": name,
|
||||
"description": description or "",
|
||||
"mode": "draft",
|
||||
},
|
||||
"fields": [],
|
||||
"global_values": {},
|
||||
"server": {
|
||||
"smtp": {
|
||||
"host": "",
|
||||
"port": 587,
|
||||
"username": "",
|
||||
"password": "",
|
||||
"security": "starttls",
|
||||
},
|
||||
"imap": {
|
||||
"enabled": False,
|
||||
"host": "",
|
||||
"port": 993,
|
||||
"username": "",
|
||||
"password": "",
|
||||
"security": "tls",
|
||||
"sent_folder": "auto",
|
||||
},
|
||||
},
|
||||
"recipients": {
|
||||
"from": {"name": "", "email": ""},
|
||||
"allow_individual_from": False,
|
||||
"to": [],
|
||||
"allow_individual_to": True,
|
||||
"cc": [],
|
||||
"allow_individual_cc": False,
|
||||
"bcc": [],
|
||||
"allow_individual_bcc": False,
|
||||
"reply_to": [],
|
||||
"allow_individual_reply_to": False,
|
||||
"bounce_to": [],
|
||||
"allow_individual_bounce_to": False,
|
||||
"disposition_notification_to": [],
|
||||
"allow_individual_disposition_notification_to": False,
|
||||
},
|
||||
"template": {
|
||||
"subject": "",
|
||||
"text": "",
|
||||
"html": None,
|
||||
},
|
||||
"attachments": {
|
||||
"base_path": ".",
|
||||
"allow_individual": True,
|
||||
"send_without_attachments": False,
|
||||
"global": [],
|
||||
"missing_behavior": "ask",
|
||||
"ambiguous_behavior": "ask",
|
||||
},
|
||||
"entries": {
|
||||
"inline": [],
|
||||
"defaults": {
|
||||
"active": True,
|
||||
"combine_to": False,
|
||||
"combine_cc": True,
|
||||
"combine_bcc": True,
|
||||
"combine_reply_to": True,
|
||||
"combine_bounce_to": True,
|
||||
"combine_disposition_notification_to": True,
|
||||
"combine_attachments": True,
|
||||
"attachments": [],
|
||||
},
|
||||
},
|
||||
"validation_policy": {
|
||||
"missing_required_attachment": "ask",
|
||||
"missing_optional_attachment": "warn",
|
||||
"ambiguous_attachment_match": "ask",
|
||||
"missing_email": "block",
|
||||
"template_error": "block",
|
||||
},
|
||||
"delivery": {
|
||||
"rate_limit": {
|
||||
"messages_per_minute": 5,
|
||||
"concurrency": 1,
|
||||
},
|
||||
"imap_append_sent": {
|
||||
"enabled": False,
|
||||
"folder": "auto",
|
||||
},
|
||||
"retry": {
|
||||
"max_attempts": 3,
|
||||
"backoff_seconds": [60, 300, 900],
|
||||
},
|
||||
},
|
||||
"status_tracking": {
|
||||
"enabled": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def create_minimal_campaign(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
user_id: str | None,
|
||||
external_id: str,
|
||||
name: str,
|
||||
description: str | None = None,
|
||||
current_flow: str = CampaignVersionFlow.CREATE.value,
|
||||
current_step: str = "basics",
|
||||
) -> tuple[Campaign, CampaignVersion]:
|
||||
existing = session.query(Campaign).filter(Campaign.tenant_id == tenant_id, Campaign.external_id == external_id).one_or_none()
|
||||
if existing:
|
||||
raise CampaignPersistenceError(f"Campaign with id '{external_id}' already exists for this tenant")
|
||||
|
||||
campaign = Campaign(
|
||||
tenant_id=tenant_id,
|
||||
created_by_user_id=user_id,
|
||||
external_id=external_id,
|
||||
name=name,
|
||||
description=description,
|
||||
status=CampaignStatus.DRAFT.value,
|
||||
)
|
||||
session.add(campaign)
|
||||
session.flush()
|
||||
|
||||
version = CampaignVersion(
|
||||
campaign_id=campaign.id,
|
||||
version_number=1,
|
||||
raw_json=minimal_campaign_json(external_id=external_id, name=name, description=description),
|
||||
schema_version="1.0",
|
||||
workflow_state=CampaignVersionWorkflowState.EDITING.value,
|
||||
current_flow=current_flow,
|
||||
current_step=current_step,
|
||||
is_complete=False,
|
||||
editor_state={"created_from": "minimal_campaign"},
|
||||
autosaved_at=datetime.now(UTC),
|
||||
)
|
||||
session.add(version)
|
||||
session.flush()
|
||||
campaign.current_version_id = version.id
|
||||
session.add(campaign)
|
||||
_write_campaign_snapshot(version)
|
||||
session.commit()
|
||||
return campaign, version
|
||||
|
||||
|
||||
def get_campaign_version_for_tenant(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
campaign_id: str,
|
||||
version_id: str,
|
||||
) -> CampaignVersion:
|
||||
campaign = session.get(Campaign, campaign_id)
|
||||
version = session.get(CampaignVersion, version_id)
|
||||
if not campaign or campaign.tenant_id != tenant_id or not version or version.campaign_id != campaign.id:
|
||||
raise CampaignPersistenceError("Campaign version not found")
|
||||
return version
|
||||
|
||||
|
||||
def update_campaign_version(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
campaign_id: str,
|
||||
version_id: str,
|
||||
raw_json: dict[str, Any] | None = None,
|
||||
current_flow: str | None = None,
|
||||
current_step: str | None = None,
|
||||
workflow_state: str | None = None,
|
||||
is_complete: bool | None = None,
|
||||
editor_state: dict[str, Any] | None = None,
|
||||
source_filename: str | None = None,
|
||||
source_base_path: str | None = None,
|
||||
autosave: bool = False,
|
||||
) -> CampaignVersion:
|
||||
version = get_campaign_version_for_tenant(session, tenant_id=tenant_id, campaign_id=campaign_id, version_id=version_id)
|
||||
campaign = session.get(Campaign, campaign_id)
|
||||
assert campaign is not None
|
||||
|
||||
if raw_json is not None:
|
||||
runtime_json = normalize_campaign_paths(raw_json, source_base_path) if source_base_path else copy.deepcopy(raw_json)
|
||||
version.raw_json = runtime_json
|
||||
version.schema_version = str(runtime_json.get("version", version.schema_version or "1.0"))
|
||||
campaign_meta = runtime_json.get("campaign") if isinstance(runtime_json.get("campaign"), dict) else {}
|
||||
if campaign_meta:
|
||||
campaign.name = campaign_meta.get("name") or campaign.name
|
||||
campaign.description = campaign_meta.get("description", campaign.description)
|
||||
campaign.external_id = campaign_meta.get("id") or campaign.external_id
|
||||
|
||||
if current_flow is not None:
|
||||
version.current_flow = current_flow
|
||||
if current_step is not None:
|
||||
version.current_step = current_step
|
||||
if workflow_state is not None:
|
||||
version.workflow_state = workflow_state
|
||||
if is_complete is not None:
|
||||
version.is_complete = is_complete
|
||||
if editor_state is not None:
|
||||
version.editor_state = editor_state
|
||||
if source_filename is not None:
|
||||
version.source_filename = source_filename
|
||||
if source_base_path is not None:
|
||||
version.source_base_path = source_base_path
|
||||
if autosave:
|
||||
version.autosaved_at = datetime.now(UTC)
|
||||
|
||||
# Changes invalidate previous build and validation summaries.
|
||||
if raw_json is not None:
|
||||
version.validation_summary = None
|
||||
version.build_summary = None
|
||||
session.query(CampaignIssue).filter(CampaignIssue.campaign_version_id == version.id).delete(synchronize_session=False)
|
||||
|
||||
session.add(version)
|
||||
session.add(campaign)
|
||||
session.flush()
|
||||
_write_campaign_snapshot(version)
|
||||
session.commit()
|
||||
return version
|
||||
|
||||
|
||||
def publish_campaign_version(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
campaign_id: str,
|
||||
version_id: str,
|
||||
) -> CampaignVersion:
|
||||
version = get_campaign_version_for_tenant(session, tenant_id=tenant_id, campaign_id=campaign_id, version_id=version_id)
|
||||
campaign = session.get(Campaign, campaign_id)
|
||||
assert campaign is not None
|
||||
version.workflow_state = CampaignVersionWorkflowState.APPROVED.value
|
||||
version.published_at = datetime.now(UTC)
|
||||
campaign.current_version_id = version.id
|
||||
campaign.status = CampaignStatus.VALIDATED.value
|
||||
session.add(version)
|
||||
session.add(campaign)
|
||||
session.commit()
|
||||
return version
|
||||
|
||||
|
||||
def validate_campaign_partial(raw_json: dict[str, Any], *, section: str | None = None) -> dict[str, Any]:
|
||||
"""Lightweight UI-facing validation for incomplete campaign working copies.
|
||||
|
||||
This is intentionally less strict than campaign.schema.json validation. It
|
||||
lets the WebUI autosave and validate one wizard step at a time.
|
||||
"""
|
||||
|
||||
issues: list[dict[str, Any]] = []
|
||||
|
||||
def issue(severity: str, sec: str, field: str, code: str, message: str) -> None:
|
||||
if section is None or section == sec:
|
||||
issues.append({
|
||||
"severity": severity,
|
||||
"section": sec,
|
||||
"field": field,
|
||||
"code": code,
|
||||
"message": message,
|
||||
})
|
||||
|
||||
campaign = raw_json.get("campaign") if isinstance(raw_json.get("campaign"), dict) else {}
|
||||
if not campaign.get("id"):
|
||||
issue("error", "basics", "campaign.id", "missing_campaign_id", "Campaign id is required.")
|
||||
if not campaign.get("name"):
|
||||
issue("error", "basics", "campaign.name", "missing_campaign_name", "Campaign name is required.")
|
||||
|
||||
recipients = raw_json.get("recipients") if isinstance(raw_json.get("recipients"), dict) else {}
|
||||
sender = recipients.get("from") if isinstance(recipients.get("from"), dict) else {}
|
||||
if not sender.get("email"):
|
||||
issue("warning", "sender", "recipients.from.email", "missing_sender_email", "Sender email is not configured yet.")
|
||||
|
||||
entries = raw_json.get("entries") if isinstance(raw_json.get("entries"), dict) else {}
|
||||
has_inline = bool(entries.get("inline"))
|
||||
has_source = isinstance(entries.get("source"), dict)
|
||||
if not has_inline and not has_source:
|
||||
issue("warning", "recipients", "entries", "missing_recipients", "No inline recipients or external recipient source configured yet.")
|
||||
if has_source:
|
||||
mapping = entries.get("mapping") if isinstance(entries.get("mapping"), dict) else {}
|
||||
if not any(key in mapping for key in ("to.0.email", "to.email", "email")):
|
||||
issue("warning", "recipients", "entries.mapping", "missing_email_mapping", "No email field mapping is configured.")
|
||||
|
||||
template = raw_json.get("template") if isinstance(raw_json.get("template"), dict) else {}
|
||||
if not template.get("subject") and not (isinstance(template.get("source"), dict) and template["source"].get("subject_path")):
|
||||
issue("warning", "template", "template.subject", "missing_subject", "Template subject is empty.")
|
||||
if not template.get("text") and not template.get("html") and not isinstance(template.get("source"), dict):
|
||||
issue("warning", "template", "template", "missing_template_body", "No text, HTML or file-based template body configured yet.")
|
||||
|
||||
attachments = raw_json.get("attachments") if isinstance(raw_json.get("attachments"), dict) else {}
|
||||
if not attachments.get("base_path"):
|
||||
issue("info", "attachments", "attachments.base_path", "missing_attachment_base_path", "Attachment base path is not configured yet.")
|
||||
|
||||
delivery = raw_json.get("delivery") if isinstance(raw_json.get("delivery"), dict) else {}
|
||||
rate_limit = delivery.get("rate_limit") if isinstance(delivery.get("rate_limit"), dict) else {}
|
||||
messages_per_minute = rate_limit.get("messages_per_minute")
|
||||
if messages_per_minute is not None:
|
||||
try:
|
||||
if int(messages_per_minute) < 1:
|
||||
issue("error", "send", "delivery.rate_limit.messages_per_minute", "invalid_rate_limit", "Messages per minute must be at least 1.")
|
||||
except (TypeError, ValueError):
|
||||
issue("error", "send", "delivery.rate_limit.messages_per_minute", "invalid_rate_limit", "Messages per minute must be a number.")
|
||||
|
||||
return {
|
||||
"ok": not any(item["severity"] == "error" for item in issues),
|
||||
"section": section,
|
||||
"error_count": sum(1 for item in issues if item["severity"] == "error"),
|
||||
"warning_count": sum(1 for item in issues if item["severity"] == "warning"),
|
||||
"info_count": sum(1 for item in issues if item["severity"] == "info"),
|
||||
"issues": issues,
|
||||
}
|
||||
1
server/app/mailer/reports/__init__.py
Normal file
1
server/app/mailer/reports/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Reporting helpers for campaigns and jobs."""
|
||||
351
server/app/mailer/reports/campaigns.py
Normal file
351
server/app/mailer/reports/campaigns.py
Normal file
@@ -0,0 +1,351 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import math
|
||||
from collections import Counter
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.models import (
|
||||
Campaign,
|
||||
CampaignIssue,
|
||||
CampaignJob,
|
||||
CampaignVersion,
|
||||
ImapAppendAttempt,
|
||||
SendAttempt,
|
||||
)
|
||||
from app.mailer.campaign.loader import load_campaign_config
|
||||
from app.mailer.persistence.campaigns import _write_campaign_snapshot
|
||||
|
||||
|
||||
class CampaignReportError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _utcnow_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _counter(values: list[str | None]) -> dict[str, int]:
|
||||
return dict(Counter(value or "unknown" for value in values))
|
||||
|
||||
|
||||
def _get_campaign(session: Session, *, tenant_id: str, campaign_id: str) -> Campaign:
|
||||
campaign = session.query(Campaign).filter(Campaign.tenant_id == tenant_id, Campaign.id == campaign_id).one_or_none()
|
||||
if not campaign:
|
||||
raise CampaignReportError(f"Campaign not found or not accessible: {campaign_id}")
|
||||
return campaign
|
||||
|
||||
|
||||
def _current_version(session: Session, campaign: Campaign) -> CampaignVersion | None:
|
||||
if not campaign.current_version_id:
|
||||
return None
|
||||
version = session.get(CampaignVersion, campaign.current_version_id)
|
||||
if version and version.campaign_id == campaign.id:
|
||||
return version
|
||||
return None
|
||||
|
||||
|
||||
def _version_info(version: CampaignVersion | None) -> dict[str, Any] | None:
|
||||
if not version:
|
||||
return None
|
||||
return {
|
||||
"id": version.id,
|
||||
"version_number": version.version_number,
|
||||
"schema_version": version.schema_version,
|
||||
"source_filename": version.source_filename,
|
||||
"created_at": version.created_at.isoformat() if version.created_at else None,
|
||||
"validation_summary": version.validation_summary,
|
||||
"build_summary": version.build_summary,
|
||||
}
|
||||
|
||||
|
||||
def _load_delivery_info(version: CampaignVersion | None, jobs: list[CampaignJob]) -> dict[str, Any]:
|
||||
"""Extract rate-limit and IMAP settings from the version JSON where possible.
|
||||
|
||||
This stays best-effort so reports still work if the schema evolves or a
|
||||
partial/invalid campaign snapshot exists.
|
||||
"""
|
||||
|
||||
default = {
|
||||
"rate_limit": {"messages_per_minute": None, "concurrency": None},
|
||||
"imap_append_sent": {"enabled": None, "folder": None},
|
||||
"retry": {"max_attempts": None, "backoff_seconds": []},
|
||||
"estimated_remaining_send_seconds": None,
|
||||
"estimated_remaining_send_human": None,
|
||||
}
|
||||
if not version:
|
||||
return default
|
||||
try:
|
||||
snapshot_path = _write_campaign_snapshot(version)
|
||||
config = load_campaign_config(snapshot_path)
|
||||
except Exception as exc: # pragma: no cover - reporting should not fail hard here
|
||||
default["load_error"] = str(exc)
|
||||
return default
|
||||
|
||||
messages_per_minute = config.delivery.rate_limit.messages_per_minute
|
||||
pending = [job for job in jobs if job.send_status in {"queued", "failed_temporary", "sending"}]
|
||||
estimated_seconds = None
|
||||
if messages_per_minute and pending:
|
||||
estimated_seconds = int(math.ceil((len(pending) / messages_per_minute) * 60))
|
||||
|
||||
return {
|
||||
"rate_limit": {
|
||||
"messages_per_minute": messages_per_minute,
|
||||
"concurrency": config.delivery.rate_limit.concurrency,
|
||||
},
|
||||
"imap_append_sent": {
|
||||
"enabled": config.delivery.imap_append_sent.enabled,
|
||||
"folder": config.delivery.imap_append_sent.folder,
|
||||
},
|
||||
"retry": {
|
||||
"max_attempts": config.delivery.retry.max_attempts,
|
||||
"backoff_seconds": config.delivery.retry.backoff_seconds,
|
||||
},
|
||||
"estimated_remaining_send_seconds": estimated_seconds,
|
||||
"estimated_remaining_send_human": _human_duration(estimated_seconds),
|
||||
}
|
||||
|
||||
|
||||
def _human_duration(seconds: int | None) -> str | None:
|
||||
if seconds is None:
|
||||
return None
|
||||
if seconds < 60:
|
||||
return f"{seconds}s"
|
||||
minutes, sec = divmod(seconds, 60)
|
||||
if minutes < 60:
|
||||
return f"{minutes}m {sec}s" if sec else f"{minutes}m"
|
||||
hours, minute = divmod(minutes, 60)
|
||||
return f"{hours}h {minute}m" if minute else f"{hours}h"
|
||||
|
||||
|
||||
def _issue_summary_from_jobs(jobs: list[CampaignJob]) -> dict[str, Any]:
|
||||
severity_counter: Counter[str] = Counter()
|
||||
code_counter: Counter[str] = Counter()
|
||||
behavior_counter: Counter[str] = Counter()
|
||||
total = 0
|
||||
for job in jobs:
|
||||
for issue in job.issues_snapshot or []:
|
||||
if not isinstance(issue, dict):
|
||||
continue
|
||||
total += 1
|
||||
severity_counter[issue.get("severity") or "unknown"] += 1
|
||||
code_counter[issue.get("code") or "unknown"] += 1
|
||||
if issue.get("behavior"):
|
||||
behavior_counter[issue["behavior"]] += 1
|
||||
return {
|
||||
"total": total,
|
||||
"by_severity": dict(severity_counter),
|
||||
"by_code": dict(code_counter),
|
||||
"by_behavior": dict(behavior_counter),
|
||||
}
|
||||
|
||||
|
||||
def _attachment_summary(jobs: list[CampaignJob]) -> dict[str, Any]:
|
||||
status_counter: Counter[str] = Counter()
|
||||
behavior_counter: Counter[str] = Counter()
|
||||
total_configs = 0
|
||||
total_matched_files = 0
|
||||
zip_enabled = 0
|
||||
missing = 0
|
||||
ambiguous = 0
|
||||
for job in jobs:
|
||||
for attachment in job.resolved_attachments or []:
|
||||
if not isinstance(attachment, dict):
|
||||
continue
|
||||
total_configs += 1
|
||||
status = attachment.get("status") or "unknown"
|
||||
status_counter[status] += 1
|
||||
if attachment.get("behavior"):
|
||||
behavior_counter[attachment["behavior"]] += 1
|
||||
matches = attachment.get("matches") or []
|
||||
if isinstance(matches, list):
|
||||
total_matched_files += len(matches)
|
||||
if attachment.get("zip_enabled"):
|
||||
zip_enabled += 1
|
||||
if status == "missing":
|
||||
missing += 1
|
||||
if status == "ambiguous":
|
||||
ambiguous += 1
|
||||
return {
|
||||
"total_attachment_configs": total_configs,
|
||||
"total_matched_files": total_matched_files,
|
||||
"zip_enabled_configs": zip_enabled,
|
||||
"missing_configs": missing,
|
||||
"ambiguous_configs": ambiguous,
|
||||
"by_status": dict(status_counter),
|
||||
"by_behavior": dict(behavior_counter),
|
||||
}
|
||||
|
||||
|
||||
def _recent_failures(jobs: list[CampaignJob], *, limit: int = 20) -> list[dict[str, Any]]:
|
||||
failed = [job for job in jobs if job.last_error or str(job.send_status).startswith("failed") or job.imap_status == "failed"]
|
||||
failed.sort(key=lambda job: job.updated_at or job.created_at, reverse=True)
|
||||
return [
|
||||
{
|
||||
"job_id": job.id,
|
||||
"entry_index": job.entry_index,
|
||||
"entry_id": job.entry_id,
|
||||
"recipient_email": job.recipient_email,
|
||||
"validation_status": job.validation_status,
|
||||
"send_status": job.send_status,
|
||||
"imap_status": job.imap_status,
|
||||
"attempt_count": job.attempt_count,
|
||||
"last_error": job.last_error,
|
||||
"updated_at": job.updated_at.isoformat() if job.updated_at else None,
|
||||
}
|
||||
for job in failed[:limit]
|
||||
]
|
||||
|
||||
|
||||
def _job_row(job: CampaignJob) -> dict[str, Any]:
|
||||
return {
|
||||
"job_id": job.id,
|
||||
"entry_index": job.entry_index,
|
||||
"entry_id": job.entry_id,
|
||||
"recipient_email": job.recipient_email,
|
||||
"subject": job.subject,
|
||||
"build_status": job.build_status,
|
||||
"validation_status": job.validation_status,
|
||||
"queue_status": job.queue_status,
|
||||
"send_status": job.send_status,
|
||||
"imap_status": job.imap_status,
|
||||
"attempt_count": job.attempt_count,
|
||||
"queued_at": job.queued_at.isoformat() if job.queued_at else None,
|
||||
"sent_at": job.sent_at.isoformat() if job.sent_at else None,
|
||||
"last_error": job.last_error,
|
||||
"eml_size_bytes": job.eml_size_bytes,
|
||||
"issues_count": len(job.issues_snapshot or []),
|
||||
"attachment_config_count": len(job.resolved_attachments or []),
|
||||
"matched_file_count": sum(len(item.get("matches") or []) for item in (job.resolved_attachments or []) if isinstance(item, dict)),
|
||||
}
|
||||
|
||||
|
||||
def generate_campaign_report(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
campaign_id: str,
|
||||
include_jobs: bool = False,
|
||||
include_recent_failures: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Generate a dashboard/report payload for one campaign.
|
||||
|
||||
The shape is intentionally web-UI friendly: status counters for cards,
|
||||
issue/attachment summaries for review panels, and optional job rows for
|
||||
tables/export.
|
||||
"""
|
||||
|
||||
campaign = _get_campaign(session, tenant_id=tenant_id, campaign_id=campaign_id)
|
||||
version = _current_version(session, campaign)
|
||||
jobs = (
|
||||
session.query(CampaignJob)
|
||||
.filter(CampaignJob.tenant_id == tenant_id, CampaignJob.campaign_id == campaign.id)
|
||||
.order_by(CampaignJob.entry_index.asc())
|
||||
.all()
|
||||
)
|
||||
job_ids = [job.id for job in jobs]
|
||||
send_attempts = session.query(SendAttempt).filter(SendAttempt.job_id.in_(job_ids)).count() if job_ids else 0
|
||||
imap_attempts = session.query(ImapAppendAttempt).filter(ImapAppendAttempt.job_id.in_(job_ids)).count() if job_ids else 0
|
||||
persisted_issues = session.query(CampaignIssue).filter(CampaignIssue.tenant_id == tenant_id, CampaignIssue.campaign_id == campaign.id).count()
|
||||
|
||||
validation_counts = _counter([job.validation_status for job in jobs])
|
||||
queue_counts = _counter([job.queue_status for job in jobs])
|
||||
send_counts = _counter([job.send_status for job in jobs])
|
||||
imap_counts = _counter([job.imap_status for job in jobs])
|
||||
build_counts = _counter([job.build_status for job in jobs])
|
||||
|
||||
queueable = sum(1 for job in jobs if job.validation_status in {"ready", "warning"} and job.build_status == "built")
|
||||
needs_attention = sum(
|
||||
1
|
||||
for job in jobs
|
||||
if job.validation_status in {"needs_review", "blocked"}
|
||||
or job.send_status in {"failed_temporary", "failed_permanent"}
|
||||
or job.imap_status == "failed"
|
||||
)
|
||||
sent = send_counts.get("sent", 0)
|
||||
failed = send_counts.get("failed_temporary", 0) + send_counts.get("failed_permanent", 0)
|
||||
|
||||
report: dict[str, Any] = {
|
||||
"generated_at": _utcnow_iso(),
|
||||
"campaign": {
|
||||
"id": campaign.id,
|
||||
"external_id": campaign.external_id,
|
||||
"name": campaign.name,
|
||||
"description": campaign.description,
|
||||
"status": campaign.status,
|
||||
"created_at": campaign.created_at.isoformat() if campaign.created_at else None,
|
||||
"updated_at": campaign.updated_at.isoformat() if campaign.updated_at else None,
|
||||
},
|
||||
"current_version": _version_info(version),
|
||||
"cards": {
|
||||
"jobs_total": len(jobs),
|
||||
"queueable": queueable,
|
||||
"needs_attention": needs_attention,
|
||||
"sent": sent,
|
||||
"failed": failed,
|
||||
"imap_appended": imap_counts.get("appended", 0),
|
||||
"imap_failed": imap_counts.get("failed", 0),
|
||||
},
|
||||
"status_counts": {
|
||||
"build": build_counts,
|
||||
"validation": validation_counts,
|
||||
"queue": queue_counts,
|
||||
"send": send_counts,
|
||||
"imap": imap_counts,
|
||||
},
|
||||
"issues": {
|
||||
**_issue_summary_from_jobs(jobs),
|
||||
"persisted_campaign_issue_count": persisted_issues,
|
||||
},
|
||||
"attachments": _attachment_summary(jobs),
|
||||
"attempts": {
|
||||
"send_attempts": int(send_attempts),
|
||||
"imap_append_attempts": int(imap_attempts),
|
||||
},
|
||||
"delivery": _load_delivery_info(version, jobs),
|
||||
}
|
||||
if include_recent_failures:
|
||||
report["recent_failures"] = _recent_failures(jobs)
|
||||
if include_jobs:
|
||||
report["jobs"] = [_job_row(job) for job in jobs]
|
||||
return report
|
||||
|
||||
|
||||
def generate_jobs_csv(session: Session, *, tenant_id: str, campaign_id: str) -> str:
|
||||
campaign = _get_campaign(session, tenant_id=tenant_id, campaign_id=campaign_id)
|
||||
jobs = (
|
||||
session.query(CampaignJob)
|
||||
.filter(CampaignJob.tenant_id == tenant_id, CampaignJob.campaign_id == campaign.id)
|
||||
.order_by(CampaignJob.entry_index.asc())
|
||||
.all()
|
||||
)
|
||||
rows = [_job_row(job) for job in jobs]
|
||||
fieldnames = [
|
||||
"job_id",
|
||||
"entry_index",
|
||||
"entry_id",
|
||||
"recipient_email",
|
||||
"subject",
|
||||
"build_status",
|
||||
"validation_status",
|
||||
"queue_status",
|
||||
"send_status",
|
||||
"imap_status",
|
||||
"attempt_count",
|
||||
"queued_at",
|
||||
"sent_at",
|
||||
"last_error",
|
||||
"eml_size_bytes",
|
||||
"issues_count",
|
||||
"attachment_config_count",
|
||||
"matched_file_count",
|
||||
]
|
||||
buffer = io.StringIO()
|
||||
writer = csv.DictWriter(buffer, fieldnames=fieldnames)
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
return buffer.getvalue()
|
||||
210
server/app/mailer/reports/emailing.py
Normal file
210
server/app/mailer/reports/emailing.py
Normal file
@@ -0,0 +1,210 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from email.message import EmailMessage
|
||||
from email.utils import formataddr
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.models import Campaign, CampaignVersion
|
||||
from app.mailer.campaign.loader import load_campaign_config
|
||||
from app.mailer.campaign.models import CampaignConfig, SmtpConfig
|
||||
from app.mailer.persistence.campaigns import _write_campaign_snapshot
|
||||
from app.mailer.reports.campaigns import CampaignReportError, generate_campaign_report, generate_jobs_csv
|
||||
from app.mailer.sending.smtp import SmtpConfigurationError, SmtpSendResult, send_email_message
|
||||
|
||||
|
||||
class CampaignReportEmailError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class CampaignReportEmailResult:
|
||||
campaign_id: str
|
||||
to: list[str]
|
||||
subject: str
|
||||
dry_run: bool
|
||||
sent: bool
|
||||
attached_jobs_csv: bool
|
||||
attached_report_json: bool
|
||||
smtp_host: str | None = None
|
||||
smtp_port: int | None = None
|
||||
accepted_count: int | None = None
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"campaign_id": self.campaign_id,
|
||||
"to": self.to,
|
||||
"subject": self.subject,
|
||||
"dry_run": self.dry_run,
|
||||
"sent": self.sent,
|
||||
"attached_jobs_csv": self.attached_jobs_csv,
|
||||
"attached_report_json": self.attached_report_json,
|
||||
"smtp_host": self.smtp_host,
|
||||
"smtp_port": self.smtp_port,
|
||||
"accepted_count": self.accepted_count,
|
||||
}
|
||||
|
||||
|
||||
def _current_version(session: Session, campaign: Campaign) -> CampaignVersion:
|
||||
version = session.get(CampaignVersion, campaign.current_version_id) if campaign.current_version_id else None
|
||||
if version is None:
|
||||
version = (
|
||||
session.query(CampaignVersion)
|
||||
.filter(CampaignVersion.campaign_id == campaign.id)
|
||||
.order_by(CampaignVersion.version_number.desc())
|
||||
.first()
|
||||
)
|
||||
if version is None:
|
||||
raise CampaignReportEmailError("Campaign has no version")
|
||||
return version
|
||||
|
||||
|
||||
def _load_config(version: CampaignVersion) -> CampaignConfig:
|
||||
snapshot_path = _write_campaign_snapshot(version)
|
||||
return load_campaign_config(snapshot_path)
|
||||
|
||||
|
||||
def _effective_from(config: CampaignConfig) -> tuple[str, str | None]:
|
||||
if config.recipients.from_:
|
||||
return config.recipients.from_.email, config.recipients.from_.name
|
||||
if config.server.smtp and config.server.smtp.username and "@" in config.server.smtp.username:
|
||||
return config.server.smtp.username, None
|
||||
raise SmtpConfigurationError("Report email requires recipients.from.email or an SMTP username that is an email address")
|
||||
|
||||
|
||||
def _text_summary(report: dict[str, Any]) -> str:
|
||||
campaign = report["campaign"]
|
||||
cards = report["cards"]
|
||||
status = report["status_counts"]
|
||||
delivery = report.get("delivery", {})
|
||||
lines = [
|
||||
f"Campaign report: {campaign['name']}",
|
||||
"",
|
||||
f"Campaign ID: {campaign['id']}",
|
||||
f"External ID: {campaign['external_id']}",
|
||||
f"Status: {campaign['status']}",
|
||||
"",
|
||||
"Overview",
|
||||
f"- Jobs total: {cards['jobs_total']}",
|
||||
f"- Queueable: {cards['queueable']}",
|
||||
f"- Needs attention: {cards['needs_attention']}",
|
||||
f"- Sent: {cards['sent']}",
|
||||
f"- Failed: {cards['failed']}",
|
||||
f"- IMAP appended: {cards['imap_appended']}",
|
||||
f"- IMAP failed: {cards['imap_failed']}",
|
||||
"",
|
||||
f"Build status: {status.get('build', {})}",
|
||||
f"Validation status: {status.get('validation', {})}",
|
||||
f"Queue status: {status.get('queue', {})}",
|
||||
f"Send status: {status.get('send', {})}",
|
||||
f"IMAP status: {status.get('imap', {})}",
|
||||
]
|
||||
if delivery.get("estimated_remaining_send_human"):
|
||||
lines.extend(["", f"Estimated remaining send time: {delivery['estimated_remaining_send_human']}"])
|
||||
lines.extend(["", "This report was generated by MultiMailer."])
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def build_report_message(
|
||||
*,
|
||||
campaign: Campaign,
|
||||
config: CampaignConfig,
|
||||
report: dict[str, Any],
|
||||
to: list[str],
|
||||
jobs_csv: str | None = None,
|
||||
report_json: dict[str, Any] | None = None,
|
||||
) -> EmailMessage:
|
||||
from_email, from_name = _effective_from(config)
|
||||
subject = f"MultiMailer report: {campaign.name}"
|
||||
|
||||
msg = EmailMessage()
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = formataddr((from_name or from_email, from_email))
|
||||
msg["To"] = ", ".join(to)
|
||||
msg["X-MultiMailer-Report"] = "campaign"
|
||||
msg.set_content(_text_summary(report))
|
||||
|
||||
if jobs_csv is not None:
|
||||
filename = f"multimailer-{campaign.external_id}-jobs.csv"
|
||||
msg.add_attachment(jobs_csv.encode("utf-8"), maintype="text", subtype="csv", filename=filename)
|
||||
if report_json is not None:
|
||||
filename = f"multimailer-{campaign.external_id}-report.json"
|
||||
msg.add_attachment(
|
||||
json.dumps(report_json, indent=2, ensure_ascii=False, default=str).encode("utf-8"),
|
||||
maintype="application",
|
||||
subtype="json",
|
||||
filename=filename,
|
||||
)
|
||||
return msg
|
||||
|
||||
|
||||
def send_campaign_report_email(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
campaign_id: str,
|
||||
to: list[str],
|
||||
include_jobs: bool = False,
|
||||
attach_jobs_csv: bool = True,
|
||||
attach_report_json: bool = False,
|
||||
dry_run: bool = False,
|
||||
) -> CampaignReportEmailResult:
|
||||
campaign = session.get(Campaign, campaign_id)
|
||||
if not campaign or campaign.tenant_id != tenant_id:
|
||||
raise CampaignReportError("Campaign not found")
|
||||
if not to:
|
||||
raise CampaignReportEmailError("At least one report recipient is required")
|
||||
|
||||
version = _current_version(session, campaign)
|
||||
config = _load_config(version)
|
||||
smtp_config: SmtpConfig | None = config.server.smtp
|
||||
if smtp_config is None:
|
||||
raise SmtpConfigurationError("Campaign has no SMTP configuration")
|
||||
|
||||
report = generate_campaign_report(session, tenant_id=tenant_id, campaign_id=campaign_id, include_jobs=include_jobs)
|
||||
jobs_csv = generate_jobs_csv(session, tenant_id=tenant_id, campaign_id=campaign_id) if attach_jobs_csv else None
|
||||
report_json = report if attach_report_json else None
|
||||
message = build_report_message(
|
||||
campaign=campaign,
|
||||
config=config,
|
||||
report=report,
|
||||
to=to,
|
||||
jobs_csv=jobs_csv,
|
||||
report_json=report_json,
|
||||
)
|
||||
envelope_from, _ = _effective_from(config)
|
||||
|
||||
if dry_run:
|
||||
return CampaignReportEmailResult(
|
||||
campaign_id=campaign.id,
|
||||
to=to,
|
||||
subject=str(message["Subject"]),
|
||||
dry_run=True,
|
||||
sent=False,
|
||||
attached_jobs_csv=jobs_csv is not None,
|
||||
attached_report_json=report_json is not None,
|
||||
smtp_host=smtp_config.host,
|
||||
smtp_port=smtp_config.port,
|
||||
)
|
||||
|
||||
result: SmtpSendResult = send_email_message(
|
||||
message,
|
||||
smtp_config=smtp_config,
|
||||
envelope_from=envelope_from,
|
||||
envelope_recipients=to,
|
||||
)
|
||||
return CampaignReportEmailResult(
|
||||
campaign_id=campaign.id,
|
||||
to=to,
|
||||
subject=str(message["Subject"]),
|
||||
dry_run=False,
|
||||
sent=True,
|
||||
attached_jobs_csv=jobs_csv is not None,
|
||||
attached_report_json=report_json is not None,
|
||||
smtp_host=result.host,
|
||||
smtp_port=result.port,
|
||||
accepted_count=result.accepted_count,
|
||||
)
|
||||
804
server/app/mailer/schema/campaign.schema.json
Normal file
804
server/app/mailer/schema/campaign.schema.json
Normal file
@@ -0,0 +1,804 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://multimailer.local/schema/campaign.schema.json",
|
||||
"title": "MultiMailer Campaign",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"version",
|
||||
"campaign",
|
||||
"template",
|
||||
"entries"
|
||||
],
|
||||
"properties": {
|
||||
"version": {
|
||||
"type": "string",
|
||||
"const": "1.0"
|
||||
},
|
||||
"campaign": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id",
|
||||
"name"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"draft",
|
||||
"test",
|
||||
"send"
|
||||
],
|
||||
"default": "draft"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"fields": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"string",
|
||||
"integer",
|
||||
"double",
|
||||
"date",
|
||||
"password"
|
||||
],
|
||||
"default": "string"
|
||||
},
|
||||
"label": {
|
||||
"type": "string"
|
||||
},
|
||||
"required": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"default": []
|
||||
},
|
||||
"global_values": {
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"default": {}
|
||||
},
|
||||
"server": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"smtp": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"host": {
|
||||
"type": "string"
|
||||
},
|
||||
"port": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 65535
|
||||
},
|
||||
"username": {
|
||||
"type": "string"
|
||||
},
|
||||
"password": {
|
||||
"type": "string"
|
||||
},
|
||||
"security": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"plain",
|
||||
"tls",
|
||||
"starttls"
|
||||
],
|
||||
"default": "starttls"
|
||||
},
|
||||
"timeout_seconds": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"default": 30
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"imap": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"host": {
|
||||
"type": "string"
|
||||
},
|
||||
"port": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 65535
|
||||
},
|
||||
"username": {
|
||||
"type": "string"
|
||||
},
|
||||
"password": {
|
||||
"type": "string"
|
||||
},
|
||||
"security": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"plain",
|
||||
"tls",
|
||||
"starttls"
|
||||
],
|
||||
"default": "tls"
|
||||
},
|
||||
"sent_folder": {
|
||||
"type": "string",
|
||||
"default": "auto"
|
||||
},
|
||||
"timeout_seconds": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"default": 30
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"recipients": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"from": {
|
||||
"$ref": "#/$defs/recipient"
|
||||
},
|
||||
"allow_individual_from": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"to": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/recipient"
|
||||
},
|
||||
"default": []
|
||||
},
|
||||
"allow_individual_to": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"cc": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/recipient"
|
||||
},
|
||||
"default": []
|
||||
},
|
||||
"allow_individual_cc": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"bcc": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/recipient"
|
||||
},
|
||||
"default": []
|
||||
},
|
||||
"allow_individual_bcc": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"reply_to": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/recipient"
|
||||
},
|
||||
"default": []
|
||||
},
|
||||
"allow_individual_reply_to": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"bounce_to": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/recipient"
|
||||
},
|
||||
"default": []
|
||||
},
|
||||
"allow_individual_bounce_to": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"disposition_notification_to": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/recipient"
|
||||
},
|
||||
"default": []
|
||||
},
|
||||
"allow_individual_disposition_notification_to": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"default": {}
|
||||
},
|
||||
"template": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"subject"
|
||||
],
|
||||
"properties": {
|
||||
"subject": {
|
||||
"type": "string"
|
||||
},
|
||||
"text": {
|
||||
"type": "string"
|
||||
},
|
||||
"html": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"source"
|
||||
],
|
||||
"properties": {
|
||||
"source": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"type"
|
||||
],
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "files"
|
||||
},
|
||||
"subject_path": {
|
||||
"type": "string"
|
||||
},
|
||||
"text_path": {
|
||||
"type": "string"
|
||||
},
|
||||
"html_path": {
|
||||
"type": "string"
|
||||
},
|
||||
"encoding": {
|
||||
"type": "string",
|
||||
"default": "utf-8"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"attachments": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"base_path": {
|
||||
"type": "string",
|
||||
"default": ".",
|
||||
"description": "Campaign-level base path. Global and entry attachment base_dir values are resolved relative to this path unless absolute."
|
||||
},
|
||||
"allow_individual": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"send_without_attachments": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Legacy compatibility flag. Prefer validation_policy and per-config missing_behavior for new campaigns."
|
||||
},
|
||||
"global": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/attachment_config"
|
||||
},
|
||||
"default": []
|
||||
},
|
||||
"missing_behavior": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"block",
|
||||
"ask",
|
||||
"drop",
|
||||
"continue",
|
||||
"warn"
|
||||
],
|
||||
"default": "ask"
|
||||
},
|
||||
"ambiguous_behavior": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"block",
|
||||
"ask",
|
||||
"drop",
|
||||
"continue",
|
||||
"warn"
|
||||
],
|
||||
"default": "ask"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"default": {
|
||||
"base_path": ".",
|
||||
"global": []
|
||||
}
|
||||
},
|
||||
"entries": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"inline"
|
||||
],
|
||||
"properties": {
|
||||
"inline": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/entry"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"source",
|
||||
"mapping"
|
||||
],
|
||||
"properties": {
|
||||
"source": {
|
||||
"$ref": "#/$defs/source"
|
||||
},
|
||||
"mapping": {
|
||||
"type": "object",
|
||||
"description": "Internal campaign path -> source column/key. Examples: to.0.email, fields.number, attachments.0.file_filter.",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"defaults": {
|
||||
"$ref": "#/$defs/entry"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"validation_policy": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"missing_required_attachment": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"block",
|
||||
"ask",
|
||||
"drop",
|
||||
"continue",
|
||||
"warn"
|
||||
],
|
||||
"default": "ask"
|
||||
},
|
||||
"missing_optional_attachment": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"block",
|
||||
"ask",
|
||||
"drop",
|
||||
"continue",
|
||||
"warn"
|
||||
],
|
||||
"default": "warn"
|
||||
},
|
||||
"ambiguous_attachment_match": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"block",
|
||||
"ask",
|
||||
"drop",
|
||||
"continue",
|
||||
"warn"
|
||||
],
|
||||
"default": "ask"
|
||||
},
|
||||
"missing_email": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"block",
|
||||
"drop"
|
||||
],
|
||||
"default": "block"
|
||||
},
|
||||
"template_error": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"block",
|
||||
"drop"
|
||||
],
|
||||
"default": "block"
|
||||
},
|
||||
"inactive_entry": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"drop",
|
||||
"block",
|
||||
"warn"
|
||||
],
|
||||
"default": "drop"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"default": {}
|
||||
},
|
||||
"delivery": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"rate_limit": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"messages_per_minute": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"default": 5
|
||||
},
|
||||
"concurrency": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"default": 1
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"imap_append_sent": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"folder": {
|
||||
"type": "string",
|
||||
"default": "auto"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"retry": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"max_attempts": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"default": 3
|
||||
},
|
||||
"backoff_seconds": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"default": [
|
||||
60,
|
||||
300,
|
||||
900
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"default": {}
|
||||
},
|
||||
"status_tracking": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
},
|
||||
"initial_build_status": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"built",
|
||||
"build_failed"
|
||||
],
|
||||
"default": "built"
|
||||
},
|
||||
"initial_send_status": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"draft",
|
||||
"queued"
|
||||
],
|
||||
"default": "draft"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"default": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"$defs": {
|
||||
"recipient": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"email"
|
||||
],
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string",
|
||||
"format": "email"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"to",
|
||||
"cc",
|
||||
"bcc",
|
||||
"reply_to",
|
||||
"bounce_to",
|
||||
"disposition_notification_to"
|
||||
],
|
||||
"default": "to"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"attachment_config": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"base_dir",
|
||||
"file_filter"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Optional stable ID for UI/status references."
|
||||
},
|
||||
"label": {
|
||||
"type": "string"
|
||||
},
|
||||
"base_dir": {
|
||||
"type": "string",
|
||||
"description": "Directory relative to attachments.base_path unless absolute."
|
||||
},
|
||||
"file_filter": {
|
||||
"type": "string",
|
||||
"description": "Glob/filter expression, rendered with global/local fields before matching."
|
||||
},
|
||||
"include_subdirs": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"required": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
},
|
||||
"allow_multiple": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"missing_behavior": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"block",
|
||||
"ask",
|
||||
"drop",
|
||||
"continue",
|
||||
"warn"
|
||||
],
|
||||
"default": "ask"
|
||||
},
|
||||
"ambiguous_behavior": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"block",
|
||||
"ask",
|
||||
"drop",
|
||||
"continue",
|
||||
"warn"
|
||||
],
|
||||
"default": "ask"
|
||||
},
|
||||
"zip": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"filename_template": {
|
||||
"type": "string"
|
||||
},
|
||||
"password_template": {
|
||||
"type": "string"
|
||||
},
|
||||
"method": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"zip_standard",
|
||||
"aes"
|
||||
],
|
||||
"default": "aes"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"default": {
|
||||
"enabled": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"entry": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"active": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
},
|
||||
"from": {
|
||||
"$ref": "#/$defs/recipient"
|
||||
},
|
||||
"to": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/recipient"
|
||||
},
|
||||
"default": []
|
||||
},
|
||||
"combine_to": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
},
|
||||
"cc": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/recipient"
|
||||
},
|
||||
"default": []
|
||||
},
|
||||
"combine_cc": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
},
|
||||
"bcc": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/recipient"
|
||||
},
|
||||
"default": []
|
||||
},
|
||||
"combine_bcc": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
},
|
||||
"reply_to": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/recipient"
|
||||
},
|
||||
"default": []
|
||||
},
|
||||
"combine_reply_to": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
},
|
||||
"bounce_to": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/recipient"
|
||||
},
|
||||
"default": []
|
||||
},
|
||||
"combine_bounce_to": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
},
|
||||
"disposition_notification_to": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/recipient"
|
||||
},
|
||||
"default": []
|
||||
},
|
||||
"combine_disposition_notification_to": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
},
|
||||
"attachments": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/attachment_config"
|
||||
},
|
||||
"default": []
|
||||
},
|
||||
"combine_attachments": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
},
|
||||
"fields": {
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"default": {}
|
||||
},
|
||||
"last_sent": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"source": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"type",
|
||||
"path"
|
||||
],
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"csv",
|
||||
"json"
|
||||
]
|
||||
},
|
||||
"path": {
|
||||
"type": "string"
|
||||
},
|
||||
"delimiter": {
|
||||
"type": "string",
|
||||
"default": ";"
|
||||
},
|
||||
"encoding": {
|
||||
"type": "string",
|
||||
"default": "utf-8"
|
||||
},
|
||||
"has_header": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
1
server/app/mailer/sending/__init__.py
Normal file
1
server/app/mailer/sending/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Sending helpers for MultiMailer."""
|
||||
195
server/app/mailer/sending/imap.py
Normal file
195
server/app/mailer/sending/imap.py
Normal file
@@ -0,0 +1,195 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import imaplib
|
||||
import re
|
||||
import socket
|
||||
import ssl
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
from app.mailer.campaign.models import ImapConfig, TransportSecurity
|
||||
|
||||
|
||||
class ImapConfigurationError(ValueError):
|
||||
"""Raised when IMAP settings are incomplete or inconsistent."""
|
||||
|
||||
|
||||
class ImapAppendError(RuntimeError):
|
||||
"""Raised when APPENDing to Sent fails.
|
||||
|
||||
temporary=True means retrying later may help. temporary=False means the
|
||||
configuration or mailbox choice probably needs user/admin attention.
|
||||
"""
|
||||
|
||||
def __init__(self, message: str, *, temporary: bool | None = None):
|
||||
super().__init__(message)
|
||||
self.temporary = temporary
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ImapAppendResult:
|
||||
host: str
|
||||
port: int
|
||||
security: str
|
||||
folder: str
|
||||
bytes_appended: int
|
||||
response: str | None = None
|
||||
|
||||
|
||||
def _require_imap_config(config: ImapConfig) -> tuple[str, int]:
|
||||
if not config.enabled:
|
||||
raise ImapConfigurationError("IMAP is disabled")
|
||||
if not config.host:
|
||||
raise ImapConfigurationError("IMAP host is required")
|
||||
if not config.port:
|
||||
raise ImapConfigurationError("IMAP port is required")
|
||||
if bool(config.username) != bool(config.password):
|
||||
raise ImapConfigurationError("IMAP username and password must be provided together, or both omitted")
|
||||
return config.host, config.port
|
||||
|
||||
|
||||
def _open_imap(config: ImapConfig) -> imaplib.IMAP4:
|
||||
host, port = _require_imap_config(config)
|
||||
context = ssl.create_default_context()
|
||||
|
||||
try:
|
||||
if config.security == TransportSecurity.TLS:
|
||||
client: imaplib.IMAP4 = imaplib.IMAP4_SSL(host=host, port=port, timeout=config.timeout_seconds, ssl_context=context)
|
||||
else:
|
||||
client = imaplib.IMAP4(host=host, port=port, timeout=config.timeout_seconds)
|
||||
if config.security == TransportSecurity.STARTTLS:
|
||||
typ, data = client.starttls(ssl_context=context)
|
||||
if typ != "OK":
|
||||
raise ImapAppendError(f"IMAP STARTTLS failed: {data!r}", temporary=True)
|
||||
|
||||
if config.username and config.password:
|
||||
typ, data = client.login(config.username, config.password)
|
||||
if typ != "OK":
|
||||
raise ImapAppendError(f"IMAP login failed: {data!r}", temporary=False)
|
||||
return client
|
||||
except Exception:
|
||||
try:
|
||||
client.logout() # type: ignore[possibly-undefined]
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
def _decode_item(item: bytes | str | None) -> str:
|
||||
if item is None:
|
||||
return ""
|
||||
if isinstance(item, bytes):
|
||||
return item.decode("utf-8", errors="replace")
|
||||
return item
|
||||
|
||||
|
||||
def _extract_mailbox_name(list_response_line: bytes | str) -> tuple[str, set[str]] | None:
|
||||
"""Best-effort parser for IMAP LIST response lines.
|
||||
|
||||
Example lines:
|
||||
(\\HasNoChildren \\Sent) "/" "Sent"
|
||||
(\\HasNoChildren) "/" "Sent Items"
|
||||
"""
|
||||
|
||||
line = _decode_item(list_response_line).strip()
|
||||
flags_match = re.match(r"^\((?P<flags>[^)]*)\)\s+", line)
|
||||
flags = set()
|
||||
if flags_match:
|
||||
flags = {part.lower() for part in flags_match.group("flags").split()}
|
||||
|
||||
quoted = re.findall(r'"((?:[^"\\]|\\.)*)"', line)
|
||||
if quoted:
|
||||
# Usually: delimiter, mailbox. Take the last quoted token.
|
||||
return quoted[-1].replace(r'\"', '"'), flags
|
||||
|
||||
# Fallback for unquoted final atom.
|
||||
parts = line.split()
|
||||
if parts:
|
||||
return parts[-1], flags
|
||||
return None
|
||||
|
||||
|
||||
def discover_sent_folder(client: imaplib.IMAP4) -> str | None:
|
||||
typ, data = client.list()
|
||||
if typ != "OK" or not data:
|
||||
return None
|
||||
|
||||
parsed: list[tuple[str, set[str]]] = []
|
||||
for item in data:
|
||||
extracted = _extract_mailbox_name(item)
|
||||
if extracted:
|
||||
parsed.append(extracted)
|
||||
|
||||
for name, flags in parsed:
|
||||
if "\\sent" in flags or "\\sentmail" in flags:
|
||||
return name
|
||||
|
||||
common_names = [
|
||||
"Sent",
|
||||
"Sent Items",
|
||||
"Sent Messages",
|
||||
"Gesendet",
|
||||
"Gesendete Elemente",
|
||||
"INBOX.Sent",
|
||||
"INBOX/Sent",
|
||||
]
|
||||
names = {name.lower(): name for name, _ in parsed}
|
||||
for candidate in common_names:
|
||||
if candidate.lower() in names:
|
||||
return names[candidate.lower()]
|
||||
return None
|
||||
|
||||
|
||||
def _effective_sent_folder(*, config: ImapConfig, requested_folder: str | None, client: imaplib.IMAP4) -> str:
|
||||
if requested_folder and requested_folder != "auto":
|
||||
return requested_folder
|
||||
if config.sent_folder and config.sent_folder != "auto":
|
||||
return config.sent_folder
|
||||
discovered = discover_sent_folder(client)
|
||||
if discovered:
|
||||
return discovered
|
||||
raise ImapConfigurationError("Could not discover Sent folder; configure delivery.imap_append_sent.folder or server.imap.sent_folder")
|
||||
|
||||
|
||||
def append_message_to_sent(
|
||||
message_bytes: bytes,
|
||||
*,
|
||||
imap_config: ImapConfig,
|
||||
folder: str | None = None,
|
||||
) -> ImapAppendResult:
|
||||
"""Append a sent MIME message to the configured IMAP Sent folder.
|
||||
|
||||
The SMTP send remains authoritative. APPEND is a separate best-effort step
|
||||
and should not be used to decide whether an email was sent.
|
||||
"""
|
||||
|
||||
host, port = _require_imap_config(imap_config)
|
||||
client: imaplib.IMAP4 | None = None
|
||||
try:
|
||||
client = _open_imap(imap_config)
|
||||
target_folder = _effective_sent_folder(config=imap_config, requested_folder=folder, client=client)
|
||||
internal_date = imaplib.Time2Internaldate(time.time())
|
||||
typ, data = client.append(target_folder, "\\Seen", internal_date, message_bytes)
|
||||
if typ != "OK":
|
||||
raise ImapAppendError(f"IMAP APPEND failed for folder {target_folder!r}: {data!r}", temporary=False)
|
||||
response = "; ".join(_decode_item(item) for item in (data or [])) or None
|
||||
return ImapAppendResult(
|
||||
host=host,
|
||||
port=port,
|
||||
security=imap_config.security.value,
|
||||
folder=target_folder,
|
||||
bytes_appended=len(message_bytes),
|
||||
response=response,
|
||||
)
|
||||
except ImapAppendError:
|
||||
raise
|
||||
except (OSError, socket.timeout, imaplib.IMAP4.abort) as exc:
|
||||
raise ImapAppendError(f"IMAP append failed: {exc}", temporary=True) from exc
|
||||
except imaplib.IMAP4.error as exc:
|
||||
raise ImapAppendError(f"IMAP append failed: {exc}", temporary=False) from exc
|
||||
finally:
|
||||
if client is not None:
|
||||
try:
|
||||
client.logout()
|
||||
except Exception:
|
||||
pass
|
||||
606
server/app/mailer/sending/jobs.py
Normal file
606
server/app/mailer/sending/jobs.py
Normal file
@@ -0,0 +1,606 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime, timezone
|
||||
from email import policy
|
||||
from email.parser import BytesParser
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.models import (
|
||||
Campaign,
|
||||
CampaignJob,
|
||||
CampaignStatus,
|
||||
CampaignVersion,
|
||||
JobBuildStatus,
|
||||
JobImapStatus,
|
||||
JobQueueStatus,
|
||||
JobSendStatus,
|
||||
JobValidationStatus,
|
||||
ImapAppendAttempt,
|
||||
SendAttempt,
|
||||
)
|
||||
from app.mailer.campaign.loader import load_campaign_config
|
||||
from app.mailer.campaign.models import CampaignConfig
|
||||
from app.mailer.persistence.campaigns import _write_campaign_snapshot
|
||||
from app.mailer.sending.rate_limit import wait_for_rate_limit
|
||||
from app.mailer.sending.smtp import SmtpConfigurationError, SmtpSendError, send_email_message
|
||||
from app.mailer.sending.imap import ImapAppendError, ImapConfigurationError, append_message_to_sent
|
||||
|
||||
|
||||
class QueueingError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class SendJobError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class QueueCampaignResult:
|
||||
campaign_id: str
|
||||
version_id: str
|
||||
queued_count: int
|
||||
skipped_count: int
|
||||
blocked_count: int
|
||||
enqueued_count: int
|
||||
dry_run: bool = False
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"campaign_id": self.campaign_id,
|
||||
"version_id": self.version_id,
|
||||
"queued_count": self.queued_count,
|
||||
"skipped_count": self.skipped_count,
|
||||
"blocked_count": self.blocked_count,
|
||||
"enqueued_count": self.enqueued_count,
|
||||
"dry_run": self.dry_run,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SendJobResult:
|
||||
job_id: str
|
||||
status: str
|
||||
attempt_number: int
|
||||
dry_run: bool = False
|
||||
message: str | None = None
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"job_id": self.job_id,
|
||||
"status": self.status,
|
||||
"attempt_number": self.attempt_number,
|
||||
"dry_run": self.dry_run,
|
||||
"message": self.message,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AppendSentResult:
|
||||
job_id: str
|
||||
status: str
|
||||
attempt_number: int
|
||||
dry_run: bool = False
|
||||
folder: str | None = None
|
||||
message: str | None = None
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"job_id": self.job_id,
|
||||
"status": self.status,
|
||||
"attempt_number": self.attempt_number,
|
||||
"dry_run": self.dry_run,
|
||||
"folder": self.folder,
|
||||
"message": self.message,
|
||||
}
|
||||
|
||||
|
||||
QUEUEABLE_VALIDATION_STATUSES = {
|
||||
JobValidationStatus.READY.value,
|
||||
JobValidationStatus.WARNING.value,
|
||||
}
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _get_campaign_for_tenant(session: Session, *, campaign_id: str, tenant_id: str) -> Campaign:
|
||||
campaign = session.query(Campaign).filter(Campaign.id == campaign_id, Campaign.tenant_id == tenant_id).one_or_none()
|
||||
if not campaign:
|
||||
raise QueueingError(f"Campaign not found or not accessible: {campaign_id}")
|
||||
return campaign
|
||||
|
||||
|
||||
def _get_current_version(session: Session, campaign: Campaign, version_id: str | None = None) -> CampaignVersion:
|
||||
wanted = version_id or campaign.current_version_id
|
||||
if not wanted:
|
||||
raise QueueingError("Campaign has no current version")
|
||||
version = session.get(CampaignVersion, wanted)
|
||||
if not version or version.campaign_id != campaign.id:
|
||||
raise QueueingError(f"Campaign version not found or not part of campaign: {wanted}")
|
||||
return version
|
||||
|
||||
|
||||
def _load_version_campaign_config(version: CampaignVersion) -> tuple[Path, CampaignConfig]:
|
||||
snapshot_path = _write_campaign_snapshot(version)
|
||||
return snapshot_path, load_campaign_config(snapshot_path)
|
||||
|
||||
|
||||
def _celery_enqueue_send_job(job_id: str) -> None:
|
||||
from app.celery_app import celery
|
||||
|
||||
celery.send_task("multimailer.send_email", args=[job_id], queue="send_email")
|
||||
|
||||
|
||||
def _celery_enqueue_append_sent_job(job_id: str) -> None:
|
||||
from app.celery_app import celery
|
||||
|
||||
celery.send_task("multimailer.append_sent", args=[job_id], queue="append_sent")
|
||||
|
||||
|
||||
def queue_campaign_jobs(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
campaign_id: str,
|
||||
version_id: str | None = None,
|
||||
enqueue_celery: bool = True,
|
||||
include_warnings: bool = True,
|
||||
dry_run: bool = False,
|
||||
) -> QueueCampaignResult:
|
||||
"""Move queueable DB jobs to QUEUED and optionally enqueue Celery tasks."""
|
||||
|
||||
campaign = _get_campaign_for_tenant(session, campaign_id=campaign_id, tenant_id=tenant_id)
|
||||
version = _get_current_version(session, campaign, version_id=version_id)
|
||||
|
||||
allowed_validation = {JobValidationStatus.READY.value}
|
||||
if include_warnings:
|
||||
allowed_validation.add(JobValidationStatus.WARNING.value)
|
||||
|
||||
jobs = (
|
||||
session.query(CampaignJob)
|
||||
.filter(CampaignJob.tenant_id == tenant_id, CampaignJob.campaign_version_id == version.id)
|
||||
.order_by(CampaignJob.entry_index.asc())
|
||||
.all()
|
||||
)
|
||||
if not jobs:
|
||||
raise QueueingError("Campaign version has no jobs. Build messages before queueing.")
|
||||
|
||||
queued: list[CampaignJob] = []
|
||||
skipped_count = 0
|
||||
blocked_count = 0
|
||||
for job in jobs:
|
||||
if job.queue_status in {JobQueueStatus.CANCELLED.value, JobQueueStatus.SENDING.value} or job.send_status == JobSendStatus.SENT.value:
|
||||
skipped_count += 1
|
||||
continue
|
||||
if job.build_status != JobBuildStatus.BUILT.value or job.validation_status not in allowed_validation:
|
||||
blocked_count += 1
|
||||
continue
|
||||
if not job.eml_local_path and not job.eml_storage_key:
|
||||
job.last_error = "Job has no generated EML path/storage key. Rebuild with write_eml enabled before queueing."
|
||||
blocked_count += 1
|
||||
continue
|
||||
|
||||
queued.append(job)
|
||||
if not dry_run:
|
||||
job.queue_status = JobQueueStatus.QUEUED.value
|
||||
job.send_status = JobSendStatus.QUEUED.value
|
||||
job.queued_at = _utcnow()
|
||||
job.last_error = None
|
||||
session.add(job)
|
||||
|
||||
if not dry_run:
|
||||
if queued:
|
||||
campaign.status = CampaignStatus.QUEUED.value
|
||||
session.add(campaign)
|
||||
session.commit()
|
||||
|
||||
enqueued_count = 0
|
||||
if enqueue_celery and not dry_run:
|
||||
for job in queued:
|
||||
_celery_enqueue_send_job(job.id)
|
||||
enqueued_count += 1
|
||||
|
||||
return QueueCampaignResult(
|
||||
campaign_id=campaign.id,
|
||||
version_id=version.id,
|
||||
queued_count=len(queued),
|
||||
skipped_count=skipped_count,
|
||||
blocked_count=blocked_count,
|
||||
enqueued_count=enqueued_count,
|
||||
dry_run=dry_run,
|
||||
)
|
||||
|
||||
|
||||
def enqueue_existing_queued_jobs(session: Session, *, tenant_id: str, campaign_id: str) -> int:
|
||||
campaign = _get_campaign_for_tenant(session, campaign_id=campaign_id, tenant_id=tenant_id)
|
||||
jobs = (
|
||||
session.query(CampaignJob)
|
||||
.filter(
|
||||
CampaignJob.tenant_id == tenant_id,
|
||||
CampaignJob.campaign_id == campaign.id,
|
||||
CampaignJob.queue_status == JobQueueStatus.QUEUED.value,
|
||||
CampaignJob.send_status.in_([JobSendStatus.QUEUED.value, JobSendStatus.FAILED_TEMPORARY.value]),
|
||||
)
|
||||
.order_by(CampaignJob.entry_index.asc())
|
||||
.all()
|
||||
)
|
||||
for job in jobs:
|
||||
_celery_enqueue_send_job(job.id)
|
||||
return len(jobs)
|
||||
|
||||
|
||||
def pause_campaign_jobs(session: Session, *, tenant_id: str, campaign_id: str) -> dict[str, Any]:
|
||||
campaign = _get_campaign_for_tenant(session, campaign_id=campaign_id, tenant_id=tenant_id)
|
||||
changed = (
|
||||
session.query(CampaignJob)
|
||||
.filter(
|
||||
CampaignJob.tenant_id == tenant_id,
|
||||
CampaignJob.campaign_id == campaign.id,
|
||||
CampaignJob.queue_status == JobQueueStatus.QUEUED.value,
|
||||
)
|
||||
.update({CampaignJob.queue_status: JobQueueStatus.PAUSED.value}, synchronize_session=False)
|
||||
)
|
||||
if changed:
|
||||
campaign.status = CampaignStatus.READY_TO_QUEUE.value
|
||||
session.add(campaign)
|
||||
session.commit()
|
||||
return {"campaign_id": campaign.id, "paused_count": int(changed)}
|
||||
|
||||
|
||||
def resume_campaign_jobs(session: Session, *, tenant_id: str, campaign_id: str, enqueue_celery: bool = True) -> dict[str, Any]:
|
||||
campaign = _get_campaign_for_tenant(session, campaign_id=campaign_id, tenant_id=tenant_id)
|
||||
jobs = (
|
||||
session.query(CampaignJob)
|
||||
.filter(
|
||||
CampaignJob.tenant_id == tenant_id,
|
||||
CampaignJob.campaign_id == campaign.id,
|
||||
CampaignJob.queue_status == JobQueueStatus.PAUSED.value,
|
||||
)
|
||||
.order_by(CampaignJob.entry_index.asc())
|
||||
.all()
|
||||
)
|
||||
for job in jobs:
|
||||
job.queue_status = JobQueueStatus.QUEUED.value
|
||||
job.send_status = JobSendStatus.QUEUED.value
|
||||
session.add(job)
|
||||
if jobs:
|
||||
campaign.status = CampaignStatus.QUEUED.value
|
||||
session.add(campaign)
|
||||
session.commit()
|
||||
|
||||
enqueued_count = 0
|
||||
if enqueue_celery:
|
||||
for job in jobs:
|
||||
_celery_enqueue_send_job(job.id)
|
||||
enqueued_count += 1
|
||||
return {"campaign_id": campaign.id, "resumed_count": len(jobs), "enqueued_count": enqueued_count}
|
||||
|
||||
|
||||
def cancel_campaign_jobs(session: Session, *, tenant_id: str, campaign_id: str) -> dict[str, Any]:
|
||||
campaign = _get_campaign_for_tenant(session, campaign_id=campaign_id, tenant_id=tenant_id)
|
||||
jobs = (
|
||||
session.query(CampaignJob)
|
||||
.filter(
|
||||
CampaignJob.tenant_id == tenant_id,
|
||||
CampaignJob.campaign_id == campaign.id,
|
||||
CampaignJob.send_status.notin_([JobSendStatus.SENT.value]),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for job in jobs:
|
||||
if job.queue_status != JobQueueStatus.SENDING.value:
|
||||
job.queue_status = JobQueueStatus.CANCELLED.value
|
||||
job.send_status = JobSendStatus.CANCELLED.value
|
||||
session.add(job)
|
||||
campaign.status = CampaignStatus.CANCELLED.value
|
||||
session.add(campaign)
|
||||
session.commit()
|
||||
return {"campaign_id": campaign.id, "cancelled_count": len(jobs)}
|
||||
|
||||
|
||||
def _load_eml_bytes_for_job(job: CampaignJob) -> bytes:
|
||||
if job.eml_local_path:
|
||||
path = Path(job.eml_local_path)
|
||||
if not path.exists():
|
||||
raise SendJobError(f"Generated EML file does not exist: {path}")
|
||||
return path.read_bytes()
|
||||
raise SendJobError("Only local EML paths are supported for sending in this implementation step")
|
||||
|
||||
|
||||
def _load_eml_for_job(job: CampaignJob):
|
||||
return BytesParser(policy=policy.default).parsebytes(_load_eml_bytes_for_job(job))
|
||||
|
||||
|
||||
def _addresses_from_job(job: CampaignJob, field: str) -> list[str]:
|
||||
data = job.resolved_recipients or {}
|
||||
values = data.get(field) or []
|
||||
return [item.get("email") for item in values if isinstance(item, dict) and item.get("email")]
|
||||
|
||||
|
||||
def _sender_from_job(job: CampaignJob, config: CampaignConfig) -> str:
|
||||
data = job.resolved_recipients or {}
|
||||
bounce_to = _addresses_from_job(job, "bounce_to")
|
||||
if bounce_to:
|
||||
return bounce_to[0]
|
||||
from_data = data.get("from") if isinstance(data, dict) else None
|
||||
if isinstance(from_data, dict) and from_data.get("email"):
|
||||
return from_data["email"]
|
||||
if config.server.smtp and config.server.smtp.username:
|
||||
return config.server.smtp.username
|
||||
raise SmtpConfigurationError("No envelope sender could be determined")
|
||||
|
||||
|
||||
def _recipients_from_job(job: CampaignJob) -> list[str]:
|
||||
recipients: list[str] = []
|
||||
for field in ["to", "cc", "bcc"]:
|
||||
recipients.extend(_addresses_from_job(job, field))
|
||||
# Preserve order while de-duplicating.
|
||||
return list(dict.fromkeys(recipients))
|
||||
|
||||
|
||||
def _record_attempt_start(session: Session, job: CampaignJob) -> SendAttempt:
|
||||
attempt = SendAttempt(
|
||||
job_id=job.id,
|
||||
attempt_number=job.attempt_count + 1,
|
||||
started_at=_utcnow(),
|
||||
)
|
||||
job.attempt_count += 1
|
||||
job.queue_status = JobQueueStatus.SENDING.value
|
||||
job.send_status = JobSendStatus.SENDING.value
|
||||
job.last_error = None
|
||||
session.add(attempt)
|
||||
session.add(job)
|
||||
session.commit()
|
||||
return attempt
|
||||
|
||||
|
||||
def _update_campaign_after_job(session: Session, campaign_id: str) -> None:
|
||||
session.flush()
|
||||
campaign = session.get(Campaign, campaign_id)
|
||||
if not campaign:
|
||||
return
|
||||
remaining = (
|
||||
session.query(CampaignJob)
|
||||
.filter(
|
||||
CampaignJob.campaign_id == campaign_id,
|
||||
CampaignJob.queue_status.in_([JobQueueStatus.QUEUED.value, JobQueueStatus.SENDING.value, JobQueueStatus.PAUSED.value]),
|
||||
)
|
||||
.count()
|
||||
)
|
||||
failed = (
|
||||
session.query(CampaignJob)
|
||||
.filter(
|
||||
CampaignJob.campaign_id == campaign_id,
|
||||
CampaignJob.send_status.in_([JobSendStatus.FAILED_TEMPORARY.value, JobSendStatus.FAILED_PERMANENT.value]),
|
||||
)
|
||||
.count()
|
||||
)
|
||||
sent = session.query(CampaignJob).filter(CampaignJob.campaign_id == campaign_id, CampaignJob.send_status == JobSendStatus.SENT.value).count()
|
||||
if remaining:
|
||||
campaign.status = CampaignStatus.QUEUED.value
|
||||
elif failed:
|
||||
campaign.status = CampaignStatus.FAILED.value if not sent else CampaignStatus.NEEDS_REVIEW.value
|
||||
elif sent:
|
||||
campaign.status = CampaignStatus.SENT.value
|
||||
session.add(campaign)
|
||||
|
||||
|
||||
def send_campaign_job(session: Session, *, job_id: str, dry_run: bool = False, use_rate_limit: bool = True, enqueue_imap_task: bool = False) -> SendJobResult:
|
||||
job = session.get(CampaignJob, job_id)
|
||||
if not job:
|
||||
raise SendJobError(f"Job not found: {job_id}")
|
||||
if job.queue_status == JobQueueStatus.CANCELLED.value or job.send_status == JobSendStatus.CANCELLED.value:
|
||||
return SendJobResult(job_id=job_id, status="cancelled", attempt_number=job.attempt_count, dry_run=dry_run)
|
||||
if job.queue_status == JobQueueStatus.PAUSED.value:
|
||||
return SendJobResult(job_id=job_id, status="paused", attempt_number=job.attempt_count, dry_run=dry_run)
|
||||
if job.send_status == JobSendStatus.SENT.value:
|
||||
return SendJobResult(job_id=job_id, status="already_sent", attempt_number=job.attempt_count, dry_run=dry_run)
|
||||
if job.queue_status not in {JobQueueStatus.QUEUED.value, JobQueueStatus.SENDING.value}:
|
||||
raise SendJobError(f"Job is not queued: {job.queue_status}")
|
||||
|
||||
version = session.get(CampaignVersion, job.campaign_version_id)
|
||||
if not version:
|
||||
raise SendJobError("Campaign version not found")
|
||||
_, config = _load_version_campaign_config(version)
|
||||
if not config.server.smtp:
|
||||
raise SmtpConfigurationError("Campaign has no SMTP configuration")
|
||||
|
||||
message = _load_eml_for_job(job)
|
||||
envelope_from = _sender_from_job(job, config)
|
||||
envelope_recipients = _recipients_from_job(job)
|
||||
if not envelope_recipients:
|
||||
raise SmtpConfigurationError("No envelope recipients could be determined")
|
||||
|
||||
if dry_run:
|
||||
return SendJobResult(
|
||||
job_id=job.id,
|
||||
status="dry_run",
|
||||
attempt_number=job.attempt_count,
|
||||
dry_run=True,
|
||||
message=f"Would send to {len(envelope_recipients)} recipient(s) from {envelope_from}",
|
||||
)
|
||||
|
||||
attempt = _record_attempt_start(session, job)
|
||||
try:
|
||||
wait_for_rate_limit(
|
||||
key=f"tenant:{job.tenant_id}:campaign:{job.campaign_id}",
|
||||
messages_per_minute=config.delivery.rate_limit.messages_per_minute,
|
||||
enabled=use_rate_limit,
|
||||
)
|
||||
|
||||
result = send_email_message(
|
||||
message,
|
||||
smtp_config=config.server.smtp,
|
||||
envelope_from=envelope_from,
|
||||
envelope_recipients=envelope_recipients,
|
||||
)
|
||||
attempt.finished_at = _utcnow()
|
||||
attempt.smtp_response = json.dumps(asdict(result), default=str)
|
||||
job.queue_status = JobQueueStatus.DRAFT.value
|
||||
job.send_status = JobSendStatus.SENT.value
|
||||
job.sent_at = _utcnow()
|
||||
if config.delivery.imap_append_sent.enabled:
|
||||
job.imap_status = JobImapStatus.PENDING.value
|
||||
else:
|
||||
job.imap_status = JobImapStatus.NOT_REQUESTED.value
|
||||
job.last_error = None
|
||||
session.add(attempt)
|
||||
session.add(job)
|
||||
_update_campaign_after_job(session, job.campaign_id)
|
||||
session.commit()
|
||||
if enqueue_imap_task and job.imap_status == JobImapStatus.PENDING.value:
|
||||
_celery_enqueue_append_sent_job(job.id)
|
||||
return SendJobResult(job_id=job.id, status="sent", attempt_number=attempt.attempt_number)
|
||||
|
||||
except (SmtpConfigurationError, SmtpSendError, SendJobError, OSError) as exc:
|
||||
attempt.finished_at = _utcnow()
|
||||
attempt.error_type = exc.__class__.__name__
|
||||
attempt.error_message = str(exc)
|
||||
job.last_error = str(exc)
|
||||
retry = getattr(exc, "temporary", None) is True
|
||||
max_attempts = config.delivery.retry.max_attempts
|
||||
if retry and job.attempt_count < max_attempts:
|
||||
job.queue_status = JobQueueStatus.QUEUED.value
|
||||
job.send_status = JobSendStatus.FAILED_TEMPORARY.value
|
||||
else:
|
||||
job.queue_status = JobQueueStatus.DRAFT.value
|
||||
job.send_status = JobSendStatus.FAILED_PERMANENT.value
|
||||
session.add(attempt)
|
||||
session.add(job)
|
||||
_update_campaign_after_job(session, job.campaign_id)
|
||||
session.commit()
|
||||
raise
|
||||
|
||||
|
||||
|
||||
|
||||
def _record_imap_attempt_start(session: Session, job: CampaignJob) -> ImapAppendAttempt:
|
||||
existing_count = session.query(ImapAppendAttempt).filter(ImapAppendAttempt.job_id == job.id).count()
|
||||
attempt = ImapAppendAttempt(
|
||||
job_id=job.id,
|
||||
attempt_number=existing_count + 1,
|
||||
status="running",
|
||||
)
|
||||
job.imap_status = JobImapStatus.PENDING.value
|
||||
job.last_error = None
|
||||
session.add(attempt)
|
||||
session.add(job)
|
||||
session.commit()
|
||||
return attempt
|
||||
|
||||
|
||||
def _effective_imap_folder(config: CampaignConfig) -> str:
|
||||
delivery_folder = config.delivery.imap_append_sent.folder
|
||||
if delivery_folder and delivery_folder != "auto":
|
||||
return delivery_folder
|
||||
if config.server.imap and config.server.imap.sent_folder and config.server.imap.sent_folder != "auto":
|
||||
return config.server.imap.sent_folder
|
||||
return "auto"
|
||||
|
||||
|
||||
def append_sent_for_job(session: Session, *, job_id: str, dry_run: bool = False) -> AppendSentResult:
|
||||
"""Append one successfully sent job's exact EML to the configured IMAP Sent folder."""
|
||||
|
||||
job = session.get(CampaignJob, job_id)
|
||||
if not job:
|
||||
raise SendJobError(f"Job not found: {job_id}")
|
||||
if job.send_status != JobSendStatus.SENT.value:
|
||||
return AppendSentResult(job_id=job_id, status="not_sent", attempt_number=0, dry_run=dry_run, message="Job has not been sent")
|
||||
if job.imap_status == JobImapStatus.NOT_REQUESTED.value:
|
||||
return AppendSentResult(job_id=job_id, status="not_requested", attempt_number=0, dry_run=dry_run)
|
||||
if job.imap_status == JobImapStatus.APPENDED.value:
|
||||
attempts = session.query(ImapAppendAttempt).filter(ImapAppendAttempt.job_id == job.id).count()
|
||||
return AppendSentResult(job_id=job_id, status="already_appended", attempt_number=attempts, dry_run=dry_run)
|
||||
if job.imap_status == JobImapStatus.SKIPPED.value:
|
||||
attempts = session.query(ImapAppendAttempt).filter(ImapAppendAttempt.job_id == job.id).count()
|
||||
return AppendSentResult(job_id=job_id, status="skipped", attempt_number=attempts, dry_run=dry_run)
|
||||
|
||||
version = session.get(CampaignVersion, job.campaign_version_id)
|
||||
if not version:
|
||||
raise SendJobError("Campaign version not found")
|
||||
_, config = _load_version_campaign_config(version)
|
||||
if not config.delivery.imap_append_sent.enabled:
|
||||
job.imap_status = JobImapStatus.NOT_REQUESTED.value
|
||||
session.add(job)
|
||||
session.commit()
|
||||
return AppendSentResult(job_id=job.id, status="not_requested", attempt_number=0, dry_run=dry_run)
|
||||
if not config.server.imap or not config.server.imap.enabled:
|
||||
job.imap_status = JobImapStatus.SKIPPED.value
|
||||
job.last_error = "IMAP append requested, but server.imap is missing or disabled"
|
||||
session.add(job)
|
||||
session.commit()
|
||||
return AppendSentResult(job_id=job.id, status="skipped", attempt_number=0, dry_run=dry_run, message=job.last_error)
|
||||
|
||||
message_bytes = _load_eml_bytes_for_job(job)
|
||||
folder = _effective_imap_folder(config)
|
||||
if dry_run:
|
||||
attempts = session.query(ImapAppendAttempt).filter(ImapAppendAttempt.job_id == job.id).count()
|
||||
return AppendSentResult(
|
||||
job_id=job.id,
|
||||
status="dry_run",
|
||||
attempt_number=attempts,
|
||||
dry_run=True,
|
||||
folder=folder,
|
||||
message=f"Would append {len(message_bytes)} bytes to IMAP folder {folder!r}",
|
||||
)
|
||||
|
||||
attempt = _record_imap_attempt_start(session, job)
|
||||
try:
|
||||
result = append_message_to_sent(
|
||||
message_bytes,
|
||||
imap_config=config.server.imap,
|
||||
folder=None if folder == "auto" else folder,
|
||||
)
|
||||
attempt.status = "appended"
|
||||
attempt.folder = result.folder
|
||||
job.imap_status = JobImapStatus.APPENDED.value
|
||||
job.last_error = None
|
||||
session.add(attempt)
|
||||
session.add(job)
|
||||
session.commit()
|
||||
return AppendSentResult(job_id=job.id, status="appended", attempt_number=attempt.attempt_number, folder=result.folder)
|
||||
except (ImapConfigurationError, ImapAppendError, SendJobError, OSError) as exc:
|
||||
attempt.status = "failed"
|
||||
attempt.folder = None if folder == "auto" else folder
|
||||
attempt.error_message = str(exc)
|
||||
job.imap_status = JobImapStatus.FAILED.value
|
||||
job.last_error = str(exc)
|
||||
session.add(attempt)
|
||||
session.add(job)
|
||||
session.commit()
|
||||
raise
|
||||
|
||||
|
||||
def enqueue_pending_imap_appends(session: Session, *, tenant_id: str, campaign_id: str, enqueue_celery: bool = True, dry_run: bool = False) -> dict[str, Any]:
|
||||
campaign = _get_campaign_for_tenant(session, campaign_id=campaign_id, tenant_id=tenant_id)
|
||||
jobs = (
|
||||
session.query(CampaignJob)
|
||||
.filter(
|
||||
CampaignJob.tenant_id == tenant_id,
|
||||
CampaignJob.campaign_id == campaign.id,
|
||||
CampaignJob.send_status == JobSendStatus.SENT.value,
|
||||
CampaignJob.imap_status.in_([JobImapStatus.PENDING.value, JobImapStatus.FAILED.value]),
|
||||
)
|
||||
.order_by(CampaignJob.entry_index.asc())
|
||||
.all()
|
||||
)
|
||||
if not dry_run and enqueue_celery:
|
||||
for job in jobs:
|
||||
_celery_enqueue_append_sent_job(job.id)
|
||||
return {
|
||||
"campaign_id": campaign.id,
|
||||
"pending_count": len(jobs),
|
||||
"enqueued_count": 0 if dry_run or not enqueue_celery else len(jobs),
|
||||
"dry_run": dry_run,
|
||||
}
|
||||
|
||||
def next_retry_delay(config: CampaignConfig, attempt_count: int) -> int:
|
||||
delays = config.delivery.retry.backoff_seconds or [60]
|
||||
index = max(0, min(attempt_count - 1, len(delays) - 1))
|
||||
return int(delays[index])
|
||||
57
server/app/mailer/sending/rate_limit.py
Normal file
57
server/app/mailer/sending/rate_limit.py
Normal file
@@ -0,0 +1,57 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
from redis import Redis
|
||||
from redis.exceptions import RedisError
|
||||
|
||||
from app.settings import settings
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RateLimitDecision:
|
||||
key: str
|
||||
messages_per_minute: int
|
||||
gap_seconds: float
|
||||
waited_seconds: float
|
||||
|
||||
|
||||
def _redis_client() -> Redis:
|
||||
return Redis.from_url(settings.redis_url, decode_responses=True)
|
||||
|
||||
|
||||
def wait_for_rate_limit(*, key: str, messages_per_minute: int, enabled: bool = True) -> RateLimitDecision:
|
||||
"""Throttle sends across worker processes using Redis when available.
|
||||
|
||||
The implementation stores the next allowed send timestamp per key. A Redis
|
||||
lock keeps multiple Celery processes from reading/updating the timestamp at
|
||||
the same time. If Redis is unavailable, it falls back to no distributed wait;
|
||||
the per-container Celery concurrency still protects local development.
|
||||
"""
|
||||
|
||||
messages_per_minute = max(1, int(messages_per_minute or 1))
|
||||
gap = 60.0 / messages_per_minute
|
||||
if not enabled:
|
||||
return RateLimitDecision(key=key, messages_per_minute=messages_per_minute, gap_seconds=gap, waited_seconds=0.0)
|
||||
|
||||
redis_key = f"multimailer:ratelimit:{key}:next_allowed"
|
||||
lock_key = f"multimailer:ratelimit:{key}:lock"
|
||||
waited = 0.0
|
||||
|
||||
try:
|
||||
client = _redis_client()
|
||||
with client.lock(lock_key, timeout=30, blocking_timeout=30):
|
||||
now = time.time()
|
||||
raw_next = client.get(redis_key)
|
||||
next_allowed = float(raw_next) if raw_next else now
|
||||
if next_allowed > now:
|
||||
waited = next_allowed - now
|
||||
time.sleep(waited)
|
||||
now = time.time()
|
||||
client.set(redis_key, now + gap, ex=max(60, int(gap * 10)))
|
||||
except (RedisError, TimeoutError, ValueError):
|
||||
# Development fallback: do not fail sending because Redis is absent.
|
||||
waited = 0.0
|
||||
|
||||
return RateLimitDecision(key=key, messages_per_minute=messages_per_minute, gap_seconds=gap, waited_seconds=waited)
|
||||
157
server/app/mailer/sending/smtp.py
Normal file
157
server/app/mailer/sending/smtp.py
Normal file
@@ -0,0 +1,157 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import smtplib
|
||||
import ssl
|
||||
from dataclasses import dataclass
|
||||
from email.message import EmailMessage
|
||||
from email.utils import formataddr
|
||||
|
||||
from app.mailer.campaign.models import SmtpConfig, TransportSecurity
|
||||
|
||||
|
||||
class SmtpConfigurationError(ValueError):
|
||||
"""Raised when SMTP settings are incomplete or inconsistent."""
|
||||
|
||||
|
||||
class SmtpSendError(RuntimeError):
|
||||
"""Raised when an SMTP send attempt fails."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SmtpSendResult:
|
||||
host: str
|
||||
port: int
|
||||
security: str
|
||||
envelope_from: str
|
||||
envelope_recipients: list[str]
|
||||
refused_recipients: dict[str, tuple[int, bytes | str]]
|
||||
|
||||
@property
|
||||
def accepted_count(self) -> int:
|
||||
return len(self.envelope_recipients) - len(self.refused_recipients)
|
||||
|
||||
|
||||
def _require_smtp_config(config: SmtpConfig) -> tuple[str, int]:
|
||||
if not config.host:
|
||||
raise SmtpConfigurationError("SMTP host is required")
|
||||
if not config.port:
|
||||
raise SmtpConfigurationError("SMTP port is required")
|
||||
if bool(config.username) != bool(config.password):
|
||||
raise SmtpConfigurationError("SMTP username and password must be provided together, or both omitted")
|
||||
return config.host, config.port
|
||||
|
||||
|
||||
def _open_smtp(config: SmtpConfig) -> smtplib.SMTP:
|
||||
host, port = _require_smtp_config(config)
|
||||
context = ssl.create_default_context()
|
||||
|
||||
try:
|
||||
if config.security == TransportSecurity.TLS:
|
||||
smtp: smtplib.SMTP = smtplib.SMTP_SSL(host=host, port=port, timeout=config.timeout_seconds, context=context)
|
||||
smtp.ehlo()
|
||||
else:
|
||||
smtp = smtplib.SMTP(host=host, port=port, timeout=config.timeout_seconds)
|
||||
smtp.ehlo()
|
||||
if config.security == TransportSecurity.STARTTLS:
|
||||
smtp.starttls(context=context)
|
||||
smtp.ehlo()
|
||||
|
||||
if config.username and config.password:
|
||||
smtp.login(config.username, config.password)
|
||||
return smtp
|
||||
except Exception:
|
||||
# If construction/login fails after a socket was created, smtplib usually closes
|
||||
# on GC, but explicit cleanup is safer when the variable exists.
|
||||
try:
|
||||
smtp.quit() # type: ignore[possibly-undefined]
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
def _decode_refused(refused: dict[str, tuple[int, bytes]]) -> dict[str, tuple[int, bytes | str]]:
|
||||
normalized: dict[str, tuple[int, bytes | str]] = {}
|
||||
for recipient, (code, response) in refused.items():
|
||||
try:
|
||||
normalized[recipient] = (code, response.decode("utf-8", errors="replace"))
|
||||
except AttributeError:
|
||||
normalized[recipient] = (code, response)
|
||||
return normalized
|
||||
|
||||
|
||||
def prepare_test_message(
|
||||
message: EmailMessage,
|
||||
*,
|
||||
test_recipient: str,
|
||||
test_recipient_name: str | None = None,
|
||||
) -> EmailMessage:
|
||||
"""Return a safe copy of a generated campaign message for test delivery.
|
||||
|
||||
The original recipient headers are removed so a test send cannot accidentally
|
||||
leak the real To/Cc list or deliver to the real recipients. The envelope
|
||||
recipient must also be supplied separately to send_email_message().
|
||||
"""
|
||||
|
||||
test_message = copy.deepcopy(message)
|
||||
|
||||
for header in ["To", "Cc", "Bcc"]:
|
||||
if header in test_message:
|
||||
del test_message[header]
|
||||
|
||||
# Replace potential previous marker headers if the user test-sends an EML twice.
|
||||
for header in ["X-MultiMailer-Test-Send"]:
|
||||
if header in test_message:
|
||||
del test_message[header]
|
||||
|
||||
test_message["To"] = formataddr((test_recipient_name or test_recipient, test_recipient))
|
||||
test_message["X-MultiMailer-Test-Send"] = "true"
|
||||
return test_message
|
||||
|
||||
|
||||
def send_email_message(
|
||||
message: EmailMessage,
|
||||
*,
|
||||
smtp_config: SmtpConfig,
|
||||
envelope_from: str,
|
||||
envelope_recipients: list[str],
|
||||
) -> SmtpSendResult:
|
||||
"""Send an EmailMessage through SMTP.
|
||||
|
||||
This low-level function deliberately receives explicit envelope sender and
|
||||
recipients. Headers and SMTP envelope are related but not identical; Bcc and
|
||||
future bounce-address handling depend on keeping them separate.
|
||||
"""
|
||||
|
||||
host, port = _require_smtp_config(smtp_config)
|
||||
if not envelope_from:
|
||||
raise SmtpConfigurationError("SMTP envelope sender is required")
|
||||
if not envelope_recipients:
|
||||
raise SmtpConfigurationError("at least one SMTP envelope recipient is required")
|
||||
|
||||
try:
|
||||
with _open_smtp(smtp_config) as smtp:
|
||||
refused = smtp.send_message(
|
||||
message,
|
||||
from_addr=envelope_from,
|
||||
to_addrs=envelope_recipients,
|
||||
)
|
||||
except smtplib.SMTPAuthenticationError as exc:
|
||||
raise SmtpSendError(f"SMTP authentication failed: {exc.smtp_code} {exc.smtp_error!r}") from exc
|
||||
except smtplib.SMTPRecipientsRefused as exc:
|
||||
raise SmtpSendError(f"all SMTP recipients were refused: {_decode_refused(exc.recipients)}") from exc
|
||||
except smtplib.SMTPSenderRefused as exc:
|
||||
raise SmtpSendError(f"SMTP sender was refused: {exc.smtp_code} {exc.smtp_error!r}") from exc
|
||||
except smtplib.SMTPResponseException as exc:
|
||||
raise SmtpSendError(f"SMTP error: {exc.smtp_code} {exc.smtp_error!r}") from exc
|
||||
except (OSError, smtplib.SMTPException) as exc:
|
||||
raise SmtpSendError(f"SMTP send failed: {exc}") from exc
|
||||
|
||||
return SmtpSendResult(
|
||||
host=host,
|
||||
port=port,
|
||||
security=smtp_config.security.value,
|
||||
envelope_from=envelope_from,
|
||||
envelope_recipients=list(envelope_recipients),
|
||||
refused_recipients=_decode_refused(refused),
|
||||
)
|
||||
0
server/app/mailer/services/__init__.py
Normal file
0
server/app/mailer/services/__init__.py
Normal file
13
server/app/mailer/services/attachment_matching.py
Normal file
13
server/app/mailer/services/attachment_matching.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from app.mailer.domain.campaign import MailAttachmentConfig
|
||||
|
||||
|
||||
def match_files(base_path: Path, config: MailAttachmentConfig) -> list[Path]:
|
||||
directory = base_path / config.base_dir
|
||||
if not directory.exists():
|
||||
return []
|
||||
iterator = directory.rglob(config.file_filter) if config.include_subdirs else directory.glob(config.file_filter)
|
||||
return sorted(path for path in iterator if path.is_file() and path.stat().st_size >= 0)
|
||||
135
server/app/mailer/services/campaign_executor.py
Normal file
135
server/app/mailer/services/campaign_executor.py
Normal file
@@ -0,0 +1,135 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import smtplib
|
||||
from email.message import EmailMessage
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
from app.mailer.domain.campaign import MailCampaign, MailEntry, MailServerSettings, TransportSecurity
|
||||
from app.mailer.domain.queue import MailQueue
|
||||
from app.mailer.domain.recipients import Recipient, RecipientType
|
||||
from app.mailer.services.attachment_matching import match_files
|
||||
from app.mailer.services.zip_service import create_encrypted_zip
|
||||
|
||||
|
||||
def _recipient_header(recipients: Iterable[Recipient], recipient_type: RecipientType) -> str:
|
||||
return ", ".join(r.formatted() for r in recipients if r.type == recipient_type)
|
||||
|
||||
|
||||
def _recipient_values(recipients: list[Recipient]) -> dict[str, str]:
|
||||
def rows(recipient_type: RecipientType) -> list[Recipient]:
|
||||
return [r for r in recipients if r.type == recipient_type]
|
||||
|
||||
def joined(recipient_type: RecipientType, mode: str) -> str:
|
||||
selected = rows(recipient_type)
|
||||
if mode == "address":
|
||||
return ", ".join(r.address for r in selected)
|
||||
if mode == "name":
|
||||
return ", ".join(r.name or r.address for r in selected)
|
||||
return ", ".join(r.formatted() for r in selected)
|
||||
|
||||
return {
|
||||
"mm_recipients": joined(RecipientType.TO, "formatted"),
|
||||
"mm_recipients_address": joined(RecipientType.TO, "address"),
|
||||
"mm_recipients_name": joined(RecipientType.TO, "name"),
|
||||
"mm_cc": joined(RecipientType.CC, "formatted"),
|
||||
"mm_cc_address": joined(RecipientType.CC, "address"),
|
||||
"mm_cc_name": joined(RecipientType.CC, "name"),
|
||||
"mm_bcc": joined(RecipientType.BCC, "formatted"),
|
||||
"mm_bcc_address": joined(RecipientType.BCC, "address"),
|
||||
"mm_bcc_name": joined(RecipientType.BCC, "name"),
|
||||
}
|
||||
|
||||
|
||||
def _message_attachment_paths(campaign: MailCampaign, entry: MailEntry) -> list[Path]:
|
||||
paths: list[Path] = []
|
||||
if entry.combine_attachments:
|
||||
for config in campaign.global_attachment_configs:
|
||||
paths.extend(match_files(campaign.base_attachment_path, config))
|
||||
if campaign.individual_attachments:
|
||||
for config in entry.attachment_configs:
|
||||
paths.extend(match_files(campaign.base_attachment_path, config))
|
||||
return paths
|
||||
|
||||
|
||||
def get_not_sent_files(campaign: MailCampaign) -> set[Path]:
|
||||
"""Return files matching campaign attachment configs that are not referenced by any built entry."""
|
||||
all_files: set[Path] = set()
|
||||
used_files: set[Path] = set()
|
||||
for config in campaign.global_attachment_configs:
|
||||
all_files.update(match_files(campaign.base_attachment_path, config))
|
||||
for entry in campaign.mail_entries:
|
||||
files = set(_message_attachment_paths(campaign, entry))
|
||||
used_files.update(files)
|
||||
all_files.update(files)
|
||||
return all_files - used_files
|
||||
|
||||
|
||||
def build_message(campaign: MailCampaign, entry: MailEntry, *, zip_attachments: bool = True) -> EmailMessage | None:
|
||||
recipients = campaign.all_recipients_for(entry)
|
||||
attachments = _message_attachment_paths(campaign, entry)
|
||||
if not attachments and not campaign.send_without_attachments:
|
||||
return None
|
||||
|
||||
values = {}
|
||||
values.update(_recipient_values(recipients))
|
||||
values.update(campaign.field_contents.as_value_map("global"))
|
||||
values.update(entry.field_contents.as_value_map("local"))
|
||||
|
||||
message = EmailMessage()
|
||||
sender = entry.from_recipient if campaign.individual_from and entry.from_recipient else campaign.global_from
|
||||
if sender:
|
||||
message["From"] = sender.formatted()
|
||||
to_header = _recipient_header(recipients, RecipientType.TO)
|
||||
cc_header = _recipient_header(recipients, RecipientType.CC)
|
||||
if to_header:
|
||||
message["To"] = to_header
|
||||
if cc_header:
|
||||
message["Cc"] = cc_header
|
||||
message["Subject"] = campaign.subject_template.apply_values(values)
|
||||
message.set_content(campaign.mail_template.apply_values(values))
|
||||
|
||||
attachment_paths = attachments
|
||||
|
||||
if attachments and zip_attachments:
|
||||
number = entry.get_field_content_from_name("number").as_string() if "number" in entry.field_contents.field_map else "attachments"
|
||||
password = entry.get_field_content_from_name("password").as_string() if "password" in entry.field_contents.field_map else ""
|
||||
zip_path = campaign.base_attachment_path / f"{number}.zip"
|
||||
attachment_paths = [create_encrypted_zip(zip_path, attachments, password)]
|
||||
|
||||
for attachment_path in attachment_paths:
|
||||
data = attachment_path.read_bytes()
|
||||
message.add_attachment(data, maintype="application", subtype="octet-stream", filename=attachment_path.name)
|
||||
return message
|
||||
|
||||
|
||||
def build_mail_queue(campaign: MailCampaign, *, zip_attachments: bool = True) -> MailQueue:
|
||||
queue = MailQueue()
|
||||
for entry in campaign.mail_entries:
|
||||
if not entry.is_active:
|
||||
continue
|
||||
message = build_message(campaign, entry, zip_attachments=zip_attachments)
|
||||
if message is not None:
|
||||
queue.add_mail(message)
|
||||
return queue
|
||||
|
||||
|
||||
def send_mail_queue(settings: MailServerSettings, queue: MailQueue) -> MailQueue:
|
||||
retry_queue = MailQueue()
|
||||
if settings.transport_security == TransportSecurity.TLS:
|
||||
smtp = smtplib.SMTP_SSL(settings.server, settings.resolved_port(), timeout=60)
|
||||
else:
|
||||
smtp = smtplib.SMTP(settings.server, settings.resolved_port(), timeout=60)
|
||||
try:
|
||||
if settings.transport_security == TransportSecurity.STARTTLS:
|
||||
smtp.starttls()
|
||||
if settings.username:
|
||||
smtp.login(settings.username, settings.password)
|
||||
for message in queue:
|
||||
try:
|
||||
smtp.send_message(message)
|
||||
except Exception:
|
||||
retry_queue.add_mail(message)
|
||||
finally:
|
||||
smtp.quit()
|
||||
return retry_queue
|
||||
25
server/app/mailer/services/zip_service.py
Normal file
25
server/app/mailer/services/zip_service.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import pyzipper
|
||||
except ImportError: # pragma: no cover
|
||||
pyzipper = None
|
||||
|
||||
|
||||
def create_encrypted_zip(output_path: Path, files: list[Path], password: str) -> Path:
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if pyzipper is None:
|
||||
raise RuntimeError("pyzipper is required for writing encrypted ZIP files")
|
||||
with pyzipper.AESZipFile(
|
||||
output_path,
|
||||
"w",
|
||||
compression=pyzipper.ZIP_DEFLATED,
|
||||
encryption=pyzipper.WZ_AES,
|
||||
) as zip_file:
|
||||
if password:
|
||||
zip_file.setpassword(password.encode("utf-8"))
|
||||
for file_path in files:
|
||||
zip_file.write(file_path, arcname=file_path.name)
|
||||
return output_path
|
||||
Reference in New Issue
Block a user