"""Email backends. R2-B spike: only ConsoleEmailBackend is functional. SendGrid is a stub intentionally left to be wired in R3-C. """ from __future__ import annotations import json import sys from datetime import datetime, timezone from pathlib import Path from typing import Optional, Protocol try: from sendgrid import SendGridAPIClient from sendgrid.helpers.mail import Mail, ReplyTo except ImportError: # pragma: no cover — SendGrid installed separately. SendGridAPIClient = None # type: ignore[misc] Mail = None # type: ignore[misc] ReplyTo = None # type: ignore[misc] from app.config import settings # Repository convention: backend/var/email_outbox.jsonl # This module lives at backend/app/services/email.py — go up two levels to # reach backend/, then var/. _BACKEND_ROOT = Path(__file__).resolve().parent.parent.parent OUTBOX_PATH = _BACKEND_ROOT / "var" / "email_outbox.jsonl" class EmailBackend(Protocol): def send( self, to: str, subject: str, html: str, text: Optional[str] = None, ) -> None: ... class ConsoleEmailBackend: """Dev/spike backend. Prints to stdout AND appends a JSON line to backend/var/email_outbox.jsonl so the round-trip can be inspected after the fact. """ def send( self, to: str, subject: str, html: str, text: Optional[str] = None, ) -> None: record = { "ts": datetime.now(timezone.utc).isoformat(), "to": to, "subject": subject, "html": html, "text": text, } print( f"[ConsoleEmailBackend] -> {to} | {subject}", file=sys.stdout, flush=True, ) OUTBOX_PATH.parent.mkdir(parents=True, exist_ok=True) with OUTBOX_PATH.open("a", encoding="utf-8") as f: f.write(json.dumps(record) + "\n") class SendGridEmailBackend: def __init__(self) -> None: self._client = SendGridAPIClient(settings.SENDGRID_API_KEY) self._from_email = settings.SENDGRID_FROM_EMAIL self._reply_to = settings.SENDGRID_REPLY_TO def send( self, to: str, subject: str, html: str, text: Optional[str] = None, ) -> None: message = Mail( from_email=self._from_email, to_emails=to, subject=subject, html_content=html, plain_text_content=text, ) message.reply_to = ReplyTo(self._reply_to) response = self._client.send(message) if response.status_code >= 400: raise RuntimeError( f"SendGrid error {response.status_code}: {response.body}" ) def get_email_backend() -> EmailBackend: backend = (settings.EMAIL_BACKEND or "console").lower() if backend == "sendgrid": return SendGridEmailBackend() return ConsoleEmailBackend()