mock server, file and folder management

This commit is contained in:
2026-06-12 02:18:30 +02:00
parent b67c8abdc5
commit f3db5fc5cf
28 changed files with 3049 additions and 6 deletions

View File

@@ -183,9 +183,14 @@ def _recipient_values(entry: EntryConfig) -> dict[str, str]:
def _template_values(config: CampaignConfig, entry: EntryConfig) -> dict[str, Any]:
values: dict[str, Any] = {}
for field in config.fields:
values.setdefault(field.name, "")
values.setdefault(f"global::{field.name}", "")
values.setdefault(f"local::{field.name}", "")
for key, value in config.global_values.items():
values[f"global::{key}"] = value
for key, value in effective_entry_field_values(config, entry).items():
values[key] = value
values[f"local::{key}"] = value
if entry.id:
values["local::id"] = entry.id

View File

@@ -213,6 +213,7 @@ class AttachmentBasePathConfig(StrictModel):
name: str
path: str = "."
allow_individual: bool = False
unsent_warning: bool = False
# Legacy UI builds briefly wrote a source value. Keep accepting it so older
# drafts do not become invalid merely because the current UI no longer shows
# or edits that column.
@@ -222,11 +223,24 @@ class AttachmentBasePathConfig(StrictModel):
class AttachmentConfig(StrictModel):
id: str | None = None
label: str | None = None
# Legacy UI helper. Current attachment resolution ignores this value and
# treats direct files as plain file_filter patterns without wildcards.
# Keep accepting it so existing drafts with {"type": ""}, "direct"
# or "pattern" remain valid.
type_: str | None = Field(default=None, alias="type")
base_dir: str
file_filter: str
include_subdirs: bool = False
required: bool = True
allow_multiple: bool = False
@field_validator("type_", mode="before")
@classmethod
def empty_type_means_unset(cls, value: Any) -> Any:
if value == "":
return None
return value
# None means: inherit from validation_policy. Explicit values remain
# supported for backwards compatibility and per-rule overrides.
missing_behavior: Behavior | None = None
@@ -335,6 +349,7 @@ class ValidationPolicy(StrictModel):
missing_optional_attachment: Behavior = Behavior.WARN
ambiguous_attachment_match: Behavior = Behavior.ASK
ignore_empty_fields: bool = False
unsent_attachment_files: Behavior = Behavior.WARN
missing_email: MissingAddressBehavior = MissingAddressBehavior.BLOCK
template_error: MissingAddressBehavior = MissingAddressBehavior.BLOCK
inactive_entry: InactiveEntryBehavior = InactiveEntryBehavior.DROP

View File

@@ -0,0 +1 @@
"""Development-only mail sandbox helpers."""

View File

