emailverifier.dev
Posts

How to Prevent Duplicate Transactional Emails

| 7 min read | Usama Ejaz
One business event enters a durable outbox, where duplicate keyed retry paths converge through one idempotency gate; one email exits while the duplicate path stops.

Prevent duplicate transactional emails by assigning one stable idempotency key to the business event, inserting the email intent in the same database transaction as that event, and letting a worker claim the outbox row with a lease. Pass the same key to your email provider when it supports idempotent sends.

Those controls solve different failure windows. A unique key stops two API requests from creating two email intents. The outbox stops a committed order or password change from losing its email. A worker lease stops two workers from deliberately sending the same row at once.

There is still one honest limit: if the provider accepts the email and your worker crashes before recording that fact, a retry may send a second copy. You can close that last gap only when the downstream send recognizes the same idempotency key, or when you reconcile an authoritative provider result before retrying.

Why do duplicate emails happen?

The common bug is a dual write. An application commits business state in its database, then calls an email API. The two writes cannot normally share one transaction.

commit order ------ success
call email API ---- timeout

What happened?
  A. provider never accepted the request
  B. provider accepted it, but the response was lost

Retrying is correct in case A and risky in case B. Treating the timeout as success reverses the risk: it avoids a possible duplicate but may lose the receipt entirely.

AWS describes the transactional outbox as a way to resolve this dual-write problem by committing the business update and an outbox record together. Its guidance also warns that the publishing process can produce duplicate messages, so the receiving side still needs idempotent processing.

Email transport has a similar ambiguity later in the path. RFC 5321 notes that timeouts around the final SMTP reply can produce duplicate messages. Application idempotency cannot remove every duplicate that could occur after handoff. It can prevent your own command, queue, and worker from multiplying a single intent.

Which idempotency key should you use?

Derive the key from the event that is allowed to send exactly one kind of email, not from an HTTP request or worker attempt.

receipt:order_8c2f
password-changed:user_42:change_019
trial-ending:account_91:2026-10-20

A random key generated inside each retry is useless because every attempt looks new. An address alone is too broad because one person can legitimately receive many receipts. A template name alone is also too broad because many events can use the same template.

I store a hash of the immutable send payload beside the key. If the same key arrives with the same hash, I return the existing result. If it arrives with different recipient, template, or semantic data, I reject it as a conflict. Silently accepting changed data under an old key hides a programming error.

Keep this key separate from the email Message-ID. A stable Message-ID can help correlate copies, but mailbox threading and duplicate handling do not make it a safe database constraint. Your idempotency key belongs to the business operation.

How does the outbox remove the first crash gap?

Create the business record and email intent in one local database transaction. Here is a compact PostgreSQL shape:

CREATE TABLE email_outbox (
  id uuid PRIMARY KEY,
  idempotency_key text UNIQUE NOT NULL,
  payload_hash text NOT NULL,
  recipient text NOT NULL,
  template text NOT NULL,
  template_data jsonb NOT NULL,
  attempts integer NOT NULL DEFAULT 0,
  locked_until timestamptz,
  sent_at timestamptz,
  provider_message_id text,
  created_at timestamptz NOT NULL DEFAULT now()
);

BEGIN;

UPDATE orders
SET paid_at = now()
WHERE id = 'order_8c2f' AND paid_at IS NULL;

INSERT INTO email_outbox (
  id, idempotency_key, payload_hash,
  recipient, template, template_data
)
VALUES (
  gen_random_uuid(), 'receipt:order_8c2f', 'sha256:...',
  'buyer@example.com', 'receipt', '{"orderId":"order_8c2f"}'
)
ON CONFLICT (idempotency_key) DO NOTHING;

COMMIT;

The production transaction should check that the order transition actually occurred before inserting the intent. On a conflict, fetch the existing row and compare payload_hash. PostgreSQL documents ON CONFLICT as the atomic alternative to a unique-constraint error, including under concurrency.

