Verification emails look simple until retries, worker restarts, and impatient users all hit the same POST /verify-email flow. In most systems the real bug is not SMTP delivery. It is missing idempotency rules between your REST API, job table, and auth state. I have seen teams patch this with boolean flags, and it works... right until traffic spikes a bit.
The failure mode is usually a race, not SMTP
The common sequence is boring:
- User signs up.
- API inserts the account row.
- API or worker enqueues a verification email.
- User clicks resend because the first message is slow.
- Two workers now think they should send the same intent.
If your system stores only sent=true, you cannot tell whether a send is in progress, already committed, or safe to retry. That gap creates duplicate emails, stale tokens, and support noise. It also makes debugging kinda annoying because logs often show "success" for both attempts.
What helped me most was treating verification email delivery as a state machine owned by the backend, not as a side effect hidden behind a mail client wrapper.
A small state model fixes most duplicate sends
For verification flows, I like one row per delivery intent with these fields:
user_id-
purposesuch assignup_verification idempotency_key-
stateinpending | claimed | sent | consumed | failed token_hash-
claimed_at,sent_at,failure_code
The rule is simple: the API creates or reuses the current intent, and the worker may only send after it atomically claims the row. That removes the "did two workers race?" question from app code and pushes it into data consistency, where PostgreSQL is much better equiped to help.
I also keep the idempotency key stable for a short replay window. For example, if the same authenticated user hits resend within five minutes, the API can return 202 Accepted and point to the existing intent instead of minting a new token each time. That makes auth behavior easier to reason about, and users get a more predictable flow.
Node.js example: claim work before sending
Here is the shape I prefer in a Node.js service:
async function claimVerificationIntent(db, userId) {
const result = await db.query(`
update email_delivery_intents
set state = 'claimed',
claimed_at = now()
where id = (
select id
from email_delivery_intents
where user_id = $1
and purpose = 'signup_verification'
and state = 'pending'
order by created_at asc
for update skip locked
limit 1
)
returning id, token_hash, idempotency_key
`, [userId]);
return result.rows[0] ?? null;
}
Two details matter more than the SQL syntax:
-
for update skip lockedprevents workers from claiming the same pending row. - The state transition happens before the email provider call, so your retry logic knows whether it is replaying a claim or creating new work.
After a successful send, update the same row to sent and persist the provider message id if you have one. If the provider times out, do not blindly insert a second intent. First inspect whether the original claim may still finish. This is where many APIs get a little messy and start spraying duplicates.
On the API side, I keep resend logic explicit:
if (existingIntent && existingIntent.state !== 'failed') {
return { status: 202, intentId: existingIntent.id };
}
await createPendingIntent({ userId, purpose: 'signup_verification' });
return { status: 202 };
This approach fits nicely with other backend patterns too. If you already use an outbox table for domain events, the verification intent can either become the outbox record or reference it directly. The point is to keep one source of truth for "should an email be sent?".
Where a temporary email address helps in testing
I do not use email sandboxes only for UI tests. They are useful for backend contract checks, especially when you need to prove that one resend request still maps to one effective email intent. A temporary email address is handy when you want fresh inbox state per test case without leaking messages into shared accounts.
For example, one practical pattern is:
- create a user
- trigger signup verification twice with the same auth context
- assert one
senttransition in the database - inspect one inbox for one token
That kind of workflow pairs well with isolated inbox checks and safer invite email debugging, because both push teams toward cleaner evidence when they debug email behavior.
If you need a disposable inbox during local development, keep it a tiny part of the system design rather than the center of it. I sometimes use tools like temp mail so for quick manual verification, but the stronger win is still your database contract. Even if someone types tempail mail into a note or test case, the backend should remain deterministic.
Q&A
Should I create a new token on every resend?
Not always. If the previous token is still valid and the user is within a short replay window, reusing the same intent is often simpler and less error-prone. Rotate only when your security model or abuse controls actually require it.
What should the API return to clients?
I prefer 202 Accepted for resend operations because the actual delivery is asynchronous. The response can include a stable intent identifier, which is more useful for tracing than a vague "email sent" message that maybe was not sent yet.
Does this pattern only help authentication flows?
No. Password reset, invite acceptance, magic links, and billing notifications all benefit from the same model. Once you separate intent creation from send execution, the rest of the system gets easier to test and reason about, even if the first version feels a bit more work.
The boring conclusion is the useful one: model email delivery as backend state, claim work before sending, and keep resend semantics explicit. Do that, and a lot of auth-email flakiness just... stops happening.
Top comments (0)