Release v0.1.2
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user