initial commit after split
This commit is contained in:
12
src/govoplan_campaign/backend/messages/__init__.py
Normal file
12
src/govoplan_campaign/backend/messages/__init__.py
Normal file
@@ -0,0 +1,12 @@
|
||||
"""Message building and review helpers."""
|
||||
|
||||
from .builder import build_campaign_messages
|
||||
from .models import CampaignBuildReport, MessageDraft, MessageIssue, MessageValidationStatus
|
||||
|
||||
__all__ = [
|
||||
"build_campaign_messages",
|
||||
"CampaignBuildReport",
|
||||
"MessageDraft",
|
||||
"MessageIssue",
|
||||
"MessageValidationStatus",
|
||||
]
|
||||
618
src/govoplan_campaign/backend/messages/builder.py
Normal file
618
src/govoplan_campaign/backend/messages/builder.py
Normal file
@@ -0,0 +1,618 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import mimetypes
|
||||
import re
|
||||
import tempfile
|
||||
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 (
|
||||
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,
|
||||
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 .models import (
|
||||
CampaignBuildReport,
|
||||
ImapStatus,
|
||||
MessageAddress,
|
||||
MessageAttachmentSummary,
|
||||
MessageDraft,
|
||||
MessageIssue,
|
||||
MessageValidationStatus,
|
||||
)
|
||||
|
||||
_DOLLAR_FIELD_PATTERN = re.compile(r"(?<!\\)\$\{(.*?)(?<!\\)\}")
|
||||
_BRACE_FIELD_PATTERN = re.compile(r"(?<!\\)\{\{\s*(.*?)\s*\}\}")
|
||||
|
||||
|
||||
def _normalize_template_key(raw: str) -> str:
|
||||
key = raw.strip()
|
||||
if key.startswith("fields."):
|
||||
key = key.removeprefix("fields.")
|
||||
elif key.startswith("local."):
|
||||
key = "local::" + key.removeprefix("local.")
|
||||
elif key.startswith("global."):
|
||||
key = "global::" + key.removeprefix("global.")
|
||||
|
||||
if key.startswith("local::") or key.startswith("global::"):
|
||||
return key
|
||||
if key.startswith("local:"):
|
||||
return "local::" + key.removeprefix("local:")
|
||||
if key.startswith("global:"):
|
||||
return "global::" + key.removeprefix("global:")
|
||||
return key
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class BuiltMessage:
|
||||
draft: MessageDraft
|
||||
mime: EmailMessage | None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class CampaignBuildResult:
|
||||
report: CampaignBuildReport
|
||||
built_messages: list[BuiltMessage]
|
||||
|
||||
|
||||
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 _render_template(template: str, values: dict[str, Any], *, keep_missing: bool = True) -> str:
|
||||
def replace(match: re.Match[str]) -> str:
|
||||
key = _normalize_template_key(match.group(1))
|
||||
if key in values:
|
||||
value = values[key]
|
||||
return "" if value is None else str(value)
|
||||
return match.group(0) if keep_missing else ""
|
||||
|
||||
rendered = _DOLLAR_FIELD_PATTERN.sub(replace, template)
|
||||
rendered = _BRACE_FIELD_PATTERN.sub(replace, rendered)
|
||||
return rendered.replace(r"\${", "${").replace(r"\}", "}")
|
||||
|
||||
|
||||
def _find_unresolved_placeholders(text: str | None) -> set[str]:
|
||||
if not text:
|
||||
return set()
|
||||
return {
|
||||
_normalize_template_key(match.group(1))
|
||||
for pattern in (_DOLLAR_FIELD_PATTERN, _BRACE_FIELD_PATTERN)
|
||||
for match in pattern.finditer(text)
|
||||
}
|
||||
|
||||
|
||||
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 _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,
|
||||
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 _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 _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)
|
||||
counter = 2
|
||||
while candidate.casefold() in used:
|
||||
candidate = f"{path.stem} ({counter}){path.suffix}"
|
||||
counter += 1
|
||||
used.add(candidate.casefold())
|
||||
return candidate
|
||||
|
||||
|
||||
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[Path]] = {}
|
||||
archive_attachments: dict[str, list[ResolvedAttachment]] = {}
|
||||
used_message_filenames: set[str] = set()
|
||||
|
||||
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)
|
||||
archive_attachments.setdefault(attachment.zip_archive_id, []).append(attachment)
|
||||
continue
|
||||
for path in match_paths:
|
||||
filename = _unique_attachment_filename(path.name, used_message_filenames)
|
||||
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, []))
|
||||
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,
|
||||
)
|
||||
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 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,
|
||||
) -> BuiltMessage:
|
||||
resolution = resolve_entry_attachments(config=config, campaign_file=campaign_file, entry=entry, entry_index=entry_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)
|
||||
|
||||
ignored_field_overrides = ignored_entry_field_overrides(config, entry)
|
||||
if ignored_field_overrides:
|
||||
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:
|
||||
validation_status = MessageValidationStatus.WARNING
|
||||
|
||||
if not entry.active:
|
||||
draft = MessageDraft(
|
||||
entry_index=entry_index,
|
||||
entry_id=entry.id,
|
||||
active=False,
|
||||
build_status=BuildStatus.BUILD_FAILED,
|
||||
validation_status=MessageValidationStatus.INACTIVE,
|
||||
send_status=SendStatus.DRAFT,
|
||||
imap_status=ImapStatus.SKIPPED,
|
||||
from_=_message_address(sender),
|
||||
from_all=_message_addresses(senders),
|
||||
to=_message_addresses(recipients["to"]),
|
||||
cc=_message_addresses(recipients["cc"]),
|
||||
bcc=_message_addresses(recipients["bcc"]),
|
||||
reply_to=_message_addresses(recipients["reply_to"]),
|
||||
bounce_to=_message_addresses(recipients["bounce_to"]),
|
||||
disposition_notification_to=_message_addresses(recipients["disposition_notification_to"]),
|
||||
attachments=_attachment_summaries(resolution),
|
||||
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)
|
||||
|
||||
if not recipients["to"]:
|
||||
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"))
|
||||
validation_status = MessageValidationStatus.BLOCKED if behavior == MissingAddressBehavior.BLOCK.value else MessageValidationStatus.EXCLUDED
|
||||
|
||||
subject_template, text_template, html_template = _load_template_parts(config, campaign_file)
|
||||
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)
|
||||
)
|
||||
if unresolved:
|
||||
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",
|
||||
)
|
||||
)
|
||||
validation_status = MessageValidationStatus.BLOCKED if behavior == MissingAddressBehavior.BLOCK.value else MessageValidationStatus.EXCLUDED
|
||||
|
||||
message = EmailMessage()
|
||||
try:
|
||||
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
|
||||
|
||||
if html_body is not None:
|
||||
message.set_content(text_body or "")
|
||||
message.add_alternative(html_body, subtype="html")
|
||||
else:
|
||||
message.set_content(text_body or "")
|
||||
|
||||
if work_dir is None:
|
||||
work_dir = output_dir or Path(tempfile.mkdtemp(prefix="multimailer-build-"))
|
||||
attachment_count = _attach_files(
|
||||
message=message,
|
||||
config=config,
|
||||
entry=entry,
|
||||
entry_index=entry_index,
|
||||
resolution=resolution,
|
||||
values=values,
|
||||
work_dir=work_dir,
|
||||
)
|
||||
build_status = BuildStatus.BUILT
|
||||
except ZipBuildError as exc:
|
||||
issues.append(MessageIssue(severity="error", code=exc.code, message=str(exc), behavior="block", source="attachments"))
|
||||
validation_status = MessageValidationStatus.BLOCKED
|
||||
build_status = BuildStatus.BUILD_FAILED
|
||||
attachment_count = 0
|
||||
message = None # type: ignore[assignment]
|
||||
except Exception as exc:
|
||||
issues.append(MessageIssue(severity="error", code="build_failed", message=str(exc), behavior="block", source="builder"))
|
||||
validation_status = MessageValidationStatus.BLOCKED
|
||||
build_status = BuildStatus.BUILD_FAILED
|
||||
attachment_count = 0
|
||||
message = None # type: ignore[assignment]
|
||||
|
||||
eml_path: str | None = None
|
||||
eml_size: int | None = None
|
||||
if write_eml and output_dir is not None and message is not None:
|
||||
eml_path, eml_size = _write_eml(message, output_dir, entry, entry_index)
|
||||
|
||||
draft = MessageDraft(
|
||||
entry_index=entry_index,
|
||||
entry_id=entry.id,
|
||||
active=entry.active,
|
||||
build_status=build_status,
|
||||
validation_status=validation_status,
|
||||
send_status=SendStatus.DRAFT,
|
||||
imap_status=_imap_initial_status(config) if build_status == BuildStatus.BUILT else ImapStatus.SKIPPED,
|
||||
subject=subject,
|
||||
from_=_message_address(sender),
|
||||
from_all=_message_addresses(senders),
|
||||
to=_message_addresses(recipients["to"]),
|
||||
cc=_message_addresses(recipients["cc"]),
|
||||
bcc=_message_addresses(recipients["bcc"]),
|
||||
reply_to=_message_addresses(recipients["reply_to"]),
|
||||
bounce_to=_message_addresses(recipients["bounce_to"]),
|
||||
disposition_notification_to=_message_addresses(recipients["disposition_notification_to"]),
|
||||
attachment_count=attachment_count,
|
||||
attachments=_attachment_summaries(resolution),
|
||||
issues=issues,
|
||||
eml_path=eml_path,
|
||||
eml_size_bytes=eml_size,
|
||||
)
|
||||
return BuiltMessage(draft=draft, mime=message)
|
||||
|
||||
|
||||
|
||||
def _unsent_attachment_issues(
|
||||
*,
|
||||
config: CampaignConfig,
|
||||
campaign_file: str | Path,
|
||||
built_messages: list[BuiltMessage],
|
||||
) -> 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
|
||||
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
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="multimailer-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,
|
||||
)
|
||||
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),
|
||||
)
|
||||
|
||||
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],
|
||||
)
|
||||
return CampaignBuildResult(report=report, built_messages=built_messages)
|
||||
147
src/govoplan_campaign/backend/messages/models.py
Normal file
147
src/govoplan_campaign/backend/messages/models.py
Normal file
@@ -0,0 +1,147 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from govoplan_campaign.backend.campaign.models import BuildStatus, SendStatus
|
||||
|
||||
|
||||
class MessageValidationStatus(StrEnum):
|
||||
READY = "ready"
|
||||
WARNING = "warning"
|
||||
NEEDS_REVIEW = "needs_review"
|
||||
BLOCKED = "blocked"
|
||||
EXCLUDED = "excluded"
|
||||
INACTIVE = "inactive"
|
||||
|
||||
|
||||
class ImapStatus(StrEnum):
|
||||
NOT_REQUESTED = "not_requested"
|
||||
PENDING = "pending"
|
||||
APPENDED = "appended"
|
||||
FAILED = "failed"
|
||||
SKIPPED = "skipped"
|
||||
|
||||
|
||||
class MessageIssue(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
severity: Literal["info", "warning", "error"]
|
||||
code: str
|
||||
message: str
|
||||
behavior: str | None = None
|
||||
source: str | None = None
|
||||
|
||||
|
||||
class MessageAddress(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
email: str
|
||||
name: str | None = None
|
||||
|
||||
|
||||
class MessageAttachmentSummary(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
attachment_id: str | None = None
|
||||
label: str | None = None
|
||||
status: str
|
||||
behavior: str | None = None
|
||||
required: bool
|
||||
allow_multiple: bool
|
||||
zip_enabled: bool
|
||||
zip_mode: str = "inherit"
|
||||
zip_archive_id: str | None = None
|
||||
zip_filename: str | None = None
|
||||
base_path_name: str | None = None
|
||||
base_path: str | None = None
|
||||
file_filter: str
|
||||
directory: str
|
||||
matches: list[str] = Field(default_factory=list)
|
||||
managed_matches: list[dict[str, object]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class MessageDraft(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
||||
|
||||
entry_index: int
|
||||
entry_id: str | None = None
|
||||
active: bool
|
||||
|
||||
build_status: BuildStatus
|
||||
validation_status: MessageValidationStatus
|
||||
send_status: SendStatus
|
||||
imap_status: ImapStatus
|
||||
|
||||
subject: str | None = None
|
||||
from_: MessageAddress | None = Field(default=None, alias="from")
|
||||
from_all: list[MessageAddress] = Field(default_factory=list)
|
||||
to: list[MessageAddress] = Field(default_factory=list)
|
||||
cc: list[MessageAddress] = Field(default_factory=list)
|
||||
bcc: list[MessageAddress] = Field(default_factory=list)
|
||||
reply_to: list[MessageAddress] = Field(default_factory=list)
|
||||
bounce_to: list[MessageAddress] = Field(default_factory=list)
|
||||
disposition_notification_to: list[MessageAddress] = Field(default_factory=list)
|
||||
|
||||
attachment_count: int = 0
|
||||
attachments: list[MessageAttachmentSummary] = Field(default_factory=list)
|
||||
issues: list[MessageIssue] = Field(default_factory=list)
|
||||
|
||||
eml_path: str | None = None
|
||||
eml_size_bytes: int | None = None
|
||||
|
||||
@property
|
||||
def is_queueable(self) -> bool:
|
||||
return self.active and self.build_status == BuildStatus.BUILT and self.validation_status in {
|
||||
MessageValidationStatus.READY,
|
||||
MessageValidationStatus.WARNING,
|
||||
}
|
||||
|
||||
|
||||
class CampaignBuildReport(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
campaign_id: str
|
||||
campaign_name: str
|
||||
campaign_file: str
|
||||
entries_count: int
|
||||
inactive_entries_count: int = 0
|
||||
messages: list[MessageDraft] = Field(default_factory=list)
|
||||
|
||||
@property
|
||||
def built_count(self) -> int:
|
||||
return sum(1 for message in self.messages if message.build_status == BuildStatus.BUILT)
|
||||
|
||||
@property
|
||||
def build_failed_count(self) -> int:
|
||||
return sum(1 for message in self.messages if message.build_status == BuildStatus.BUILD_FAILED)
|
||||
|
||||
@property
|
||||
def ready_count(self) -> int:
|
||||
return sum(1 for message in self.messages if message.validation_status == MessageValidationStatus.READY)
|
||||
|
||||
@property
|
||||
def warning_count(self) -> int:
|
||||
return sum(1 for message in self.messages if message.validation_status == MessageValidationStatus.WARNING)
|
||||
|
||||
@property
|
||||
def needs_review_count(self) -> int:
|
||||
return sum(1 for message in self.messages if message.validation_status == MessageValidationStatus.NEEDS_REVIEW)
|
||||
|
||||
@property
|
||||
def blocked_count(self) -> int:
|
||||
return sum(1 for message in self.messages if message.validation_status == MessageValidationStatus.BLOCKED)
|
||||
|
||||
@property
|
||||
def excluded_count(self) -> int:
|
||||
return sum(1 for message in self.messages if message.validation_status == MessageValidationStatus.EXCLUDED)
|
||||
|
||||
@property
|
||||
def inactive_count(self) -> int:
|
||||
return self.inactive_entries_count
|
||||
|
||||
@property
|
||||
def queueable_count(self) -> int:
|
||||
return sum(1 for message in self.messages if message.is_queueable)
|
||||
Reference in New Issue
Block a user