Files
govoplan-campaign/src/govoplan_campaign/backend/messages/builder.py

973 lines
35 KiB
Python

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
from pathlib import Path
from typing import Any, Iterable
from govoplan_campaign.backend.attachments.resolver import (
AttachmentMatchIndex,
AttachmentMatchStatus,
EntryAttachmentResolution,
MessageAttachmentStatus,
ResolvedAttachment,
resolve_entry_attachments,
)
from govoplan_campaign.backend.campaign.addressing import effective_address_lists, formatted_recipient
from govoplan_campaign.backend.campaign.entries import load_campaign_entries
from govoplan_campaign.backend.campaign.field_values import ignored_entry_field_overrides
from govoplan_campaign.backend.campaign.models import (
Behavior,
BuildStatus,
CampaignConfig,
EntryConfig,
MissingAddressBehavior,
RecipientConfig,
SendStatus,
TemplateBodyMode,
ZipArchiveConfig,
ZipPasswordMode,
ZipPasswordScope,
)
from govoplan_campaign.backend.campaign.template_values import build_template_values
from govoplan_campaign.backend.services.zip_service import create_zip_archive
from govoplan_campaign.backend.template_rendering import (
find_unresolved_placeholders as _find_unresolved_placeholders,
render_template as _render_template,
)
from .models import (
CampaignBuildReport,
ImapStatus,
MessageAddress,
MessageAttachmentSummary,
MessageDraft,
MessageIssue,
MessageValidationStatus,
)
@dataclass(slots=True)
class BuiltMessage:
draft: MessageDraft
mime: EmailMessage | None
@dataclass(slots=True)
class CampaignBuildResult:
report: CampaignBuildReport
built_messages: list[BuiltMessage]
@dataclass(slots=True)
class _EntryMessageContext:
resolution: EntryAttachmentResolution
senders: list[RecipientConfig]
sender: RecipientConfig | None
recipients: dict[str, list[RecipientConfig]]
issues: list[MessageIssue]
validation_status: MessageValidationStatus
@dataclass(slots=True)
class _RenderedMessageTemplate:
subject: str
text_body: str | None
html_body: str | None
body_mode: str
values: dict[str, Any]
@dataclass(slots=True)
class _MimeBuildResult:
message: EmailMessage | None
build_status: BuildStatus
validation_status: MessageValidationStatus
attachment_count: int
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 _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 formatted_recipient(recipient)
def _format_recipient_header(recipients: Iterable[RecipientConfig]) -> str:
return ", ".join(_format_recipient(recipient) for recipient in recipients)
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 _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)
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,
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,
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 _append_no_attachment_coverage_issue(issues: list[MessageIssue]) -> None:
if any(issue.code == "missing_attachment_coverage" for issue in issues):
return
issues.append(
MessageIssue(
severity="error",
code="missing_attachment_coverage",
message="No attachment file was resolved for this message, and sending without attachments is not allowed.",
behavior=Behavior.BLOCK.value,
source="attachments",
)
)
def _send_without_attachments_behavior(config: CampaignConfig) -> Behavior:
return config.attachments.send_without_attachments_behavior or (
Behavior.CONTINUE if config.attachments.send_without_attachments else Behavior.BLOCK
)
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
class ZipBuildError(RuntimeError):
def __init__(self, code: str, message: str):
super().__init__(message)
self.code = code
def _zip_password(archive: ZipArchiveConfig, values: dict[str, Any]) -> str:
if not archive.password_enabled:
return ""
# Preserve support for campaigns written by the original single-archive
# implementation while all new archives use field + scope.
mode = archive.password_mode
if mode == ZipPasswordMode.DIRECT:
password = archive.password or ""
if not password:
raise ZipBuildError("zip_password_missing", f"ZIP archive {archive.name!r} has no fixed password")
return password
if mode == ZipPasswordMode.TEMPLATE:
password = _render_template(archive.password_template or "", values, keep_missing=False)
if not password:
raise ZipBuildError("zip_password_value_missing", f"ZIP archive {archive.name!r} produced an empty password")
return password
field_name = (archive.password_field or "").strip()
if not field_name:
raise ZipBuildError("zip_password_field_missing", f"ZIP archive {archive.name!r} has password protection enabled, but no password field")
scope = archive.password_scope.value if isinstance(archive.password_scope, ZipPasswordScope) else str(archive.password_scope)
value = values.get(f"{scope}::{field_name}")
# Legacy field-based configurations did not declare a scope.
if value in (None, "") and mode == ZipPasswordMode.FIELD:
value = values.get(field_name)
password = "" if value is None else str(value)
if not password:
qualifier = "campaign-global" if scope == ZipPasswordScope.GLOBAL.value else "recipient"
raise ZipBuildError(
"zip_password_value_missing",
f"The {qualifier} ZIP password field {field_name!r} is empty for archive {archive.name!r}",
)
return password
def _archive_filename(archive: ZipArchiveConfig, values: dict[str, Any], entry_index: int) -> str:
rendered = _render_template(archive.name or "attachments.zip", values, keep_missing=False)
filename = _safe_filename(rendered, f"entry-{entry_index:04d}-attachments.zip")
return filename if filename.lower().endswith(".zip") else f"{filename}.zip"
def _unique_attachment_filename(filename: str, used: set[str]) -> str:
candidate = filename
path = Path(filename)
counter = 2
while candidate.casefold() in used:
candidate = f"{path.stem} ({counter}){path.suffix}"
counter += 1
used.add(candidate.casefold())
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,
config: CampaignConfig,
entry: EntryConfig,
entry_index: int,
resolution: EntryAttachmentResolution,
values: dict[str, Any],
work_dir: Path,
) -> int:
attached_count = 0
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 = []
attachment.zip_filename = None
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:
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 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_archive_members(archive_members.get(archive.id, []))
if not members:
continue
filename = _unique_attachment_filename(_archive_filename(archive, values, entry_index), used_message_filenames)
password = _zip_password(archive, values)
archive_path = create_zip_archive(
work_dir / "_zip" / f"entry-{entry_index:04d}" / _safe_filename(archive.id, "archive") / filename,
members,
password,
archive.method.value,
)
data, maintype, subtype = _attachment_bytes(archive_path)
message.add_attachment(data, maintype=maintype, subtype=subtype, filename=filename)
attached_count += 1
for attachment in archive_attachments.get(archive.id, []):
attachment.zip_filename = filename
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 _entry_message_context(
*,
config: CampaignConfig,
campaign_file: str | Path,
entry: EntryConfig,
entry_index: int,
attachment_match_index: AttachmentMatchIndex | None,
) -> _EntryMessageContext:
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
recipients = {key: value for key, value in effective_addresses.items() if key != "from"}
issues = _message_issues_from_attachment_resolution(resolution)
validation_status = _validation_status_from_attachment_status(resolution.status)
validation_status = _apply_ignored_field_override_issues(config, entry, issues, validation_status)
return _EntryMessageContext(
resolution=resolution,
senders=senders,
sender=sender,
recipients=recipients,
issues=issues,
validation_status=validation_status,
)
def _apply_ignored_field_override_issues(
config: CampaignConfig,
entry: EntryConfig,
issues: list[MessageIssue],
validation_status: MessageValidationStatus,
) -> MessageValidationStatus:
ignored_field_overrides = ignored_entry_field_overrides(config, entry)
if not ignored_field_overrides:
return validation_status
issues.append(
MessageIssue(
severity="warning",
code="field_override_not_allowed",
message=(
"Recipient field value(s) ignored because the campaign field does not allow overrides: "
+ ", ".join(ignored_field_overrides)
),
behavior="warn",
source="fields",
)
)
if validation_status == MessageValidationStatus.READY:
return MessageValidationStatus.WARNING
return validation_status
def _message_draft(
*,
config: CampaignConfig,
entry: EntryConfig,
entry_index: int,
context: _EntryMessageContext,
build_status: BuildStatus,
validation_status: MessageValidationStatus,
send_status: SendStatus = SendStatus.DRAFT,
imap_status: ImapStatus | None = None,
subject: str | None = None,
attachment_count: int = 0,
issues: list[MessageIssue] | None = None,
eml_path: str | None = None,
eml_size: int | None = None,
) -> MessageDraft:
if validation_status == MessageValidationStatus.EXCLUDED:
# Exclusion is a completed validation decision, not a pending delivery.
# Keep both transport projections explicit so reports never imply that
# SMTP or IMAP work is still expected for this row.
send_status = SendStatus.SKIPPED
imap_status = ImapStatus.SKIPPED
if imap_status is None:
imap_status = _imap_initial_status(config) if build_status == BuildStatus.BUILT else ImapStatus.SKIPPED
return MessageDraft(
entry_index=entry_index,
entry_id=entry.id,
active=entry.active,
build_status=build_status,
validation_status=validation_status,
send_status=send_status,
imap_status=imap_status,
subject=subject,
from_=_message_address(context.sender),
from_all=_message_addresses(context.senders),
to=_message_addresses(context.recipients["to"]),
cc=_message_addresses(context.recipients["cc"]),
bcc=_message_addresses(context.recipients["bcc"]),
reply_to=_message_addresses(context.recipients["reply_to"]),
bounce_to=_message_addresses(context.recipients["bounce_to"]),
disposition_notification_to=_message_addresses(context.recipients["disposition_notification_to"]),
attachment_count=attachment_count,
attachments=_attachment_summaries(context.resolution),
issues=issues if issues is not None else context.issues,
eml_path=eml_path,
eml_size_bytes=eml_size,
)
def _inactive_entry_message(
*,
config: CampaignConfig,
entry: EntryConfig,
entry_index: int,
context: _EntryMessageContext,
) -> BuiltMessage:
draft = _message_draft(
config=config,
entry=entry,
entry_index=entry_index,
context=context,
build_status=BuildStatus.BUILD_FAILED,
validation_status=MessageValidationStatus.INACTIVE,
imap_status=ImapStatus.SKIPPED,
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)
def _validate_required_recipients(
config: CampaignConfig,
recipients: dict[str, list[RecipientConfig]],
issues: list[MessageIssue],
validation_status: MessageValidationStatus,
) -> MessageValidationStatus:
if recipients["to"]:
return validation_status
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",
)
)
if behavior == MissingAddressBehavior.BLOCK.value:
return MessageValidationStatus.BLOCKED
return MessageValidationStatus.EXCLUDED
def _validate_required_sender(
senders: list[RecipientConfig],
issues: list[MessageIssue],
validation_status: MessageValidationStatus,
) -> MessageValidationStatus:
if senders:
return validation_status
issues.append(
MessageIssue(
severity="error",
code="missing_sender",
message="No effective From address is configured; Campaign must resolve the sender before building.",
behavior="block",
source="recipients",
)
)
return MessageValidationStatus.BLOCKED
def _render_message_template(
config: CampaignConfig,
campaign_file: str | Path,
entry: EntryConfig,
) -> _RenderedMessageTemplate:
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 = None
html_body = None
if text_template is not None:
text_body = _render_template(text_template or "", values, keep_missing=keep_missing_placeholders)
if html_template is not None:
html_body = _render_template(html_template or "", values, keep_missing=keep_missing_placeholders)
return _RenderedMessageTemplate(
subject=subject,
text_body=text_body,
html_body=html_body,
body_mode=body_mode,
values=values,
)
def _unresolved_template_fields(rendered: _RenderedMessageTemplate) -> list[str]:
unresolved_fields = _find_unresolved_placeholders(rendered.subject)
if rendered.body_mode in {TemplateBodyMode.TEXT.value, TemplateBodyMode.BOTH.value}:
unresolved_fields |= _find_unresolved_placeholders(rendered.text_body)
if rendered.body_mode in {TemplateBodyMode.HTML.value, TemplateBodyMode.BOTH.value}:
unresolved_fields |= _find_unresolved_placeholders(rendered.html_body)
return sorted(unresolved_fields)
def _validate_rendered_template(
config: CampaignConfig,
rendered: _RenderedMessageTemplate,
issues: list[MessageIssue],
validation_status: MessageValidationStatus,
) -> MessageValidationStatus:
unresolved = _unresolved_template_fields(rendered)
if not unresolved:
return validation_status
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",
)
)
if behavior == MissingAddressBehavior.BLOCK.value:
return MessageValidationStatus.BLOCKED
return MessageValidationStatus.EXCLUDED
def _populate_message_headers(
message: EmailMessage,
*,
senders: list[RecipientConfig],
recipients: dict[str, list[RecipientConfig]],
subject: str,
) -> None:
message["Date"] = formatdate(localtime=True)
message["Message-ID"] = make_msgid()
if senders:
message["From"] = _format_recipient_header(senders)
if len(senders) > 1:
# RFC 5322 requires a singular Sender when From contains more
# than one mailbox. The first effective From address remains
# the SMTP envelope sender and compatibility primary value.
message["Sender"] = _format_recipient(senders[0])
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
def _populate_message_body(message: EmailMessage, rendered: _RenderedMessageTemplate) -> None:
effective_html_body = rendered.html_body if rendered.html_body and rendered.html_body.strip() else None
if rendered.body_mode == TemplateBodyMode.HTML.value:
message.set_content(effective_html_body or "", subtype="html")
elif rendered.body_mode == TemplateBodyMode.TEXT.value:
message.set_content(rendered.text_body or "")
elif effective_html_body is not None:
message.set_content(rendered.text_body or "")
message.add_alternative(effective_html_body, subtype="html")
else:
message.set_content(rendered.text_body or "")
def _build_mime_message(
*,
config: CampaignConfig,
entry: EntryConfig,
entry_index: int,
output_dir: Path | None,
work_dir: Path | None,
context: _EntryMessageContext,
rendered: _RenderedMessageTemplate,
) -> _MimeBuildResult:
message = EmailMessage()
try:
_populate_message_headers(
message,
senders=context.senders,
recipients=context.recipients,
subject=rendered.subject,
)
_populate_message_body(message, rendered)
if work_dir is None:
work_dir = output_dir or Path(tempfile.mkdtemp(prefix="govoplan-build-"))
attachment_count = _attach_files(
message=message,
config=config,
entry=entry,
entry_index=entry_index,
resolution=context.resolution,
values=rendered.values,
work_dir=work_dir,
)
if attachment_count == 0 and context.resolution.attachments and _send_without_attachments_behavior(config) == Behavior.BLOCK:
_append_no_attachment_coverage_issue(context.issues)
return _MimeBuildResult(
message=None,
build_status=BuildStatus.BUILD_FAILED,
validation_status=MessageValidationStatus.BLOCKED,
attachment_count=0,
)
return _MimeBuildResult(
message=message,
build_status=BuildStatus.BUILT,
validation_status=context.validation_status,
attachment_count=attachment_count,
)
except ZipBuildError as exc:
context.issues.append(
MessageIssue(
severity="error",
code=exc.code,
message=str(exc),
behavior="block",
source="attachments",
)
)
except Exception as exc:
context.issues.append(
MessageIssue(
severity="error",
code="build_failed",
message=str(exc),
behavior="block",
source="builder",
)
)
return _MimeBuildResult(
message=None,
build_status=BuildStatus.BUILD_FAILED,
validation_status=MessageValidationStatus.BLOCKED,
attachment_count=0,
)
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,
attachment_match_index: AttachmentMatchIndex | None = None,
) -> BuiltMessage:
context = _entry_message_context(
config=config,
campaign_file=campaign_file,
entry=entry,
entry_index=entry_index,
attachment_match_index=attachment_match_index,
)
if not entry.active:
return _inactive_entry_message(config=config, entry=entry, entry_index=entry_index, context=context)
context.validation_status = _validate_required_sender(
context.senders,
context.issues,
context.validation_status,
)
context.validation_status = _validate_required_recipients(
config,
context.recipients,
context.issues,
context.validation_status,
)
rendered = _render_message_template(config, campaign_file, entry)
context.validation_status = _validate_rendered_template(
config,
rendered,
context.issues,
context.validation_status,
)
mime_result = _build_mime_message(
config=config,
entry=entry,
entry_index=entry_index,
output_dir=output_dir,
work_dir=work_dir,
context=context,
rendered=rendered,
)
eml_path: str | None = None
eml_size: int | None = None
if write_eml and output_dir is not None and mime_result.message is not None:
eml_path, eml_size = _write_eml(mime_result.message, output_dir, entry, entry_index)
draft = _message_draft(
config=config,
entry=entry,
entry_index=entry_index,
context=context,
build_status=mime_result.build_status,
validation_status=mime_result.validation_status,
subject=rendered.subject,
attachment_count=mime_result.attachment_count,
eml_path=eml_path,
eml_size=eml_size,
)
return BuiltMessage(draft=draft, mime=mime_result.message)
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:
return []
matched_files = {
Path(match).resolve()
for built in built_messages
for attachment in built.draft.attachments
for match in attachment.matches
}
issues: list[MessageIssue] = []
for base_path in config.attachments.base_paths:
if not base_path.unsent_warning:
continue
directory = _resolve(campaign_file, base_path.path)
if not directory.exists() or not directory.is_dir():
continue
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
shown = ", ".join(str(path.relative_to(directory)) for path in unsent[:10])
if len(unsent) > 10:
shown += f", … (+{len(unsent) - 10} more)"
issues.append(
_issue_from_behavior(
code="unsent_attachment_files",
message=f"{len(unsent)} file(s) in attachment source {base_path.name!r} are not used by any message: {shown}",
behavior=behavior,
source=f"attachments:{base_path.name}",
)
)
return issues
def _apply_campaign_level_issues(built_messages: list[BuiltMessage], issues: list[MessageIssue]) -> None:
if not issues:
return
for built in built_messages:
if not built.draft.active:
continue
built.draft.issues.extend(issues)
status = built.draft.validation_status
for issue in issues:
if issue.behavior:
status = _apply_behavior(status, issue.behavior)
built.draft.validation_status = status
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
started = time.perf_counter()
attachment_match_index = AttachmentMatchIndex()
with tempfile.TemporaryDirectory(prefix="govoplan-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,
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,
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,
campaign_file=str(campaign_path),
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)