inital commit
This commit is contained in:
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,
|
||||
)
|
||||
Reference in New Issue
Block a user