289 lines
13 KiB
Python
289 lines
13 KiB
Python
from __future__ import annotations
|
|
|
|
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.storage.campaign_attachments import (
|
|
annotate_built_messages_with_managed_files,
|
|
prepared_campaign_snapshot,
|
|
public_attachment_summary_payload,
|
|
)
|
|
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 [public_attachment_summary_payload(attachment) 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 prepared_campaign_snapshot(
|
|
session,
|
|
tenant_id=tenant_id,
|
|
campaign_id=campaign.id,
|
|
raw_json=version.raw_json if isinstance(version.raw_json, dict) else {},
|
|
include_bytes=True,
|
|
prefix="multimailer-mock-send-",
|
|
) as prepared:
|
|
config = load_campaign_config(prepared.path)
|
|
validation_report = validate_campaign_config(config, campaign_file=prepared.path, check_files=check_files)
|
|
build_result = build_campaign_messages(config, campaign_file=prepared.path, write_eml=False)
|
|
annotate_built_messages_with_managed_files(build_result.built_messages, prepared.managed_files_by_local_path)
|
|
|
|
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)},
|
|
}
|