Release v0.1.2

This commit is contained in:
2026-06-25 19:58:20 +02:00
parent 39ad3500e2
commit 23318c709a
98 changed files with 3432 additions and 2339 deletions

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
import mimetypes
import re
import tempfile
import time
from dataclasses import dataclass
from email.message import EmailMessage
from email.utils import make_msgid, formatdate
@@ -10,6 +11,7 @@ from pathlib import Path
from typing import Any, Iterable
from govoplan_campaign.backend.attachments.resolver import (
AttachmentMatchIndex,
AttachmentMatchStatus,
EntryAttachmentResolution,
MessageAttachmentStatus,
@@ -27,6 +29,7 @@ from govoplan_campaign.backend.campaign.models import (
MissingAddressBehavior,
RecipientConfig,
SendStatus,
TemplateBodyMode,
ZipArchiveConfig,
ZipPasswordMode,
ZipPasswordScope,
@@ -144,6 +147,13 @@ def _load_template_parts(config: CampaignConfig, campaign_file: str | Path) -> t
return template.subject or "", template.text, template.html
def _template_body_mode(config: CampaignConfig) -> str:
mode = config.template.body_mode
if isinstance(mode, TemplateBodyMode):
return mode.value
return str(mode or TemplateBodyMode.BOTH.value)
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)
@@ -181,6 +191,10 @@ def _attachment_summaries(resolution: EntryAttachmentResolution) -> list[Message
zip_mode=attachment.zip_mode.value,
zip_archive_id=attachment.zip_archive_id,
zip_filename=attachment.zip_filename,
message_filename_template=attachment.message_filename_template,
zip_entry_name_template=attachment.zip_entry_name_template,
message_filenames=attachment.message_filenames,
zip_entry_names=attachment.zip_entry_names,
base_path_name=attachment.base_path_name,
base_path=attachment.base_path,
file_filter=attachment.file_filter,
@@ -267,18 +281,6 @@ def _archive_filename(archive: ZipArchiveConfig, values: dict[str, Any], entry_i
return filename if filename.lower().endswith(".zip") else f"{filename}.zip"
def _deduplicated_paths(paths: list[Path]) -> list[Path]:
unique: list[Path] = []
seen: set[str] = set()
for path in paths:
key = str(path.resolve())
if key in seen:
continue
seen.add(key)
unique.append(path)
return unique
def _unique_attachment_filename(filename: str, used: set[str]) -> str:
candidate = filename
path = Path(filename)
@@ -290,6 +292,59 @@ def _unique_attachment_filename(filename: str, used: set[str]) -> str:
return candidate
def _deduplicated_archive_members(members: list[tuple[Path, str]]) -> list[tuple[Path, str]]:
unique: list[tuple[Path, str]] = []
seen: set[str] = set()
for path, archive_name in members:
key = str(path.resolve())
if key in seen:
continue
seen.add(key)
unique.append((path, archive_name))
return unique
def _attachment_filename_values(
*,
values: dict[str, Any],
attachment: ResolvedAttachment,
path: Path,
position: int,
) -> dict[str, Any]:
filename_values = dict(values)
filename_values.update({
"file.name": path.name,
"file.stem": path.stem,
"file.suffix": path.suffix,
"file.ext": path.suffix[1:] if path.suffix.startswith(".") else path.suffix,
"file.index": position,
"attachment.id": attachment.attachment_id or "",
"attachment.label": attachment.label or "",
})
return filename_values
def _render_attachment_filename(
*,
template: str | None,
attachment: ResolvedAttachment,
path: Path,
values: dict[str, Any],
position: int,
) -> str:
if not template:
return path.name
rendered = _render_template(
template,
_attachment_filename_values(values=values, attachment=attachment, path=path, position=position),
keep_missing=False,
)
filename = _safe_filename(rendered, path.name)
if path.suffix and not Path(filename).suffix:
filename = f"{filename}{path.suffix}"
return filename
def _attach_files(
*,
message: EmailMessage,
@@ -301,26 +356,50 @@ def _attach_files(
work_dir: Path,
) -> int:
attached_count = 0
archive_members: dict[str, list[Path]] = {}
archive_members: dict[str, list[tuple[Path, str]]] = {}
archive_attachments: dict[str, list[ResolvedAttachment]] = {}
used_message_filenames: set[str] = set()
used_zip_member_filenames: dict[str, set[str]] = {}
for attachment in resolution.attachments:
attachment.message_filenames = []
attachment.zip_entry_names = []
for attachment in resolution.attachments:
if attachment.status != AttachmentMatchStatus.OK or not attachment.matches:
continue
match_paths = [Path(match) for match in attachment.matches]
if attachment.zip_enabled and attachment.zip_archive_id:
archive_members.setdefault(attachment.zip_archive_id, []).extend(match_paths)
used_archive_names = used_zip_member_filenames.setdefault(attachment.zip_archive_id, set())
for position, path in enumerate(match_paths, start=1):
requested = _render_attachment_filename(
template=attachment.zip_entry_name_template,
attachment=attachment,
path=path,
values=values,
position=position,
)
archive_name = _unique_attachment_filename(requested, used_archive_names)
archive_members.setdefault(attachment.zip_archive_id, []).append((path, archive_name))
attachment.zip_entry_names.append(archive_name)
archive_attachments.setdefault(attachment.zip_archive_id, []).append(attachment)
continue
for path in match_paths:
filename = _unique_attachment_filename(path.name, used_message_filenames)
for position, path in enumerate(match_paths, start=1):
requested = _render_attachment_filename(
template=attachment.message_filename_template,
attachment=attachment,
path=path,
values=values,
position=position,
)
filename = _unique_attachment_filename(requested, used_message_filenames)
attachment.message_filenames.append(filename)
data, maintype, subtype = _attachment_bytes(path)
message.add_attachment(data, maintype=maintype, subtype=subtype, filename=filename)
attached_count += 1
for archive in config.attachments.zip.archives:
members = _deduplicated_paths(archive_members.get(archive.id, []))
members = _deduplicated_archive_members(archive_members.get(archive.id, []))
if not members:
continue
filename = _unique_attachment_filename(_archive_filename(archive, values, entry_index), used_message_filenames)
@@ -361,8 +440,9 @@ def build_entry_message(
output_dir: Path | None = None,
write_eml: bool = False,
work_dir: Path | None = None,
attachment_match_index: AttachmentMatchIndex | None = None,
) -> BuiltMessage:
resolution = resolve_entry_attachments(config=config, campaign_file=campaign_file, entry=entry, entry_index=entry_index)
resolution = resolve_entry_attachments(config=config, campaign_file=campaign_file, entry=entry, entry_index=entry_index, match_index=attachment_match_index)
effective_addresses = effective_address_lists(config, entry)
senders = effective_addresses["from"]
sender = senders[0] if senders else None
@@ -412,17 +492,19 @@ def build_entry_message(
validation_status = MessageValidationStatus.BLOCKED if behavior == MissingAddressBehavior.BLOCK.value else MessageValidationStatus.EXCLUDED
subject_template, text_template, html_template = _load_template_parts(config, campaign_file)
body_mode = _template_body_mode(config)
values = build_template_values(config, entry)
keep_missing_placeholders = not config.validation_policy.ignore_empty_fields
subject = _render_template(subject_template, values, keep_missing=keep_missing_placeholders)
text_body = _render_template(text_template or "", values, keep_missing=keep_missing_placeholders) if text_template is not None else None
html_body = _render_template(html_template or "", values, keep_missing=keep_missing_placeholders) if html_template is not None else None
unresolved = sorted(
_find_unresolved_placeholders(subject)
| _find_unresolved_placeholders(text_body)
| _find_unresolved_placeholders(html_body)
)
unresolved_fields = _find_unresolved_placeholders(subject)
if body_mode in {TemplateBodyMode.TEXT.value, TemplateBodyMode.BOTH.value}:
unresolved_fields |= _find_unresolved_placeholders(text_body)
if body_mode in {TemplateBodyMode.HTML.value, TemplateBodyMode.BOTH.value}:
unresolved_fields |= _find_unresolved_placeholders(html_body)
unresolved = sorted(unresolved_fields)
if unresolved:
behavior = config.validation_policy.template_error.value
issues.append(
@@ -458,9 +540,14 @@ def build_entry_message(
# 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:
effective_html_body = html_body if html_body and html_body.strip() else None
if body_mode == TemplateBodyMode.HTML.value:
message.set_content(effective_html_body or "", subtype="html")
elif body_mode == TemplateBodyMode.TEXT.value:
message.set_content(text_body or "")
message.add_alternative(html_body, subtype="html")
elif effective_html_body is not None:
message.set_content(text_body or "")
message.add_alternative(effective_html_body, subtype="html")
else:
message.set_content(text_body or "")
@@ -526,6 +613,7 @@ def _unsent_attachment_issues(
config: CampaignConfig,
campaign_file: str | Path,
built_messages: list[BuiltMessage],
attachment_match_index: AttachmentMatchIndex | None = None,
) -> list[MessageIssue]:
behavior = config.validation_policy.unsent_attachment_files.value
if behavior == Behavior.CONTINUE.value:
@@ -545,7 +633,10 @@ def _unsent_attachment_issues(
directory = _resolve(campaign_file, base_path.path)
if not directory.exists() or not directory.is_dir():
continue
all_files = sorted(path.resolve() for path in directory.rglob("*") if path.is_file())
if attachment_match_index is not None:
all_files = sorted(path.resolve() for path in attachment_match_index.iter_files(directory, recursive=True))
else:
all_files = sorted(path.resolve() for path in directory.rglob("*") if path.is_file())
unsent = [path for path in all_files if path not in matched_files]
if not unsent:
continue
@@ -587,6 +678,8 @@ def build_campaign_messages(
entries = load_campaign_entries(config, campaign_file=campaign_path)
output_path = Path(output_dir).resolve() if output_dir is not None else None
started = time.perf_counter()
attachment_match_index = AttachmentMatchIndex()
with tempfile.TemporaryDirectory(prefix="multimailer-build-") as tmp:
work_dir = output_path or Path(tmp)
built_messages = [
@@ -598,15 +691,22 @@ def build_campaign_messages(
output_dir=output_path,
write_eml=write_eml,
work_dir=work_dir,
attachment_match_index=attachment_match_index,
)
for index, entry in enumerate(entries, start=1)
if entry.active
]
_apply_campaign_level_issues(
built_messages,
_unsent_attachment_issues(config=config, campaign_file=campaign_path, built_messages=built_messages),
_unsent_attachment_issues(
config=config,
campaign_file=campaign_path,
built_messages=built_messages,
attachment_match_index=attachment_match_index,
),
)
rules_resolved = sum(len(built.draft.attachments) for built in built_messages)
report = CampaignBuildReport(
campaign_id=config.campaign.id,
campaign_name=config.campaign.name,
@@ -614,5 +714,9 @@ def build_campaign_messages(
entries_count=len(entries),
inactive_entries_count=sum(1 for entry in entries if not entry.active),
messages=[built.draft for built in built_messages],
attachment_resolution_profile=attachment_match_index.stats(
duration_ms=(time.perf_counter() - started) * 1000,
rules_resolved=rules_resolved,
),
)
return CampaignBuildResult(report=report, built_messages=built_messages)

View File

@@ -55,6 +55,10 @@ class MessageAttachmentSummary(BaseModel):
zip_mode: str = "inherit"
zip_archive_id: str | None = None
zip_filename: str | None = None
message_filename_template: str | None = None
zip_entry_name_template: str | None = None
message_filenames: list[str] = Field(default_factory=list)
zip_entry_names: list[str] = Field(default_factory=list)
base_path_name: str | None = None
base_path: str | None = None
file_filter: str
@@ -109,6 +113,7 @@ class CampaignBuildReport(BaseModel):
entries_count: int
inactive_entries_count: int = 0
messages: list[MessageDraft] = Field(default_factory=list)
attachment_resolution_profile: dict[str, object] = Field(default_factory=dict)
@property
def built_count(self) -> int: