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