Send magic-link email in the background (instant request response)

The SMTP send (connect → TLS → login → handoff to the relay) ran synchronously
inside POST /api/auth/email/start, so the "Sending…" button waited the whole
handshake. Move it to a FastAPI BackgroundTask: the token is created + committed,
the request returns immediately, and the email sends off the request path. Reply
stays identical (no account enumeration). Tests pass (TestClient runs the task).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-03 01:25:56 +00:00
parent 9237180608
commit 28dc79d0b7
+15 -7
View File
@@ -22,7 +22,7 @@ from contextlib import contextmanager
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path 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.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel from pydantic import BaseModel
@@ -74,6 +74,14 @@ def _current_user(conn: sqlite3.Connection, request: Request) -> sqlite3.Row | N
return user 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: def _set_session_cookie(response: Response, token: str) -> None:
response.set_cookie( response.set_cookie(
SESSION_COOKIE, token, max_age=SESSION_MAX_AGE, 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) ---------- # --- Auth: passwordless magic link (Google added in Phase 2) ----------
@app.post("/api/auth/email/start") @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) email = auth.normalize_email(body.email)
if not _EMAIL_RE.match(email): if not _EMAIL_RE.match(email):
raise HTTPException(status_code=422, detail="Please enter a valid email address.") raise HTTPException(status_code=422, detail="Please enter a valid email address.")
link = None
with get_conn() as conn: with get_conn() as conn:
# Light abuse guard: cap recent tokens per address (still reply OK). # Light abuse guard: cap recent tokens per address (still reply OK).
recent = conn.execute( recent = conn.execute(
@@ -300,11 +309,10 @@ def create_app() -> FastAPI:
raw = auth.create_login_token(conn, email) raw = auth.create_login_token(conn, email)
conn.commit() conn.commit()
link = f"{PUBLIC_BASE_URL}/auth/verify?token={raw}" link = f"{PUBLIC_BASE_URL}/auth/verify?token={raw}"
try: # Hand the (slow) SMTP send to a background task so the request returns
email_send.send_magic_link(email, link) # immediately. Reply is always identical (no account enumeration).
except Exception: if link:
pass # never leak send failures or whether the address exists background_tasks.add_task(_send_link_safe, email, link)
# Always identical (no account enumeration).
return {"ok": True} return {"ok": True}
@app.post("/api/auth/email/verify", response_model=SessionOut) @app.post("/api/auth/email/verify", response_model=SessionOut)