intermittent commit

This commit is contained in:
2026-07-14 13:22:10 +02:00
parent 3f0b14a726
commit 2e593b7fa4
31 changed files with 3523 additions and 1672 deletions

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
from dataclasses import dataclass
from email import policy
from email.message import EmailMessage
from typing import Any
@@ -19,6 +20,30 @@ class MockCampaignSendError(RuntimeError):
pass
@dataclass(slots=True)
class _MockSendOutcome:
row: dict[str, Any]
sent_count: int = 0
failed_count: int = 0
skipped_count: int = 0
imap_appended_count: int = 0
imap_failed_count: int = 0
@dataclass(slots=True)
class _MockSendBatch:
results: list[dict[str, Any]]
sent_count: int = 0
failed_count: int = 0
skipped_count: int = 0
imap_appended_count: int = 0
imap_failed_count: int = 0
@property
def attempted_count(self) -> int:
return self.sent_count + self.failed_count
def _mock_mailbox() -> Any | None:
return mail_integration().mock_mailbox()
@@ -116,6 +141,163 @@ def _can_mock_send(
return False, f"Validation status is {message.validation_status.value}"
def _mock_send_batch(
*,
config: Any,
built_messages: list[Any],
mailbox: Any | None,
send: bool,
include_warnings: bool,
include_needs_review: bool,
append_sent: bool,
) -> _MockSendBatch:
batch = _MockSendBatch(results=[])
for built in built_messages:
outcome = _mock_send_message(
config=config,
built=built,
mailbox=mailbox,
send=send,
include_warnings=include_warnings,
include_needs_review=include_needs_review,
append_sent=append_sent,
)
batch.results.append(outcome.row)
batch.sent_count += outcome.sent_count
batch.failed_count += outcome.failed_count
batch.skipped_count += outcome.skipped_count
batch.imap_appended_count += outcome.imap_appended_count
batch.imap_failed_count += outcome.imap_failed_count
return batch
def _mock_send_message(
*,
config: Any,
built: Any,
mailbox: Any | None,
send: bool,
include_warnings: bool,
include_needs_review: bool,
append_sent: bool,
) -> _MockSendOutcome:
draft = built.draft
row = _mock_send_row(draft)
can_send, skip_reason = _can_mock_send(
draft,
include_warnings=include_warnings,
include_needs_review=include_needs_review,
)
if not can_send or built.mime is None:
row.update({"status": "skipped", "message": skip_reason or "Message has no MIME output"})
return _MockSendOutcome(row=row, skipped_count=1)
recipients = _recipient_emails(draft)
envelope_from = _envelope_from(draft)
if not recipients:
row.update({"status": "skipped", "message": "No envelope recipients"})
return _MockSendOutcome(row=row, skipped_count=1)
if not send:
row.update({
"status": "ready",
"message": f"Would send to {len(recipients)} recipient(s)",
"envelope_from": envelope_from,
"envelope_recipients": recipients,
})
return _MockSendOutcome(row=row)
return _send_mock_smtp_message(
config=config,
built=built,
mailbox=mailbox,
row=row,
recipients=recipients,
envelope_from=envelope_from,
append_sent=append_sent,
)
def _mock_send_row(draft: MessageDraft) -> dict[str, Any]:
return {
"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),
}
def _send_mock_smtp_message(
*,
config: Any,
built: Any,
mailbox: Any | None,
row: dict[str, Any],
recipients: list[str],
envelope_from: str,
append_sent: bool,
) -> _MockSendOutcome:
try:
if mailbox is not None and mailbox.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 = mailbox.record_smtp_delivery(
built.mime,
envelope_from=envelope_from,
envelope_recipients=accepted,
smtp_host="mock.smtp.local",
)
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,
})
imap_appended, imap_failed = _append_mock_sent_message(config, built, mailbox, row, append_sent=append_sent)
return _MockSendOutcome(row=row, sent_count=1, imap_appended_count=imap_appended, imap_failed_count=imap_failed)
except Exception as exc:
row.update({"status": "failed", "message": str(exc), "envelope_from": envelope_from, "envelope_recipients": recipients})
return _MockSendOutcome(row=row, failed_count=1)
def _append_mock_sent_message(
config: Any,
built: Any,
mailbox: Any | None,
row: dict[str, Any],
*,
append_sent: bool,
) -> tuple[int, int]:
if not append_sent:
return 0, 0
try:
if mailbox is not None and mailbox.consume_fail_next_imap():
raise MockCampaignSendError("Configured mock failure: next IMAP append fails")
folder = _mock_sent_folder(config)
imap_record = mailbox.record_imap_append(_raw_message_bytes(built.mime), folder=folder, imap_host="mock.imap.local")
row.update({"imap_status": "appended", "imap_message_id": imap_record.id, "imap_folder": folder})
return 1, 0
except Exception as exc:
row.update({"imap_status": "failed", "imap_error": str(exc)})
return 0, 1
def _mock_sent_folder(config: Any) -> str:
if config.delivery.imap_append_sent.folder and config.delivery.imap_append_sent.folder != "auto":
return config.delivery.imap_append_sent.folder
if config.server.imap and config.server.imap.sent_folder and config.server.imap.sent_folder != "auto":
return config.server.imap.sent_folder
return "Sent"
def run_mock_campaign_send(
session: Session,
*,
@@ -158,7 +340,7 @@ def run_mock_campaign_send(
campaign_id=campaign.id,
raw_json=version.raw_json if isinstance(version.raw_json, dict) else {},
include_bytes=True,
prefix="multimailer-mock-send-",
prefix="govoplan-mock-send-",
) as prepared:
prepared_raw = load_campaign_json(prepared.path)
config = load_campaign_config_from_json(session, tenant_id=tenant_id, raw_json=prepared_raw, campaign_id=campaign.id)
@@ -166,86 +348,15 @@ def run_mock_campaign_send(
build_result = build_campaign_messages(config, campaign_file=prepared.path, write_eml=False)
files.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 mailbox is not None and mailbox.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 = mailbox.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 mailbox is not None and mailbox.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 = mailbox.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)
send_batch = _mock_send_batch(
config=config,
built_messages=build_result.built_messages,
mailbox=mailbox,
send=send,
include_warnings=include_warnings,
include_needs_review=include_needs_review,
append_sent=append_sent,
)
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})
@@ -261,7 +372,6 @@ def run_mock_campaign_send(
"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,
@@ -273,19 +383,19 @@ def run_mock_campaign_send(
"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}},
{"key": "send", "label": "Mock SMTP delivery", "status": "skipped" if not send else ("ok" if send_batch.failed_count == 0 else "needs_review"), "summary": {"attempted": send_batch.attempted_count, "sent": send_batch.sent_count, "failed": send_batch.failed_count, "skipped": send_batch.skipped_count}},
{"key": "imap", "label": "Mock IMAP Sent append", "status": "skipped" if not send or not append_sent else ("ok" if send_batch.imap_failed_count == 0 else "needs_review"), "summary": {"appended": send_batch.imap_appended_count, "failed": send_batch.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,
"attempted_count": send_batch.attempted_count,
"sent_count": send_batch.sent_count,
"failed_count": send_batch.failed_count,
"skipped_count": send_batch.skipped_count,
"imap_appended_count": send_batch.imap_appended_count,
"imap_failed_count": send_batch.imap_failed_count,
"results": send_batch.results,
},
"mailbox": {"messages": mailbox.list_records(limit=200) if mailbox is not None else []},
}