Appearance
Registration emails
After register, email a one-time link. User clicks → we mark
email_verified_at. Nodemailer sends; Ethereal catches mail in local dev so you don't need Gmail credentials on day one.
Flow
Token table
sql
CREATE TABLE IF NOT EXISTS email_tokens (
token TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
type TEXT NOT NULL, -- 'verify' | 'reset'
expires_at TEXT NOT NULL,
used_at TEXT,
FOREIGN KEY (user_id) REFERENCES users(id)
);Store a random token (not a JWT). Hashing tokens at rest is nicer in production; for learning, a long random string is OK if expiry + one-time use are enforced.
ts
import crypto from 'node:crypto'
export function createEmailToken(userId: number, type: 'verify' | 'reset', hoursValid = 24) {
const token = crypto.randomBytes(32).toString('hex')
const expires_at = new Date(Date.now() + hoursValid * 3600_000).toISOString()
db.prepare(
`INSERT INTO email_tokens (token, user_id, type, expires_at) VALUES (?, ?, ?, ?)`,
).run(token, userId, type, expires_at)
return token
}Nodemailer + Ethereal (local)
bash
npm install nodemailer
npm install -D @types/nodemailerts
// src/mail.ts
import nodemailer from 'nodemailer'
import { config } from './config.js'
let transporter: nodemailer.Transporter
export async function getMailer() {
if (transporter) return transporter
if (config.smtpHost) {
transporter = nodemailer.createTransport({
host: config.smtpHost,
port: config.smtpPort,
auth: { user: config.smtpUser, pass: config.smtpPass },
})
} else {
// Ethereal fake SMTP - prints a preview URL to the console
const testAccount = await nodemailer.createTestAccount()
transporter = nodemailer.createTransport({
host: 'smtp.ethereal.email',
port: 587,
secure: false,
auth: { user: testAccount.user, pass: testAccount.pass },
})
}
return transporter
}
export async function sendMail(opts: { to: string; subject: string; text: string; html?: string }) {
const mailer = await getMailer()
const info = await mailer.sendMail({
from: config.mailFrom,
...opts,
})
const preview = nodemailer.getTestMessageUrl(info)
if (preview) console.log('Preview email:', preview)
return info
}When Ethereal is used, check the terminal for a Preview email: link - open it like a fake inbox. 🙌
Send on register
ts
const user = await createUser(email, password)
const token = createEmailToken(user!.id, 'verify', 24)
const link = `${config.appUrl}/verify?token=${token}`
await sendMail({
to: user!.email,
subject: 'Confirm your email',
text: `Open this link to verify: ${link}`,
html: `<p>Open this link to verify:</p><p><a href="${link}">${link}</a></p>`,
})
res.status(201).json({ token: signToken(user!.id), user: publicUser(user!) })The SPA route /verify just reads token from the query and POSTs it to the API.
Verify endpoint
ts
authRouter.post('/verify', (req, res, next) => {
try {
const token = String(req.body?.token ?? '')
const row = db
.prepare(
`SELECT * FROM email_tokens WHERE token = ? AND type = 'verify'`,
)
.get(token) as
| { user_id: number; expires_at: string; used_at: string | null }
| undefined
if (!row || row.used_at) throw new HttpError(400, 'invalid token')
if (new Date(row.expires_at) < new Date()) throw new HttpError(400, 'token expired')
db.prepare(`UPDATE users SET email_verified_at = datetime('now') WHERE id = ?`).run(row.user_id)
db.prepare(`UPDATE email_tokens SET used_at = datetime('now') WHERE token = ?`).run(token)
res.json({ ok: true })
} catch (err) {
next(err)
}
})Production mail (pointer only)
Swap Ethereal for Resend / SES / Postmark / SMTP from your host. Same sendMail call - different transport config. Templates, bounce handling, and deliverability are their own rabbit hole; we're not going there today.
UX notes for FE
- After register, show "check your inbox" even if the API already returned a JWT
- Resend-verify button →
POST /api/auth/resend-verification(same token machinery) - Don't reveal whether an email exists on resend (always say "if that account exists…")
Next: forgot password - same token table, different type.
Best,
-- ł.