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 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)