Email verification is often treated as one operation: create a user, generate a token, and send an email before returning from the API request. That shape is easy to write, but it gives the request handler too much responsibility.
If the database commit succeeds and the email provider call fails, the user exists without a usable verification message. If the provider accepts the message and the request times out, a retry may send a duplicate. The resulting bug is not really an email bug. It is a transaction-boundary bug in the authentication workflow.
Why sending inside the request is a reliability bug
An HTTP request should decide whether the user and its verification intent were stored. It should not also be the only place where delivery can happen. Provider latency, connection resets, and process restarts are normal failure modes, and a request handler cannot reliably recover from all of them.
The dangerous sequence looks like this:
- Insert the user.
- Create a verification token.
- Call the email provider.
- Commit the database transaction.
If step three succeeds but step four fails, the email has escaped while the database may roll back. The reverse ordering is just as awkward: a commit can succeed after the provider call was never made. A queue is usefull only when the database records the intent with the business change.
The PostgreSQL outbox boundary
The outbox pattern puts a durable event row in the same PostgreSQL transaction as the authentication state. The API creates the account, stores a hashed verification token, and inserts an email.verification.requested row. PostgreSQL commits all three records together or none of them.
The request can then return a stable response such as “check your email.” A separate worker reads pending outbox rows and talks to the provider. When the worker process crashs, another worker can safely continue from the durable row instead of guessing what the request did.
For a signup flow, the boundary can be represented with a small table:
CREATE TABLE email_outbox (
id uuid PRIMARY KEY,
event_type text NOT NULL,
aggregate_id uuid NOT NULL,
dedupe_key text NOT NULL UNIQUE,
payload jsonb NOT NULL,
status text NOT NULL DEFAULT 'pending',
attempts integer NOT NULL DEFAULT 0,
available_at timestamptz NOT NULL DEFAULT now(),
sent_at timestamptz,
last_error text,
created_at timestamptz NOT NULL DEFAULT now()
);
The unique dedupe_key is important. For example, use a key derived from the verification request id, not from a timestamp. A database retry can then attempt the insert again without creating a second delivery intent.
A small schema and worker contract
The worker should claim a bounded batch using FOR UPDATE SKIP LOCKED, set a lease or processing state, and commit that claim before making the network call. It should not hold a PostgreSQL row lock while waiting for the provider.
After a successful provider response, mark the outbox row as sent and save the provider message id. If the provider call fails, increment attempts, save a redacted error, and calculate a future available_at. The worker should not send before the transaction is commit; the outbox row is the signal that the intent is durable.
There is an unavoidable edge case between the provider accepting a message and the worker recording sent. A process can stop in that gap, so the system is normally at-least-once. Idempotency must therefore exist at the provider boundary when possible, or the application must tolerate a rare duplicate verification email.
Keep the payload small and explicit. It can include the recipient, a template name, a verification request id, and an expiry timestamp. Do not put a raw token into logs or general-purpose job metadata. The token should be rendered only in the controlled sending path.
Idempotency for retries and authentication state
The verification endpoint needs its own state machine. A token can be pending, consumed, or expired; a successful verification changes it once. These records gives the API a clear answer when a user clicks the same link twice, while the outbox handles the separate question of whether the message was delivered.
When a user requests another email, create a new request id and invalidate or supersede the old request according to the product policy. Do not silently reuse an old outbox row just because the address is the same. The send operation and the authentication decision are related, but they are not the same state transition.
For approval or invitation flows, write the final recipient and template variables into the event before the worker starts. This follows the same idea as freezing the approval plan before sending: a later retry should replay a known decision, not rebuild it from mutable application data.
Testing the delivery boundary
Test the database transaction separately from the provider adapter. A rollback should leave no user, token, or outbox row. A committed signup should leave exactly one deduplicated event. A worker retry should preserve the verification request id and should not turn a transient timeout into a new authentication identity.
For end-to-end tests, use an isolated mailbox and assert on the request id or another safe correlation value. A email-test waits that explain themselves workflow makes it easier to distinguish a slow provider from a missing outbox event. For staging checks, a temp mail email can keep test identities away from real customer addresses, but it should not replace assertions on your own outbox and token state.
Test names and search aliases sometimes contain phrases such as tamp mail com or dummy e mail. Keep those as plain text if they are useful for locating old fixtures; they are not a substitute for a stable correlation id.
The most useful observability fields are the outbox id, dedupe key, aggregate id, attempt number, provider message id, and final status. Redact addresses and tokens according to the environment policy. If a worker retries is visible without the original error class, operators will have a hard time separating provider failures from application bugs.
Questions teams usually ask
Does an outbox guarantee exactly-once email delivery?
No. It makes the business event durable and supports safe retries, but the handoff to an external provider still has an uncertainty window. Design for at-least-once processing and use provider idempotency when it is available.
Should the API wait for the email to be sent?
Usually no. Return after the account and outbox event commit. Expose a delivery status separately if the product needs it, and let the worker own provider latency.
When is a full message broker justified?
Start with a PostgreSQL outbox when the service already depends on PostgreSQL and the delivery volume is moderate. A broker may become useful for cross-service fan-out, independent consumer scaling, or stronger operational isolation, but the transactional outbox remains a useful write boundary.
An email verification flow is easier to operate when the database records intent before the network call. The outbox does not make delivery perfect; it makes failure visible, retryable, and tied to an authentication request. That is a much stronger contract then asking an HTTP handler to be both a transaction coordinator and an email worker.
Top comments (0)