Files
multi-seal-mail/server/app/mailer/dev/mock_campaign.py

281 lines
13 KiB
Python

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)},
}