"""Transactional email sender with a dev-mode log fallback. Thin wrapper around the Resend API (``resend.Emails.send``). Renders both HTML and plaintext magic-link bodies from Jinja templates to keep copy out of Python code. Security and UX rules --------------------- - **Never raise on missing credentials in development.** A 500 from the login POST would expose whether an email is on the allowlist (successful sends would succeed, non-allowlisted "sends" would still short-circuit) and it would also break local dev. In development we log a ``magic_link_dev_fallback`` structured event with the full magic-link URL so the developer can copy it. - **Production must fail at startup** if Resend credentials are absent; that validator lives in :class:`app.config.Settings`, not here. - Never log the raw token on its own — only as part of the URL in the dev fallback (which is the whole point of the fallback). """ from __future__ import annotations from datetime import datetime from typing import Optional import structlog from fastapi.templating import Jinja2Templates from app.config import Settings _log = structlog.get_logger(__name__) class EmailService: """Send magic-link emails via Resend, with a dev-mode log fallback.""" def __init__(self, settings: Settings, templates: Jinja2Templates) -> None: """Store dependencies by reference. Parameters ---------- settings: Application settings; used to pick up ``resend_api_key`` / ``resend_from`` / ``app_env`` at send time so rotating them at runtime (dev) works. templates: Shared Jinja2 environment. We reuse the app-level one so template autoescape defaults and the search path match the rest of the site. """ self._settings: Settings = settings self._templates: Jinja2Templates = templates def send_magic_link( self, *, to: str, url: str, display_name: str, ttl_min: int, expires_at: datetime, ) -> None: """Send a magic-link email to ``to`` or log the URL in dev. Behavior -------- - If ``settings.resend_api_key`` is truthy, render both bodies and send via Resend. - Otherwise (development only — the production config validator refuses to boot without a key), emit a structured log event ``magic_link_dev_fallback`` that includes the full URL. Never raises in the dev fallback path; errors from the Resend API surface as logged exceptions so the request-handler layer always returns the same response shape regardless of whether the email was actually sent (CWE-200 / anti-enumeration). """ # Build both template bodies first so any rendering error # surfaces before we talk to the network. ctx = { "display_name": display_name, "magic_link_url": url, "expires_at": expires_at.isoformat(), "ttl_min": ttl_min, } html_body = self._render("emails/magic_link.html", ctx) text_body = self._render("emails/magic_link.txt", ctx) api_key: Optional[str] = self._settings.resend_api_key sender: Optional[str] = self._settings.resend_from # Dev fallback path: no key configured. Log the URL at INFO so # the developer can complete the flow, and return. if not api_key or not sender: _log.info( "magic_link_dev_fallback", to=to, # Raw token is embedded in the URL; acceptable because # this path ONLY runs in local dev (production validator # refuses to boot without RESEND_API_KEY). magic_link_url=url, ttl_min=ttl_min, ) return # Real send. We import here to avoid taking a hard import-time # dependency on `resend`'s module-level state during tests that # never exercise the send path. try: import resend # type: ignore[import-untyped] resend.api_key = api_key resend.Emails.send( { "from": sender, "to": to, "subject": "Your Chicken Babies R Us admin login link", "html": html_body, "text": text_body, } ) _log.info("magic_link_email_sent", to=to) except Exception: # noqa: BLE001 # Do NOT re-raise from the request path — see anti-enumeration # note at module top. Log with redacted context. _log.exception("magic_link_email_failed", to=to) # ------------------------------------------------------------------ # Internals # ------------------------------------------------------------------ def _render(self, template_name: str, context: dict) -> str: """Render a Jinja template to a string. We use the underlying Jinja environment directly so we get a plain string back (``Jinja2Templates.TemplateResponse`` wraps the output in an HTTP response, which is not what we want for outbound email bodies). """ template = self._templates.env.get_template(template_name) return template.render(**context)