mock server, file and folder management
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
1
server/app/mailer/dev/__init__.py
Normal file
1
server/app/mailer/dev/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Development-only mail sandbox helpers."""
|
||||
280
server/app/mailer/dev/mock_campaign.py
Normal file
280
server/app/mailer/dev/mock_campaign.py
Normal 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)},
|
||||
}
|
||||
327
server/app/mailer/dev/mock_mailbox.py
Normal file
327
server/app/mailer/dev/mock_mailbox.py
Normal 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
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user