589 lines
20 KiB
Python
589 lines
20 KiB
Python
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from email import policy
|
|
from email.message import EmailMessage
|
|
from typing import Any
|
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
from govoplan_campaign.backend.db.models import Campaign, CampaignVersion
|
|
from govoplan_campaign.backend.campaign.loader import load_campaign_json
|
|
from govoplan_campaign.backend.campaign.validation import validate_campaign_config
|
|
from govoplan_campaign.backend.persistence.campaigns import load_campaign_config_from_json
|
|
from govoplan_campaign.backend.messages.builder import build_campaign_messages
|
|
from govoplan_campaign.backend.messages.models import MessageAddress, MessageDraft, MessageValidationStatus
|
|
from govoplan_campaign.backend.integrations import files_integration, mail_integration
|
|
from govoplan_campaign.backend.path_security import assert_server_safe_campaign_paths
|
|
|
|
|
|
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()
|
|
|
|
|
|
def _require_mock_mailbox() -> Any:
|
|
mailbox = _mock_mailbox()
|
|
if mailbox is None:
|
|
raise MockCampaignSendError("Mail module is not available")
|
|
return mailbox
|
|
|
|
|
|
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 [files_integration().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_),
|
|
"from_all": _message_addresses_payload(message.from_all),
|
|
"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]:
|
|
mailbox = _mock_mailbox()
|
|
needle = ((mailbox.get_failures() if mailbox else {}).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 _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 _mock_campaign_version(
|
|
session: Session,
|
|
*,
|
|
tenant_id: str,
|
|
campaign_id: str,
|
|
version_id: str | None,
|
|
) -> tuple[Campaign, CampaignVersion]:
|
|
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"
|
|
)
|
|
return campaign, version
|
|
|
|
|
|
def _mock_mailbox_for_run(*, send: bool, clear_mailbox: bool) -> Any | None:
|
|
mailbox = _require_mock_mailbox() if send or clear_mailbox else _mock_mailbox()
|
|
if clear_mailbox and mailbox is not None:
|
|
mailbox.clear_records()
|
|
return mailbox
|
|
|
|
|
|
def _build_mock_campaign_run(
|
|
session: Session,
|
|
*,
|
|
tenant_id: str,
|
|
campaign: Campaign,
|
|
version: CampaignVersion,
|
|
mailbox: Any | None,
|
|
send: bool,
|
|
include_warnings: bool,
|
|
include_needs_review: bool,
|
|
append_sent: bool,
|
|
check_files: bool,
|
|
) -> tuple[Any, Any, _MockSendBatch]:
|
|
files = files_integration()
|
|
raw_json = version.raw_json if isinstance(version.raw_json, dict) else {}
|
|
assert_server_safe_campaign_paths(
|
|
raw_json,
|
|
managed_files_available=files.available,
|
|
)
|
|
with files.prepared_campaign_snapshot(
|
|
session,
|
|
tenant_id=tenant_id,
|
|
campaign_id=campaign.id,
|
|
raw_json=raw_json,
|
|
include_bytes=True,
|
|
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,
|
|
)
|
|
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,
|
|
)
|
|
files.annotate_built_messages_with_managed_files(
|
|
build_result.built_messages,
|
|
prepared.managed_files_by_local_path,
|
|
)
|
|
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,
|
|
)
|
|
return validation_report, build_result, send_batch
|
|
|
|
|
|
def _mock_validation_payload(validation_report: Any) -> dict[str, Any]:
|
|
payload = validation_report.model_dump(mode="json")
|
|
payload.update(
|
|
{
|
|
"ok": validation_report.ok,
|
|
"error_count": validation_report.error_count,
|
|
"warning_count": validation_report.warning_count,
|
|
}
|
|
)
|
|
return payload
|
|
|
|
|
|
def _mock_build_payload(build_result: Any) -> dict[str, Any]:
|
|
report = build_result.report
|
|
payload = report.model_dump(mode="json")
|
|
payload.update(
|
|
{
|
|
"built_count": report.built_count,
|
|
"queueable_count": report.queueable_count,
|
|
"needs_review_count": report.needs_review_count,
|
|
"blocked_count": report.blocked_count,
|
|
"warning_count": report.warning_count,
|
|
"ready_count": report.ready_count,
|
|
"messages": [_message_payload(message) for message in report.messages],
|
|
}
|
|
)
|
|
return payload
|
|
|
|
|
|
def _mock_send_steps(
|
|
*,
|
|
validation_report: Any,
|
|
validation_payload: dict[str, Any],
|
|
build_report: Any,
|
|
send_batch: _MockSendBatch,
|
|
send: bool,
|
|
append_sent: bool,
|
|
) -> list[dict[str, Any]]:
|
|
return [
|
|
{
|
|
"key": "validate",
|
|
"label": "Validate campaign JSON",
|
|
"status": "ok" if validation_report.ok else "needs_review",
|
|
"summary": validation_payload,
|
|
},
|
|
{
|
|
"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 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,
|
|
},
|
|
},
|
|
]
|
|
|
|
|
|
def _mock_campaign_send_response(
|
|
*,
|
|
campaign: Campaign,
|
|
version: CampaignVersion,
|
|
mailbox: Any | None,
|
|
validation_report: Any,
|
|
validation_payload: dict[str, Any],
|
|
build_result: Any,
|
|
build_payload: dict[str, Any],
|
|
send_batch: _MockSendBatch,
|
|
send: bool,
|
|
include_warnings: bool,
|
|
include_needs_review: bool,
|
|
append_sent: bool,
|
|
) -> dict[str, Any]:
|
|
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": _mock_send_steps(
|
|
validation_report=validation_report,
|
|
validation_payload=validation_payload,
|
|
build_report=build_result.report,
|
|
send_batch=send_batch,
|
|
send=send,
|
|
append_sent=append_sent,
|
|
),
|
|
"validation": validation_payload,
|
|
"build": build_payload,
|
|
"send": {
|
|
"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 []
|
|
},
|
|
}
|
|
|
|
|
|
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, version = _mock_campaign_version(
|
|
session,
|
|
tenant_id=tenant_id,
|
|
campaign_id=campaign_id,
|
|
version_id=version_id,
|
|
)
|
|
mailbox = _mock_mailbox_for_run(send=send, clear_mailbox=clear_mailbox)
|
|
validation_report, build_result, send_batch = _build_mock_campaign_run(
|
|
session,
|
|
tenant_id=tenant_id,
|
|
campaign=campaign,
|
|
version=version,
|
|
mailbox=mailbox,
|
|
send=send,
|
|
include_warnings=include_warnings,
|
|
include_needs_review=include_needs_review,
|
|
append_sent=append_sent,
|
|
check_files=check_files,
|
|
)
|
|
validation_payload = _mock_validation_payload(validation_report)
|
|
build_payload = _mock_build_payload(build_result)
|
|
return _mock_campaign_send_response(
|
|
campaign=campaign,
|
|
version=version,
|
|
mailbox=mailbox,
|
|
validation_report=validation_report,
|
|
validation_payload=validation_payload,
|
|
build_result=build_result,
|
|
build_payload=build_payload,
|
|
send_batch=send_batch,
|
|
send=send,
|
|
include_warnings=include_warnings,
|
|
include_needs_review=include_needs_review,
|
|
append_sent=append_sent,
|
|
)
|