first version able to send

This commit is contained in:
2026-06-11 00:06:44 +02:00
parent ce43f2658f
commit 3b06f3670e
12 changed files with 740 additions and 67 deletions

View File

@@ -10,7 +10,7 @@ from pydantic import BaseModel, ConfigDict, Field
from app.mailer.campaign.entries import load_campaign_entries
from app.mailer.campaign.field_values import effective_entry_field_values
from app.mailer.campaign.models import AttachmentConfig, Behavior, CampaignConfig, EntryConfig
from app.mailer.campaign.models import AttachmentBasePathConfig, AttachmentConfig, Behavior, CampaignConfig, EntryConfig
class AttachmentScope(StrEnum):
@@ -57,6 +57,8 @@ class ResolvedAttachment(BaseModel):
label: str | None = None
base_dir_template: str
file_filter_template: str
base_path_name: str | None = None
base_path: str | None = None
base_dir: str
file_filter: str
directory: str
@@ -192,15 +194,99 @@ def _template_values(config: CampaignConfig, entry: EntryConfig) -> dict[str, An
return values
def _iter_effective_attachment_configs(config: CampaignConfig, entry: EntryConfig) -> Iterable[tuple[AttachmentScope, int, AttachmentConfig]]:
def _rendered_base_dir(config: AttachmentConfig, values: dict[str, Any]) -> str:
rendered = _render_template(config.base_dir, values).strip()
return rendered or "."
def _base_path_by_path(config: CampaignConfig, rendered_base_dir: str) -> AttachmentBasePathConfig | None:
for base_path in config.attachments.base_paths:
if base_path.path == rendered_base_dir:
return base_path
return None
def _default_base_path(config: CampaignConfig) -> AttachmentBasePathConfig:
return config.attachments.base_paths[0]
def _selected_base_path(config: CampaignConfig, rendered_base_dir: str) -> AttachmentBasePathConfig | None:
if config.attachments.base_paths:
if rendered_base_dir in {"", "."}:
return _default_base_path(config)
return _base_path_by_path(config, rendered_base_dir)
return None
def _rule_allows_multiple(config: AttachmentConfig, rendered_file_filter: str) -> bool:
"""Return whether a rule may produce multiple attachments.
New UI versions no longer expose allow_multiple. Treat wildcard patterns as
inherently multi-match-capable while keeping the legacy allow_multiple flag
for old campaign JSON.
"""
return config.allow_multiple or any(char in rendered_file_filter for char in "*?[")
def _missing_behavior(campaign_config: CampaignConfig, config: AttachmentConfig) -> Behavior:
if config.missing_behavior is not None:
return config.missing_behavior
if config.required:
return campaign_config.validation_policy.missing_required_attachment
return campaign_config.validation_policy.missing_optional_attachment
def _ambiguous_behavior(campaign_config: CampaignConfig, config: AttachmentConfig) -> Behavior:
return config.ambiguous_behavior or campaign_config.validation_policy.ambiguous_attachment_match
def _entry_attachment_allowed(config: CampaignConfig, attachment_config: AttachmentConfig, values: dict[str, Any]) -> bool:
rendered_base_dir = _rendered_base_dir(attachment_config, values)
individual_paths = config.attachments.individual_base_path_values
if individual_paths:
return rendered_base_dir in individual_paths
return config.attachments.allow_individual
def _iter_effective_attachment_configs(
config: CampaignConfig,
entry: EntryConfig,
values: dict[str, Any],
) -> 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):
for index, attachment_config in enumerate(entry.attachments):
if _entry_attachment_allowed(config, attachment_config, values):
yield AttachmentScope.ENTRY, index, attachment_config
def _resolve_attachment_directory(
*,
campaign_file: str | Path,
campaign_config: CampaignConfig,
rendered_base_dir: str,
) -> tuple[Path, AttachmentBasePathConfig | None]:
"""Resolve the directory for an attachment rule.
Legacy campaigns used attachments.base_path as the root and base_dir as a
child directory. Current WebUI campaigns select one named base path directly
in base_dir. Prefer the new base_paths list when present to avoid resolving
e.g. attachments/base_path + base_dir twice.
"""
selected_base_path = _selected_base_path(campaign_config, rendered_base_dir)
if selected_base_path is not None:
return _resolve_path(campaign_file, selected_base_path.path), selected_base_path
if campaign_config.attachments.base_paths:
return _resolve_path(campaign_file, rendered_base_dir), None
legacy_root = _resolve_path(campaign_file, campaign_config.attachments.base_path)
return (legacy_root / rendered_base_dir).resolve(), None
def _match_files(directory: Path, file_filter: str, include_subdirs: bool) -> list[Path]:
if not directory.exists() or not directory.is_dir():
return []
@@ -227,7 +313,7 @@ def _issue_for_ambiguous(config: AttachmentConfig, behavior: Behavior, match_cou
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",
message=f"Attachment filter {config.file_filter!r} matched {match_count} files, but it is configured as a direct/single-file selection",
behavior=behavior,
)
@@ -235,27 +321,32 @@ def _issue_for_ambiguous(config: AttachmentConfig, behavior: Behavior, match_cou
def _resolve_one_config(
*,
campaign_file: str | Path,
attachments_base_path: Path,
campaign_config: CampaignConfig,
values: dict[str, Any],
scope: AttachmentScope,
index: int,
config: AttachmentConfig,
) -> ResolvedAttachment:
rendered_base_dir = _render_template(config.base_dir, values)
rendered_base_dir = _rendered_base_dir(config, values)
rendered_file_filter = _render_template(config.file_filter, values)
directory = (attachments_base_path / rendered_base_dir).resolve()
directory, selected_base_path = _resolve_attachment_directory(
campaign_file=campaign_file,
campaign_config=campaign_config,
rendered_base_dir=rendered_base_dir,
)
matches = _match_files(directory, rendered_file_filter, config.include_subdirs)
allow_multiple = _rule_allows_multiple(config, rendered_file_filter)
issues: list[AttachmentIssue] = []
behavior: Behavior | None = None
if not matches:
status = AttachmentMatchStatus.MISSING
behavior = config.missing_behavior
behavior = _missing_behavior(campaign_config, config)
issues.append(_issue_for_missing(config, behavior))
elif len(matches) > 1 and not config.allow_multiple:
elif len(matches) > 1 and not allow_multiple:
status = AttachmentMatchStatus.AMBIGUOUS
behavior = config.ambiguous_behavior
behavior = _ambiguous_behavior(campaign_config, config)
issues.append(_issue_for_ambiguous(config, behavior, len(matches)))
else:
status = AttachmentMatchStatus.OK
@@ -267,12 +358,14 @@ def _resolve_one_config(
label=config.label,
base_dir_template=config.base_dir,
file_filter_template=config.file_filter,
base_path_name=selected_base_path.name if selected_base_path else None,
base_path=selected_base_path.path if selected_base_path else None,
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,
allow_multiple=allow_multiple,
zip_enabled=config.zip.enabled,
status=status,
behavior=behavior,
@@ -303,16 +396,15 @@ def resolve_entry_attachments(
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):
for scope, index, attachment_config in _iter_effective_attachment_configs(config, entry, values):
resolved.append(
_resolve_one_config(
campaign_file=campaign_file,
attachments_base_path=attachments_base_path,
campaign_config=config,
values=values,
scope=scope,
index=index,
@@ -333,7 +425,7 @@ def resolve_entry_attachments(
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)
base_path = _resolve_path(campaign_file, config.attachments.base_paths[0].path if config.attachments.base_paths else 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)