@@ -0,0 +1,280 @@
from __future__ import annotations
import json
import tempfile
from email import policy
from email.message import EmailMessage
from pathlib import Path
from typing import Any
from sqlalchemy.orm import Session
from app.db.models import Campaign, CampaignVersion
from app.mailer.campaign.loader import load_campaign_config
from app.mailer.campaign.validation import validate_campaign_config
from app.mailer.messages.builder import build_campaign_messages
from app.mailer.messages.models import MessageAddress, MessageDraft, MessageValidationStatus
from app.mailer.dev.mock_mailbox import (
clear_records,
consume_fail_next_imap,
consume_fail_next_smtp,
get_failures,
list_records,
record_imap_append,
record_smtp_delivery,
)
class MockCampaignSendError(RuntimeError):
pass
def _message_address_payload(address: MessageAddress | None) -> dict[str, Any] | None:
if address is None:
return None
return {"email": address.email, "name": address.name}
def _message_addresses_payload(addresses: list[MessageAddress]) -> list[dict[str, Any]]:
return [{"email": item.email, "name": item.name} for item in addresses]
def _issue_payloads(message: MessageDraft) -> list[dict[str, Any]]:
return [issue.model_dump(mode="json") for issue in message.issues]
def _attachment_payloads(message: MessageDraft) -> list[dict[str, Any]]:
return [attachment.model_dump(mode="json") for attachment in message.attachments]
def _message_payload(message: MessageDraft) -> dict[str, Any]:
return {
"entry_index": message.entry_index,
"entry_id": message.entry_id,
"active": message.active,
"subject": message.subject,
"from": _message_address_payload(message.from_),
"to": _message_addresses_payload(message.to),
"cc": _message_addresses_payload(message.cc),
"bcc": _message_addresses_payload(message.bcc),
"reply_to": _message_addresses_payload(message.reply_to),
"build_status": str(message.build_status.value if hasattr(message.build_status, "value") else message.build_status),
"validation_status": message.validation_status.value,
"send_status": str(message.send_status.value if hasattr(message.send_status, "value") else message.send_status),
"imap_status": message.imap_status.value,
"attachment_count": message.attachment_count,
"attachments": _attachment_payloads(message),
"issues": _issue_payloads(message),
"eml_size_bytes": message.eml_size_bytes,
"queueable": message.is_queueable,
}
def _recipient_emails(message: MessageDraft) -> list[str]:
values = [item.email for item in message.to + message.cc + message.bcc if item.email]
return list(dict.fromkeys(values))
def _envelope_from(message: MessageDraft, *, fallback: str = "mock-sender@mock.local") -> str:
if message.bounce_to:
return message.bounce_to[0].email
if message.from_ and message.from_.email:
return message.from_.email
return fallback
def _raw_message_bytes(message: EmailMessage) -> bytes:
return message.as_bytes(policy=policy.SMTP)
def _smtp_rejection_matches(recipients: list[str]) -> list[str]:
needle = (get_failures().get("smtp_reject_recipients_containing") or "").strip().lower()
if not needle:
return []
return [recipient for recipient in recipients if needle in recipient.lower()]
def _can_mock_send(
message: MessageDraft,
*,
include_warnings: bool,
include_needs_review: bool,
) -> tuple[bool, str | None]:
if not message.active:
return False, "Recipient is inactive"
if str(message.build_status.value if hasattr(message.build_status, "value") else message.build_status) != "built":
return False, f"Message is not built ({message.build_status})"
if message.validation_status == MessageValidationStatus.READY:
return True, None
if message.validation_status == MessageValidationStatus.WARNING and include_warnings:
return True, None
if message.validation_status == MessageValidationStatus.NEEDS_REVIEW and include_needs_review:
return True, None
return False, f"Validation status is {message.validation_status.value}"
def run_mock_campaign_send(
session: Session,
*,
tenant_id: str,
campaign_id: str,
version_id: str | None = None,
send: bool = False,
include_warnings: bool = True,
include_needs_review: bool = False,
append_sent: bool = True,
clear_mailbox: bool = False,
check_files: bool = False,
) -> dict[str, Any]:
"""Validate, build and optionally mock-send a version without mutating it.
This is a dev/test route. It does not change campaign/version status, does
not queue real jobs and does not use the configured SMTP/IMAP servers. It
records mock SMTP deliveries and mock IMAP appends in the integrated mock
mailbox only when send=True.
"""
campaign = session.query(Campaign).filter(Campaign.id == campaign_id, Campaign.tenant_id == tenant_id).one_or_none()
if not campaign:
raise MockCampaignSendError("Campaign not found or not accessible")
wanted_version_id = version_id or campaign.current_version_id
if not wanted_version_id:
raise MockCampaignSendError("Campaign has no current version")
version = session.get(CampaignVersion, wanted_version_id)
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()
with tempfile.TemporaryDirectory(prefix="multimailer-mock-send-") as temp_dir:
temp_path = Path(temp_dir)
campaign_path = temp_path / f"campaign-{version.id}.json"
campaign_path.write_text(json.dumps(version.raw_json, ensure_ascii=False, indent=2), encoding="utf-8")
config = load_campaign_config(campaign_path)
validation_report = validate_campaign_config(config, campaign_file=campaign_path, check_files=check_files)
build_result = build_campaign_messages(config, campaign_file=campaign_path, write_eml=False)
send_results: list[dict[str, Any]] = []
sent_count = 0
failed_count = 0
skipped_count = 0
imap_appended_count = 0
imap_failed_count = 0
for built in build_result.built_messages:
draft = built.draft
can_send, skip_reason = _can_mock_send(draft, include_warnings=include_warnings, include_needs_review=include_needs_review)
row: dict[str, Any] = {
"entry_index": draft.entry_index,
"entry_id": draft.entry_id,
"subject": draft.subject,
"validation_status": draft.validation_status.value,
"build_status": str(draft.build_status.value if hasattr(draft.build_status, "value") else draft.build_status),
"to": _message_addresses_payload(draft.to),
"attachments": _attachment_payloads(draft),
"issues": _issue_payloads(draft),
}
if not can_send or built.mime is None:
skipped_count += 1
row.update({"status": "skipped", "message": skip_reason or "Message has no MIME output"})
send_results.append(row)
continue
recipients = _recipient_emails(draft)
envelope_from = _envelope_from(draft)
if not recipients:
skipped_count += 1
row.update({"status": "skipped", "message": "No envelope recipients"})
send_results.append(row)
continue
if not send:
row.update({"status": "ready", "message": f"Would send to {len(recipients)} recipient(s)", "envelope_from": envelope_from, "envelope_recipients": recipients})
send_results.append(row)
continue
try:
if 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")
sent_count += 1
row.update({
"status": "sent",
"message": f"Mock SMTP captured as {smtp_record.id}",
"smtp_message_id": smtp_record.id,
"envelope_from": envelope_from,
"envelope_recipients": accepted,
"refused_recipients": rejected,
})
if append_sent:
try:
if 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_appended_count += 1
row.update({"imap_status": "appended", "imap_message_id": imap_record.id, "imap_folder": folder})
except Exception as exc:
imap_failed_count += 1
row.update({"imap_status": "failed", "imap_error": str(exc)})
except Exception as exc:
failed_count += 1
row.update({"status": "failed", "message": str(exc), "envelope_from": envelope_from, "envelope_recipients": recipients})
send_results.append(row)
validation_json = validation_report.model_dump(mode="json")
validation_json.update({"ok": validation_report.ok, "error_count": validation_report.error_count, "warning_count": validation_report.warning_count})
build_report = build_result.report
build_json = build_report.model_dump(mode="json")
build_json.update({
"built_count": build_report.built_count,
"queueable_count": build_report.queueable_count,
"needs_review_count": build_report.needs_review_count,
"blocked_count": build_report.blocked_count,
"warning_count": build_report.warning_count,
"ready_count": build_report.ready_count,
"messages": [_message_payload(message) for message in build_report.messages],
})
attempted_count = sum(1 for row in send_results if row.get("status") in {"sent", "failed"})
return {
"campaign_id": campaign.id,
"version_id": version.id,
"version_number": version.version_number,
"send_requested": send,
"include_warnings": include_warnings,
"include_needs_review": include_needs_review,
"append_sent": append_sent,
"steps": [
{"key": "validate", "label": "Validate campaign JSON", "status": "ok" if validation_report.ok else "needs_review", "summary": validation_json},
{"key": "build", "label": "Build messages", "status": "ok" if build_report.queueable_count else "needs_review", "summary": {"built": build_report.built_count, "queueable": build_report.queueable_count, "needs_review": build_report.needs_review_count, "blocked": build_report.blocked_count}},
{"key": "send", "label": "Mock SMTP delivery", "status": "skipped" if not send else ("ok" if failed_count == 0 else "needs_review"), "summary": {"attempted": attempted_count, "sent": sent_count, "failed": failed_count, "skipped": skipped_count}},
{"key": "imap", "label": "Mock IMAP Sent append", "status": "skipped" if not send or not append_sent else ("ok" if imap_failed_count == 0 else "needs_review"), "summary": {"appended": imap_appended_count, "failed": imap_failed_count}},
],
"validation": validation_json,
"build": build_json,
"send": {
"attempted_count": attempted_count,
"sent_count": sent_count,
"failed_count": failed_count,
"skipped_count": skipped_count,
"imap_appended_count": imap_appended_count,
"imap_failed_count": imap_failed_count,
"results": send_results,
},
"mailbox": {"messages": list_records(limit=200)},
}

