Email verification looks like a small feature: create a token, send a message, and accept the token when the user clicks it. In a production backend, it is a distributed workflow with several clocks and retry paths. A message can arrive late, a client can repeat the request, and a worker can process the same delivery job twice.
The cleanest way I have found to keep this manageable is to treat verification as a state machine. The REST API owns valid transitions, PostgreSQL records the durable facts, and the mail worker only delivers an already-created message. This separation makes failures visible instead of hiding them behind a boolean such as email_verified.
Why verification needs explicit states
An account usually moves through more than two states. A useful minimal model is:
pending -> issued -> delivered -> confirmed
\-> expired
pending -> cancelled
issued means the service committed a token and queued delivery. It does not mean the user received the message. delivered means a provider accepted the message, not that the link was opened. Those distinctions matter when support investigates a missing email or when a retry job decides what it is allowed to do.
The transition endpoint should be deliberately boring. For example, POST /v1/email-verifications can create a new attempt, while POST /v1/email-verifications/{id}/confirm consumes one. A repeated confirmation should return the same successful outcome when the attempt is already confirmed; it should not create a second account-side effect.
For changes to an existing address, I also keep a risk snapshot with the attempt. The snapshot records the address hash, account identifier, and relevant policy version at issue time. This gives operators a stable explanation when a later policy change differs from the original decision. The idea pairs well with these risk snapshots for email changes.
The PostgreSQL model
The database should enforce the facts that the application depends on. A compact schema can look like this:
CREATE TABLE email_verifications (
id uuid PRIMARY KEY,
account_id uuid NOT NULL,
address_hash bytea NOT NULL,
token_digest bytea NOT NULL UNIQUE,
state text NOT NULL CHECK (state IN ('issued', 'delivered', 'confirmed', 'expired', 'cancelled')),
expires_at timestamptz NOT NULL,
confirmed_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX email_verifications_active_idx
ON email_verifications (account_id, expires_at)
WHERE state IN ('issued', 'delivered');
Store a digest of the token, not the raw token. The raw value belongs only in the link sent to the user. A unique digest prevents accidental duplicate issuance, while the partial index keeps the lookup for active attempts small enough to be useful as the table grows.
A transactional outbox is the next important piece. Insert the verification row and its email.send event in one transaction. The worker can retry the event until the provider accepts it, without the API pretending that delivery happened. It is a little more table work, but the behavior is much more clear.
Designing idempotent REST API transitions
Clients retry for ordinary reasons: mobile networks switch, a reverse proxy times out, or a browser is refreshed. Require an idempotency key on issuance and persist it with the request result. Two requests with the same key should resolve to the same verification ID and not send two messages.
The confirmation transaction should lock only the target row and verify the token digest in the same operation. In pseudocode:
BEGIN
SELECT verification FOR UPDATE
IF state = 'confirmed': return the stored success result
IF state NOT IN ('issued', 'delivered'): return an expired response
IF expires_at <= now(): set state = 'expired'; return an expired response
IF digest(provided_token) != token_digest: record a failed attempt; return an invalid response
set state = 'confirmed', confirmed_at = now()
mark the account email as verified
COMMIT
The account update and verification transition must commit together. Otherwise a timeout can leave the token marked confirmed while the account still appears unverified. That bug is annoyng because a retry then looks successful but the user cannot proceed.
Do not return different timing behavior for an unknown account and an existing account. Rate-limit by account, address hash, and client context as appropriate. The API response can be generic while internal audit records preserve the exact reason.
Handling mailbox and token expiry
There are two expiries here, and they should not be confused. The token expiry is a security rule enforced by the confirmation transaction. The mailbox expiry is an external testing concern. A free throwaway email address may disappear before a slow integration test reads it, so test code should poll until a deadline and then report whether it saw no message, the wrong message, or an expired token.
When testing with a tempmail disposable inbox, keep it outside production account data and never use it as a long-term recovery address. Test fixtures should include the lease or cleanup deadline. Even a malformed input such as tepm mail com can be useful in validation tests, but it should remain a test case rather than a link or a trusted domain.
A cleanup worker can mark old attempts as expired in batches. The worker should be safe to run twice: use a predicate such as state IN ('issued', 'delivered') AND expires_at <= now() and update rows conditionally. Do not delete immediately if audit or abuse investigation needs the record.
Operational checks
Useful metrics follow the state machine:
- issued-to-delivered latency, grouped by provider;
- delivered-to-confirmed conversion over a bounded window;
- duplicate idempotency-key rate;
- confirmation attempts after expiry;
- outbox age and retry count.
Log verification IDs, event IDs, and policy versions. Redact addresses, tokens, and message contents. A dashboard that only counts successful confirmations can hide a provider outage for several minutes, so alert on outbox age as well.
The implementation is not finished when the happy path works. Check a repeated issuance, a repeated confirmation, a late delivery, an expired token, a worker retry, and a transaction rollback. Those cases are where the state model earns its keep.
Common questions
Should a user be able to request another token?
Yes, but impose a cooldown and expire or cancel the previous active attempt. Otherwise several valid links create confusing support cases. The newest attempt can be the only one that changes the account state.
Is a separate mail service required?
No. The important boundary is the outbox and the delivery worker. The provider can change later without changing the REST API state transitions.
What should a test assert?
Assert the durable transition, not only that a message appeared. Check the token's single-use behavior, the expiry response, the account update, and the audit event. A staging inbox is just one observation in that chain.
Explicit states make email verification easier to reason about. PostgreSQL protects the invariants, idempotency protects retries, and the outbox keeps delivery honest. The result is a backend workflow that can explain what happened when the network, provider, or user behaves in an unexpected way.
Top comments (0)