"""Send transport interface. Pluggable Transport interface for the outbox send operation. Selection order: 1. SMTP configured (SMTP_HOST set) -> SmtpTransport 2. Else -> ClipboardTransport (marks sent + stores payload for UI copy) The approval gate checks (confirmed, unexpired, hash match) are UNCHANGED and enforced in the API layer before transport.send() is called. """ from __future__ import annotations import os import smtplib import ssl from datetime import datetime, timezone from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from typing import Any, Protocol class Transport(Protocol): def send(self, payload: dict[str, Any]) -> dict[str, Any]: """Send a payload. Returns a result dict with at least 'success' bool.""" ... class EchoTransport: """Default transport: records payload, returns success. No real email is sent. Used for POC. """ def __init__(self) -> None: self.sent: list[dict[str, Any]] = [] def send(self, payload: dict[str, Any]) -> dict[str, Any]: self.sent.append(payload) return { "success": True, "sent_at": datetime.now(timezone.utc).isoformat(), "echo": payload, } class ClipboardTransport: """Fallback transport: marks sent and stores payload for UI copy/paste. No real email is sent. The payload is stored so the UI can show it to the user for manual copy-paste into their email client. """ def __init__(self) -> None: self.sent: list[dict[str, Any]] = [] def send(self, payload: dict[str, Any]) -> dict[str, Any]: self.sent.append(payload) return { "success": True, "sent_at": datetime.now(timezone.utc).isoformat(), "transport": "clipboard", "payload": payload, } class SmtpTransport: """SMTP transport: sends real email via SMTP. Configuration from env: SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, SMTP_FROM SSL on port 465, STARTTLS otherwise. """ def __init__( self, host: str | None = None, port: int | None = None, user: str | None = None, password: str | None = None, from_addr: str | None = None, ) -> None: self.host = host or os.environ.get("SMTP_HOST", "") self.port = int(port or os.environ.get("SMTP_PORT", "587")) self.user = user or os.environ.get("SMTP_USER", "") self.password = password or os.environ.get("SMTP_PASS", "") self.from_addr = from_addr or os.environ.get("SMTP_FROM", self.user) def send(self, payload: dict[str, Any]) -> dict[str, Any]: to = payload.get("to", "") subject = payload.get("subject", "(no subject)") body = payload.get("body", "") msg = MIMEMultipart() msg["From"] = self.from_addr msg["To"] = to msg["Subject"] = subject msg.attach(MIMEText(body, "plain")) try: if self.port == 465: context = ssl.create_default_context() with smtplib.SMTP_SSL(self.host, self.port, context=context) as server: if self.user and self.password: server.login(self.user, self.password) server.sendmail(self.from_addr, [to], msg.as_string()) else: with smtplib.SMTP(self.host, self.port) as server: server.ehlo() if self.user and self.password: server.starttls() server.ehlo() server.login(self.user, self.password) server.sendmail(self.from_addr, [to], msg.as_string()) return { "success": True, "sent_at": datetime.now(timezone.utc).isoformat(), "transport": "smtp", } except Exception as exc: return { "success": False, "sent_at": datetime.now(timezone.utc).isoformat(), "transport": "smtp", "error": str(exc), } def is_smtp_configured() -> bool: """Check if SMTP is configured (SMTP_HOST is set).""" return bool(os.environ.get("SMTP_HOST", "").strip()) # Singleton instance _default_transport: Transport | None = None def get_transport() -> Transport: """Get the transport. Selects SMTP if configured, else Clipboard.""" global _default_transport if _default_transport is None: if is_smtp_configured(): _default_transport = SmtpTransport() else: _default_transport = ClipboardTransport() return _default_transport def set_transport(t: Transport) -> None: """Override the transport (for testing).""" global _default_transport _default_transport = t def reset_transport() -> None: """Reset to default (for testing).""" global _default_transport _default_transport = None