Skip to content

feat/user email verification - #49

Merged
ethnjs merged 12 commits into
mainfrom
feat/user-email-verification
Jul 25, 2026
Merged

feat/user email verification#49
ethnjs merged 12 commits into
mainfrom
feat/user-email-verification

Conversation

@ethnjs

@ethnjs ethnjs commented Jul 25, 2026

Copy link
Copy Markdown
Owner

Background

Users can register and log in without verifying their email. Verification is a nudge, not a blocker — unverified users can access the app but see a banner prompting them to verify (banner itself is tracked separately, see Notes).

Changes

Backend

  • email_verified column on User (Boolean, nullable=False, default=False) with migration — kept separate from is_active, which belongs to the admin-activation flow
  • app/core/email_verification.py:
    • generate_verification_token(user_id: int) -> str — signed JWT, 24h exp, aud: "email_verification" claim
    • verify_verification_token(token: str) -> int | None — returns user_id if valid, None if expired/invalid/wrong audience
  • app/services/email_service.py — thin async wrapper around Resend (resend, using send_async); send_verification_email(to: str, token: str); sends from verify@nexus.ethanshih.com (deliberately not noreply@, to avoid spam-filtering), no reply_to
  • GET /auth/verify-email/?token= — public; validates token, sets email_verified=True; idempotent; 400 with a clear message on invalid/expired tokens
  • POST /auth/send-email-verification/ — authenticated; 400 if already verified; # todo: add rate limiting
  • RESEND_API_KEY added to Settings (app/core/config.py) and .env
  • FRONTEND_URL added/confirmed in Settings — used to build the verification link in the email body

Intentionally disabled for now: the actual send call in send-email-verification is commented out (_send_verification_email(...)), and register() never calls it either. This is deliberate — Resend's free tier has a sending limit and we don't want every registration burning it during dev/testing. Wiring both call sites back up is a follow-up, not a bug.

Frontend

  • /verify-email page — reads token from query params on mount, calls authApi.verifyEmail(token); loading → success or error state; success shows a "go to dashboard" button when logged in; error surfaces the backend's message (e.g. "Invalid or expired token")
  • VerifyModal shown over the sign-up flow's profile-enrichment step, after step 1 registration: "We sent a verification email to {email}. You can verify later — let's finish your profile."
  • authApi.verifyEmail(token)GET /auth/verify-email/?token=
  • authApi.sendEmailVerification()POST /auth/send-email-verification/
  • proxy.ts: visiting /verify-email without a token param redirects to /dashboard (logged in) or / (logged out)

Testing

  • Verified token generation/validation, including expired-token and wrong-audience rejection
  • Manually walked /verify-email success and error states (using a manually-minted token, since sending is currently disabled)
  • Manually verified idempotency (visiting the verify link twice doesn't error)
  • Confirmed branch builds/runs in isolation, based on top of feat/signin-and-signup

Known gaps / follow-ups

  • No email is actually sent yet, on purpose. Both call sites (register() and send-email-verification) are wired for it but the send is disabled to stay under the Resend free-tier limit during dev. Re-enable both before this feature is user-facing.
  • resend is a plain dependency (resend==2.32.2) — no [async] extra needed; send_async is a normal method on the base package.

Notes

  • Sending domain verify@nexus.ethanshih.com is verified in Resend; SPF/DKIM DNS records are set in Vercel DNS; no MX records, since this is send-only
  • Token carries an aud claim specifically so a login/session JWT can't be reused as a verification token (see security gap above for the unaddressed reverse case)
  • The dashboard nudge banner for unverified email is tracked separately in #45
  • This PR is based on top of feat/signin-and-signup — depends on that landing first, or should be rebased onto main once it does
  • Unrelated changes riding along in this branch, worth a quick look during review: database_url default flipped from SQLite to Postgres in config.py; _find_user_by_id moved from users.py to shared core/users.py; small Modal.tsx/Input.tsx tweaks and a new Spinner.tsx component

@ethnjs ethnjs linked an issue Jul 25, 2026 that may be closed by this pull request
12 tasks
@vercel

vercel Bot commented Jul 25, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
nexus Ready Ready Preview, Comment Jul 25, 2026 9:44pm

@railway-app
railway-app Bot temporarily deployed to nexus / nexus-pr-49 July 25, 2026 21:44 Destroyed
@ethnjs
ethnjs merged commit fcce3f8 into main Jul 25, 2026
3 of 4 checks passed
@ethnjs
ethnjs deleted the feat/user-email-verification branch July 25, 2026 21:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: user email verification

1 participant