If the transaction rolls back, neither change exists. If it commits, the email intent remains available even when the application process dies before a provider call. That is the guarantee the outbox adds. It does not mean the external send joins the database transaction.

How should multiple workers claim email safely?

Claim a small batch inside a short transaction, set a lease, commit, and only then perform the network call. Do not hold a database transaction open while waiting for the provider.

WITH candidate AS (
  SELECT id
  FROM email_outbox
  WHERE sent_at IS NULL
    AND (locked_until IS NULL OR locked_until < now())
  ORDER BY created_at
  FOR UPDATE SKIP LOCKED
  LIMIT 1
)
UPDATE email_outbox AS e
SET locked_until = now() + interval '2 minutes',
    attempts = attempts + 1
FROM candidate
WHERE e.id = candidate.id
RETURNING e.*;

PostgreSQL explicitly calls out SKIP LOCKED for multiple consumers of a queue-like table. The lease handles a worker that dies after claiming: another worker can recover the row after locked_until.

After a successful provider response, record sent_at and the provider's message identifier. On a definite rejection, record the terminal failure. On a timeout, keep the attempt and diagnostic, then move into the ambiguous path instead of pretending the send definitely failed.

What happens at each crash point?

Failure pointDurable evidenceSafe next action
Before the database commitNo business change and no outbox rowRetry the command with the same key
After commit, before worker claimOne pending outbox rowLet a worker claim it
After claim, before provider callUnsent row with an expiring leaseRetry after the lease expires
After provider acceptance, before sent_atAmbiguous rowReconcile or retry with downstream idempotency
After sent_atCompleted rowDo not send again

The fourth row is the one many “exactly once” diagrams skip. Your database cannot know whether an external system accepted a request whose response never arrived. A permanent local sent flag written before the call can lose email. Written after the call, it leaves a small duplicate window.

If your provider accepts an idempotency key, pass the outbox key unchanged on every attempt and confirm its retention and conflict rules. If it does not, check whether its API exposes a lookup by your custom reference. Otherwise choose an explicit retry policy for ambiguous attempts and make the duplicate risk observable.

What did the failure lab prove?

I ran a six-fixture model of the pipeline. It checked repeated commands, payload conflicts, a crash before the provider call, two workers competing for one row, and the crash immediately after provider acceptance.

PASS same command creates one outbox intent
PASS same key with changed payload is rejected
PASS crash before provider call remains retryable
PASS one worker claims one pending row
PASS post-acceptance crash can duplicate without downstream dedupe
PASS downstream idempotency closes the post-acceptance gap
6/6 fixtures passed

The test is a state model, not a substitute for running fault injection against your database and provider adapter. In production I would add concurrent database tests, kill the worker at each boundary, delay provider responses beyond the client timeout, and verify metrics for pending, leased, ambiguous, sent, and terminal rows.

The same rule applies to inbound events. Authenticate and deduplicate a webhook before it changes state, as the webhook security guide shows. Do not let a repeated “delivered” or “bounced” callback enqueue a second customer email.

Does email verification prevent duplicate sends?

No. Verification helps you assess address, domain, and mailbox evidence before sending. It does not coordinate retries or prove that a previous API call was accepted. Keep verification outcome, send intent, provider handoff, SMTP delivery, and later bounce evidence as separate states.

This distinction also keeps retry behavior honest. An application timeout is not an SMTP 4xx, and neither one proves a mailbox is invalid. The SMTP retry-queue guide owns what happens after a mail server temporarily defers a message. This design owns whether your application creates and hands off the transactional email more than once.

So my production rule is simple: one stable key per business email, one outbox row committed with the business event, one leased worker claim at a time, and one explicit policy for the post-acceptance gap. That will not promise impossible end-to-end exactly-once delivery. It will give you a system whose remaining duplicate risk is narrow, visible, and testable.

Continue reading