think
16px
820px

Email delivery, OTP verification, and password recovery — design

Date: 2026-08-25
Status: implemented
Trigger: demo-28 walkthrough findings (Efran, 2026-08-25)

Problem

Three gaps found while clicking through the public site before the 28 Aug
hands-on:

  1. Registration takes an email address but never proves the person controls it.
    The account is therefore worth less than it looks — and the register form
    already carries a warning that identity is not verified against Dukcapil, so
    "controls this mailbox" is the only claim an account can honestly make.
    Right now it cannot even make that one.
  2. Neither surface has password recovery. A public user or a staff member who
    forgets a password is locked out permanently: public_users and
    staff_users both have a resetPassword method, but nothing user-facing
    reaches it. Staff recovery today means someone with shell access editing
    SQLite by hand.
  3. Registration had no confirm-password field, so a typo in the one
    unrecoverable field created a dead account. (Fixed in the same batch; it is
    the reason recovery matters more than it looks.)

Constraint that shapes everything

There may be no mail server. This is an on-prem government deployment; the
staging box has no SMTP relay configured and the production relay is not ours
to provision. A design that assumes mail works would ship a "Lupa kata sandi?"
link that silently does nothing, and an OTP gate that locks every new user out
of their own account. That is strictly worse than having neither feature.

So mail is optional infrastructure, and every feature built on it degrades
to today's behaviour when it is absent:

Mail configured Registration Recovery
yes email → OTP → account "Lupa kata sandi?" sends a reset link
no account created immediately (byte-identical to today) link is not rendered; endpoints return 503 mail_disabled

The no-mail path is the default. Forgetting to configure anything leaves the
system exactly as it behaves now.

Transport: SMTP only

One transport, nodemailer over SMTP. SES is configured through this same
transport
, using its SMTP interface
(email-smtp.<region>.amazonaws.com:587 + SES SMTP credentials) rather than
the AWS SDK. Reasons:

  • One code path to test instead of two, and the one that exists is the one an
    on-prem Postfix/Exchange relay also speaks.
  • No AWS SDK dependency, no IAM credential handling, no region config in an
    environment whose whole premise is that it runs on our own hardware.
  • Switching between an on-prem relay and SES becomes an env change, not a
    deploy of different code.

The cost is losing SES-API-only features (per-message event publishing,
templates). Neither is wanted here.

Mail is considered enabled iff SMTP_HOST is set and MAIL_ENABLED is not
explicitly off. There is no "configured but broken" state to reason about at
feature-flag level: a relay that rejects the connection surfaces as a send
failure, handled per-flow below.

Flows

Registration with OTP (public only)

OTP runs before the account exists, not after:

POST /api/auth/register/start   { email, password, nik, fullName }
    validate  stash pending registration in Redis (10 min)  mail 6-digit code
    200 { otpRequired: true }
POST /api/auth/register/verify  { email, code }
    check code  create account  sign session cookie  200 { user }

Verifying before creation means an unverified account can never exist, so no
downstream code has to ask "is this user verified?" and no half-created row
needs cleaning up. The cost is holding a bcrypt-hashed password in Redis for up
to 10 minutes; the pending record stores the hash, never the plaintext.

When mail is disabled the client never calls these routes — it posts to the
existing POST /api/auth/register, which is untouched.

Password recovery (both surfaces)

POST /api/auth/forgot-password  { email }    always 200 (no enumeration)
GET  /reset-password?token=                 form (new password + confirm)
POST /api/auth/reset-password   { token, password }
  • The response to forgot-password is identical for known and unknown emails.
    Leaking which addresses have accounts is a real disclosure on a citizen
    surface, and on the staff surface it enumerates civil servants.
  • The token is 32 random bytes, stored hashed (SHA-256) under a Redis key
    keyed by that hash, so a Redis dump does not yield working reset links.
  • Single-use: the key is deleted on redemption, inside the same request.
  • resetPassword() on both stores bumps token_version, so every existing
    session for that account is revoked the moment the password changes. That is
    the desired behaviour when the reason for resetting is "someone else may have
    my password".

Abuse limits

Both mail-sending endpoints are rate-limited per email address AND per IP
through the existing Redis counters, because an unlimited "send mail to this
address" endpoint is an open relay for harassment. Demo mode does not
bypass these: unmetered questions are the point of demo mode; unmetered
outbound email is not.

Failure behaviour

  • Send fails (relay down, auth rejected): registration returns 502
    mail_send_failed and no pending record is kept, so the user can retry
    cleanly. Recovery still returns 200 — telling an anonymous caller that the
    relay is down, on an endpoint that must not reveal whether the account
    exists, gives away timing information for free.
  • Redis unreachable: OTP and reset both fail closed (no account created, no
    password changed). Unlike rate limiting, failing open here would mean
    accepting any code or any token.

What this does not do

  • No email change flow for existing accounts.
  • No re-send throttle UI beyond the plain rate-limit error.
  • No verification for accounts created before this shipped, and none for staff
    accounts (they are created by an admin, who already knows the address).
  • No HTML email design system: the messages are short, plain-text-first, with a
    minimal HTML alternative.