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