Skip to content

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/nodemailer
ts
// 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,

-- ł.

Notes-style Express tutorial for front-end folks