View File

@@ -0,0 +1,327 @@
from __future__ import annotations
import json
import re
import shutil
from dataclasses import dataclass
from datetime import datetime, timezone
from email import policy
from email.message import EmailMessage
from email.parser import BytesParser
from pathlib import Path
from typing import Any
from uuid import uuid4
from app.settings import settings
MOCK_SMTP_HOSTS = {"mock", "mock.smtp", "mock.smtp.local", "mock-mail", "__mock_smtp__"}
MOCK_IMAP_HOSTS = {"mock", "mock.imap", "mock.imap.local", "mock-mail", "__mock_imap__"}
MOCK_IMAP_FOLDERS = [
{"name": "INBOX", "flags": []},
{"name": "Sent", "flags": ["\\Sent"]},
{"name": "Drafts", "flags": ["\\Drafts"]},
{"name": "Trash", "flags": ["\\Trash"]},
{"name": "Archive", "flags": ["\\Archive"]},
]
@dataclass(frozen=True, slots=True)
class MockDeliveryRecord:
id: str
kind: str
created_at: str
envelope_from: str | None
envelope_recipients: list[str]
subject: str | None
from_header: str | None
to_header: str | None
cc_header: str | None
bcc_header: str | None
message_id: str | None
size_bytes: int
body_preview: str | None
attachment_count: int
folder: str | None = None
smtp_host: str | None = None
imap_host: str | None = None
raw_filename: str | None = None
headers: dict[str, str] | None = None
attachments: list[dict[str, Any]] | None = None
def as_dict(self, *, include_raw: bool = False) -> dict[str, Any]:
data = {
"id": self.id,
"kind": self.kind,
"created_at": self.created_at,
"envelope_from": self.envelope_from,
"envelope_recipients": self.envelope_recipients,
"subject": self.subject,
"from_header": self.from_header,
"to_header": self.to_header,
"cc_header": self.cc_header,
"bcc_header": self.bcc_header,
"message_id": self.message_id,
"size_bytes": self.size_bytes,
"body_preview": self.body_preview,
"attachment_count": self.attachment_count,
"folder": self.folder,
"smtp_host": self.smtp_host,
"imap_host": self.imap_host,
"raw_filename": self.raw_filename,
"headers": self.headers or {},
"attachments": self.attachments or [],
}
if include_raw:
data["raw_eml"] = read_raw_message(self.id)
return data
def _base_dir() -> Path:
path = Path(settings.mock_mailbox_dir)
path.mkdir(parents=True, exist_ok=True)
(path / "messages").mkdir(parents=True, exist_ok=True)
return path
def _message_dir() -> Path:
return _base_dir() / "messages"
def _failure_path() -> Path:
return _base_dir() / "failures.json"
def normalize_mock_host(host: str | None) -> str:
return (host or "").strip().lower()
def is_mock_smtp_host(host: str | None) -> bool:
return normalize_mock_host(host) in MOCK_SMTP_HOSTS
def is_mock_imap_host(host: str | None) -> bool:
return normalize_mock_host(host) in MOCK_IMAP_HOSTS
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _message_to_bytes(message: EmailMessage | bytes) -> bytes:
if isinstance(message, bytes):
return message
return message.as_bytes(policy=policy.SMTP)
def _parse_message(raw: bytes) -> EmailMessage:
return BytesParser(policy=policy.default).parsebytes(raw)
def _header_text(message: EmailMessage, name: str) -> str | None:
value = message.get(name)
return str(value) if value is not None else None
def _body_preview(message: EmailMessage) -> str | None:
body = None
try:
if message.is_multipart():
body = message.get_body(preferencelist=("plain", "html"))
else:
body = message
if body is None:
return None
content = body.get_content()
except Exception:
return None
text = re.sub(r"\s+", " ", str(content)).strip()
if not text:
return None
return text[:600]
def _attachment_summaries(message: EmailMessage) -> list[dict[str, Any]]:
attachments: list[dict[str, Any]] = []
for part in message.iter_attachments():
payload = part.get_payload(decode=True) or b""
attachments.append(
{
"filename": part.get_filename(),
"content_type": part.get_content_type(),
"size_bytes": len(payload),
}
)
return attachments
def _headers(message: EmailMessage) -> dict[str, str]:
return {str(key): str(value) for key, value in message.items()}
def _record_from_raw(
raw: bytes,
*,
kind: str,
envelope_from: str | None = None,
envelope_recipients: list[str] | None = None,
folder: str | None = None,
smtp_host: str | None = None,
imap_host: str | None = None,
) -> MockDeliveryRecord:
message_id = uuid4().hex
raw_filename = f"{message_id}.eml"
json_filename = f"{message_id}.json"
message = _parse_message(raw)
attachments = _attachment_summaries(message)
record = MockDeliveryRecord(
id=message_id,
kind=kind,
created_at=_now_iso(),
envelope_from=envelope_from,
envelope_recipients=list(envelope_recipients or []),
subject=_header_text(message, "Subject"),
from_header=_header_text(message, "From"),
to_header=_header_text(message, "To"),
cc_header=_header_text(message, "Cc"),
bcc_header=_header_text(message, "Bcc"),
message_id=_header_text(message, "Message-ID"),
size_bytes=len(raw),
body_preview=_body_preview(message),
attachment_count=len(attachments),
folder=folder,
smtp_host=smtp_host,
imap_host=imap_host,
raw_filename=raw_filename,
headers=_headers(message),
attachments=attachments,
)
message_dir = _message_dir()
(message_dir / raw_filename).write_bytes(raw)
(message_dir / json_filename).write_text(json.dumps(record.as_dict(), indent=2, ensure_ascii=False), encoding="utf-8")
return record
def record_smtp_delivery(
message: EmailMessage,
*,
envelope_from: str,
envelope_recipients: list[str],
smtp_host: str | None = None,
) -> MockDeliveryRecord:
return _record_from_raw(
_message_to_bytes(message),
kind="smtp",
envelope_from=envelope_from,
envelope_recipients=envelope_recipients,
smtp_host=smtp_host,
)
def record_imap_append(
message_bytes: bytes,
*,
folder: str,
imap_host: str | None = None,
) -> MockDeliveryRecord:
return _record_from_raw(message_bytes, kind="imap_append", folder=folder, imap_host=imap_host)
def _load_record(record_id: str) -> dict[str, Any] | None:
path = _message_dir() / f"{record_id}.json"
if not path.exists():
return None
try:
return json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
return None
def list_records(*, kind: str | None = None, limit: int = 100) -> list[dict[str, Any]]:
records: list[dict[str, Any]] = []
for path in _message_dir().glob("*.json"):
try:
record = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
continue
if kind and record.get("kind") != kind:
continue
records.append(record)
records.sort(key=lambda item: str(item.get("created_at") or ""), reverse=True)
return records[: max(1, min(limit, 500))]
def get_record(record_id: str, *, include_raw: bool = True) -> dict[str, Any] | None:
record = _load_record(record_id)
if record and include_raw:
record["raw_eml"] = read_raw_message(record_id)
return record
def read_raw_message(record_id: str) -> str | None:
record = _load_record(record_id)
if not record:
return None
raw_filename = record.get("raw_filename")
if not raw_filename:
return None
raw_path = _message_dir() / str(raw_filename)
if not raw_path.exists():
return None
return raw_path.read_text(encoding="utf-8", errors="replace")
def clear_records() -> int:
message_dir = _message_dir()
count = len(list(message_dir.glob("*.json")))
if message_dir.exists():
shutil.rmtree(message_dir)
message_dir.mkdir(parents=True, exist_ok=True)
return count
def get_failures() -> dict[str, Any]:
path = _failure_path()
if not path.exists():
return {
"fail_next_smtp": False,
"fail_next_imap": False,
"smtp_reject_recipients_containing": None,
}
try:
data = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
data = {}
return {
"fail_next_smtp": bool(data.get("fail_next_smtp")),
"fail_next_imap": bool(data.get("fail_next_imap")),
"smtp_reject_recipients_containing": data.get("smtp_reject_recipients_containing") or None,
}
def set_failures(*, fail_next_smtp: bool | None = None, fail_next_imap: bool | None = None, smtp_reject_recipients_containing: str | None = None) -> dict[str, Any]:
current = get_failures()
if fail_next_smtp is not None:
current["fail_next_smtp"] = fail_next_smtp
if fail_next_imap is not None:
current["fail_next_imap"] = fail_next_imap
current["smtp_reject_recipients_containing"] = smtp_reject_recipients_containing or None
_failure_path().write_text(json.dumps(current, indent=2), encoding="utf-8")
return current
def consume_fail_next_smtp() -> bool:
current = get_failures()
if current.get("fail_next_smtp"):
current["fail_next_smtp"] = False
_failure_path().write_text(json.dumps(current, indent=2), encoding="utf-8")
return True
return False
def consume_fail_next_imap() -> bool:
current = get_failures()
if current.get("fail_next_imap"):
current["fail_next_imap"] = False
_failure_path().write_text(json.dumps(current, indent=2), encoding="utf-8")
return True
return False

