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

@@ -2,6 +2,8 @@ from __future__ import annotations
import fnmatch
import re
import time
from dataclasses import dataclass, field
from enum import StrEnum
from pathlib import Path
from typing import Any, Iterable
@@ -69,6 +71,10 @@ class ResolvedAttachment(BaseModel):
zip_mode: ZipRuleMode = ZipRuleMode.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)
status: AttachmentMatchStatus
behavior: Behavior | None = None
matches: list[str] = Field(default_factory=list)
@@ -99,6 +105,7 @@ class AttachmentResolutionReport(BaseModel):
attachments_base_path: str
entries_count: int
entries: list[EntryAttachmentResolution] = Field(default_factory=list)
profile: dict[str, Any] = Field(default_factory=dict)
@property
def ready_count(self) -> int:
@@ -304,7 +311,55 @@ def _resolve_attachment_directory(
return (legacy_root / rendered_base_dir).resolve(), None
def _match_files(directory: Path, file_filter: str, include_subdirs: bool) -> list[Path]:
@dataclass(slots=True)
class AttachmentMatchIndex:
_files_by_directory: dict[tuple[str, bool], list[Path]] = field(default_factory=dict)
filesystem_scans: int = 0
indexed_files: int = 0
fallback_globs: int = 0
def _directory_key(self, directory: Path, *, recursive: bool) -> tuple[str, bool]:
return str(directory.resolve()), recursive
def _indexed_files(self, directory: Path, *, recursive: bool) -> list[Path]:
key = self._directory_key(directory, recursive=recursive)
if key not in self._files_by_directory:
iterator = directory.rglob("*") if recursive else directory.iterdir()
files = sorted(path for path in iterator if path.is_file())
self._files_by_directory[key] = files
self.filesystem_scans += 1
self.indexed_files += len(files)
return self._files_by_directory[key]
def iter_files(self, directory: Path, *, recursive: bool = True) -> list[Path]:
if not directory.exists() or not directory.is_dir():
return []
return list(self._indexed_files(directory, recursive=recursive))
def match_files(self, directory: Path, file_filter: str, include_subdirs: bool) -> list[Path]:
if not directory.exists() or not directory.is_dir():
return []
if not include_subdirs and ("/" in file_filter or "\\" in file_filter):
# Preserve pathlib.glob semantics for legacy filters that include a
# relative path segment instead of only a filename pattern.
self.fallback_globs += 1
return sorted(path for path in directory.glob(file_filter) if path.is_file())
return sorted(path for path in self._indexed_files(directory, recursive=include_subdirs) if fnmatch.fnmatch(path.name, file_filter))
def stats(self, *, duration_ms: float, rules_resolved: int) -> dict[str, Any]:
return {
"duration_ms": round(duration_ms, 2),
"rules_resolved": rules_resolved,
"indexed_directories": len(self._files_by_directory),
"filesystem_scans": self.filesystem_scans,
"indexed_files": self.indexed_files,
"fallback_globs": self.fallback_globs,
}
def _match_files(directory: Path, file_filter: str, include_subdirs: bool, match_index: AttachmentMatchIndex | None = None) -> list[Path]:
if match_index is not None:
return match_index.match_files(directory, file_filter, include_subdirs)
if not directory.exists() or not directory.is_dir():
return []
if include_subdirs:
@@ -343,6 +398,7 @@ def _resolve_one_config(
scope: AttachmentScope,
index: int,
config: AttachmentConfig,
match_index: AttachmentMatchIndex | None = None,
) -> ResolvedAttachment:
rendered_base_dir = _rendered_base_dir(config, values)
rendered_file_filter = _render_template(config.file_filter, values)
@@ -352,7 +408,7 @@ def _resolve_one_config(
attachment_config=config,
rendered_base_dir=rendered_base_dir,
)
matches = _match_files(directory, rendered_file_filter, config.include_subdirs)
matches = _match_files(directory, rendered_file_filter, config.include_subdirs, match_index)
allow_multiple = _rule_allows_multiple(config, rendered_file_filter)
issues: list[AttachmentIssue] = []
@@ -389,6 +445,8 @@ def _resolve_one_config(
zip_mode=config.zip.mode,
zip_archive_id=archive.id if archive else None,
zip_filename=_render_zip_filename(archive, values),
message_filename_template=config.message_filename_template,
zip_entry_name_template=config.zip_entry_name_template,
status=status,
behavior=behavior,
matches=[str(path) for path in matches],
@@ -417,6 +475,7 @@ def resolve_entry_attachments(
campaign_file: str | Path,
entry: EntryConfig,
entry_index: int,
match_index: AttachmentMatchIndex | None = None,
) -> EntryAttachmentResolution:
values = build_template_values(config, entry)
resolved: list[ResolvedAttachment] = []
@@ -431,6 +490,7 @@ def resolve_entry_attachments(
scope=scope,
index=index,
config=attachment_config,
match_index=match_index,
)
)
@@ -448,10 +508,13 @@ 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_paths[0].path if config.attachments.base_paths else config.attachments.base_path)
started = time.perf_counter()
match_index = AttachmentMatchIndex()
resolved_entries = [
resolve_entry_attachments(config=config, campaign_file=campaign_file, entry=entry, entry_index=index)
resolve_entry_attachments(config=config, campaign_file=campaign_file, entry=entry, entry_index=index, match_index=match_index)
for index, entry in enumerate(entries, start=1)
]
rules_resolved = sum(len(entry.attachments) for entry in resolved_entries)
return AttachmentResolutionReport(
campaign_id=config.campaign.id,
campaign_name=config.campaign.name,
@@ -459,4 +522,5 @@ def resolve_campaign_attachments(config: CampaignConfig, *, campaign_file: str |
attachments_base_path=str(base_path),
entries_count=len(entries),
entries=resolved_entries,
profile=match_index.stats(duration_ms=(time.perf_counter() - started) * 1000, rules_resolved=rules_resolved),
)

View File

@@ -47,11 +47,20 @@ def _default_schema_path() -> Path:
return Path(__file__).resolve().parents[1] / "schema" / "campaign.schema.json"
def _default_schema_ui_path() -> Path:
return Path(__file__).resolve().parents[1] / "schema" / "campaign.schema.ui.json"
def load_campaign_schema(schema_path: str | Path | None = None) -> dict[str, Any]:
path = Path(schema_path) if schema_path else _default_schema_path()
return load_campaign_json(path)
def load_campaign_schema_ui(schema_path: str | Path | None = None) -> dict[str, Any]:
path = Path(schema_path) if schema_path else _default_schema_ui_path()
return load_campaign_json(path)
def validate_against_schema(data: dict[str, Any], schema_path: str | Path | None = None) -> None:
schema = load_campaign_schema(schema_path)
validator = Draft202012Validator(schema, format_checker=FormatChecker())

View File

@@ -6,7 +6,7 @@ from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from govoplan_mail.backend.config import ImapConfig, SmtpConfig, TransportSecurity
from govoplan_core.mail.config import ImapConfig, ImapServerConfig, SmtpConfig, SmtpServerConfig, TransportCredentials, TransportSecurity
class StrictModel(BaseModel):
@@ -108,12 +108,58 @@ class FieldDefinition(StrictModel):
can_override: bool = True
class MailServerCredentials(StrictModel):
smtp: TransportCredentials = Field(default_factory=TransportCredentials)
imap: TransportCredentials = Field(default_factory=TransportCredentials)
class ServerConfig(StrictModel):
mail_profile_id: str | None = None
inherit_smtp_credentials: bool = True
inherit_imap_credentials: bool = True
smtp: SmtpConfig | None = None
imap: ImapConfig | None = None
smtp: SmtpServerConfig | None = None
imap: ImapServerConfig | None = None
credentials: MailServerCredentials = Field(default_factory=MailServerCredentials)
@model_validator(mode="before")
@classmethod
def normalize_legacy_credentials(cls, value: Any) -> Any:
if not isinstance(value, dict):
return value
data = dict(value)
credentials = data.get("credentials") if isinstance(data.get("credentials"), dict) else {}
credentials = {key: dict(item) for key, item in credentials.items() if isinstance(item, dict)}
for protocol in ("smtp", "imap"):
transport = data.get(protocol)
if not isinstance(transport, dict):
continue
next_transport = dict(transport)
next_credentials = dict(credentials.get(protocol) or {})
for field in ("username", "password"):
if field in next_transport and field not in next_credentials:
next_credentials[field] = next_transport[field]
next_transport.pop(field, None)
next_transport.pop("enabled", None)
data[protocol] = next_transport
if next_credentials:
credentials[protocol] = next_credentials
if credentials:
data["credentials"] = credentials
return data
def runtime_smtp_config(self) -> SmtpConfig | None:
if self.smtp is None:
return None
payload = self.smtp.model_dump(mode="json")
payload.update(self.credentials.smtp.model_dump(mode="json", exclude_none=True))
return SmtpConfig.model_validate(payload)
def runtime_imap_config(self) -> ImapConfig | None:
if self.imap is None:
return None
payload = self.imap.model_dump(mode="json")
payload.update(self.credentials.imap.model_dump(mode="json", exclude_none=True))
return ImapConfig.model_validate(payload)
class RecipientConfig(StrictModel):
@@ -180,10 +226,17 @@ class TemplateSourceConfig(StrictModel):
return self
class TemplateBodyMode(StrEnum):
TEXT = "text"
HTML = "html"
BOTH = "both"
class TemplateConfig(StrictModel):
subject: str | None = None
text: str | None = None
html: str | None = None
body_mode: TemplateBodyMode = TemplateBodyMode.BOTH
source: TemplateSourceConfig | None = None
@model_validator(mode="after")
@@ -343,6 +396,8 @@ class AttachmentConfig(StrictModel):
include_subdirs: bool = False
required: bool = True
allow_multiple: bool = False
message_filename_template: str | None = None
zip_entry_name_template: str | None = None
@field_validator("type_", mode="before")
@classmethod

View File

@@ -301,25 +301,27 @@ def validate_campaign_config(
issues.extend(_attachment_path_issues(config))
issues.extend(_zip_configuration_issues(config))
if config.server.imap and config.server.imap.enabled:
missing = [name for name in ["host", "port", "username", "password"] if getattr(config.server.imap, name) in (None, "")]
if missing:
runtime_imap = config.server.runtime_imap_config()
if config.delivery.imap_append_sent.enabled:
if runtime_imap is None:
issues.append(_issue(
Severity.ERROR,
"incomplete_imap_config",
"IMAP append is enabled, but these IMAP settings are missing: " + ", ".join(missing),
"/server/imap",
Severity.WARNING,
"delivery_imap_enabled_without_server_imap",
"delivery.imap_append_sent is enabled, but no server.imap configuration is present",
"/delivery/imap_append_sent/enabled",
))
else:
missing = [name for name in ["host", "port", "username", "password"] if getattr(runtime_imap, name) in (None, "")]
if missing:
issues.append(_issue(
Severity.ERROR,
"incomplete_imap_config",
"IMAP append is enabled, but these IMAP settings are missing: " + ", ".join(missing),
"/server/imap",
))
if config.delivery.imap_append_sent.enabled and not (config.server.imap and config.server.imap.enabled):
issues.append(_issue(
Severity.WARNING,
"delivery_imap_enabled_without_server_imap",
"delivery.imap_append_sent is enabled, but server.imap.enabled is not true",
"/delivery/imap_append_sent/enabled",
))
if config.campaign.mode == "send" and not config.server.smtp:
runtime_smtp = config.server.runtime_smtp_config()
if config.campaign.mode == "send" and not runtime_smtp:
issues.append(_issue(
Severity.ERROR,
"missing_smtp_config",
@@ -327,8 +329,8 @@ def validate_campaign_config(
"/server/smtp",
))
if config.server.smtp:
missing = [name for name in ["host", "port"] if getattr(config.server.smtp, name) in (None, "")]
if runtime_smtp:
missing = [name for name in ["host", "port"] if getattr(runtime_smtp, name) in (None, "")]
if missing:
issues.append(_issue(
Severity.WARNING,

View File

@@ -2,36 +2,41 @@ from __future__ import annotations
from email import policy
from email.message import EmailMessage
from importlib import import_module
from types import ModuleType
from typing import Any
from sqlalchemy.orm import Session
from govoplan_campaign.backend.db.models import Campaign, CampaignVersion
from govoplan_core.core.optional import reraise_unless_missing_package
from govoplan_campaign.backend.campaign.loader import load_campaign_json
from govoplan_campaign.backend.campaign.validation import validate_campaign_config
from govoplan_campaign.backend.persistence.campaigns import load_campaign_config_from_json
from govoplan_campaign.backend.messages.builder import build_campaign_messages
from govoplan_campaign.backend.messages.models import MessageAddress, MessageDraft, MessageValidationStatus
from govoplan_files.backend.storage.campaign_attachments import (
annotate_built_messages_with_managed_files,
prepared_campaign_snapshot,
public_attachment_summary_payload,
)
from govoplan_mail.backend.dev.mock_mailbox import (
clear_records,
consume_fail_next_imap,
consume_fail_next_smtp,
get_failures,
list_records,
record_imap_append,
record_smtp_delivery,
)
from govoplan_campaign.backend.integrations import files_integration
class MockCampaignSendError(RuntimeError):
pass
def _mock_mailbox() -> ModuleType | None:
try:
return import_module("govoplan_mail.backend.dev.mock_mailbox")
except ModuleNotFoundError as exc:
reraise_unless_missing_package(exc, "govoplan_mail")
return None
def _require_mock_mailbox() -> ModuleType:
mailbox = _mock_mailbox()
if mailbox is None:
raise MockCampaignSendError("Mail module is not available")
return mailbox
def _message_address_payload(address: MessageAddress | None) -> dict[str, Any] | None:
if address is None:
return None
@@ -47,7 +52,7 @@ def _issue_payloads(message: MessageDraft) -> list[dict[str, Any]]:
def _attachment_payloads(message: MessageDraft) -> list[dict[str, Any]]:
return [public_attachment_summary_payload(attachment) for attachment in message.attachments]
return [files_integration().public_attachment_summary_payload(attachment) for attachment in message.attachments]
def _message_payload(message: MessageDraft) -> dict[str, Any]:
@@ -92,7 +97,8 @@ def _raw_message_bytes(message: EmailMessage) -> bytes:
def _smtp_rejection_matches(recipients: list[str]) -> list[str]:
needle = (get_failures().get("smtp_reject_recipients_containing") or "").strip().lower()
mailbox = _mock_mailbox()
needle = ((mailbox.get_failures() if mailbox else {}).get("smtp_reject_recipients_containing") or "").strip().lower()
if not needle:
return []
return [recipient for recipient in recipients if needle in recipient.lower()]
@@ -148,10 +154,12 @@ def run_mock_campaign_send(
if not version or version.campaign_id != campaign.id:
raise MockCampaignSendError("Campaign version not found or not part of campaign")
if clear_mailbox:
clear_records()
mailbox = _require_mock_mailbox() if send or clear_mailbox else _mock_mailbox()
if clear_mailbox and mailbox is not None:
mailbox.clear_records()
with prepared_campaign_snapshot(
files = files_integration()
with files.prepared_campaign_snapshot(
session,
tenant_id=tenant_id,
campaign_id=campaign.id,
@@ -163,7 +171,7 @@ def run_mock_campaign_send(
config = load_campaign_config_from_json(session, tenant_id=tenant_id, raw_json=prepared_raw, campaign_id=campaign.id)
validation_report = validate_campaign_config(config, campaign_file=prepared.path, check_files=check_files)
build_result = build_campaign_messages(config, campaign_file=prepared.path, write_eml=False)
annotate_built_messages_with_managed_files(build_result.built_messages, prepared.managed_files_by_local_path)
files.annotate_built_messages_with_managed_files(build_result.built_messages, prepared.managed_files_by_local_path)
send_results: list[dict[str, Any]] = []
sent_count = 0
@@ -206,14 +214,14 @@ def run_mock_campaign_send(
continue
try:
if consume_fail_next_smtp():
if mailbox is not None and mailbox.consume_fail_next_smtp():
raise MockCampaignSendError("Configured mock failure: next SMTP delivery fails")
rejected = _smtp_rejection_matches(recipients)
if rejected and len(rejected) == len(recipients):
raise MockCampaignSendError(f"Configured mock failure: all recipients rejected ({', '.join(rejected)})")
accepted = [recipient for recipient in recipients if recipient not in rejected]
smtp_record = record_smtp_delivery(built.mime, envelope_from=envelope_from, envelope_recipients=accepted, smtp_host="mock.smtp.local")
smtp_record = mailbox.record_smtp_delivery(built.mime, envelope_from=envelope_from, envelope_recipients=accepted, smtp_host="mock.smtp.local")
sent_count += 1
row.update({
"status": "sent",
@@ -226,14 +234,14 @@ def run_mock_campaign_send(
if append_sent:
try:
if consume_fail_next_imap():
if mailbox is not None and mailbox.consume_fail_next_imap():
raise MockCampaignSendError("Configured mock failure: next IMAP append fails")
folder = "Sent"
if config.delivery.imap_append_sent.folder and config.delivery.imap_append_sent.folder != "auto":
folder = config.delivery.imap_append_sent.folder
elif config.server.imap and config.server.imap.sent_folder and config.server.imap.sent_folder != "auto":
folder = config.server.imap.sent_folder
imap_record = record_imap_append(_raw_message_bytes(built.mime), folder=folder, imap_host="mock.imap.local")
imap_record = mailbox.record_imap_append(_raw_message_bytes(built.mime), folder=folder, imap_host="mock.imap.local")
imap_appended_count += 1
row.update({"imap_status": "appended", "imap_message_id": imap_record.id, "imap_folder": folder})
except Exception as exc:
@@ -286,5 +294,5 @@ def run_mock_campaign_send(
"imap_failed_count": imap_failed_count,
"results": send_results,
},
"mailbox": {"messages": list_records(limit=200)},
"mailbox": {"messages": mailbox.list_records(limit=200) if mailbox is not None else []},
}

View File

@@ -1,210 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass, field
from enum import StrEnum
from pathlib import Path
from .fields import Field, FieldConfiguration, FieldContents
from .recipients import Recipient, RecipientList
from .template import MailTemplate
class TransportSecurity(StrEnum):
PLAIN = "plain"
TLS = "tls"
STARTTLS = "starttls"
@property
def standard_port(self) -> int:
return 465 if self == TransportSecurity.TLS else 587
@dataclass
class MailServerSettings:
server: str = ""
port: int | None = None
username: str = ""
password: str = ""
transport_security: TransportSecurity = TransportSecurity.PLAIN
def use_plain(self) -> "MailServerSettings":
self.transport_security = TransportSecurity.PLAIN
self.port = self.port or self.transport_security.standard_port
return self
def use_tls(self) -> "MailServerSettings":
self.transport_security = TransportSecurity.TLS
self.port = self.port or self.transport_security.standard_port
return self
def use_starttls(self) -> "MailServerSettings":
self.transport_security = TransportSecurity.STARTTLS
self.port = self.port or self.transport_security.standard_port
return self
def resolved_port(self) -> int:
return self.port or self.transport_security.standard_port
@dataclass(frozen=True, slots=True)
class MailAttachmentConfig:
base_dir: Path
file_filter: str = "*"
include_subdirs: bool = False
@dataclass
class MailEntry:
field_config: FieldConfiguration
is_active: bool = True
from_recipient: Recipient | None = None
to: RecipientList = field(default_factory=RecipientList)
cc: RecipientList = field(default_factory=RecipientList)
bcc: RecipientList = field(default_factory=RecipientList)
combine_to: bool = True
combine_cc: bool = True
combine_bcc: bool = True
attachment_configs: list[MailAttachmentConfig] = field(default_factory=list)
combine_attachments: bool = True
field_contents: FieldContents = field(init=False)
def __post_init__(self) -> None:
self.field_contents = FieldContents(self.field_config)
def add_to(self, recipient: Recipient) -> "MailEntry":
self.to.add_recipient(recipient)
return self
def add_cc(self, recipient: Recipient) -> "MailEntry":
self.cc.add_recipient(recipient)
return self
def add_bcc(self, recipient: Recipient) -> "MailEntry":
self.bcc.add_recipient(recipient)
return self
def no_combine_to(self) -> "MailEntry":
self.combine_to = False
return self
def combine_to_recipients(self) -> "MailEntry":
self.combine_to = True
return self
def no_combine_attachments(self) -> "MailEntry":
self.combine_attachments = False
return self
def combine_attachments_with_global(self) -> "MailEntry":
self.combine_attachments = True
return self
def add_mail_attachment_config(self, config: MailAttachmentConfig) -> "MailEntry":
self.attachment_configs.append(config)
return self
def set_field_content_for_name(self, name: str, value: Field | object) -> "MailEntry":
if not self.field_contents.set_field_content_for_name(name, value):
raise KeyError(f"unknown field: {name}")
return self
def get_field_content_from_name(self, name: str) -> Field:
return self.field_contents.get_field_content_from_name(name)
@dataclass
class MailCampaign:
mail_server_settings: MailServerSettings | None = None
global_from: Recipient | None = None
global_to: RecipientList = field(default_factory=RecipientList)
global_cc: RecipientList = field(default_factory=RecipientList)
global_bcc: RecipientList = field(default_factory=RecipientList)
individual_from: bool = False
individual_to: bool = False
individual_cc: bool = False
individual_bcc: bool = False
base_attachment_path: Path = Path(".")
global_attachment_configs: list[MailAttachmentConfig] = field(default_factory=list)
individual_attachments: bool = False
send_without_attachments: bool = True
field_config: FieldConfiguration = field(default_factory=FieldConfiguration)
field_contents: FieldContents = field(init=False)
subject_template: MailTemplate = field(default_factory=MailTemplate)
mail_template: MailTemplate = field(default_factory=MailTemplate)
mail_entries: list[MailEntry] = field(default_factory=list)
def __post_init__(self) -> None:
self.field_contents = FieldContents(self.field_config)
@classmethod
def with_server_settings(cls, settings: MailServerSettings) -> "MailCampaign":
return cls(mail_server_settings=settings)
def add_field(self, name: str, field_type) -> "MailCampaign":
from .fields import FieldDescription
self.field_config.add_field_at_end(FieldDescription(name, field_type))
self.field_contents.ensure_field(self.field_config.get_field_description(name)) # type: ignore[arg-type]
for entry in self.mail_entries:
entry.field_contents.ensure_field(self.field_config.get_field_description(name)) # type: ignore[arg-type]
return self
def set_from(self, recipient: Recipient) -> "MailCampaign":
self.global_from = recipient
return self
def add_to(self, recipient: Recipient) -> "MailCampaign":
self.global_to.add_recipient(recipient)
return self
def allow_individual_to(self) -> "MailCampaign":
self.individual_to = True
return self
def disallow_individual_to(self) -> "MailCampaign":
self.individual_to = False
return self
def allow_individual_attachments(self) -> "MailCampaign":
self.individual_attachments = True
return self
def disallow_individual_attachments(self) -> "MailCampaign":
self.individual_attachments = False
return self
def dont_send_without_attachments(self) -> "MailCampaign":
self.send_without_attachments = False
return self
def send_without_attachments_allowed(self) -> "MailCampaign":
self.send_without_attachments = True
return self
def add_new_mail_entry(self) -> MailEntry:
entry = MailEntry(self.field_config)
self.mail_entries.append(entry)
return entry
def set_field_content_for_name(self, name: str, value: Field | object) -> "MailCampaign":
if not self.field_contents.set_field_content_for_name(name, value):
raise KeyError(f"unknown field: {name}")
return self
def get_field_content_from_name(self, name: str) -> Field:
return self.field_contents.get_field_content_from_name(name)
def all_recipients_for(self, entry: MailEntry) -> list[Recipient]:
recipients: list[Recipient] = []
if not self.individual_to or entry.combine_to:
recipients.extend(self.global_to.recipients)
if not self.individual_cc or entry.combine_cc:
recipients.extend(self.global_cc.recipients)
if not self.individual_bcc or entry.combine_bcc:
recipients.extend(self.global_bcc.recipients)
if self.individual_to:
recipients.extend(entry.to.recipients)
if self.individual_cc:
recipients.extend(entry.cc.recipients)
if self.individual_bcc:
recipients.extend(entry.bcc.recipients)
return recipients

View File

@@ -1,126 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import date, datetime
from enum import StrEnum
from typing import Any
class FieldType(StrEnum):
STRING = "string"
INTEGER = "integer"
DOUBLE = "double"
DATE = "date"
PASSWORD = "password"
@dataclass(slots=True)
class FieldDescription:
name: str
type: FieldType = FieldType.STRING
can_override: bool = True
@dataclass(slots=True)
class Field:
content: Any
@classmethod
def with_content(cls, content: Any) -> "Field":
if content is None:
raise ValueError("content must not be None")
return cls(content=content)
@property
def type(self) -> FieldType:
if isinstance(self.content, bool):
return FieldType.STRING
if isinstance(self.content, int):
return FieldType.INTEGER
if isinstance(self.content, float):
return FieldType.DOUBLE
if isinstance(self.content, (date, datetime)):
return FieldType.DATE
if isinstance(self.content, (bytes, bytearray)):
return FieldType.PASSWORD
return FieldType.STRING
def as_string(self) -> str:
if isinstance(self.content, (bytes, bytearray)):
return self.content.decode("utf-8")
if isinstance(self.content, (date, datetime)):
return self.content.isoformat()
return str(self.content)
@dataclass
class FieldConfiguration:
fields: list[FieldDescription] = field(default_factory=list)
def add_field_at_end(self, field_description: FieldDescription) -> "FieldConfiguration":
return self.add_field_at_position(len(self.fields), field_description)
def add_field_at_start(self, field_description: FieldDescription) -> "FieldConfiguration":
return self.add_field_at_position(0, field_description)
def add_field_at_position(self, position: int, field_description: FieldDescription) -> "FieldConfiguration":
if self.has_field(field_description.name):
raise ValueError(f"field already exists: {field_description.name}")
position = max(0, min(position, len(self.fields)))
self.fields.insert(position, field_description)
return self
def has_field(self, name: str) -> bool:
return any(f.name == name for f in self.fields)
def get_field_description(self, name: str) -> FieldDescription | None:
return next((f for f in self.fields if f.name == name), None)
def get_field_names(self) -> list[str]:
return [f.name for f in self.fields]
@dataclass
class FieldContents:
field_config: FieldConfiguration
field_map: dict[str, Field] = field(default_factory=dict)
def __post_init__(self) -> None:
for field_description in self.field_config.fields:
self.ensure_field(field_description)
def ensure_field(self, field_description: FieldDescription) -> None:
if field_description.name in self.field_map:
return
match field_description.type:
case FieldType.INTEGER:
value = 0
case FieldType.DOUBLE:
value = 0.0
case FieldType.DATE:
value = date.today()
case FieldType.PASSWORD:
value = b""
case _:
value = ""
self.field_map[field_description.name] = Field.with_content(value)
def get_field_content_from_name(self, name: str) -> Field:
try:
return self.field_map[name]
except KeyError as exc:
raise KeyError(f"unknown field: {name}") from exc
def set_field_content_for_name(self, name: str, value: Field | Any) -> bool:
if name not in self.field_map:
return False
if not isinstance(value, Field):
value = Field.with_content(value)
expected = self.field_map[name].type
if expected != value.type and expected != FieldType.PASSWORD:
raise TypeError(f"field {name!r} expects {expected}, got {value.type}")
self.field_map[name] = value
return True
def as_value_map(self, prefix: str) -> dict[str, str]:
return {f"{prefix}::{name}": field.as_string() for name, field in self.field_map.items()}

View File

@@ -1,29 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass, field
from email.message import EmailMessage
from typing import Iterator
@dataclass
class MailQueue:
messages: list[EmailMessage] = field(default_factory=list)
def add_mail(self, message: EmailMessage) -> None:
self.messages.append(message)
def remove_mail(self, message: EmailMessage) -> bool:
if message in self.messages:
self.messages.remove(message)
return True
return False
@property
def mail_count(self) -> int:
return len(self.messages)
def is_empty(self) -> bool:
return not self.messages
def __iter__(self) -> Iterator[EmailMessage]:
return iter(self.messages)

View File

@@ -1,43 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass, field
from email.utils import formataddr
from enum import StrEnum
class RecipientType(StrEnum):
TO = "to"
CC = "cc"
BCC = "bcc"
@dataclass(frozen=True, slots=True)
class Recipient:
address: str
name: str | None = None
type: RecipientType = RecipientType.TO
def formatted(self) -> str:
return formataddr((self.name or self.address, self.address))
@dataclass
class RecipientList:
recipients: list[Recipient] = field(default_factory=list)
def add_recipient(self, recipient: Recipient) -> "RecipientList":
if recipient not in self.recipients:
self.recipients.append(recipient)
return self
def add_multiple_recipients(self, recipients: list[Recipient] | tuple[Recipient, ...]) -> "RecipientList":
for recipient in recipients:
self.add_recipient(recipient)
return self
def clear_all_recipients(self) -> "RecipientList":
self.recipients.clear()
return self
def by_type(self, recipient_type: RecipientType) -> list[Recipient]:
return [r for r in self.recipients if r.type == recipient_type]

View File

@@ -1,28 +0,0 @@
from __future__ import annotations
import re
from dataclasses import dataclass
_FIELD_PATTERN = re.compile(r"(?<!\\)\$\{(.*?)(?<!\\)\}")
@dataclass
class MailTemplate:
template_string: str = ""
def set_template_string(self, template: str) -> "MailTemplate":
self.template_string = template
return self
def get_used_fields(self) -> set[str]:
return set(_FIELD_PATTERN.findall(self.template_string))
def apply_values(self, values: dict[str, str], *, keep_missing: bool = True) -> str:
def replace(match: re.Match[str]) -> str:
key = match.group(1)
if key in values:
return values[key]
return match.group(0) if keep_missing else ""
rendered = _FIELD_PATTERN.sub(replace, self.template_string)
return rendered.replace(r"\${", "${").replace(r"\}", "}")

View File

@@ -0,0 +1,230 @@
from __future__ import annotations
import json
import shutil
import tempfile
from contextlib import contextmanager
from pathlib import Path
from typing import Any, Iterator
from govoplan_campaign.backend.runtime import capability
FILES_CAPABILITY = "files.campaign_attachments"
MAIL_CAPABILITY = "mail.campaign_delivery"
class OptionalModuleUnavailable(RuntimeError):
pass
class SmtpConfigurationError(RuntimeError):
pass
class SmtpSendError(RuntimeError):
def __init__(self, message: str, *, temporary: bool = False, outcome_unknown: bool = False) -> None:
super().__init__(message)
self.temporary = temporary
self.outcome_unknown = outcome_unknown
class ImapConfigurationError(RuntimeError):
pass
class ImapAppendError(RuntimeError):
pass
class MailProfileError(OptionalModuleUnavailable):
pass
class _PreparedCampaignSnapshot:
def __init__(self, directory: Path, path: Path, raw_json: dict[str, Any]) -> None:
self._directory = directory
self.path = path
self.raw_json = raw_json
self.managed_files_by_local_path: dict[str, Any] = {}
self.shared_assets: list[Any] = []
def cleanup(self) -> None:
shutil.rmtree(self._directory, ignore_errors=True)
class FilesCampaignIntegration:
def __init__(self, delegate: Any | None = None) -> None:
self._delegate = delegate
@property
def available(self) -> bool:
return self._delegate is not None
@contextmanager
def prepared_campaign_snapshot(self, *args: Any, **kwargs: Any) -> Iterator[Any]:
if self._delegate is not None:
with self._delegate.prepared_campaign_snapshot(*args, **kwargs) as prepared:
yield prepared
return
raw_json = kwargs.get("raw_json") if isinstance(kwargs.get("raw_json"), dict) else {}
prefix = str(kwargs.get("prefix") or "govoplan-campaign-")
directory = Path(tempfile.mkdtemp(prefix=prefix))
snapshot = _PreparedCampaignSnapshot(directory, directory / "campaign.json", raw_json)
snapshot.path.write_text(json.dumps(raw_json, ensure_ascii=False, indent=2), encoding="utf-8")
try:
yield snapshot
finally:
snapshot.cleanup()
def managed_match_payloads(self, matches: Any, managed_files_by_local_path: dict[str, Any]) -> list[dict[str, Any]]:
if self._delegate is None:
return []
return self._delegate.managed_match_payloads(matches, managed_files_by_local_path)
def public_attachment_summary_payload(self, attachment: Any) -> dict[str, Any]:
if self._delegate is not None:
return self._delegate.public_attachment_summary_payload(attachment)
if hasattr(attachment, "model_dump"):
return attachment.model_dump(mode="json")
if isinstance(attachment, dict):
return dict(attachment)
return {"path": str(attachment)}
def annotate_built_messages_with_managed_files(self, built_messages: Any, managed_files_by_local_path: dict[str, Any]) -> None:
if self._delegate is not None:
self._delegate.annotate_built_messages_with_managed_files(built_messages, managed_files_by_local_path)
def record_campaign_attachment_uses_for_jobs(self, session: Any, jobs: Any, *, stage: str) -> None:
if self._delegate is not None:
self._delegate.record_campaign_attachment_uses_for_jobs(session, jobs, stage=stage)
def current_version_and_blob(self, session: Any, asset: Any) -> tuple[Any, Any]:
if self._delegate is None:
raise OptionalModuleUnavailable("Files module is not available")
return self._delegate.current_version_and_blob(session, asset)
def mark_job_attachment_uses_sent(self, session: Any, job: Any) -> None:
if self._delegate is not None:
self._delegate.mark_job_attachment_uses_sent(session, job)
class MailCampaignIntegration:
def __init__(self, delegate: Any | None = None) -> None:
self._delegate = delegate
if delegate is not None:
self.MailProfileError = getattr(delegate, "MailProfileError", MailProfileError)
MailProfileError = MailProfileError
SmtpConfigurationError = SmtpConfigurationError
SmtpSendError = SmtpSendError
ImapConfigurationError = ImapConfigurationError
ImapAppendError = ImapAppendError
@property
def available(self) -> bool:
return self._delegate is not None
def _require(self) -> Any:
if self._delegate is None:
raise MailProfileError("Mail module is not available")
return self._delegate
def materialize_campaign_mail_profile_config(self, session: Any, **kwargs: Any) -> dict[str, Any]:
if self._delegate is None:
raw_json = kwargs.get("raw_json")
if self.mail_profile_id_from_campaign_json(raw_json if isinstance(raw_json, dict) else {}):
raise MailProfileError("Campaign mail-server profiles require the mail module")
return dict(raw_json) if isinstance(raw_json, dict) else {}
try:
return self._delegate.materialize_campaign_mail_profile_config(session, **kwargs)
except getattr(self._delegate, "MailProfileError", MailProfileError) as exc:
raise MailProfileError(str(exc)) from exc
def assert_campaign_mail_policy_allows_json(self, session: Any, **kwargs: Any) -> None:
if self._delegate is None:
raw_json = kwargs.get("raw_json")
profile_id = self.mail_profile_id_from_campaign_json(raw_json if isinstance(raw_json, dict) else {})
if profile_id:
raise MailProfileError("Campaign mail-server profiles require the mail module")
return None
try:
return self._delegate.assert_campaign_mail_policy_allows_json(session, **kwargs)
except getattr(self._delegate, "MailProfileError", MailProfileError) as exc:
raise MailProfileError(str(exc)) from exc
def assert_mail_policy_allows_send(self, session: Any, **kwargs: Any) -> None:
delegate = self._require()
try:
return delegate.assert_mail_policy_allows_send(session, **kwargs)
except getattr(delegate, "MailProfileError", MailProfileError) as exc:
raise MailProfileError(str(exc)) from exc
def mail_profile_id_from_campaign_json(self, raw_json: dict[str, Any]) -> str | None:
if self._delegate is not None:
return self._delegate.mail_profile_id_from_campaign_json(raw_json)
server = raw_json.get("server") if isinstance(raw_json, dict) else None
profile_id = server.get("mail_profile_id") if isinstance(server, dict) else None
if profile_id is None and isinstance(server, dict):
profile_id = server.get("profile_id")
return str(profile_id).strip() if profile_id else None
def ensure_mail_profile_allowed_for_campaign(self, session: Any, **kwargs: Any) -> Any:
delegate = self._require()
try:
return delegate.ensure_mail_profile_allowed_for_campaign(session, **kwargs)
except getattr(delegate, "MailProfileError", MailProfileError) as exc:
raise MailProfileError(str(exc)) from exc
def smtp_config_from_profile(self, profile: Any) -> Any:
return self._require().smtp_config_from_profile(profile)
def imap_config_from_profile(self, profile: Any) -> Any:
return self._require().imap_config_from_profile(profile)
def effective_profile_credentials_inherited(self, session: Any, **kwargs: Any) -> bool:
return self._require().effective_profile_credentials_inherited(session, **kwargs)
def apply_campaign_credentials(self, profile_payload: dict[str, Any], server: dict[str, Any], protocol: str) -> dict[str, Any]:
return self._require().apply_campaign_credentials(profile_payload, server, protocol)
def wait_for_rate_limit(self, **kwargs: Any) -> None:
if self._delegate is None:
return None
return self._delegate.wait_for_rate_limit(**kwargs)
def send_email_bytes(self, *args: Any, **kwargs: Any) -> Any:
delegate = self._require()
try:
return delegate.send_email_bytes(*args, **kwargs)
except getattr(delegate, "SmtpSendError", SmtpSendError) as exc:
raise SmtpSendError(str(exc), temporary=bool(getattr(exc, "temporary", False)), outcome_unknown=bool(getattr(exc, "outcome_unknown", False))) from exc
except getattr(delegate, "SmtpConfigurationError", SmtpConfigurationError) as exc:
raise SmtpConfigurationError(str(exc)) from exc
def append_message_to_sent(self, *args: Any, **kwargs: Any) -> Any:
delegate = self._require()
try:
return delegate.append_message_to_sent(*args, **kwargs)
except getattr(delegate, "ImapAppendError", ImapAppendError) as exc:
raise ImapAppendError(str(exc)) from exc
except getattr(delegate, "ImapConfigurationError", ImapConfigurationError) as exc:
raise ImapConfigurationError(str(exc)) from exc
def send_email_message(self, *args: Any, **kwargs: Any) -> Any:
delegate = self._require()
try:
return delegate.send_email_message(*args, **kwargs)
except getattr(delegate, "SmtpSendError", SmtpSendError) as exc:
raise SmtpSendError(str(exc), temporary=bool(getattr(exc, "temporary", False)), outcome_unknown=bool(getattr(exc, "outcome_unknown", False))) from exc
except getattr(delegate, "SmtpConfigurationError", SmtpConfigurationError) as exc:
raise SmtpConfigurationError(str(exc)) from exc
def files_integration() -> FilesCampaignIntegration:
return FilesCampaignIntegration(capability(FILES_CAPABILITY))
def mail_integration() -> MailCampaignIntegration:
return MailCampaignIntegration(capability(MAIL_CAPABILITY))

View File

@@ -98,7 +98,9 @@ ROLE_TEMPLATES = (
def _campaigns_router(context: ModuleContext):
del context
from govoplan_campaign.backend.runtime import configure_runtime
configure_runtime(registry=context.registry, settings=context.settings)
from govoplan_campaign.backend.router import router
return router
@@ -108,8 +110,8 @@ manifest = ModuleManifest(
id="campaigns",
name="Campaigns",
version="1.0.0",
dependencies=("access", "files", "mail"),
optional_dependencies=(),
dependencies=("access",),
optional_dependencies=("files", "mail"),
permissions=PERMISSIONS,
route_factory=_campaigns_router,
role_templates=ROLE_TEMPLATES,

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:

View File

@@ -31,13 +31,7 @@ from govoplan_campaign.backend.messages.builder import build_campaign_messages
from govoplan_campaign.backend.messages.models import MessageDraft
from govoplan_campaign.backend.sending.execution import create_execution_snapshot
from govoplan_campaign.backend.campaign.models import CampaignConfig
from govoplan_mail.backend.mail_profiles import materialize_campaign_mail_profile_config
from govoplan_files.backend.storage.campaign_usage import record_campaign_attachment_uses_for_jobs
from govoplan_files.backend.storage.campaign_attachments import (
annotate_built_messages_with_managed_files,
prepared_campaign_snapshot,
public_attachment_summary_payload,
)
from govoplan_campaign.backend.integrations import files_integration, mail_integration
RUNTIME_DIR = Path(__file__).resolve().parents[3] / "runtime"
CAMPAIGN_SNAPSHOT_DIR = RUNTIME_DIR / "campaign_snapshots"
@@ -64,7 +58,7 @@ def load_campaign_config_from_json(
owner_user_id: str | None = None,
owner_group_id: str | None = None,
) -> CampaignConfig:
materialized = materialize_campaign_mail_profile_config(
materialized = mail_integration().materialize_campaign_mail_profile_config(
session,
tenant_id=tenant_id,
raw_json=raw_json,
@@ -274,7 +268,8 @@ def validate_campaign_version(
raise CampaignPersistenceError(f"{lock_label.capitalize()} campaign versions cannot be validated. Unlock or create an editable copy instead.")
if check_files:
with prepared_campaign_snapshot(
files = files_integration()
with files.prepared_campaign_snapshot(
session,
tenant_id=tenant_id,
campaign_id=campaign.id,
@@ -379,7 +374,7 @@ def _job_from_message(
"bounce_to": [item.model_dump(mode="json") for item in message.bounce_to],
"disposition_notification_to": [item.model_dump(mode="json") for item in message.disposition_notification_to],
},
resolved_attachments=[public_attachment_summary_payload(item) for item in message.attachments],
resolved_attachments=[files_integration().public_attachment_summary_payload(item) for item in message.attachments],
issues_snapshot=[item.model_dump(mode="json") for item in message.issues],
last_error="; ".join(issue.message for issue in message.issues if issue.severity == "error") or None,
)
@@ -405,7 +400,8 @@ def build_campaign_version(
_ensure_version_validated_and_locked(version)
output_dir = BUILD_OUTPUT_DIR / campaign.id / version.id
with prepared_campaign_snapshot(
files = files_integration()
with files.prepared_campaign_snapshot(
session,
tenant_id=tenant_id,
campaign_id=campaign.id,
@@ -416,11 +412,11 @@ def build_campaign_version(
managed_raw = load_campaign_json(prepared.path)
managed_config = load_campaign_config_from_json(session, tenant_id=tenant_id, raw_json=managed_raw, campaign_id=campaign.id)
result = build_campaign_messages(managed_config, campaign_file=prepared.path, output_dir=output_dir, write_eml=write_eml)
annotate_built_messages_with_managed_files(result.built_messages, prepared.managed_files_by_local_path)
files.annotate_built_messages_with_managed_files(result.built_messages, prepared.managed_files_by_local_path)
report_json = result.report.model_dump(mode="json", by_alias=True)
for message_payload, message in zip(report_json.get("messages", []), result.report.messages, strict=False):
if isinstance(message_payload, dict):
message_payload["attachments"] = [public_attachment_summary_payload(item) for item in message.attachments]
message_payload["attachments"] = [files.public_attachment_summary_payload(item) for item in message.attachments]
report_json["built_at"] = datetime.now(UTC).isoformat()
report_json["build_token"] = uuid4().hex
report_json.update({
@@ -459,17 +455,18 @@ def build_campaign_version(
# records in bulk. This avoids one flush plus several metadata queries per
# recipient for large campaigns.
session.flush()
record_campaign_attachment_uses_for_jobs(
files.record_campaign_attachment_uses_for_jobs(
session,
[job for job, _message in job_build_pairs],
stage="built",
)
if not managed_config.server.smtp:
runtime_smtp = managed_config.server.runtime_smtp_config()
if not runtime_smtp:
raise CampaignPersistenceError("Campaign has no SMTP configuration; an execution snapshot cannot be created")
execution_snapshot, execution_snapshot_hash = create_execution_snapshot(
version,
smtp=managed_config.server.smtp,
imap=managed_config.server.imap,
smtp=runtime_smtp,
imap=managed_config.server.runtime_imap_config(),
delivery=managed_config.delivery,
jobs=[job for job, _message in job_build_pairs],
build_summary=report_json,

View File

@@ -18,7 +18,7 @@ from govoplan_campaign.backend.db.models import (
JobSendStatus,
)
from govoplan_campaign.backend.sending.execution import clear_execution_snapshot
from govoplan_mail.backend.mail_profiles import assert_campaign_mail_policy_allows_json
from govoplan_campaign.backend.integrations import mail_integration
from govoplan_campaign.backend.persistence.campaigns import (
CampaignPersistenceError,
_next_version_number,
@@ -77,19 +77,18 @@ def minimal_campaign_json(*, external_id: str, name: str, description: str | Non
"smtp": {
"host": "",
"port": 587,
"username": "",
"password": "",
"security": "starttls",
},
"imap": {
"enabled": False,
"host": "",
"port": 993,
"username": "",
"password": "",
"security": "tls",
"sent_folder": "auto",
},
"credentials": {
"smtp": {"username": "", "password": ""},
"imap": {"username": "", "password": ""},
},
},
"recipients": {
"from": [],
@@ -339,7 +338,7 @@ def fork_campaign_version_for_edit(
base_json = raw_json if raw_json is not None else copy.deepcopy(source.raw_json)
runtime_json = normalize_campaign_paths(base_json, source_base_path) if source_base_path else copy.deepcopy(base_json)
assert_campaign_mail_policy_allows_json(session, tenant_id=tenant_id, raw_json=runtime_json, campaign_id=campaign.id)
mail_integration().assert_campaign_mail_policy_allows_json(session, tenant_id=tenant_id, raw_json=runtime_json, campaign_id=campaign.id)
new_version = CampaignVersion(
campaign_id=campaign.id,
@@ -502,7 +501,7 @@ def update_campaign_version(
if raw_json is not None:
runtime_json = normalize_campaign_paths(raw_json, source_base_path) if source_base_path else copy.deepcopy(raw_json)
assert_campaign_mail_policy_allows_json(session, tenant_id=tenant_id, raw_json=runtime_json, campaign_id=campaign.id)
mail_integration().assert_campaign_mail_policy_allows_json(session, tenant_id=tenant_id, raw_json=runtime_json, campaign_id=campaign.id)
version.raw_json = runtime_json
version.schema_version = str(runtime_json.get("version", version.schema_version or "1.0"))
_apply_campaign_metadata(campaign, runtime_json)
@@ -796,9 +795,22 @@ def validate_campaign_partial(raw_json: dict[str, Any], *, section: str | None =
issue("warning", "recipients", "entries.mapping", "missing_email_mapping", "No email field mapping is configured.")
template = raw_json.get("template") if isinstance(raw_json.get("template"), dict) else {}
if not template.get("subject") and not (isinstance(template.get("source"), dict) and template["source"].get("subject_path")):
source_template = template.get("source") if isinstance(template.get("source"), dict) else {}
if not template.get("subject") and not source_template.get("subject_path"):
issue("warning", "template", "template.subject", "missing_subject", "Template subject is empty.")
if not template.get("text") and not template.get("html") and not isinstance(template.get("source"), dict):
explicit_body_mode = template.get("body_mode") if template.get("body_mode") in {"text", "html", "both"} else None
has_text_body = bool(template.get("text")) or bool(source_template.get("text_path"))
has_html_body = bool(template.get("html")) or bool(source_template.get("html_path"))
if explicit_body_mode == "text" and not has_text_body:
issue("warning", "template", "template.text", "missing_template_text_body", "Template body mode is text only, but no text body is configured.")
elif explicit_body_mode == "html" and not has_html_body:
issue("warning", "template", "template.html", "missing_template_html_body", "Template body mode is HTML only, but no HTML body is configured.")
elif explicit_body_mode == "both":
if not has_text_body:
issue("warning", "template", "template.text", "missing_template_text_body", "Template body mode is both, but no text body is configured.")
if not has_html_body:
issue("warning", "template", "template.html", "missing_template_html_body", "Template body mode is both, but no HTML body is configured.")
elif not has_text_body and not has_html_body and not source_template:
issue("warning", "template", "template", "missing_template_body", "No text, HTML or file-based template body configured yet.")
attachments = raw_json.get("attachments") if isinstance(raw_json.get("attachments"), dict) else {}

View File

@@ -13,7 +13,7 @@ from govoplan_campaign.backend.campaign.loader import load_campaign_config
from govoplan_campaign.backend.campaign.models import CampaignConfig, SmtpConfig
from govoplan_campaign.backend.persistence.campaigns import _write_campaign_snapshot
from govoplan_campaign.backend.reports.campaigns import CampaignReportError, generate_campaign_report, generate_jobs_csv
from govoplan_mail.backend.sending.smtp import SmtpConfigurationError, SmtpSendResult, send_email_message
from govoplan_campaign.backend.integrations import SmtpConfigurationError, mail_integration
class CampaignReportEmailError(RuntimeError):
@@ -70,8 +70,9 @@ def _load_config(version: CampaignVersion) -> CampaignConfig:
def _effective_from(config: CampaignConfig) -> tuple[str, str | None]:
if config.recipients.from_:
return config.recipients.from_[0].email, config.recipients.from_[0].name
if config.server.smtp and config.server.smtp.username and "@" in config.server.smtp.username:
return config.server.smtp.username, None
smtp_config = config.server.runtime_smtp_config()
if smtp_config and smtp_config.username and "@" in smtp_config.username:
return smtp_config.username, None
raise SmtpConfigurationError("Report email requires a recipients.from address or an SMTP username that is an email address")
@@ -161,7 +162,7 @@ def send_campaign_report_email(
version = _selected_version(session, campaign, version_id)
config = _load_config(version)
smtp_config: SmtpConfig | None = config.server.smtp
smtp_config: SmtpConfig | None = config.server.runtime_smtp_config()
if smtp_config is None:
raise SmtpConfigurationError("Campaign has no SMTP configuration")
@@ -202,7 +203,7 @@ def send_campaign_report_email(
smtp_port=smtp_config.port,
)
result: SmtpSendResult = send_email_message(
result = mail_integration().send_email_message(
message,
smtp_config=smtp_config,
envelope_from=envelope_from,

View File

@@ -28,6 +28,7 @@ from govoplan_campaign.backend.schemas import (
CampaignResponse,
CampaignVersionDetailResponse,
CampaignVersionResponse,
CampaignWorkspaceResponse,
CampaignVersionSetStepRequest,
CampaignReviewStateRequest,
CampaignVersionUpdateRequest,
@@ -50,8 +51,7 @@ from govoplan_campaign.backend.persistence.campaigns import (
load_campaign_config_from_json,
validate_campaign_version,
)
from govoplan_files.backend.storage.files import current_version_and_blob
from govoplan_files.backend.storage.campaign_attachments import managed_match_payloads, prepared_campaign_snapshot
from govoplan_campaign.backend.integrations import files_integration
from govoplan_campaign.backend.campaign.loader import load_campaign_config, load_campaign_json
from govoplan_campaign.backend.attachments.resolver import resolve_campaign_attachments
from govoplan_core.security.time import utc_now
@@ -355,6 +355,62 @@ def get_campaign(
return CampaignResponse.model_validate(_get_campaign_for_principal(session, campaign_id, principal))
@router.get("/{campaign_id}/workspace", response_model=CampaignWorkspaceResponse)
def get_campaign_workspace(
campaign_id: str,
version_id: str | None = None,
include_current_version: bool = True,
include_summary: bool = False,
include_versions: bool = True,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")),
):
campaign = _get_campaign_for_principal(session, campaign_id, principal)
versions: list[CampaignVersion] = []
if include_versions or include_current_version:
versions = (
session.query(CampaignVersion)
.filter(CampaignVersion.campaign_id == campaign.id)
.order_by(CampaignVersion.version_number.desc())
.all()
)
selected_version_id = version_id or campaign.current_version_id or (versions[0].id if versions else None)
current_version: CampaignVersion | None = None
if include_current_version and selected_version_id:
_require_permission(principal, "campaigns:recipient:read")
current_version = (
session.query(CampaignVersion)
.filter(
CampaignVersion.id == selected_version_id,
CampaignVersion.campaign_id == campaign.id,
)
.one_or_none()
)
summary_payload: dict[str, object] | None = None
if include_summary:
try:
summary_payload = generate_campaign_report(
session,
tenant_id=principal.tenant_id,
campaign_id=campaign_id,
version_id=selected_version_id,
include_jobs=False,
)
except CampaignReportError:
summary_payload = None
return CampaignWorkspaceResponse(
campaign=CampaignResponse.model_validate(campaign),
versions=[CampaignVersionResponse.model_validate(item) for item in versions] if include_versions else [],
current_version=CampaignVersionDetailResponse.model_validate(current_version) if current_version is not None else None,
summary=summary_payload,
selected_version_id=selected_version_id,
)
@router.put("/{campaign_id}", response_model=CampaignResponse)
def update_campaign_metadata_endpoint(
campaign_id: str,
@@ -1811,6 +1867,7 @@ def append_sent(
tenant_id=principal.tenant_id,
campaign_id=campaign_id,
enqueue_celery=payload.enqueue_celery,
run_inline=payload.run_inline,
dry_run=payload.dry_run,
)
audit_from_principal(
@@ -1841,7 +1898,7 @@ class CampaignAttachmentPreviewResponse(BaseModel):
def _file_preview(session: Session, asset) -> dict[str, object]:
version, blob = current_version_and_blob(session, asset)
version, blob = files_integration().current_version_and_blob(session, asset)
return {
"id": asset.id,
"version_id": version.id,
@@ -1876,7 +1933,8 @@ def preview_campaign_attachments(
raw = raw if isinstance(raw, dict) else {}
_require_mail_profile_use_if_needed(principal, raw)
with prepared_campaign_snapshot(
files = files_integration()
with files.prepared_campaign_snapshot(
session,
tenant_id=principal.tenant_id,
campaign_id=campaign.id,
@@ -1892,7 +1950,7 @@ def preview_campaign_attachments(
for entry in report.entries:
for attachment in entry.attachments:
managed_matches = managed_match_payloads(attachment.matches, prepared.managed_files_by_local_path)
managed_matches = files.managed_match_payloads(attachment.matches, prepared.managed_files_by_local_path)
matched_asset_ids.update(str(item["asset_id"]) for item in managed_matches)
matches: list[dict[str, object]] = [
{

View File

@@ -0,0 +1,36 @@
from __future__ import annotations
from typing import Any
_runtime_registry: object | None = None
_runtime_settings: object | None = None
def configure_runtime(*, registry: object | None = None, settings: object | None = None) -> None:
global _runtime_registry, _runtime_settings
if registry is not None:
_runtime_registry = registry
if settings is not None:
_runtime_settings = settings
def get_registry() -> object | None:
return _runtime_registry
def get_settings() -> object | None:
return _runtime_settings
def capability(name: str) -> Any | None:
registry = get_registry()
if registry is None or not hasattr(registry, "capability"):
return None
return registry.capability(name)
def has_capability(name: str) -> bool:
registry = get_registry()
if registry is None or not hasattr(registry, "has_capability"):
return False
return bool(registry.has_capability(name))

View File

@@ -175,6 +175,40 @@
}
},
"additionalProperties": false
},
"credentials": {
"type": "object",
"properties": {
"smtp": {
"type": "object",
"properties": {
"username": {
"type": "string"
},
"password": {
"type": "string"
}
},
"additionalProperties": false
},
"imap": {
"type": "object",
"properties": {
"username": {
"type": "string"
},
"password": {
"type": "string"
}
},
"additionalProperties": false
}
},
"additionalProperties": false,
"default": {
"smtp": {},
"imap": {}
}
}
},
"additionalProperties": false
@@ -301,6 +335,16 @@
"string",
"null"
]
},
"body_mode": {
"type": "string",
"enum": [
"text",
"html",
"both"
],
"default": "both",
"description": "Which body parts should be generated for campaign messages."
}
},
"additionalProperties": false
@@ -336,6 +380,16 @@
}
},
"additionalProperties": false
},
"body_mode": {
"type": "string",
"enum": [
"text",
"html",
"both"
],
"default": "both",
"description": "Which body parts should be generated for campaign messages."
}
},
"additionalProperties": false
@@ -745,6 +799,22 @@
"string",
"null"
]
},
"message_filename_template": {
"type": [
"string",
"null"
],
"default": null,
"description": "Optional recipient-rendered filename template used when this rule sends files directly as message attachments. If omitted, the source filename is used."
},
"zip_entry_name_template": {
"type": [
"string",
"null"
],
"default": null,
"description": "Optional recipient-rendered filename template used for this rule's files inside recipient ZIP archives. If omitted, the source filename is used."
}
},
"additionalProperties": false

View File

@@ -0,0 +1,137 @@
{
"$schema": "https://govoplan.local/schema/campaign.schema.ui.json",
"$id": "https://govoplan.local/schema/campaign.schema.ui.json",
"schema": "campaign.schema.json",
"version": 1,
"sections": [
{
"id": "basics",
"label": "Basics",
"path": "/campaign",
"order": 10
},
{
"id": "fields",
"label": "Fields",
"path": "/fields",
"order": 20
},
{
"id": "global_values",
"label": "Global Values",
"path": "/global_values",
"order": 30
},
{
"id": "recipients",
"label": "Recipients",
"path": "/recipients",
"order": 40
},
{
"id": "template",
"label": "Template",
"path": "/template",
"order": 50
},
{
"id": "attachments",
"label": "Attachments",
"path": "/attachments",
"order": 60
},
{
"id": "mail",
"label": "Mail",
"path": "/server",
"order": 70,
"requiresCapability": "mail.campaign_delivery"
},
{
"id": "delivery",
"label": "Delivery",
"path": "/delivery",
"order": 80
},
{
"id": "review",
"label": "Review & Send",
"path": "/review",
"order": 90
}
],
"fields": {
"/attachments/global[]/message_filename_template": {
"label": "Direct attachment filename",
"control": "text",
"placeholder": "{{ file.name }}",
"templateContext": [
"global values",
"recipient fields",
"file.name",
"file.stem",
"file.suffix",
"file.ext",
"file.index",
"attachment.id",
"attachment.label"
],
"description": "Optional filename template used when files are attached directly to the message."
},
"/attachments/global[]/zip_entry_name_template": {
"label": "ZIP entry filename",
"control": "text",
"placeholder": "{{ file.name }}",
"templateContext": [
"global values",
"recipient fields",
"file.name",
"file.stem",
"file.suffix",
"file.ext",
"file.index",
"attachment.id",
"attachment.label"
],
"description": "Optional filename template used for files inside generated ZIP archives."
},
"/entries/inline[]/attachments[]/message_filename_template": {
"label": "Direct attachment filename",
"control": "text",
"placeholder": "{{ file.name }}",
"templateContext": [
"global values",
"recipient fields",
"file.name",
"file.stem",
"file.suffix",
"file.ext",
"file.index",
"attachment.id",
"attachment.label"
],
"description": "Optional filename template used when files are attached directly to the message."
},
"/entries/inline[]/attachments[]/zip_entry_name_template": {
"label": "ZIP entry filename",
"control": "text",
"placeholder": "{{ file.name }}",
"templateContext": [
"global values",
"recipient fields",
"file.name",
"file.stem",
"file.suffix",
"file.ext",
"file.index",
"attachment.id",
"attachment.label"
],
"description": "Optional filename template used for files inside generated ZIP archives."
}
},
"capabilities": {
"files.campaign_attachments": "Enable managed file chooser and frozen file-version evidence.",
"mail.campaign_delivery": "Enable mail profile selection, SMTP/IMAP validation, queueing, and delivery."
}
}

View File

@@ -5,7 +5,7 @@ from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field
from govoplan_mail.backend.config import ImapConfig, SmtpConfig
from govoplan_core.mail.config import ImapConfig, SmtpConfig
class CampaignCreateRequest(BaseModel):
@@ -137,6 +137,14 @@ class CampaignListResponse(BaseModel):
campaigns: list[CampaignResponse]
class CampaignWorkspaceResponse(BaseModel):
campaign: CampaignResponse | None = None
versions: list[CampaignVersionResponse] = Field(default_factory=list)
current_version: CampaignVersionDetailResponse | None = None
summary: dict[str, Any] | None = None
selected_version_id: str | None = None
class CampaignShareItem(BaseModel):
model_config = ConfigDict(from_attributes=True)
@@ -241,8 +249,6 @@ class MailSmtpTestRequest(SmtpConfig):
class MailImapTestRequest(ImapConfig):
"""IMAP settings supplied directly from the WebUI mail settings form."""
enabled: bool = True
@@ -426,6 +432,7 @@ class AppendSentRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
enqueue_celery: bool = True
run_inline: bool = False
dry_run: bool = False

View File

@@ -10,7 +10,7 @@ from sqlalchemy.orm import Session
from govoplan_campaign.backend.db.models import Campaign, CampaignJob, CampaignVersion, JobValidationStatus
from govoplan_campaign.backend.campaign.models import DeliveryConfig, ImapConfig, SmtpConfig
from govoplan_mail.backend.mail_profiles import MailProfileError, apply_campaign_credentials, effective_profile_credentials_inherited, ensure_mail_profile_allowed_for_campaign, imap_config_from_profile, mail_profile_id_from_campaign_json, smtp_config_from_profile
from govoplan_campaign.backend.integrations import MailProfileError, mail_integration
SNAPSHOT_VERSION = "3"
@@ -83,8 +83,12 @@ def _redacted_transport_config(config: SmtpConfig | ImapConfig | None) -> SmtpCo
def _transport_password_from_campaign_json(raw_json: dict[str, Any] | None, name: str) -> str | None:
server = raw_json.get("server") if isinstance(raw_json, dict) else None
config = server.get(name) if isinstance(server, dict) else None
credentials = server.get("credentials") if isinstance(server, dict) and isinstance(server.get("credentials"), dict) else None
config = credentials.get(name) if isinstance(credentials, dict) and isinstance(credentials.get(name), dict) else None
password = config.get("password") if isinstance(config, dict) else None
if password is None:
legacy_config = server.get(name) if isinstance(server, dict) else None
password = legacy_config.get("password") if isinstance(legacy_config, dict) else None
if password is None:
return None
return str(password)
@@ -96,14 +100,15 @@ def _server_from_campaign_json(raw_json: dict[str, Any] | None) -> dict[str, Any
def _profile_for_version(session: Session, version: CampaignVersion):
profile_id = mail_profile_id_from_campaign_json(version.raw_json if isinstance(version.raw_json, dict) else {})
mail = mail_integration()
profile_id = mail.mail_profile_id_from_campaign_json(version.raw_json if isinstance(version.raw_json, dict) else {})
if not profile_id:
return None
campaign = session.get(Campaign, version.campaign_id)
if campaign is None:
raise ExecutionSnapshotError("Campaign not found for mail-server profile resolution")
try:
return ensure_mail_profile_allowed_for_campaign(session, tenant_id=campaign.tenant_id, campaign_id=campaign.id, profile_id=profile_id, require_active=True)
return mail.ensure_mail_profile_allowed_for_campaign(session, tenant_id=campaign.tenant_id, campaign_id=campaign.id, profile_id=profile_id, require_active=True)
except MailProfileError as exc:
raise ExecutionSnapshotError(str(exc)) from exc
@@ -117,10 +122,11 @@ def runtime_smtp_config(session: Session, version: CampaignVersion, snapshot: Ex
campaign = session.get(Campaign, version.campaign_id)
if campaign is None:
raise ExecutionSnapshotError("Campaign not found for mail-server profile resolution")
profile_payload = smtp_config_from_profile(profile).model_dump(mode="json")
if effective_profile_credentials_inherited(session, tenant_id=campaign.tenant_id, campaign_id=campaign.id, server=server, protocol="smtp"):
mail = mail_integration()
profile_payload = mail.smtp_config_from_profile(profile).model_dump(mode="json")
if mail.effective_profile_credentials_inherited(session, tenant_id=campaign.tenant_id, campaign_id=campaign.id, server=server, protocol="smtp"):
return SmtpConfig.model_validate(profile_payload)
return SmtpConfig.model_validate(apply_campaign_credentials(profile_payload, server, "smtp"))
return SmtpConfig.model_validate(mail.apply_campaign_credentials(profile_payload, server, "smtp"))
payload["password"] = _transport_password_from_campaign_json(version.raw_json, "smtp")
return SmtpConfig.model_validate(payload)
@@ -131,29 +137,31 @@ def runtime_imap_config(session: Session, version: CampaignVersion, snapshot: Ex
profile = _profile_for_version(session, version)
if profile is None:
return None
imap = imap_config_from_profile(profile)
mail = mail_integration()
imap = mail.imap_config_from_profile(profile)
if imap is None:
return None
campaign = session.get(Campaign, version.campaign_id)
if campaign is None:
raise ExecutionSnapshotError("Campaign not found for mail-server profile resolution")
imap_payload = imap.model_dump(mode="json")
if effective_profile_credentials_inherited(session, tenant_id=campaign.tenant_id, campaign_id=campaign.id, server=server, protocol="imap"):
if mail.effective_profile_credentials_inherited(session, tenant_id=campaign.tenant_id, campaign_id=campaign.id, server=server, protocol="imap"):
return ImapConfig.model_validate(imap_payload)
return ImapConfig.model_validate(apply_campaign_credentials(imap_payload, server, "imap"))
return ImapConfig.model_validate(mail.apply_campaign_credentials(imap_payload, server, "imap"))
payload = snapshot.imap.model_dump(mode="json")
if not payload.get("password"):
profile = _profile_for_version(session, version)
if profile is not None:
imap = imap_config_from_profile(profile)
mail = mail_integration()
imap = mail.imap_config_from_profile(profile)
if imap is not None:
campaign = session.get(Campaign, version.campaign_id)
if campaign is None:
raise ExecutionSnapshotError("Campaign not found for mail-server profile resolution")
imap_payload = imap.model_dump(mode="json")
if effective_profile_credentials_inherited(session, tenant_id=campaign.tenant_id, campaign_id=campaign.id, server=server, protocol="imap"):
if mail.effective_profile_credentials_inherited(session, tenant_id=campaign.tenant_id, campaign_id=campaign.id, server=server, protocol="imap"):
return ImapConfig.model_validate(imap_payload)
return ImapConfig.model_validate(apply_campaign_credentials(imap_payload, server, "imap"))
return ImapConfig.model_validate(mail.apply_campaign_credentials(imap_payload, server, "imap"))
payload["password"] = _transport_password_from_campaign_json(version.raw_json, "imap")
return ImapConfig.model_validate(payload)
@@ -250,7 +258,8 @@ def ensure_execution_snapshot(session: Session, version: CampaignVersion) -> Exe
from govoplan_campaign.backend.persistence.campaigns import load_version_config
_, _, config = load_version_config(session, version.id)
if not config.server.smtp:
runtime_smtp = config.server.runtime_smtp_config()
if not runtime_smtp:
raise ExecutionSnapshotError("Campaign has no SMTP configuration")
jobs = (
session.query(CampaignJob)
@@ -262,8 +271,8 @@ def ensure_execution_snapshot(session: Session, version: CampaignVersion) -> Exe
raise ExecutionSnapshotError("Campaign version has no built jobs; rebuild it before delivery")
payload, digest = create_execution_snapshot(
version,
smtp=config.server.smtp,
imap=config.server.imap,
smtp=runtime_smtp,
imap=config.server.runtime_imap_config(),
delivery=config.delivery,
jobs=jobs,
build_summary=version.build_summary if isinstance(version.build_summary, dict) else {},

View File

@@ -28,11 +28,15 @@ from govoplan_campaign.backend.db.models import (
SendAttempt,
)
from govoplan_campaign.backend.sending.execution import ExecutionSnapshot, ExecutionSnapshotError, ensure_execution_snapshot, runtime_imap_config, runtime_smtp_config
from govoplan_mail.backend.mail_profiles import MailProfileError, assert_mail_policy_allows_send
from govoplan_mail.backend.sending.rate_limit import wait_for_rate_limit
from govoplan_mail.backend.sending.smtp import SmtpConfigurationError, SmtpSendError, send_email_bytes
from govoplan_mail.backend.sending.imap import ImapAppendError, ImapConfigurationError, append_message_to_sent
from govoplan_files.backend.storage.services import mark_job_attachment_uses_sent
from govoplan_campaign.backend.integrations import (
ImapAppendError,
ImapConfigurationError,
MailProfileError,
SmtpConfigurationError,
SmtpSendError,
files_integration,
mail_integration,
)
class QueueingError(RuntimeError):
@@ -709,7 +713,7 @@ def reconcile_job_outcome(
job.claim_token = None
job.last_error = note or "Operator confirmed SMTP acceptance after an uncertain outcome."
job.imap_status = JobImapStatus.PENDING.value if snapshot.delivery.imap_append_sent.enabled else JobImapStatus.NOT_REQUESTED.value
mark_job_attachment_uses_sent(session, job)
files_integration().mark_job_attachment_uses_sent(session, job)
attempt_status = "reconciled_smtp_accepted"
elif decision == "not_sent":
job.send_status = JobSendStatus.FAILED_TEMPORARY.value
@@ -1054,7 +1058,7 @@ def send_campaign_job(
job = session.get(CampaignJob, job.id)
assert job is not None
wait_for_rate_limit(
mail_integration().wait_for_rate_limit(
key=f"tenant:{job.tenant_id}:campaign:{job.campaign_id}",
messages_per_minute=snapshot.delivery.rate_limit.messages_per_minute,
enabled=use_rate_limit,
@@ -1062,7 +1066,7 @@ def send_campaign_job(
attempt = _record_attempt_start(session, job, claim_token)
try:
smtp_config = runtime_smtp_config(session, version, snapshot)
assert_mail_policy_allows_send(
mail_integration().assert_mail_policy_allows_send(
session,
tenant_id=job.tenant_id,
campaign_id=job.campaign_id,
@@ -1071,7 +1075,7 @@ def send_campaign_job(
from_header=_from_header_from_job(job, snapshot),
recipients=envelope_recipients,
)
result = send_email_bytes(
result = mail_integration().send_email_bytes(
message_bytes,
smtp_config=smtp_config,
envelope_from=envelope_from,
@@ -1098,7 +1102,7 @@ def send_campaign_job(
else:
job.imap_status = JobImapStatus.NOT_REQUESTED.value
job.last_error = refused_warning
mark_job_attachment_uses_sent(session, job)
files_integration().mark_job_attachment_uses_sent(session, job)
session.add(attempt)
session.add(job)
_update_campaign_after_job(session, job.campaign_id, job.campaign_version_id)
@@ -1208,9 +1212,9 @@ def append_sent_for_job(session: Session, *, job_id: str, dry_run: bool = False)
session.commit()
return AppendSentResult(job_id=job.id, status="not_requested", attempt_number=0, dry_run=dry_run)
imap_config = runtime_imap_config(session, version, snapshot)
if not imap_config or not imap_config.enabled:
if not imap_config:
job.imap_status = JobImapStatus.SKIPPED.value
job.last_error = "IMAP append requested, but the execution snapshot has no enabled IMAP configuration"
job.last_error = "IMAP append requested, but the execution snapshot has no IMAP configuration"
session.add(job)
session.commit()
return AppendSentResult(job_id=job.id, status="skipped", attempt_number=0, dry_run=dry_run, message=job.last_error)
@@ -1230,14 +1234,14 @@ def append_sent_for_job(session: Session, *, job_id: str, dry_run: bool = False)
attempt = _record_imap_attempt_start(session, job)
try:
assert_mail_policy_allows_send(
mail_integration().assert_mail_policy_allows_send(
session,
tenant_id=job.tenant_id,
campaign_id=job.campaign_id,
smtp=snapshot.smtp,
imap=imap_config,
)
result = append_message_to_sent(
result = mail_integration().append_message_to_sent(
message_bytes,
imap_config=imap_config,
folder=None if folder == "auto" else folder,
@@ -1246,7 +1250,7 @@ def append_sent_for_job(session: Session, *, job_id: str, dry_run: bool = False)
attempt.folder = result.folder
job.imap_status = JobImapStatus.APPENDED.value
job.last_error = None
mark_job_attachment_uses_sent(session, job)
files_integration().mark_job_attachment_uses_sent(session, job)
session.add(attempt)
session.add(job)
session.commit()
@@ -1263,7 +1267,15 @@ def append_sent_for_job(session: Session, *, job_id: str, dry_run: bool = False)
raise
def enqueue_pending_imap_appends(session: Session, *, tenant_id: str, campaign_id: str, enqueue_celery: bool = True, dry_run: bool = False) -> dict[str, Any]:
def enqueue_pending_imap_appends(
session: Session,
*,
tenant_id: str,
campaign_id: str,
enqueue_celery: bool = True,
run_inline: bool = False,
dry_run: bool = False,
) -> dict[str, Any]:
campaign = _get_campaign_for_tenant(session, campaign_id=campaign_id, tenant_id=tenant_id)
jobs = (
session.query(CampaignJob)
@@ -1276,15 +1288,39 @@ def enqueue_pending_imap_appends(session: Session, *, tenant_id: str, campaign_i
.order_by(CampaignJob.entry_index.asc())
.all()
)
should_enqueue = _should_enqueue_celery(enqueue_celery) and not dry_run
if should_enqueue:
should_enqueue = _should_enqueue_celery(enqueue_celery) and not dry_run and not run_inline
results: list[dict[str, Any]] = []
appended_count = 0
failed_count = 0
skipped_count = 0
if run_inline or dry_run:
for job in jobs:
try:
result = append_sent_for_job(session, job_id=job.id, dry_run=dry_run)
payload = result.as_dict()
results.append(payload)
if result.status == JobImapStatus.APPENDED.value:
appended_count += 1
elif result.status in {"skipped", "not_requested", "not_sent", "already_appended", "dry_run"}:
skipped_count += 1
except Exception as exc: # keep processing later jobs and expose per-job details
failed_count += 1
results.append({"job_id": job.id, "status": "failed", "message": str(exc)})
elif should_enqueue:
for job in jobs:
_celery_enqueue_append_sent_job(job.id)
return {
"campaign_id": campaign.id,
"pending_count": len(jobs),
"enqueued_count": len(jobs) if should_enqueue else 0,
"processed_count": len(results) if run_inline and not dry_run else 0,
"appended_count": appended_count,
"failed_count": failed_count,
"skipped_count": skipped_count,
"dry_run": dry_run,
"run_inline": run_inline,
"results": results,
}
def next_retry_delay(snapshot: ExecutionSnapshot, attempt_count: int) -> int:

View File

@@ -1,13 +0,0 @@
from __future__ import annotations
from pathlib import Path
from govoplan_campaign.backend.domain.campaign import MailAttachmentConfig
def match_files(base_path: Path, config: MailAttachmentConfig) -> list[Path]:
directory = base_path / config.base_dir
if not directory.exists():
return []
iterator = directory.rglob(config.file_filter) if config.include_subdirs else directory.glob(config.file_filter)
return sorted(path for path in iterator if path.is_file() and path.stat().st_size >= 0)

View File

@@ -1,135 +0,0 @@
from __future__ import annotations
import smtplib
from email.message import EmailMessage
from pathlib import Path
from typing import Iterable
from govoplan_campaign.backend.domain.campaign import MailCampaign, MailEntry, MailServerSettings, TransportSecurity
from govoplan_campaign.backend.domain.queue import MailQueue
from govoplan_campaign.backend.domain.recipients import Recipient, RecipientType
from govoplan_campaign.backend.services.attachment_matching import match_files
from govoplan_campaign.backend.services.zip_service import create_encrypted_zip
def _recipient_header(recipients: Iterable[Recipient], recipient_type: RecipientType) -> str:
return ", ".join(r.formatted() for r in recipients if r.type == recipient_type)
def _recipient_values(recipients: list[Recipient]) -> dict[str, str]:
def rows(recipient_type: RecipientType) -> list[Recipient]:
return [r for r in recipients if r.type == recipient_type]
def joined(recipient_type: RecipientType, mode: str) -> str:
selected = rows(recipient_type)
if mode == "address":
return ", ".join(r.address for r in selected)
if mode == "name":
return ", ".join(r.name or r.address for r in selected)
return ", ".join(r.formatted() for r in selected)
return {
"mm_recipients": joined(RecipientType.TO, "formatted"),
"mm_recipients_address": joined(RecipientType.TO, "address"),
"mm_recipients_name": joined(RecipientType.TO, "name"),
"mm_cc": joined(RecipientType.CC, "formatted"),
"mm_cc_address": joined(RecipientType.CC, "address"),
"mm_cc_name": joined(RecipientType.CC, "name"),
"mm_bcc": joined(RecipientType.BCC, "formatted"),
"mm_bcc_address": joined(RecipientType.BCC, "address"),
"mm_bcc_name": joined(RecipientType.BCC, "name"),
}
def _message_attachment_paths(campaign: MailCampaign, entry: MailEntry) -> list[Path]:
paths: list[Path] = []
if entry.combine_attachments:
for config in campaign.global_attachment_configs:
paths.extend(match_files(campaign.base_attachment_path, config))
if campaign.individual_attachments:
for config in entry.attachment_configs:
paths.extend(match_files(campaign.base_attachment_path, config))
return paths
def get_not_sent_files(campaign: MailCampaign) -> set[Path]:
"""Return files matching campaign attachment configs that are not referenced by any built entry."""
all_files: set[Path] = set()
used_files: set[Path] = set()
for config in campaign.global_attachment_configs:
all_files.update(match_files(campaign.base_attachment_path, config))
for entry in campaign.mail_entries:
files = set(_message_attachment_paths(campaign, entry))
used_files.update(files)
all_files.update(files)
return all_files - used_files
def build_message(campaign: MailCampaign, entry: MailEntry, *, zip_attachments: bool = True) -> EmailMessage | None:
recipients = campaign.all_recipients_for(entry)
attachments = _message_attachment_paths(campaign, entry)
if not attachments and not campaign.send_without_attachments:
return None
values = {}
values.update(_recipient_values(recipients))
values.update(campaign.field_contents.as_value_map("global"))
values.update(entry.field_contents.as_value_map("local"))
message = EmailMessage()
sender = entry.from_recipient if campaign.individual_from and entry.from_recipient else campaign.global_from
if sender:
message["From"] = sender.formatted()
to_header = _recipient_header(recipients, RecipientType.TO)
cc_header = _recipient_header(recipients, RecipientType.CC)
if to_header:
message["To"] = to_header
if cc_header:
message["Cc"] = cc_header
message["Subject"] = campaign.subject_template.apply_values(values)
message.set_content(campaign.mail_template.apply_values(values))
attachment_paths = attachments
if attachments and zip_attachments:
number = entry.get_field_content_from_name("number").as_string() if "number" in entry.field_contents.field_map else "attachments"
password = entry.get_field_content_from_name("password").as_string() if "password" in entry.field_contents.field_map else ""
zip_path = campaign.base_attachment_path / f"{number}.zip"
attachment_paths = [create_encrypted_zip(zip_path, attachments, password)]
for attachment_path in attachment_paths:
data = attachment_path.read_bytes()
message.add_attachment(data, maintype="application", subtype="octet-stream", filename=attachment_path.name)
return message
def build_mail_queue(campaign: MailCampaign, *, zip_attachments: bool = True) -> MailQueue:
queue = MailQueue()
for entry in campaign.mail_entries:
if not entry.is_active:
continue
message = build_message(campaign, entry, zip_attachments=zip_attachments)
if message is not None:
queue.add_mail(message)
return queue
def send_mail_queue(settings: MailServerSettings, queue: MailQueue) -> MailQueue:
retry_queue = MailQueue()
if settings.transport_security == TransportSecurity.TLS:
smtp = smtplib.SMTP_SSL(settings.server, settings.resolved_port(), timeout=60)
else:
smtp = smtplib.SMTP(settings.server, settings.resolved_port(), timeout=60)
try:
if settings.transport_security == TransportSecurity.STARTTLS:
smtp.starttls()
if settings.username:
smtp.login(settings.username, settings.password)
for message in queue:
try:
smtp.send_message(message)
except Exception:
retry_queue.add_mail(message)
finally:
smtp.quit()
return retry_queue