diff --git a/goodnews/api.py b/goodnews/api.py index df489f1..986ce20 100644 --- a/goodnews/api.py +++ b/goodnews/api.py @@ -22,7 +22,7 @@ from contextlib import contextmanager from datetime import datetime, timezone from pathlib import Path -from fastapi import FastAPI, HTTPException, Query, Request, Response +from fastapi import BackgroundTasks, FastAPI, HTTPException, Query, Request, Response from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from pydantic import BaseModel @@ -74,6 +74,14 @@ def _current_user(conn: sqlite3.Connection, request: Request) -> sqlite3.Row | N return user +def _send_link_safe(email: str, link: str) -> None: + """Send the magic link, swallowing failures (runs off the request path).""" + try: + email_send.send_magic_link(email, link) + except Exception: + pass # don't crash the worker; never surfaced to the caller anyway + + def _set_session_cookie(response: Response, token: str) -> None: response.set_cookie( SESSION_COOKIE, token, max_age=SESSION_MAX_AGE, @@ -285,10 +293,11 @@ def create_app() -> FastAPI: # --- Auth: passwordless magic link (Google added in Phase 2) ---------- @app.post("/api/auth/email/start") - def auth_email_start(body: EmailStartRequest) -> dict: + def auth_email_start(body: EmailStartRequest, background_tasks: BackgroundTasks) -> dict: email = auth.normalize_email(body.email) if not _EMAIL_RE.match(email): raise HTTPException(status_code=422, detail="Please enter a valid email address.") + link = None with get_conn() as conn: # Light abuse guard: cap recent tokens per address (still reply OK). recent = conn.execute( @@ -300,11 +309,10 @@ def create_app() -> FastAPI: raw = auth.create_login_token(conn, email) conn.commit() link = f"{PUBLIC_BASE_URL}/auth/verify?token={raw}" - try: - email_send.send_magic_link(email, link) - except Exception: - pass # never leak send failures or whether the address exists - # Always identical (no account enumeration). + # Hand the (slow) SMTP send to a background task so the request returns + # immediately. Reply is always identical (no account enumeration). + if link: + background_tasks.add_task(_send_link_safe, email, link) return {"ok": True} @app.post("/api/auth/email/verify", response_model=SessionOut)