View File

@@ -129,9 +129,14 @@ def _recipient_values(entry: EntryConfig) -> dict[str, str]:
def _template_values(config: CampaignConfig, entry: EntryConfig) -> dict[str, Any]:
values: dict[str, Any] = {}
for field in config.fields:
values.setdefault(field.name, "")
values.setdefault(f"global::{field.name}", "")
values.setdefault(f"local::{field.name}", "")
for key, value in config.global_values.items():
values[f"global::{key}"] = value
for key, value in effective_entry_field_values(config, entry).items():
values[key] = value
values[f"local::{key}"] = value
if entry.id:
values["local::id"] = entry.id
@@ -552,6 +557,62 @@ def build_entry_message(
return BuiltMessage(draft=draft, mime=message)
def _unsent_attachment_issues(
*,
config: CampaignConfig,
campaign_file: str | Path,
built_messages: list[BuiltMessage],
) -> list[MessageIssue]:
behavior = config.validation_policy.unsent_attachment_files.value
if behavior == Behavior.CONTINUE.value:
return []
matched_files = {
Path(match).resolve()
for built in built_messages
for attachment in built.draft.attachments
for match in attachment.matches
}
issues: list[MessageIssue] = []
for base_path in config.attachments.base_paths:
if not base_path.unsent_warning:
continue
directory = _resolve(campaign_file, base_path.path)
if not directory.exists() or not directory.is_dir():
continue
all_files = sorted(path.resolve() for path in directory.rglob("*") if path.is_file())
unsent = [path for path in all_files if path not in matched_files]
if not unsent:
continue
shown = ", ".join(str(path.relative_to(directory)) for path in unsent[:10])
if len(unsent) > 10:
shown += f", … (+{len(unsent) - 10} more)"
issues.append(
_issue_from_behavior(
code="unsent_attachment_files",
message=f"{len(unsent)} file(s) in attachment source {base_path.name!r} are not used by any message: {shown}",
behavior=behavior,
source=f"attachments:{base_path.name}",
)
)
return issues
def _apply_campaign_level_issues(built_messages: list[BuiltMessage], issues: list[MessageIssue]) -> None:
if not issues:
return
for built in built_messages:
if not built.draft.active:
continue
built.draft.issues.extend(issues)
status = built.draft.validation_status
for issue in issues:
if issue.behavior:
status = _apply_behavior(status, issue.behavior)
built.draft.validation_status = status
def build_campaign_messages(
config: CampaignConfig,
*,
@@ -577,6 +638,10 @@ def build_campaign_messages(
)
for index, entry in enumerate(entries, start=1)
]
_apply_campaign_level_issues(
built_messages,
_unsent_attachment_issues(config=config, campaign_file=campaign_path, built_messages=built_messages),
)
report = CampaignBuildReport(
campaign_id=config.campaign.id,

View File

@@ -25,6 +25,7 @@ from app.mailer.campaign.loader import load_campaign_config
from app.mailer.campaign.validation import Severity, validate_campaign_config
from app.mailer.messages.builder import build_campaign_messages
from app.mailer.messages.models import MessageDraft
from app.storage.services import record_campaign_attachment_uses_for_job
RUNTIME_DIR = Path(__file__).resolve().parents[3] / "runtime"
CAMPAIGN_SNAPSHOT_DIR = RUNTIME_DIR / "campaign_snapshots"
@@ -326,6 +327,7 @@ def build_campaign_version(
)
session.add(job)
session.flush()
record_campaign_attachment_uses_for_job(session, job, stage="built")
for issue in built.draft.issues:
session.add(
CampaignIssue(

View File

@@ -100,6 +100,7 @@ def minimal_campaign_json(*, external_id: str, name: str, description: str | Non
"name": "Campaign files",
"path": ".",
"allow_individual": True,
"unsent_warning": False,
}
],
"allow_individual": True,
@@ -126,6 +127,7 @@ def minimal_campaign_json(*, external_id: str, name: str, description: str | Non
"missing_required_attachment": "ask",
"missing_optional_attachment": "warn",
"ambiguous_attachment_match": "ask",
"unsent_attachment_files": "warn",
"ignore_empty_fields": False,
"missing_email": "block",
"template_error": "block",

View File

@@ -494,11 +494,24 @@
"warn"
],
"default": "drop"
},
"unsent_attachment_files": {
"type": "string",
"enum": [
"block",
"ask",
"drop",
"continue",
"warn"
],
"default": "warn",
"description": "Behavior when a base path with unsent_warning contains files that are not attached to any message."
}
},
"additionalProperties": false,
"default": {
"ignore_empty_fields": false
"ignore_empty_fields": false,
"unsent_attachment_files": "warn"
}
},
"delivery": {
@@ -714,6 +727,13 @@
"default": {
"enabled": false
}
},
"type": {
"description": "Legacy UI helper; ignored by backend. Direct files are represented as plain file_filter patterns.",
"type": [
"string",
"null"
]
}
},
"additionalProperties": false
@@ -907,6 +927,11 @@
"null"
],
"description": "Legacy UI compatibility value. Ignored by the backend."
},
"unsent_warning": {
"type": "boolean",
"default": false,
"description": "Warn according to validation_policy.unsent_attachment_files if files in this source are not attached to any built message."
}
},
"additionalProperties": false

