Skip to content

feat: user email verification #44

Description

@ethnjs

Add email verification flow so users confirm ownership of their email address after registration.

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.

Tasks

Backend

  • email_verified column — add Boolean, nullable=False, default=False to User; migration; keep separate from is_active (which is used for admin-activation flow)
  • app/core/email_verification.py — two helpers:
    • generate_verification_token(user_id: int) -> str — signed JWT with user_id, exp (24h), purpose: "email_verification"
    • verify_verification_token(token: str) -> int | None — returns user_id if valid, None if expired/invalid/wrong purpose
  • app/services/email_service.py — thin async wrapper around Resend API (resend[async], send_async); send_verification_email(to: str, token: str); from address: noreply@nexus.ethanshih.com; no reply_to header
  • Hook /auth/register/ — send verification email after user created; fire-and-forget (wrap in try/except, log error, don't fail registration)
  • GET /auth/verify-email/?token= — public; validates token, sets email_verified=True; idempotent; returns 400 with clear message on invalid/expired
  • POST /auth/resend-verification/ — authenticated; returns 400 if already verified; # TODO(temp): add rate limiting
  • RESEND_API_KEY — add to Settings in app/core/config.py, .env, and Render env vars
  • FRONTEND_URL — confirm exists in Settings or add; used to build verify link in email body

Frontend

  • /verify-email page — reads token from query params on mount; calls authApi.verifyEmail(token); shows loading → success or error state; success: button to dashboard; error: "link expired or invalid" message
  • "Check your email" state in sign-up — shown after step 1 before step 2; "We sent a verification email to {email}. You can verify later — let's finish your profile."; auto-advance or "Continue" button
  • authApi.verifyEmail(token)GET /auth/verify-email/?token=
  • authApi.resendVerification()POST /auth/resend-verification/

Notes

  • Sending domain noreply@nexus.ethanshih.com verified in Resend; SPF/DKIM DNS records set in Vercel; no MX records (no inbound)
  • Token uses purpose claim so login tokens cannot be reused as verification tokens
  • Dashboard nudge banner is a separate issue feat: dashboard banners (email, profile) #45

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions