Email verification is usually described as a token problem: create a token, send a message, and accept the link. In a production REST API, the harder problem is deciding who is allowed to send the message when a worker crashes, a request is retried, or two workers see the same pending row.
A useful pattern is to give every email job a short database lease. The lease turns an ambiguous background task into a small, observable contract. One worker owns the job for a limited time, another worker can recover it after the lease expires, and the verification endpoint remains idempotent.
This matters whether a team tests with a disposable email address or uses a real mailbox in staging. The inbox is not the reliability boundary. The job state and its ownership rules are.
Why email verification jobs need a lease
Imagine this sequence:
- The API creates an account and a verification job.
- Worker A reads the job and starts sending it.
- The worker loses its connection before marking the job as sent.
- A retry sees the same pending row and sends another message.
If the system only has pending and sent, it cannot distinguish an active worker from abandoned work. A long-running worker can also overlap with a recovery worker. That is how duplicate messages appear even when each individual component seems reasonable.
The lease adds two fields: locked_until and locked_by. A worker may claim a job only when no lease exists or the previous lease has expired. The lease is deliberately temporary. It is not a permanent ownership record, so a crashed process does not require manual cleanup.
For the token itself, use a separate expiration such as expires_at. An email job lease answers “who may process this delivery now?” A verification token answers “may this user still complete authentication?” Mixing those clocks creates confusing edge cases. An expiry model for email verification is useful here because the link and the delivery attempt have different lifetimes.
The database contract
PostgreSQL can enforce most of the important rules without a complicated queue service. A compact table might look like this:
CREATE TABLE email_jobs (
id uuid PRIMARY KEY,
user_id uuid NOT NULL,
kind text NOT NULL,
status text NOT NULL DEFAULT 'pending',
attempts integer NOT NULL DEFAULT 0,
locked_until timestamptz,
locked_by text,
available_at timestamptz NOT NULL DEFAULT now(),
last_error text,
created_at timestamptz NOT NULL DEFAULT now(),
CHECK (status IN ('pending', 'sending', 'sent', 'failed'))
);
The useful invariant is simple: a job in sending must have a current lease, while a pending job can be claimed when it is available. Add a unique constraint or idempotency key for the logical event as well. For example, an account should not create a second verification event just because the client repeated the signup request.
Keep the token hash and the delivery job separate from the user row. That lets the account remain valid when delivery is delayed, and it makes cleanup less risky. It also gives operations a place to record why a message failed without putting transient worker details into the authentication model.
Claiming work safely
The worker should claim one row in a short transaction. FOR UPDATE SKIP LOCKED prevents workers from waiting on each other, while the conditional update makes the lease acquisition explicit:
WITH next_job AS (
SELECT id
FROM email_jobs
WHERE status = 'pending'
AND available_at <= now()
AND (locked_until IS NULL OR locked_until < now())
ORDER BY created_at
FOR UPDATE SKIP LOCKED
LIMIT 1
)
UPDATE email_jobs AS job
SET status = 'sending',
locked_until = now() + interval '2 minutes',
locked_by = $1,
attempts = attempts + 1
FROM next_job
WHERE job.id = next_job.id
RETURNING job.*;
The transaction should commit before the worker calls the email provider. Holding a database lock across a network request is a bad trade: provider latency then becomes database contention. The lease is enough to express ownership while the external call happens.
After a successful provider response, update the row only when locked_by still matches the worker. If the lease expired and another worker reclaimed the job, the old worker must not overwrite the newer state. This check is small, but it prevents a late response from marking a newer attempt as sent.
Set available_at with backoff after transient failures. A permanent rejection should move to failed with a reason that support can understand. Do not retry an expired verification token forever; delivery retries and user eligibility are seperate decisions.
Keeping the REST API honest
The signup endpoint should return the account state and a stable event identifier, not promise that the email already arrived. A response like this is easier for clients to reason about:
{
"user_id": "8c9c2d3a-2f5d-4e6d-9a02-4f1a1dbb3d9a",
"verification": {
"status": "queued",
"event_id": "evt_01JEMAILVERIFY"
}
}
The verification endpoint should be idempotent too. If the token is valid, mark the user verified once and return the same successful outcome for later requests. If it is expired, return a response that tells the client to request a new message. Do not make the browser guess whether a timeout means “sent” or “not sent”.
This is also where a single source of truth for email checks helps. The API owns the state; the frontend renders it. Otherwise one screen may say “email sent” while the worker is still retrying, which is realy hard to debug from a user report.
Operational checklist
Before shipping the pattern, verify these cases:
- Two workers cannot claim the same pending row.
- A crashed worker's lease expires without an operator action.
- A late provider response cannot overwrite a newer attempt.
- The same signup idempotency key creates one verification event.
- Token expiry is checked independently from delivery retries.
- Metrics expose queued, sending, sent, failed, and expired counts.
- Logs include the event ID, but not the token or full email address.
During a test run, record the event ID and timestamps rather than relying on “latest message”. Notes like temp mailid or tempail often show up when a test harness has no stable correlation value. That small bit of evidence makes a failed delivery much easier to reproduce.
The lease is not a replacement for a proper queue at very high volume. It is a pragmatic PostgreSQL pattern for a service that needs retry-safe verification now. Keep ownership short, keep token expiry separate, and make every transition explainable. The result is a REST API that can recover from worker failures without turning every retry into another email.
Top comments (0)