View File

@@ -8,6 +8,12 @@ import time
from dataclasses import dataclass
from app.mailer.campaign.models import ImapConfig, TransportSecurity
from app.mailer.dev.mock_mailbox import (
MOCK_IMAP_FOLDERS,
consume_fail_next_imap,
is_mock_imap_host,
record_imap_append,
)
class ImapConfigurationError(ValueError):
@@ -210,6 +216,14 @@ def test_imap_login(*, imap_config: ImapConfig) -> ImapLoginTestResult:
"""
host, port = _require_imap_config(imap_config)
if is_mock_imap_host(imap_config.host):
return ImapLoginTestResult(
host=host,
port=port,
security=imap_config.security.value,
authenticated=bool(imap_config.username and imap_config.password),
)
client = _open_imap(imap_config)
try:
return ImapLoginTestResult(
@@ -229,6 +243,16 @@ def list_imap_folders(*, imap_config: ImapConfig) -> ImapFolderListResult:
"""Return folders visible through IMAP LIST and the best sent-folder guess."""
host, port = _require_imap_config(imap_config)
if is_mock_imap_host(imap_config.host):
folders = [ImapMailboxInfo(name=str(item["name"]), flags=list(item.get("flags") or [])) for item in MOCK_IMAP_FOLDERS]
return ImapFolderListResult(
host=host,
port=port,
security=imap_config.security.value,
folders=folders,
detected_sent_folder="Sent",
)
client = _open_imap(imap_config)
try:
typ, data = client.list()
@@ -272,6 +296,20 @@ def append_message_to_sent(
"""
host, port = _require_imap_config(imap_config)
if is_mock_imap_host(imap_config.host):
if consume_fail_next_imap():
raise ImapAppendError("Mock IMAP configured to fail the next append", temporary=False)
target_folder = folder or (imap_config.sent_folder if imap_config.sent_folder and imap_config.sent_folder != "auto" else "Sent")
record = record_imap_append(message_bytes, folder=target_folder, imap_host=imap_config.host)
return ImapAppendResult(
host=host,
port=port,
security=imap_config.security.value,
folder=target_folder,
bytes_appended=len(message_bytes),
response=f"mock append stored as {record.id}",
)
client: imaplib.IMAP4 | None = None
try:
client = _open_imap(imap_config)

