mock server, file and folder management
This commit is contained in:
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)},
|
||||
}
|
||||
Reference in New Issue
Block a user