from __future__ import annotations import argparse import getpass import json import os import sys from pathlib import Path from app.mailer.campaign.entries import EntryLoadError from app.mailer.campaign.loader import CampaignLoadError, load_campaign_config from app.mailer.campaign.models import SmtpConfig, TransportSecurity from app.mailer.messages.builder import BuiltMessage, build_campaign_messages from app.mailer.messages.models import MessageDraft from app.mailer.sending.smtp import ( SmtpConfigurationError, SmtpSendError, prepare_test_message, send_email_message, ) def _env(name: str) -> str | None: value = os.getenv(name) return value if value not in {None, ""} else None def _parse_security(value: str | None, fallback: TransportSecurity) -> TransportSecurity: if not value: return fallback try: return TransportSecurity(value.lower()) except ValueError as exc: allowed = ", ".join(item.value for item in TransportSecurity) raise ValueError(f"invalid SMTP security '{value}', expected one of: {allowed}") from exc def _parse_port(value: str | None, fallback: int | None) -> int | None: if not value: return fallback try: return int(value) except ValueError as exc: raise ValueError(f"invalid SMTP port '{value}'") from exc def _smtp_config_with_overrides(args: argparse.Namespace, base: SmtpConfig | None) -> SmtpConfig: config = base or SmtpConfig() password = args.smtp_password if args.smtp_password_env: password = _env(args.smtp_password_env) if password is None: raise ValueError(f"environment variable {args.smtp_password_env} is empty or not set") if args.ask_password: password = getpass.getpass("SMTP password: ") return SmtpConfig( host=args.smtp_host or _env("MULTIMAILER_SMTP_HOST") or config.host, port=_parse_port(args.smtp_port or _env("MULTIMAILER_SMTP_PORT"), config.port), username=args.smtp_username or _env("MULTIMAILER_SMTP_USERNAME") or config.username, password=password or _env("MULTIMAILER_SMTP_PASSWORD") or config.password, security=_parse_security(args.smtp_security or _env("MULTIMAILER_SMTP_SECURITY"), config.security), timeout_seconds=args.smtp_timeout or config.timeout_seconds, ) def _select_message( messages: list[BuiltMessage], *, entry_id: str | None, entry_index: int | None, allow_non_queueable: bool, ) -> BuiltMessage: candidates = messages if entry_id is not None: candidates = [item for item in candidates if item.draft.entry_id == entry_id] if not candidates: raise ValueError(f"no generated message found for entry id '{entry_id}'") if entry_index is not None: candidates = [item for item in candidates if item.draft.entry_index == entry_index] if not candidates: raise ValueError(f"no generated message found for entry index {entry_index}") if not allow_non_queueable: queueable = [item for item in candidates if item.draft.is_queueable and item.mime is not None] if queueable: return queueable[0] raise ValueError( "no queueable built message found. Fix validation issues or pass --allow-non-queueable for a deliberate test send." ) built = [item for item in candidates if item.mime is not None] if not built: raise ValueError("no built MIME message found") return built[0] def _envelope_from(draft: MessageDraft) -> str: if draft.bounce_to: return draft.bounce_to[0].email if draft.from_: return draft.from_.email raise SmtpConfigurationError("message has no sender; cannot determine SMTP envelope sender") def _write_test_eml(path: Path, message) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_bytes(bytes(message)) def _print_summary(*, draft: MessageDraft, test_to: str, smtp_config: SmtpConfig, envelope_from: str) -> None: print(f"Entry: {draft.entry_id or '#' + str(draft.entry_index)}") print(f"Subject: {draft.subject or ''}") print(f"Original validation: {draft.validation_status.value}") print(f"Original send status: {draft.send_status.value}") print(f"Test recipient: {test_to}") print(f"Envelope sender: {envelope_from}") print(f"SMTP: {smtp_config.host}:{smtp_config.port} ({smtp_config.security.value})") def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser( description="Send one generated campaign message to a test recipient. Does not mutate campaign/job status." ) parser.add_argument("--campaign", required=True, help="Path to campaign.json") parser.add_argument("--to", required=True, help="Test recipient email address. Real campaign recipients are never used.") parser.add_argument("--to-name", default=None, help="Optional display name for the test recipient") parser.add_argument("--entry-id", default=None, help="Select a specific entry by id") parser.add_argument("--entry-index", type=int, default=None, help="Select a specific entry by 1-based index") parser.add_argument( "--allow-non-queueable", action="store_true", help="Allow test-send of a built message whose validation status is needs_review/warning/blocked/excluded", ) parser.add_argument("--write-eml", default=None, help="Write the prepared test .eml to this path") parser.add_argument("--dry-run", action="store_true", help="Build and prepare the test message but do not connect to SMTP") parser.add_argument("--json", action="store_true", help="Output machine-readable JSON") parser.add_argument("--smtp-host", default=None, help="Override SMTP host") parser.add_argument("--smtp-port", default=None, help="Override SMTP port") parser.add_argument("--smtp-security", default=None, choices=[item.value for item in TransportSecurity], help="Override SMTP security") parser.add_argument("--smtp-username", default=None, help="Override SMTP username") parser.add_argument("--smtp-password", default=None, help="Override SMTP password (prefer --smtp-password-env or --ask-password)") parser.add_argument("--smtp-password-env", default=None, help="Read SMTP password from this environment variable") parser.add_argument("--ask-password", action="store_true", help="Prompt for SMTP password") parser.add_argument("--smtp-timeout", type=int, default=None, help="Override SMTP timeout in seconds") args = parser.parse_args(argv) campaign_path = Path(args.campaign).resolve() try: config = load_campaign_config(campaign_path) smtp_config = _smtp_config_with_overrides(args, config.server.smtp) result = build_campaign_messages(config, campaign_file=campaign_path) selected = _select_message( result.built_messages, entry_id=args.entry_id, entry_index=args.entry_index, allow_non_queueable=args.allow_non_queueable, ) assert selected.mime is not None envelope_from = _envelope_from(selected.draft) test_message = prepare_test_message(selected.mime, test_recipient=args.to, test_recipient_name=args.to_name) if args.write_eml: _write_test_eml(Path(args.write_eml).resolve(), test_message) if not args.json: _print_summary(draft=selected.draft, test_to=args.to, smtp_config=smtp_config, envelope_from=envelope_from) send_result = None if not args.dry_run: send_result = send_email_message( test_message, smtp_config=smtp_config, envelope_from=envelope_from, envelope_recipients=[args.to], ) except (CampaignLoadError, EntryLoadError, ValueError, SmtpConfigurationError, SmtpSendError, OSError) as exc: if args.json: print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False, indent=2)) else: print(f"Error: {exc}", file=sys.stderr) return 2 if args.json: payload = { "ok": True, "dry_run": args.dry_run, "campaign_id": config.campaign.id, "entry_id": selected.draft.entry_id, "entry_index": selected.draft.entry_index, "test_recipient": args.to, "validation_status": selected.draft.validation_status.value, "smtp": { "host": smtp_config.host, "port": smtp_config.port, "security": smtp_config.security.value, }, "send_result": None if send_result is None else { "accepted_count": send_result.accepted_count, "refused_recipients": send_result.refused_recipients, }, } print(json.dumps(payload, ensure_ascii=False, indent=2)) else: if args.dry_run: print("Dry run only; no SMTP connection attempted.") else: assert send_result is not None print(f"SMTP accepted recipients: {send_result.accepted_count}/{len(send_result.envelope_recipients)}") if send_result.refused_recipients: print(f"SMTP refused recipients: {send_result.refused_recipients}") else: print("Test message sent.") return 0 if __name__ == "__main__": raise SystemExit(main())