View File

@@ -30,6 +30,7 @@ from app.mailer.persistence.campaigns import _write_campaign_snapshot
from app.mailer.sending.rate_limit import wait_for_rate_limit
from app.mailer.sending.smtp import SmtpConfigurationError, SmtpSendError, send_email_message
from app.mailer.sending.imap import ImapAppendError, ImapConfigurationError, append_message_to_sent
from app.storage.services import mark_job_attachment_uses_sent
class QueueingError(RuntimeError):
@@ -591,6 +592,7 @@ def send_campaign_job(session: Session, *, job_id: str, dry_run: bool = False, u
else:
job.imap_status = JobImapStatus.NOT_REQUESTED.value
job.last_error = None
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)
@@ -702,6 +704,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)
session.add(attempt)
session.add(job)
session.commit()

View File

@@ -8,6 +8,12 @@ from email.message import EmailMessage
from email.utils import formataddr
from app.mailer.campaign.models import SmtpConfig, TransportSecurity
from app.mailer.dev.mock_mailbox import (
consume_fail_next_smtp,
get_failures,
is_mock_smtp_host,
record_smtp_delivery,
)
class SmtpConfigurationError(ValueError):
@@ -98,6 +104,15 @@ def test_smtp_login(*, smtp_config: SmtpConfig) -> SmtpLoginTestResult:
"""
host, port = _require_smtp_config(smtp_config)
if is_mock_smtp_host(smtp_config.host):
host, port = _require_smtp_config(smtp_config)
return SmtpLoginTestResult(
host=host,
port=port,
security=smtp_config.security.value,
authenticated=bool(smtp_config.username and smtp_config.password),
)
smtp = _open_smtp(smtp_config)
try:
return SmtpLoginTestResult(
@@ -165,6 +180,37 @@ def send_email_message(
if not envelope_recipients:
raise SmtpConfigurationError("at least one SMTP envelope recipient is required")
if is_mock_smtp_host(smtp_config.host):
if consume_fail_next_smtp():
raise SmtpSendError("Mock SMTP configured to fail the next send")
failures = get_failures()
reject_text = str(failures.get("smtp_reject_recipients_containing") or "").strip().lower()
refused: dict[str, tuple[int, bytes]] = {}
accepted = list(envelope_recipients)
if reject_text:
refused = {
recipient: (550, b"mock recipient rejected")
for recipient in envelope_recipients
if reject_text in recipient.lower()
}
accepted = [recipient for recipient in envelope_recipients if recipient not in refused]
if not accepted:
raise SmtpSendError(f"all mock SMTP recipients were refused: {_decode_refused(refused)}")
record_smtp_delivery(
message,
envelope_from=envelope_from,
envelope_recipients=accepted,
smtp_host=smtp_config.host,
)
return SmtpSendResult(
host=host,
port=port,
security=smtp_config.security.value,
envelope_from=envelope_from,
envelope_recipients=list(envelope_recipients),
refused_recipients=_decode_refused(refused),
)
try:
with _open_smtp(smtp_config) as smtp:
refused = smtp.send_message(