<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: kevindev</title>
    <description>The latest articles on DEV Community by kevindev (@kevindev27).</description>
    <link>https://dev.to/kevindev27</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4012755%2F59157d97-36db-4340-addc-f6c6776820fb.png</url>
      <title>DEV Community: kevindev</title>
      <link>https://dev.to/kevindev27</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/kevindev27"/>
    <language>en</language>
    <item>
      <title>Idempotent Verification Emails in Node APIs</title>
      <dc:creator>kevindev</dc:creator>
      <pubDate>Sat, 25 Jul 2026 05:24:07 +0000</pubDate>
      <link>https://dev.to/kevindev27/idempotent-verification-emails-in-node-apis-4i4c</link>
      <guid>https://dev.to/kevindev27/idempotent-verification-emails-in-node-apis-4i4c</guid>
      <description>&lt;p&gt;Verification emails look simple until retries, worker restarts, and impatient users all hit the same &lt;code&gt;POST /verify-email&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure mode is usually a race, not SMTP
&lt;/h2&gt;

&lt;p&gt;The common sequence is boring:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;User signs up.&lt;/li&gt;
&lt;li&gt;API inserts the account row.&lt;/li&gt;
&lt;li&gt;API or worker enqueues a verification email.&lt;/li&gt;
&lt;li&gt;User clicks resend because the first message is slow.&lt;/li&gt;
&lt;li&gt;Two workers now think they should send the same intent.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If your system stores only &lt;code&gt;sent=true&lt;/code&gt;, 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  A small state model fixes most duplicate sends
&lt;/h2&gt;

&lt;p&gt;For verification flows, I like one row per delivery intent with these fields:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;user_id&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;purpose&lt;/code&gt; such as &lt;code&gt;signup_verification&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;idempotency_key&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;state&lt;/code&gt; in &lt;code&gt;pending | claimed | sent | consumed | failed&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;token_hash&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;claimed_at&lt;/code&gt;, &lt;code&gt;sent_at&lt;/code&gt;, &lt;code&gt;failure_code&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;202 Accepted&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Node.js example: claim work before sending
&lt;/h2&gt;

&lt;p&gt;Here is the shape I prefer in a Node.js service:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;claimVerificationIntent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`
    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
  `&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details matter more than the SQL syntax:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;for update skip locked&lt;/code&gt; prevents workers from claiming the same pending row.&lt;/li&gt;
&lt;li&gt;The state transition happens before the email provider call, so your retry logic knows whether it is replaying a claim or creating new work.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;After a successful send, update the same row to &lt;code&gt;sent&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;On the API side, I keep resend logic explicit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;existingIntent&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;existingIntent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;failed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;202&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;intentId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;existingIntent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;createPendingIntent&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;purpose&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;signup_verification&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;202&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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?".&lt;/p&gt;

&lt;h2&gt;
  
  
  Where a temporary email address helps in testing
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;temporary email address&lt;/code&gt; is handy when you want fresh inbox state per test case without leaking messages into shared accounts.&lt;/p&gt;

&lt;p&gt;For example, one practical pattern is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;create a user&lt;/li&gt;
&lt;li&gt;trigger signup verification twice with the same auth context&lt;/li&gt;
&lt;li&gt;assert one &lt;code&gt;sent&lt;/code&gt; transition in the database&lt;/li&gt;
&lt;li&gt;inspect one inbox for one token&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That kind of workflow pairs well with &lt;a href="https://dev.to/ryanlee91/how-i-test-nodejs-digest-emails-without-shared-inbox-noise-54fh"&gt;isolated inbox checks&lt;/a&gt; and &lt;a href="https://dev.to/bitheirstake/safer-invite-email-debugging-notes-5ao6"&gt;safer invite email debugging&lt;/a&gt;, because both push teams toward cleaner evidence when they debug email behavior.&lt;/p&gt;

&lt;p&gt;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 &lt;a href="https://tempmailso.com" rel="noopener noreferrer"&gt;temp mail so&lt;/a&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Q&amp;amp;A
&lt;/h2&gt;

&lt;h2&gt;
  
  
  Should I create a new token on every resend?
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What should the API return to clients?
&lt;/h2&gt;

&lt;p&gt;I prefer &lt;code&gt;202 Accepted&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does this pattern only help authentication flows?
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>restapi</category>
      <category>authentication</category>
      <category>node</category>
    </item>
    <item>
      <title>Lease Email Jobs Before Your Worker Sends</title>
      <dc:creator>kevindev</dc:creator>
      <pubDate>Mon, 20 Jul 2026 11:24:44 +0000</pubDate>
      <link>https://dev.to/kevindev27/lease-email-jobs-before-your-worker-sends-mk3</link>
      <guid>https://dev.to/kevindev27/lease-email-jobs-before-your-worker-sends-mk3</guid>
      <description>&lt;p&gt;Duplicate auth emails are rarely caused by one big bug. More often, the API is fine, the queue is fine, and the worker logic is almost fine. Then a timeout lands between "picked job" and "marked sent", another worker retries, and a user gets two reset links. That kind of issue is annoyng because each individual component looks healthy in isolation.&lt;/p&gt;

&lt;p&gt;When you maintain Authentication flows, the fix I trust most is not more retry logic. It is a lease on the email job itself. Before a worker sends anything, it claims the row for a short window, records who owns it, and only then continues. The result is much calmer behavior under retries, crashes, and horizontal scaling.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why email workers still duplicate sends
&lt;/h2&gt;

&lt;p&gt;Most duplicate sends happen in one of these moments:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a worker crashes after calling the provider but before updating the database&lt;/li&gt;
&lt;li&gt;two workers poll the same ready job at nearly the same time&lt;/li&gt;
&lt;li&gt;a retry policy has no clear ownership boundary&lt;/li&gt;
&lt;li&gt;delivery polling from a &lt;code&gt;tempail mail&lt;/code&gt; inbox gets mixed with the original send timeline&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is where a lease is more useful than a plain status column. &lt;code&gt;pending&lt;/code&gt; and &lt;code&gt;sent&lt;/code&gt; are not enough once you have more than one consumer. You also need "claimed until" and "claimed by", so another worker can see that the job is already in progress and back off.&lt;/p&gt;

&lt;p&gt;If you already use &lt;a href="https://dev.to/kevindev27/postgresql-outboxes-for-signup-emails-3n7m"&gt;database-backed signup delivery&lt;/a&gt;, leasing is a natural next step. The outbox tells you what should be sent. The lease tells you who is allowed to send it right now.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lease model I prefer
&lt;/h2&gt;

&lt;p&gt;My baseline table usually has these fields:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;id&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;template&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;recipient&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;payload&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;status&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;lease_owner&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;lease_expires_at&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;sent_at&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;attempt_count&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The key rule is simple: only rows with an expired lease, or no lease at all, are eligible to be claimed. PostgreSQL makes this clean with &lt;code&gt;FOR UPDATE SKIP LOCKED&lt;/code&gt;, which is designed for concurrent workers that should not block each other unnecessarily (&lt;a href="https://www.postgresql.org/docs/current/sql-select.html" rel="noopener noreferrer"&gt;https://www.postgresql.org/docs/current/sql-select.html&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;I also keep the lease short, usually 30 to 90 seconds. Long enough for one delivery attempt, short enough that a crashed worker does not stall the pipeline for ages. If your provider latency is high, measure first instead of guessing. Google's distributed systems guidance has long pushed engineers toward observable time budgets rather than wishful defaults (&lt;a href="https://sre.google/sre-book/monitoring-distributed-systems/" rel="noopener noreferrer"&gt;https://sre.google/sre-book/monitoring-distributed-systems/&lt;/a&gt;). Same lesson here, realy.&lt;/p&gt;

&lt;h2&gt;
  
  
  A PostgreSQL query that claims work safely
&lt;/h2&gt;

&lt;p&gt;Here is the shape I like for claiming one job:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;next_job&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;
  &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;email_jobs&lt;/span&gt;
  &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'pending'&lt;/span&gt;
    &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lease_expires_at&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;or&lt;/span&gt; &lt;span class="n"&gt;lease_expires_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
  &lt;span class="k"&gt;order&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;update&lt;/span&gt; &lt;span class="n"&gt;skip&lt;/span&gt; &lt;span class="n"&gt;locked&lt;/span&gt;
  &lt;span class="k"&gt;limit&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;update&lt;/span&gt; &lt;span class="n"&gt;email_jobs&lt;/span&gt; &lt;span class="n"&gt;ej&lt;/span&gt;
&lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="n"&gt;lease_owner&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;lease_expires_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'45 seconds'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;attempt_count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;attempt_count&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;next_job&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;ej&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;next_job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;
&lt;span class="n"&gt;returning&lt;/span&gt; &lt;span class="n"&gt;ej&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ej&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;recipient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ej&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;template&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ej&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ej&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;attempt_count&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three details matter a lot:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Claim and update in one statement.&lt;/li&gt;
&lt;li&gt;Increment attempts when the lease is taken, not after the send.&lt;/li&gt;
&lt;li&gt;Return the payload immediately so the worker does not re-read stale state.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That contract makes Node.js workers much easier to reason about. A worker either owns the lease or it does not. There is less fuzzy middle state, which is where duplicated behavior loves to live.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to keep retries boring
&lt;/h2&gt;

&lt;p&gt;After the send succeeds, update the row with &lt;code&gt;status = 'sent'&lt;/code&gt;, clear the lease, and store a provider message ID if you have one. If the send fails with a retryable error, leave &lt;code&gt;status&lt;/code&gt; as &lt;code&gt;pending&lt;/code&gt; and let the lease expire naturally. If it fails permanently, mark it &lt;code&gt;failed&lt;/code&gt; and stop looping.&lt;/p&gt;

&lt;p&gt;For Auth flows, I also recommend tying the email job to a server-side token version, not just a raw recipient. If two password reset requests arrive quickly, you want the newest token to invalidate the older one, even if an older job sneaks through later. The delivery pipeline and the token model should protect each other.&lt;/p&gt;

&lt;p&gt;When you test this behavior, isolated inboxes help, but they are only supporting evidence. I prefer to combine the lease audit trail with &lt;a href="https://dev.to/pong1965/contract-test-api-emails-in-github-actions-45f8"&gt;contract checks around email APIs&lt;/a&gt;, then use a scoped inbox from &lt;a href="https://tempmailso.com" rel="noopener noreferrer"&gt;temp mail so&lt;/a&gt; when I want to verify that exactly one human-visible message arrived. That balance keeps the design backend-first instead of mailbox-first.&lt;/p&gt;

&lt;p&gt;A few logs make the system easier to trust:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;job id&lt;/li&gt;
&lt;li&gt;lease owner&lt;/li&gt;
&lt;li&gt;lease expiration&lt;/li&gt;
&lt;li&gt;token version&lt;/li&gt;
&lt;li&gt;provider message id&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If one of those is missing, debugging gets slower than it should be. You can still fix the incident, but you will burn more team energy doing it, which is never great and kinda avoidable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Q&amp;amp;A
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why not just mark rows as processing?
&lt;/h3&gt;

&lt;p&gt;Because &lt;code&gt;processing&lt;/code&gt; without an expiry becomes sticky after crashes. A lease says "processing until this exact time", which is much safer.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should the lease duration match provider timeout?
&lt;/h3&gt;

&lt;p&gt;Close to it, yes, but leave a little margin for serialization and DB update time. Do not make it huge "just in case". That tends to hide dead workers.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does this replace idempotency keys?
&lt;/h3&gt;

&lt;p&gt;No. Provider-side idempotency is still useful. The lease protects your worker coordination layer; provider idempotency protects the outbound call. Together they fail much more gracefuly than either one alone.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>postgres</category>
      <category>authentication</category>
      <category>restapi</category>
    </item>
    <item>
      <title>Replay Windows for Email Change Tokens</title>
      <dc:creator>kevindev</dc:creator>
      <pubDate>Mon, 20 Jul 2026 02:24:37 +0000</pubDate>
      <link>https://dev.to/kevindev27/replay-windows-for-email-change-tokens-1fm5</link>
      <guid>https://dev.to/kevindev27/replay-windows-for-email-change-tokens-1fm5</guid>
      <description>&lt;p&gt;Changing an account email looks simple in product mocks, but the backend path is easy to get wrong. The request touches identity, notification, and recovery flows at the same time, so a small retry bug can leave a user with two valid links or a silently stale address. I have seen this happen in otherwise careful services, especialy when the API treated "token created" as the end of the job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why email change flows replay more than teams expect
&lt;/h2&gt;

&lt;p&gt;Replay bugs usually show up at the boundary between the authenticated request and the later confirmation click. A &lt;code&gt;POST /account/email-change&lt;/code&gt; call writes a pending address, creates a token, and sends a message. Then the user opens the mail client twice, the gateway retries once, or support triggers a resend while the first token is still alive.&lt;/p&gt;

&lt;p&gt;Three patterns cause most of the mess:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The service stores only the newest token, so it cannot explain which link was clicked.&lt;/li&gt;
&lt;li&gt;Resend logic invalidates too late, leaving a short period where two links both work.&lt;/li&gt;
&lt;li&gt;Audit logs show "email updated" but not which request or token actually did it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For auth systems, that is not just annoying. Email is often the recovery channel and the notification channel, so a replay bug can realy confuse incident response. When a user says "I clicked the older email by mistake", the system should have a crisp answer instead of a shrug.&lt;/p&gt;

&lt;h2&gt;
  
  
  The token contract I use in production
&lt;/h2&gt;

&lt;p&gt;The contract I like is boring on purpose:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;users.email&lt;/code&gt; stays unchanged until the confirmation click succeeds.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;email_change_requests&lt;/code&gt; stores the old address, the proposed address, a token hash, expiry, and a &lt;code&gt;superseded_at&lt;/code&gt; timestamp.&lt;/li&gt;
&lt;li&gt;Every resend creates a new row and supersedes earlier still-open rows for the same user.&lt;/li&gt;
&lt;li&gt;The confirmation endpoint accepts exactly one active row inside a small replay-safe transaction.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last point matters more than teams first think. If two browser tabs submit the same token within a second, I want one winner and one clean "already used" response. If a user opens an older link after a resend, I want "superseded" and not a vague invalid-token page.&lt;/p&gt;

&lt;p&gt;I also keep the reason code close to the row. States like &lt;code&gt;pending&lt;/code&gt;, &lt;code&gt;confirmed&lt;/code&gt;, &lt;code&gt;expired&lt;/code&gt;, and &lt;code&gt;superseded&lt;/code&gt; are enough for most products. You do not need a large workflow engine here, but you do need a lifecycle that support and security can inspect in minuts.&lt;/p&gt;

&lt;p&gt;One small practice has saved me more than once: never reuse the same row for a resend. A fresh row keeps the audit trail legible and avoids state that is slighly impossible to reason about later.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Node.js and PostgreSQL implementation
&lt;/h2&gt;

&lt;p&gt;My write path usually starts by locking the user row, then creating a pending request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;trx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;one&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`select id, email
     from users
     where id = $1
     for update`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;trx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;none&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`update email_change_requests
     set superseded_at = now()
     where user_id = $1
       and confirmed_at is null
       and superseded_at is null
       and expires_at &amp;gt; now()`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;trx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;none&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`insert into email_change_requests
      (user_id, current_email, next_email, token_hash, expires_at, request_id)
     values ($1, $2, $3, $4, now() + interval '20 minutes', $5)`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;nextEmail&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tokenHash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;requestId&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The confirmation path is where the replay guard lives. I normally update the request row and the user row in one transaction, with a predicate that only matches an active token once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;claimed&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;update&lt;/span&gt; &lt;span class="n"&gt;email_change_requests&lt;/span&gt;
     &lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="n"&gt;confirmed_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
   &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;token_hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
     &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;confirmed_at&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;
     &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;superseded_at&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;
     &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;expires_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="n"&gt;returning&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;next_email&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;update&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;
   &lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;claimed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;next_email&lt;/span&gt;
  &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;claimed&lt;/span&gt;
 &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;claimed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;
&lt;span class="n"&gt;returning&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that second statement returns zero rows, I look up the request state and return a precise response. &lt;code&gt;410 Gone&lt;/code&gt; works well for expired or superseded tokens; &lt;code&gt;409 Conflict&lt;/code&gt; is reasonable for a token already consumed. The important thing is consistency, because clients and support tooling both start to depend on it.&lt;/p&gt;

&lt;p&gt;The reason I prefer this design in a REST API is that it scales without extra magic. PostgreSQL handles the concurrency. Node.js just carries request ids, auth context, and metrics. There are fewer hidden dependancies, which makes on-call debugging calmer.&lt;/p&gt;

&lt;p&gt;According to the 2024 Verizon Data Breach Investigations Report, stolen credentials and auth abuse remain a major factor in real incidents, which is why I do not like fuzzy confirmation flows around account identity changes: &lt;a href="https://www.verizon.com/business/resources/reports/dbir/" rel="noopener noreferrer"&gt;https://www.verizon.com/business/resources/reports/dbir/&lt;/a&gt;. Email change is not the only control, but it is part of the trust boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I verify in staging before shipping
&lt;/h2&gt;

&lt;p&gt;I do not stop at "mail arrived". For this feature, I want four checks:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A first token confirms the change and updates the account exactly once.&lt;/li&gt;
&lt;li&gt;A resend marks the older token as superseded before the new message is sent.&lt;/li&gt;
&lt;li&gt;A double-click on the same confirmation link produces one success and one deterministic failure.&lt;/li&gt;
&lt;li&gt;Audit records join cleanly by user id, request id, and token state.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This is where isolated inboxes help, even if the article itself is not about email tooling. Teams often leave fixture labels like &lt;code&gt;tempail mail&lt;/code&gt; or &lt;code&gt;tamp mail com&lt;/code&gt; in staging helpers, and that is usually a sign the test path grew organically rather than intentionally. I try to keep one inbox per test run and one request id per email-change attempt.&lt;/p&gt;

&lt;p&gt;If you already have &lt;a href="https://dev.to/kevindev27/password-reset-emails-without-queue-drift-8ed"&gt;reset delivery drift checks&lt;/a&gt; in place, reuse the same assertion style here. The browser test should not just open the latest message it sees. It should target the exact request id or scenario id. That matches the same thinking behind &lt;a href="https://dev.to/ryanlee91/react-email-tests-with-stable-scenario-ids-2ekk"&gt;stable inbox scenario ids&lt;/a&gt;, and it makes flaky auth tests a lot easier to explain.&lt;/p&gt;

&lt;p&gt;One more thing: emit a support-facing event when a token is superseded. Not because the product UI always needs it, but because the audit trail often does. When somebody says "your system changed my address without warning", that breadcrumb matters a ton.&lt;/p&gt;

&lt;h2&gt;
  
  
  Q&amp;amp;A
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Should I invalidate older email-change tokens immediately on resend?
&lt;/h3&gt;

&lt;p&gt;Yes, if the user intent is "use the latest email only". Keeping parallel valid tokens almost never helps, and it makes abuse analysis harder.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need a separate table for email-change requests?
&lt;/h3&gt;

&lt;p&gt;I think so. Reusing a generic token table can work, but teams often regret it once they need better audit detail, clearer states, or support tooling.&lt;/p&gt;

&lt;h3&gt;
  
  
  What if the user changes the target address twice in one session?
&lt;/h3&gt;

&lt;p&gt;Create a new request each time and supersede the old one. That keeps the flow honest and avoids teh weird case where one confirmation email mutates into another meaning.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>authentication</category>
      <category>restapi</category>
      <category>node</category>
    </item>
    <item>
      <title>PostgreSQL Outboxes for Signup Emails</title>
      <dc:creator>kevindev</dc:creator>
      <pubDate>Sun, 19 Jul 2026 14:24:40 +0000</pubDate>
      <link>https://dev.to/kevindev27/postgresql-outboxes-for-signup-emails-51b6</link>
      <guid>https://dev.to/kevindev27/postgresql-outboxes-for-signup-emails-51b6</guid>
      <description>&lt;p&gt;When signup emails start missing in production, the bug is usualy not inside the SMTP provider. It is often in the handoff between your &lt;code&gt;REST API&lt;/code&gt;, your database write, and the worker that sends the message later. That is why I keep coming back to a PostgreSQL outbox design for signup mail instead of pushing directly to a queue in the request path.&lt;/p&gt;

&lt;p&gt;The reason is boring in a good way. If the user row commits but the queue publish fails, support gets a "created account, no email" ticket. If the client retries during a slow network hop, you may enqueue two welcome messages. If staging uses random inboxes and nobody ties them back to one request id, debugging gets messy real fast. An outbox row makes those cases more tractable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why signup email sends fail in boring ways
&lt;/h2&gt;

&lt;p&gt;The request flow looks simple:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;code&gt;POST /signup&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;create user&lt;/li&gt;
&lt;li&gt;generate verification token&lt;/li&gt;
&lt;li&gt;queue email job&lt;/li&gt;
&lt;li&gt;return &lt;code&gt;201&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;What breaks is not the happy path, it is the seam between steps 3 and 4. Queue publish calls can timeout after your transaction committed. API clients can retry even when the first response was already on the wire. Workers can process the same job twice after a crash. None of that is dramatic, but it adds the kind of edge-case noise engineers hate cleaning up at 2 a.m.&lt;/p&gt;

&lt;p&gt;I also see staging searches get muddy because people leave notes like &lt;code&gt;tamp mail com&lt;/code&gt; or &lt;code&gt;temp gamil com&lt;/code&gt; in tickets and shell history. That is not the root cause, but it is a sign the verification process is fuzzy and too dependent on manual inbox chasing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The PostgreSQL outbox shape I keep coming back to
&lt;/h2&gt;

&lt;p&gt;I prefer storing the email intent in the same transaction as the user and token write. A minimal outbox table can be:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;email_outbox&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="n"&gt;bigserial&lt;/span&gt; &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;event_type&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;aggregate_id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;dedupe_key&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;unique&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="n"&gt;jsonb&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="s1"&gt;'pending'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;available_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="n"&gt;sent_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important field is &lt;code&gt;dedupe_key&lt;/code&gt;. For signup mail, I usually build it from a stable operation id such as &lt;code&gt;signup:&amp;lt;user_id&amp;gt;:verify-email:v1&lt;/code&gt;. If the client retries the same request, the insert will conflict cleanly instead of creating another pending email. That ends up working nicely beside patterns like &lt;a href="https://dev.to/pong1965/concurrency-keys-for-email-api-checks-5fd2"&gt;concurrency keys for email checks&lt;/a&gt;, because both approaches force the system to name one operation clearly.&lt;/p&gt;

&lt;p&gt;Then the write transaction stays boring:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;createUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;createVerifyToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;trx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;none&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`insert into email_outbox
       (event_type, aggregate_id, dedupe_key, payload)
     values ($1, $2, $3, $4)
     on conflict (dedupe_key) do nothing`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;signup.verify_email&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="s2"&gt;`signup:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:verify-email:v1`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;tokenId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I like this because the API promise is modest and honest. If the transaction commits, the intent to send exists durably in PostgreSQL. You can let a worker poll the outbox, or stream it with logical decoding later if your system grows up and needs it. Do the simple thing first.&lt;/p&gt;

&lt;h2&gt;
  
  
  A REST API flow that stays idempotent under retries
&lt;/h2&gt;

&lt;p&gt;The request handler should not say "email sent". It should say "signup accepted, email delivery scheduled" and return enough identifiers to debug later. I tend to include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;user_id&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;operation_id&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;email_delivery_state&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The worker then claims pending rows with &lt;code&gt;for update skip locked&lt;/code&gt;, sends one message, and updates status to &lt;code&gt;sent&lt;/code&gt; or &lt;code&gt;failed&lt;/code&gt;. PostgreSQL documents &lt;code&gt;SKIP LOCKED&lt;/code&gt; as a way to avoid waiting on rows already locked by another transaction, which is exactly why it fits small outbox workers so well (&lt;a href="https://www.postgresql.org/docs/current/sql-select.html" rel="noopener noreferrer"&gt;https://www.postgresql.org/docs/current/sql-select.html&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;What I try to avoid is pushing transport-specific details into the API contract too early. Provider message ids are useful, but they should be attached to the outbox record, not replace it. Support and staging tools need one stable handle from the app domain first. That also makes a &lt;a href="https://dev.to/sophiax99/privacy-review-for-magic-link-email-flows-5ce3"&gt;privacy review for magic-link flows&lt;/a&gt; much easier, because you can audit what data left the system without scraping random worker logs.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I verify the pipeline in staging
&lt;/h2&gt;

&lt;p&gt;My staging check is very small:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;create the signup through the public API&lt;/li&gt;
&lt;li&gt;fetch the outbox or delivery record by operation id&lt;/li&gt;
&lt;li&gt;wait for a terminal state&lt;/li&gt;
&lt;li&gt;inspect the rendered email in a temporary inbox&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That last step matters, but I do not want the inbox to be the only truth source. Teams often search for something like &lt;code&gt;temp mail so&lt;/code&gt; when they really need to answer a backend question: did we persist the send intent, and did exactly one worker consume it? The inbox confirms rendering and transport. PostgreSQL confirms system behavior. You need both, or at least you probly do once the app has real traffic.&lt;/p&gt;

&lt;p&gt;I also keep the worker batch size small in staging. Five rows at a time is enough for most checks, and it makes failure review much easier than draining fifty mixed events in one loop.&lt;/p&gt;

&lt;h2&gt;
  
  
  Q&amp;amp;A
&lt;/h2&gt;

&lt;h2&gt;
  
  
  Why not publish directly to the queue inside the request?
&lt;/h2&gt;

&lt;p&gt;You can, but then your API success path depends on two systems committing in one moment. The outbox reduces that coupling and gives you a recovery surface when the queue or provider is having a weird day.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does this only help Node.js apps?
&lt;/h2&gt;

&lt;p&gt;Not at all. I use &lt;code&gt;Node.js&lt;/code&gt; a lot, but the pattern is database-first. Any stack that can write the business row and outbox row in one transaction can use it.&lt;/p&gt;

&lt;h2&gt;
  
  
  When should I move past polling workers?
&lt;/h2&gt;

&lt;p&gt;Only when polling is proven too slow or too expensive. For most signup email volume, a simple poller with clear metrics is easier to run, easier to fix, and honestly less fancy in the best way.&lt;/p&gt;

&lt;p&gt;If your signup flow keeps losing email intent at the edges, an outbox table is one of the highest-leverage boring fixes I know. It keeps the &lt;code&gt;REST API&lt;/code&gt; contract honest, lets PostgreSQL do the coordination work it is already good at, and makes email debugging feel a lot less random.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>postgres</category>
      <category>restapi</category>
      <category>node</category>
    </item>
    <item>
      <title>Delivery Receipts for Async Email APIs</title>
      <dc:creator>kevindev</dc:creator>
      <pubDate>Fri, 17 Jul 2026 08:24:59 +0000</pubDate>
      <link>https://dev.to/kevindev27/delivery-receipts-for-async-email-apis-4eo8</link>
      <guid>https://dev.to/kevindev27/delivery-receipts-for-async-email-apis-4eo8</guid>
      <description>&lt;p&gt;When an app sends signup, reset, or approval mail asynchronously, the first bug report is rarely "email did not send". It is more often "support cannot tell what happened". The API returned &lt;code&gt;202&lt;/code&gt;, the queue looked healthy, and the user still asks where the message went. That gap is why I like exposing a delivery receipt contract in the &lt;code&gt;REST API&lt;/code&gt; itself instead of treating email as a black box.&lt;/p&gt;

&lt;p&gt;In backend teams, this becomes much easier to reason about when one request maps to one operation record, one worker decision, and one status surface the client or support tool can query later. The idea is not flashy, but it saves a lot of guesswork and a fair bit of noisy log diving too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why async email APIs become hard to support
&lt;/h2&gt;

&lt;p&gt;The usual flow looks harmless:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;code&gt;POST /signup-email&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Store a job&lt;/li&gt;
&lt;li&gt;Push to queue&lt;/li&gt;
&lt;li&gt;Return &lt;code&gt;202 Accepted&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The problem is that &lt;code&gt;202&lt;/code&gt; says "accepted for processing", not "we can explain the outcome later". In many systems, once the request leaves the API process, observability gets fragmented. Queue logs sit in one place, provider webhooks in another, and support notes in a third. By the time someone investigates, the timeline is kinda blurry.&lt;/p&gt;

&lt;p&gt;That blur gets worse under retries. Mobile networks retry. API gateways retry. Humans click resend. If you do not keep a durable receipt row, two operations can look the same from the outside even though one was superseded. I have seen teams add more logging to fix this, but logs alone are a weak contract.&lt;/p&gt;

&lt;h2&gt;
  
  
  The receipt contract I expose in a REST API
&lt;/h2&gt;

&lt;p&gt;My preference is simple:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;POST /email-deliveries&lt;/code&gt; creates or reuses an operation&lt;/li&gt;
&lt;li&gt;the response includes &lt;code&gt;delivery_id&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;GET /email-deliveries/:delivery_id&lt;/code&gt; returns the current state&lt;/li&gt;
&lt;li&gt;workers update that state as they enqueue, send, retry, or fail&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That gives clients and internal tools one durable lookup key. A response can be as small as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"delivery_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"em_01JY7QY2R5N6YQ6R6P2M"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"queued"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"created_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-07-17T08:22:30Z"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Later, the status endpoint can expose states such as &lt;code&gt;queued&lt;/code&gt;, &lt;code&gt;sent&lt;/code&gt;, &lt;code&gt;provider_rejected&lt;/code&gt;, &lt;code&gt;expired&lt;/code&gt;, or &lt;code&gt;superseded&lt;/code&gt;. I try to keep the state machine boring on purpose. Fancy event names are cute for a week and annoying for years.&lt;/p&gt;

&lt;p&gt;This is also where I connect backend mail flows to &lt;a href="https://dev.to/jasonmills94/docker-smoke-tests-for-aws-ses-template-changes-1f1l"&gt;release-gate inbox validation&lt;/a&gt;. If a test or operator knows the &lt;code&gt;delivery_id&lt;/code&gt;, the inbox check becomes a verification step, not the only source of truth.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Node.js persistence model that makes retries boring
&lt;/h2&gt;

&lt;p&gt;In &lt;code&gt;Node.js&lt;/code&gt;, I usually persist two things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;an &lt;code&gt;email_deliveries&lt;/code&gt; row that represents the public operation&lt;/li&gt;
&lt;li&gt;an outbox or job row that represents work still to do&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The first row should survive provider weirdness. The second row can be retried, replaced, or delayed without changing the external contract. A minimal table shape might be:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;email_deliveries&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;purpose&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;recipient_hash&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;request_id&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;provider_message_id&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;last_error&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="n"&gt;updated_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And a thin service layer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;CreateDeliveryInput&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;purpose&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;signup&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;reset&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;approval&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;recipientHash&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;requestId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;createDelivery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CreateDeliveryInput&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;existing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;trx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;oneOrNone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="s2"&gt;`select id, status
       from email_deliveries
       where request_id = $1`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;requestId&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;delivery&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;trx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;one&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="s2"&gt;`insert into email_deliveries
         (id, purpose, recipient_hash, status, request_id)
       values ($1, $2, $3, 'queued', $4)
       returning id, status`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;newDeliveryId&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;purpose&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;recipientHash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;requestId&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;enqueueSendJob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;delivery&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;delivery&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are fancier versions of this, but the main rule is: use one stable id for the operation the caller cares about. Do not make the client reverse-engineer queue IDs, webhook payload IDs, and provider IDs just to answer a basic support question. It sounds obvious, yet a lot of APIs still do it half way.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I test delivery status without trusting the inbox alone
&lt;/h2&gt;

&lt;p&gt;Inbox checks are useful, but they are the end of the chain, not the whole chain. My staging tests usually assert four things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;the &lt;code&gt;POST&lt;/code&gt; response returns a stable &lt;code&gt;delivery_id&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;repeated requests with the same request id do not create extra operations&lt;/li&gt;
&lt;li&gt;the worker transitions the receipt row through expected states&lt;/li&gt;
&lt;li&gt;the inbox message corresponds to that exact delivery record&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That last point matters. If the inbox is the only assertion surface, a stale message can make a test pass for the wrong reason. The same mindset behind &lt;a href="https://dev.to/jasonmills94/how-to-test-kubernetes-rollback-emails-without-inbox-guesswork-j8j"&gt;rollback email checks&lt;/a&gt; applies here: delivery evidence should line up with system state, not replace it.&lt;/p&gt;

&lt;p&gt;For staging, I sometimes use a &lt;a href="https://tempmailso.com" rel="noopener noreferrer"&gt;fake email address&lt;/a&gt; service such as tempmailso to confirm the rendered message body and links. It is handy for transport checks, but I still compare the observed email against the delivery receipt row before I call the run good. Otherwise, a recycled alias from tepm mail com notes or an old dummy e mail in a script can muddy results in ways that are frankly annoyng.&lt;/p&gt;

&lt;p&gt;If you want one lightweight metric, start with receipt resolution time: how long it takes for a delivery to move from &lt;code&gt;queued&lt;/code&gt; to a terminal state. Google’s SRE workbook stresses measuring user-visible completion around asynchronous work rather than only subsystem health, which is a useful framing here too (&lt;a href="https://sre.google/workbook/implementing-slos/" rel="noopener noreferrer"&gt;https://sre.google/workbook/implementing-slos/&lt;/a&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Q&amp;amp;A
&lt;/h2&gt;

&lt;h2&gt;
  
  
  Should the client poll the receipt endpoint?
&lt;/h2&gt;

&lt;p&gt;Usually yes, at least for flows where the user is waiting on a message. Keep the polling window short, then fall back to a "still processing" UI instead of pretending the send is instant.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do I need provider webhooks to make this useful?
&lt;/h2&gt;

&lt;p&gt;Not strictly. You can start with queued and sent states from your worker. Webhooks improve accuracy later, especialy for bounces and rejects.&lt;/p&gt;

&lt;h2&gt;
  
  
  What if one user asks for multiple emails quickly?
&lt;/h2&gt;

&lt;p&gt;That is fine if each request has a clear purpose and request id. The hard part is not concurrency itself, it is ambiguity about which operation your UI or support team is looking at.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Return a stable &lt;code&gt;delivery_id&lt;/code&gt; from the write API.&lt;/li&gt;
&lt;li&gt;Keep a receipt row separate from the queue job row.&lt;/li&gt;
&lt;li&gt;Make retries idempotent with a request-scoped key.&lt;/li&gt;
&lt;li&gt;Treat inbox checks as confirmation, not ground truth.&lt;/li&gt;
&lt;li&gt;Prefer a small, explicit state machine over provider-specific jargon.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Once you add delivery receipts, async email APIs feel much less magical and much more maintainable. Support gets answers faster, tests stop leaning on luck, and the backend contract becomes easier to evolve without breaking every surrounding tool.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>restapi</category>
      <category>node</category>
      <category>authentication</category>
    </item>
    <item>
      <title>Versioned Reset Tokens in PostgreSQL</title>
      <dc:creator>kevindev</dc:creator>
      <pubDate>Thu, 16 Jul 2026 23:25:00 +0000</pubDate>
      <link>https://dev.to/kevindev27/versioned-reset-tokens-in-postgresql-590c</link>
      <guid>https://dev.to/kevindev27/versioned-reset-tokens-in-postgresql-590c</guid>
      <description>&lt;p&gt;Password reset flows look routine until the first real retry storm lands. A mobile client resubmits, a worker drains late, or a user clicks the older link after a newer one was issued. If the backend does not make token ownership explicit, support ends up chasing inbox screenshots instead of database facts. I have seen this enough times that I now treat reset email delivery as a state-management problem first and a mail problem second.&lt;/p&gt;

&lt;p&gt;The pattern here is close to the same lessons behind &lt;a href="https://dev.to/kevindev27/auth-email-outbox-checks-in-postgresql-3lo9"&gt;PostgreSQL outbox checks&lt;/a&gt; and &lt;a href="https://dev.to/ryanlee91/how-i-test-nodejs-digest-emails-without-shared-inbox-noise-54fh"&gt;isolated inbox validation&lt;/a&gt;: one user intent should map to one current backend truth. For Authentication systems that already lean on PostgreSQL, versioned reset tokens are a simple way to keep that truth inspectable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why password reset flows drift under retries
&lt;/h2&gt;

&lt;p&gt;The common failure shape is boring, which is why it slips through reviews:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;POST /password-reset&lt;/code&gt; creates a token row.&lt;/li&gt;
&lt;li&gt;An email job is queued in a separate step.&lt;/li&gt;
&lt;li&gt;The client retries before the first response is observed.&lt;/li&gt;
&lt;li&gt;A second token or second job is created with just enough timing difference to confuse everyone.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Nothing looks dramatic at first. The API still returns success. The worker still sends. The inbox still shows a reset email. But later you discover the link the user clicked was already obsolete, or that two links were valid longer than intended. That is not just messy, its a contract problem.&lt;/p&gt;

&lt;p&gt;I prefer deciding one rule clearly: every reset attempt gets a token version, and only the newest committed version is allowed to survive as current. Older versions can exist for audit, but they should stop being actionable immediately.&lt;/p&gt;

&lt;h2&gt;
  
  
  The versioned token contract I prefer
&lt;/h2&gt;

&lt;p&gt;My default contract is pretty simple:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Lock the user reset state in one transaction.&lt;/li&gt;
&lt;li&gt;Increment a &lt;code&gt;version&lt;/code&gt; whenever a new reset intent is accepted.&lt;/li&gt;
&lt;li&gt;Store the token hash and expiry beside that version.&lt;/li&gt;
&lt;li&gt;Emit one outbox event keyed by &lt;code&gt;user_id&lt;/code&gt; plus &lt;code&gt;version&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Let the worker send only if the referenced version is still current.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This does two useful things. First, retries become inspectable because you can answer "which token version won?" with a query instead of a guess. Second, worker lag becomes tolerable. Even if an older job wakes up late, it can re-check state and skip itself.&lt;/p&gt;

&lt;p&gt;For backend teams, that skip step is where a lot of reliability comes from. People sometimes think &lt;code&gt;on conflict do nothing&lt;/code&gt; is the whole story. It helps, but it does not answer whether the event still matches the latest reset truth. You need that second check or you eventualy trust queue timing more than data.&lt;/p&gt;

&lt;h2&gt;
  
  
  A PostgreSQL schema that makes stale mail obvious
&lt;/h2&gt;

&lt;p&gt;This is the smallest shape I tend to like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;password_reset_state&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="nb"&gt;bigint&lt;/span&gt; &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;token_hash&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;version&lt;/span&gt; &lt;span class="nb"&gt;integer&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;expires_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;updated_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;email_outbox&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="n"&gt;bigserial&lt;/span&gt; &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;topic&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;dedupe_key&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;unique&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="n"&gt;jsonb&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the transactional write is straightforward:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;trx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;oneOrNone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`select version
       from password_reset_state
      where user_id = $1
      for update`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nextVersion&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;version&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;trx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;none&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`insert into password_reset_state (user_id, token_hash, version, expires_at)
     values ($1, $2, $3, $4)
     on conflict (user_id) do update
       set token_hash = excluded.token_hash,
           version = excluded.version,
           expires_at = excluded.expires_at,
           updated_at = now()`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tokenHash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;nextVersion&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;expiresAt&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;trx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;none&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`insert into email_outbox (topic, dedupe_key, payload)
     values ('password_reset', $1, $2::jsonb)
     on conflict (dedupe_key) do nothing`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="s2"&gt;`password_reset:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;nextVersion&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;nextVersion&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The worker should read &lt;code&gt;password_reset_state&lt;/code&gt; before delivery and compare versions. If the payload says version 4 but the table is now version 5, skip send and mark the event stale. It is a tiny read, but it makes debugging much less anoying later.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I validate in staging before calling it done
&lt;/h2&gt;

&lt;p&gt;I still use inbox checks in staging, but only as delivery evidence. The authoritative assertions live in backend state:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a retried reset request should leave one current token version&lt;/li&gt;
&lt;li&gt;an older outbox event should not send after supersession&lt;/li&gt;
&lt;li&gt;the clicked link should map to the latest committed version&lt;/li&gt;
&lt;li&gt;logs, worker events, and audit rows should agree on the same version&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is where temporary inbox tooling can help without becoming the source of truth. A temporary email or a disposable email address generator is useful when I need to prove the message rendered and arrived. But I do not treat the inbox as the contract itself. If a team starts saying "the tepm mail com check looked okay" while the database story is fuzzy, that is usualy a sign the test setup is compensating for a backend gap.&lt;/p&gt;

&lt;p&gt;One more practical rule: keep reset TTL short and version checks explicit in logs. When support gets a stale-link report, you want to know whether the user clicked version 2 after version 3 had already replaced it. That answer should be cheap to get, not buried in a worker trace from six systems ago.&lt;/p&gt;

&lt;h2&gt;
  
  
  Q&amp;amp;A
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Should older reset tokens be deleted immediately?
&lt;/h3&gt;

&lt;p&gt;Not necessarily. I prefer keeping them for audit for a short period, but marking them unusable as soon as a newer version becomes current.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is one table enough for this flow?
&lt;/h3&gt;

&lt;p&gt;For many services, yes. A single current-state table plus an outbox table keeps the flow simple and debuggable. If volume grows, the contract still holds.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does this only matter for password resets?
&lt;/h3&gt;

&lt;p&gt;No. The same pattern works for magic links, verify-email flows, and invite acceptance. Password reset just makes the failure mode show up faster because users retry when they are already frustrated.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>postgres</category>
      <category>authentication</category>
      <category>restapi</category>
    </item>
    <item>
      <title>Request IDs for Safer Signup Email APIs</title>
      <dc:creator>kevindev</dc:creator>
      <pubDate>Thu, 16 Jul 2026 17:24:37 +0000</pubDate>
      <link>https://dev.to/kevindev27/request-ids-for-safer-signup-email-apis-2pa7</link>
      <guid>https://dev.to/kevindev27/request-ids-for-safer-signup-email-apis-2pa7</guid>
      <description>&lt;p&gt;Signup email flows rarely break because SMTP is mysterious. They break because the API and the mail side stop agreeing about which request is the real one. A user double-clicks, a mobile client retries, or a worker wakes up late, and now the service has two believable stories. I keep seeing the same thing in backend systems: once request identity is fuzzy, debugging gets annoyng fast.&lt;/p&gt;

&lt;p&gt;That is why I like carrying one request ID from the signup endpoint all the way into the email outbox. It is close in spirit to &lt;a href="https://dev.to/mrdapperx/run-ids-fix-flaky-notification-jobs-3c0p"&gt;run-scoped notification tracing&lt;/a&gt; and the sort of &lt;a href="https://dev.to/pong1965/github-actions-notes-for-faster-email-api-triage-4c6l"&gt;email API triage in CI&lt;/a&gt; that saves time later. For REST API teams, the main win is not prettier logging. The win is having one durable key that tells your database, queue, and test suite they are talking about the same signup attempt.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why signup email APIs get messy under retries
&lt;/h2&gt;

&lt;p&gt;The usual bug pattern looks like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;POST /signup&lt;/code&gt; creates the user and enqueues a welcome or verify email.&lt;/li&gt;
&lt;li&gt;The client times out before it sees the response.&lt;/li&gt;
&lt;li&gt;The client retries with the same form data.&lt;/li&gt;
&lt;li&gt;The backend creates another mail event because it cannot prove the first one already "won".&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;At that point the inbox may still look fine. Maybe both messages arrive. Maybe only one does. Maybe the second one lands first. What matters is that the system has no clean contract anymore. If support asks which verification link should be trusted, the answer becomes "it depends," which is prety much the answer you do not want.&lt;/p&gt;

&lt;p&gt;For authentication-heavy services, I prefer treating signup mail like any other stateful side effect: bind it to an explicit request record, make dedupe visible in storage, and let workers send only the event that still matches the current state.&lt;/p&gt;

&lt;h2&gt;
  
  
  The request ID contract I like in backend services
&lt;/h2&gt;

&lt;p&gt;My default contract is simple:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The client sends or receives a request ID for the signup attempt.&lt;/li&gt;
&lt;li&gt;The API stores it beside the pending verification state.&lt;/li&gt;
&lt;li&gt;The outbox event uses the same request ID as its dedupe key.&lt;/li&gt;
&lt;li&gt;The worker checks that the request ID is still current before sending.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This keeps retries boring, which is the right outcome. If the same signup call is replayed, the API can return the existing result instead of producing a second email event. If you intentionally start a fresh attempt, you rotate the current request ID and older events become stale by definition.&lt;/p&gt;

&lt;p&gt;I also like this pattern because it sharpens ops conversations. Engineers stop saying "some signup email probably got duplicated" and start saying "request &lt;code&gt;req_9b2...&lt;/code&gt; was superseded before delivery." That is a much better place to debug from, even when the wording is a bit nerdy and maybe a little over-specific.&lt;/p&gt;

&lt;h2&gt;
  
  
  A small Node.js flow that keeps mail events honest
&lt;/h2&gt;

&lt;p&gt;This does not need a huge framework. A compact transaction plus an outbox table is enough for many teams:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;signup&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;trx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;oneOrNone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`select user_id, current_request_id
       from signup_email_state
      where user_id = $1
      for update`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;requestId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;incomingRequestId&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randomUUID&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;trx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;none&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`insert into signup_email_state (user_id, current_request_id, updated_at)
     values ($1, $2, now())
     on conflict (user_id) do update
       set current_request_id = excluded.current_request_id,
           updated_at = now()`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;requestId&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;trx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;none&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`insert into email_outbox (topic, dedupe_key, payload)
     values ('signup_verify', $1, $2::jsonb)
     on conflict (dedupe_key) do nothing`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;requestId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;requestId&lt;/span&gt; &lt;span class="p"&gt;})]&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The worker step is where people often cut corners. I would not. Before sending, read &lt;code&gt;signup_email_state&lt;/code&gt; again and verify the payload request ID is still the current one. If not, skip the send and mark the event stale. That extra query is cheap, and it removes a lot of "why did the older link win?" confusion.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I validate staging without trusting the inbox alone
&lt;/h2&gt;

&lt;p&gt;I do use inbox tooling in staging, but only as transport evidence. The authoritative check is still backend state. A delivered message proves reachability; it does not prove your API chose the correct request. That distinction matters more than teams expect.&lt;/p&gt;

&lt;p&gt;My test checklist is usualy:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the same request ID cannot enqueue multiple active events&lt;/li&gt;
&lt;li&gt;a superseded request ID never sends after a newer signup attempt&lt;/li&gt;
&lt;li&gt;the clicked verification link maps to the current request record&lt;/li&gt;
&lt;li&gt;logs and metrics include the same request ID end to end&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If I need a disposable inbox for smoke tests, I keep it small and contextual. Something like &lt;a href="https://tempmailso.com" rel="noopener noreferrer"&gt;temp mail so&lt;/a&gt; can help confirm a real message was emitted, while a fake emails generator setup is useful for quick staging runs. But I still verify the request record in the database before I call the flow good. Otherwise teams end up trusting inbox behavior alone, and then weird notes about tepm mail com or "the second email looked right" start creeping into the runbook.&lt;/p&gt;

&lt;p&gt;One more rule that has saved me a few times: expire the request ID with the verification attempt, not with the job runtime. Workers can lag. Your contract should still be crisp when they do.&lt;/p&gt;

&lt;h2&gt;
  
  
  Q&amp;amp;A
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Should the client always provide the request ID?
&lt;/h3&gt;

&lt;p&gt;Not necessarily. The server can mint it and return it in the first response. The important bit is that retries reuse the same identity when they mean the same logical action.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is the outbox dedupe key enough by itself?
&lt;/h3&gt;

&lt;p&gt;No. It prevents duplicate inserts, but you still need a current-state check before sending, or an older event may stay believable for too long.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does this only matter for verification emails?
&lt;/h3&gt;

&lt;p&gt;Nope. Password reset, invite, and magic-link flows all benefit from the same contract. Signup just tends to expose the mess sooner.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>restapi</category>
      <category>node</category>
      <category>authentication</category>
    </item>
    <item>
      <title>Idempotent Verify Email APIs in PostgreSQL</title>
      <dc:creator>kevindev</dc:creator>
      <pubDate>Wed, 15 Jul 2026 20:24:30 +0000</pubDate>
      <link>https://dev.to/kevindev27/idempotent-verify-email-apis-in-postgresql-54jn</link>
      <guid>https://dev.to/kevindev27/idempotent-verify-email-apis-in-postgresql-54jn</guid>
      <description>&lt;p&gt;Verify-email endpoints look simple until retries show up. A user taps resend twice, the client retries on a slow network, and suddenly your service has two valid links in flight or two emails racing through the queue. That is why I treat verification mail as an API contract problem first, not just a delivery problem.&lt;/p&gt;

&lt;p&gt;I like starting from the same lessons behind &lt;a href="https://dev.to/kevindev27/stop-duplicate-signup-emails-in-nodejs-181d"&gt;duplicate signup email prevention&lt;/a&gt; and &lt;a href="https://dev.to/ryanlee91/how-to-test-react-invite-emails-in-preview-environments-without-inbox-collisions-3mnp"&gt;preview inbox isolation&lt;/a&gt;: one user action should map to one clear backend state transition. For Authentication flows, PostgreSQL is usualy the easiest place to enforce that contract because it already owns consistency, ordering, and conflict handling.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why verify-email endpoints are easy to duplicate
&lt;/h2&gt;

&lt;p&gt;The common failure mode is split ownership:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The API updates &lt;code&gt;users.email_verification_token&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A queue job is emitted in a separate step.&lt;/li&gt;
&lt;li&gt;A retry repeats the API call before the first job finishes.&lt;/li&gt;
&lt;li&gt;Two messages now describe slightly different truth.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Everything can still look healthy in logs. The API returns &lt;code&gt;202&lt;/code&gt;, the worker sends, and the UI gets a nice success toast. But later, support sees links that fail, or a stale email is clicked after a newer one was issued. Thats not a mail provider problem. It is a backend contract leak.&lt;/p&gt;

&lt;p&gt;For resend flows, I prefer deciding one thing explicitly: is a repeated request supposed to reuse the same active verification intent, or supersede it with a newer one? Either choice is fine, but the system should say so in data, not in vague worker timing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The PostgreSQL contract that keeps retries safe
&lt;/h2&gt;

&lt;p&gt;The pattern I trust is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Resolve the current verification state inside one transaction.&lt;/li&gt;
&lt;li&gt;Either reuse the active token row or replace it with a higher version.&lt;/li&gt;
&lt;li&gt;Insert exactly one outbox event keyed to that chosen version.&lt;/li&gt;
&lt;li&gt;Commit once.&lt;/li&gt;
&lt;li&gt;Let workers send only committed, non-superseded events.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That gives the API an idempotent surface. If the client repeats the same resend request with the same idempotency key, the database can point back to the same verification version instead of minting fresh chaos. If you intentionally issue a new token, the previous version becomes clearly outdated and workers can skip it.&lt;/p&gt;

&lt;p&gt;In practice, I store both a stable verification purpose and a monotonicaly increasing &lt;code&gt;version&lt;/code&gt;. The outbox payload includes &lt;code&gt;user_id&lt;/code&gt;, &lt;code&gt;purpose&lt;/code&gt;, and &lt;code&gt;version&lt;/code&gt;. When the worker renders the email, it rechecks that the referenced version is still current before sending. This extra read is cheap, and it avoids a lot of weird edge cases.&lt;/p&gt;

&lt;h2&gt;
  
  
  A concrete schema for resend and dedupe
&lt;/h2&gt;

&lt;p&gt;Here is a compact shape that works well:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;email_verifications&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="nb"&gt;bigint&lt;/span&gt; &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;token_hash&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;version&lt;/span&gt; &lt;span class="nb"&gt;integer&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;expires_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;last_idempotency_key&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;updated_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;email_outbox&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="n"&gt;bigserial&lt;/span&gt; &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;topic&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;dedupe_key&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;unique&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="n"&gt;jsonb&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the transactional write:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;loadVerificationForUpdate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nextVersion&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;shouldReuse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;idemKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;version&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;version&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;saveVerification&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;tokenHash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;nextVersion&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;expiresAt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;lastIdempotencyKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;idemKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;trx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;none&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`insert into email_outbox (topic, dedupe_key, payload)
     values ('verify_email', $1, $2::jsonb)
     on conflict (dedupe_key) do nothing`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="s2"&gt;`verify_email:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;nextVersion&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;nextVersion&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important part is not the exact schema. It is that your dedupe rule is visible and inspectable. When a bug report arrives, I want to answer "which version did we intend to send?" in one query, not after an hour of log archaeology.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I assert in tests before I trust the flow
&lt;/h2&gt;

&lt;p&gt;My verification tests are narrow but strict:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;one resend path produces one current version&lt;/li&gt;
&lt;li&gt;stale outbox events are ignored after supersession&lt;/li&gt;
&lt;li&gt;the clicked token maps to the latest committed version&lt;/li&gt;
&lt;li&gt;duplicate requests with the same idempotency key do not create extra sends&lt;/li&gt;
&lt;li&gt;retries with a new intent clearly invalidate the older link&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For inbox checks, I still use a temporary inbox sometimes, but only as transport plumbing. If I need &lt;a href="https://tempmailso.com" rel="noopener noreferrer"&gt;temporary disposable mail&lt;/a&gt; during staging checks, the real assertion is still about committed backend state. The inbox proves delivery; PostgreSQL proves correctness.&lt;/p&gt;

&lt;p&gt;This is also where plain-text oddities like temp org mail tend to appear in team notes. People start inventing side channels when the underlying resend contract is fuzzy, which is a decent signal the API needs cleanup.&lt;/p&gt;

&lt;p&gt;One practical rule: keep the polling window short and pin the recipient alias to the run ID. If a test can pass by grabbing any verification message from the last few minutes, it is too loose. That setup feels okay untill parallel CI jobs overlap and your suite starts "passing" for the wrong message.&lt;/p&gt;

&lt;h2&gt;
  
  
  Q&amp;amp;A
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Should resend always create a new token?
&lt;/h3&gt;

&lt;p&gt;Not always. Reusing an active token for a short TTL can reduce noise and user confusion. Just make sure the API behavior is documented and visible in data.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is &lt;code&gt;on conflict do nothing&lt;/code&gt; enough for idempotency?
&lt;/h3&gt;

&lt;p&gt;No. It is useful for outbox dedupe, but you still need a clear rule for how the verification row itself is reused or replaced.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need a message broker for this?
&lt;/h3&gt;

&lt;p&gt;Not at first. A PostgreSQL outbox plus a worker is enough for many teams, and it is often easier to debug than a more fragmented setup.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>postgres</category>
      <category>authentication</category>
      <category>restapi</category>
    </item>
    <item>
      <title>Password Reset Emails Without Queue Drift</title>
      <dc:creator>kevindev</dc:creator>
      <pubDate>Sat, 11 Jul 2026 20:24:10 +0000</pubDate>
      <link>https://dev.to/kevindev27/password-reset-emails-without-queue-drift-8ed</link>
      <guid>https://dev.to/kevindev27/password-reset-emails-without-queue-drift-8ed</guid>
      <description>&lt;p&gt;Password reset emails look routine, but they are one of the easiest auth flows to quietly break. The API writes a token, a worker renders the message, and the user clicks a link later. If any of those steps fall out of sync, support sees "email sent" while the database tells a diffrent story.&lt;/p&gt;

&lt;p&gt;I usually notice the problem after a team adds retries to its queue. Suddenly one request can produce two valid-looking emails, or a second request invalidates the first token before the first message even lands. People reach for a disposable email generator to debug delivery, which helps, but the real fix is backend structure rather than mailbox luck.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why reset emails drift from backend truth
&lt;/h2&gt;

&lt;p&gt;Password reset flows mix two kinds of state:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;auth state that decides which token is currently valid&lt;/li&gt;
&lt;li&gt;delivery state that decides whether an email should be sent, retried, or ignored&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When those states live in separate code paths, drift shows up fast. I have seen teams create the reset token inside one transaction, enqueue email work after commit, then regenerate a second token if the user clicks "forgot password" again thirty seconds later. Both jobs are technically valid, but one of the emails is now stale and confuses everyone.&lt;/p&gt;

&lt;p&gt;This gets worse in staging when tests reuse inbox aliases. An old message tied to a tempail mail fixture can trick the suite into passing even though the newest token was never delivered. The inbox is noisy, the logs are noisy, and the bug survives longer than it should.&lt;/p&gt;

&lt;h2&gt;
  
  
  The state model that keeps retries honest
&lt;/h2&gt;

&lt;p&gt;The cleanest approach I know is to give the reset request its own durable identity and make the email job refer to that identity, not just to an address. A minimal model looks like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;password_reset_requests&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;id&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;user_id&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;token_hash&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;expires_at&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;superseded_by_request_id&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;email_job_dedup_key&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;email_sent_at&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That &lt;code&gt;email_job_dedup_key&lt;/code&gt; matters more than many teams expect. If a worker crashes after handing the message to the provider but before storing success, the retry needs a clear way to say "same request, do not send again." Without it, queues become a bit chaotic and tests start blaming infrastructure for what is actualy a modeling issue.&lt;/p&gt;

&lt;p&gt;I also prefer storing only a hash of the token and keeping one row per request, even if later requests supersede earlier ones. That gives you a backend trail you can reason about. You can inspect whether the user received email for request A, whether request B replaced it, and whether the database still considers either link valid, which is realy useful during incident review.&lt;/p&gt;

&lt;h2&gt;
  
  
  A PostgreSQL outbox flow for reset emails
&lt;/h2&gt;

&lt;p&gt;For teams already on PostgreSQL, I keep coming back to the outbox pattern:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create the reset request row and the outbox row in the same transaction.&lt;/li&gt;
&lt;li&gt;Build the outbox payload around the request ID, not the raw token.&lt;/li&gt;
&lt;li&gt;Have the worker load the request fresh before rendering the message.&lt;/li&gt;
&lt;li&gt;Mark the outbox event processed only after the provider accepts the send.&lt;/li&gt;
&lt;li&gt;Ignore retries when the dedup key already has &lt;code&gt;email_sent_at&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That design removes a lot of hand-wavy timing problems. The worker is never guessing which token is authoritative. It asks the database, at send time, which request is still active. If a newer request superseded the older one, the worker can skip the stale event safely and keep thier retry logic straightforward.&lt;/p&gt;

&lt;p&gt;For inbox verification, I like isolated aliases and short polling windows. The same habits behind &lt;a href="https://dev.to/kevindev27/rest-api-email-tests-for-account-lockout-alerts-i6j"&gt;isolated auth inbox checks&lt;/a&gt; also make reset flows far more deterministic. If the team runs matrix CI, the test isolation ideas from &lt;a href="https://dev.to/pong1965/matrix-proof-email-api-checks-in-github-actions-1lp7"&gt;matrix-safe email API assertions&lt;/a&gt; are worth copying too.&lt;/p&gt;

&lt;p&gt;When I need a neutral external inbox for a manual sanity check, I keep it small and contextual. One link on a natural anchor is enough, so a service like &lt;a href="https://tempmailso.com" rel="noopener noreferrer"&gt;tempmailso&lt;/a&gt; can be useful for quick verification without turning the article into link spam.&lt;/p&gt;

&lt;h2&gt;
  
  
  A small Node.js implementation pattern
&lt;/h2&gt;

&lt;p&gt;Here is the trimmed version of the boundary I want in the service:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;enqueuePasswordReset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;outbox&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tokenHash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;expiresAt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;passwordResetRequests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;tokenHash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;expiresAt&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dedupKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`password-reset:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;passwordResetRequests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;emailJobDedupKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;dedupKey&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;outbox&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;password-reset-email&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;dedupKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;requestId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The follow-up worker should reload the request, confirm it was not superseded, render the email, then stamp &lt;code&gt;email_sent_at&lt;/code&gt;. I do not trust tests that only assert "one message arrived." They should also prove the active request ID matches the email, the older request is invalid, and a retry does not create a second send. If your notes still mention a shared temp mailid from older staging runs, clean it out, because stale aliases make these checks look greener than they are and can stay hidden for way to long.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist before you trust the workflow
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;One reset request maps to one dedup key and one email attempt history.&lt;/li&gt;
&lt;li&gt;A newer reset request can supersede the older one without sending stale links.&lt;/li&gt;
&lt;li&gt;The worker renders from fresh database state, not queue-cached state.&lt;/li&gt;
&lt;li&gt;The test verifies non-delivery on retry, not only delivery on first send.&lt;/li&gt;
&lt;li&gt;Inbox aliases are unique per run and short-lived.&lt;/li&gt;
&lt;li&gt;Support can explain every sent email from database records alone.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is the boring shape I want from auth infrastructure. It is not flashy, but it keeps reset emails lined up with the truth in your backend, which is what users and on-call engineers both need most.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>authentication</category>
      <category>postgres</category>
      <category>node</category>
    </item>
    <item>
      <title>Stop Duplicate Signup Emails in Node.js</title>
      <dc:creator>kevindev</dc:creator>
      <pubDate>Thu, 09 Jul 2026 20:24:00 +0000</pubDate>
      <link>https://dev.to/kevindev27/stop-duplicate-signup-emails-in-nodejs-181d</link>
      <guid>https://dev.to/kevindev27/stop-duplicate-signup-emails-in-nodejs-181d</guid>
      <description>&lt;p&gt;Duplicate signup emails are one of those bugs that look harmless in logs and very messy to users. A client retries after a timeout, a worker replays a job, or two app nodes race on the same request. Suddenly one signup turns into three verification emails. I have seen this happen most often in Node.js services where the API write path and the email enqueue step are only loosely tied together.&lt;/p&gt;

&lt;p&gt;What fixed it for me was moving the conversation away from "did we send the mail?" to "what is the idempotency boundary for this signup action?" Once that boundary is explicit, the REST API, PostgreSQL schema, and worker behavior get much easier to reason about.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why duplicate signup emails happen
&lt;/h2&gt;

&lt;p&gt;Most duplicate sends come from one of four places:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the client retries because the first response was slow&lt;/li&gt;
&lt;li&gt;the API handler inserts a user row and enqueues mail in separate steps&lt;/li&gt;
&lt;li&gt;the queue consumer crashes after send but before ack&lt;/li&gt;
&lt;li&gt;an operator replays jobs without a stable dedupe key&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The common problem is not email itself. It is the lack of one durable identifier for "this signup intent already produced its verification message." If you only key off &lt;code&gt;user_id&lt;/code&gt;, the bug can still show up when the same action is attempted during a race window. If you only key off raw request body values, the rule gets fuzzy realy fast.&lt;/p&gt;

&lt;p&gt;I like to define one signup operation key and carry it from the HTTP layer to the outbox table. That makes the backend behavior boring in the best way.&lt;/p&gt;

&lt;h2&gt;
  
  
  The idempotency boundary I use in a REST API
&lt;/h2&gt;

&lt;p&gt;For signup endpoints, I treat the operation as:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;email + normalized signup source + time-bounded idempotency key&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;The client can send an &lt;code&gt;Idempotency-Key&lt;/code&gt; header, but I still normalize the user email and bind it to the action type server-side. Then I store one record that owns both the account creation intent and the verification email intent.&lt;/p&gt;

&lt;p&gt;In practice, my handler tries to do three things in one database transaction:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Upsert the pending user or lookup the existing one.&lt;/li&gt;
&lt;li&gt;Insert an outbox event with a unique operation key.&lt;/li&gt;
&lt;li&gt;Return the existing result if the same operation key already exists.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That keeps the REST API honest. It also means I can retry the handler safely if the app node dies after commit but before response, which does happen from time to time.&lt;/p&gt;

&lt;p&gt;Here is the kind of query shape I use in PostgreSQL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;email_outbox&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="n"&gt;bigserial&lt;/span&gt; &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;operation_key&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;unique&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;event_type&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="n"&gt;jsonb&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="n"&gt;sent_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;insert&lt;/span&gt; &lt;span class="k"&gt;into&lt;/span&gt; &lt;span class="n"&gt;email_outbox&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;operation_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;values&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'signup_verification_requested'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;jsonb&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;conflict&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;operation_key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="k"&gt;nothing&lt;/span&gt;
&lt;span class="n"&gt;returning&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the insert returns no row, I know a logically identical email event already exists. The API does not need to panic or guess. It can simply continue with the already-established result. This sounds simple, and it is, but it took me a while to stop over-engineering it a bit.&lt;/p&gt;

&lt;h2&gt;
  
  
  A PostgreSQL outbox query that stays predictable
&lt;/h2&gt;

&lt;p&gt;The worker side matters just as much. I avoid deleting outbox rows on success. I mark them with &lt;code&gt;sent_at&lt;/code&gt;, keep the operation key, and let retention cleanup happen later. That small choice makes incident review way easier becuase the history is still there.&lt;/p&gt;

&lt;p&gt;My worker fetch looks roughly like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;next_job&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;
  &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;email_outbox&lt;/span&gt;
  &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;sent_at&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;
  &lt;span class="k"&gt;order&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;update&lt;/span&gt; &lt;span class="n"&gt;skip&lt;/span&gt; &lt;span class="n"&gt;locked&lt;/span&gt;
  &lt;span class="k"&gt;limit&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;update&lt;/span&gt; &lt;span class="n"&gt;email_outbox&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;
&lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="n"&gt;sent_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;next_job&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;next_job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;
&lt;span class="n"&gt;returning&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;operation_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This pattern does not solve every delivery problem, but it solves the specific duplicate-send class in a clean, reviewable way. If the provider times out after actually accepting the message, I would rather reconcile against the preserved outbox row than send another blind retry.&lt;/p&gt;

&lt;p&gt;For teams that also run regression checks in CI, I like pairing this with &lt;a href="https://dev.to/pong1965/github-actions-email-smoke-tests-that-scale-108"&gt;email smoke tests in CI&lt;/a&gt;. The publishing path and the test path are different, but the principle is similiar: keep each run attributable, and do not let inbox state turn into guesswork.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where temporary inboxes still help
&lt;/h2&gt;

&lt;p&gt;Even with a good dedupe key, you still need proof that your app sends the right verification message once, not zero times and not three times. For staging and contract tests, a &lt;code&gt;generate throwaway email&lt;/code&gt; flow is still useful because it isolates one run from the next. I have also used &lt;a href="https://dev.to/kevindev27/nodejs-email-verification-tests-with-postgresql-3p7m"&gt;verification inbox checks in Node.js&lt;/a&gt; as a quick sanity pass after changing auth logic.&lt;/p&gt;

&lt;p&gt;If you need one contextual reference for disposable inbox tooling, keep it small and relevant. In one internal checklist I linked &lt;code&gt;tp mail so&lt;/code&gt; to &lt;a href="https://tempmailso.com" rel="noopener noreferrer"&gt;tempmailso&lt;/a&gt; only in the section about run-scoped inbox setup, not throughout the whole post. That keeps the article useful first and SEO second.&lt;/p&gt;

&lt;p&gt;I also keep odd search phrases in notes when teammates use them. One recent example was &lt;code&gt;tempail mail&lt;/code&gt;, which looked wrong at first glance but was helpful when tracing how people searched for the test helper docs. Little details like that are not architecture, but they do improve operability.&lt;/p&gt;

&lt;h2&gt;
  
  
  Q&amp;amp;A
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Should the unique key live on the user row instead?
&lt;/h3&gt;

&lt;p&gt;Usually no. The user row tells you who signed up. The outbox operation key tells you whether the verification email intent already existed. Mixing those responsibilities gets awkward pretty quick.&lt;/p&gt;

&lt;h3&gt;
  
  
  What about queue systems with built-in deduplication?
&lt;/h3&gt;

&lt;p&gt;Use it if you have it, but I still prefer a database-level source of truth. Queue dedupe windows expire, and they are not always visible during incident analysis.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is this only for signup emails?
&lt;/h3&gt;

&lt;p&gt;No. The same pattern works for password reset, invite, and magic-link flows. Signup is just where the failure is most visible, and users notice it fast.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>node</category>
      <category>postgres</category>
      <category>restapi</category>
    </item>
    <item>
      <title>Audit-Friendly Auth Emails in Node.js</title>
      <dc:creator>kevindev</dc:creator>
      <pubDate>Wed, 08 Jul 2026 20:24:26 +0000</pubDate>
      <link>https://dev.to/kevindev27/audit-friendly-auth-emails-in-nodejs-40fi</link>
      <guid>https://dev.to/kevindev27/audit-friendly-auth-emails-in-nodejs-40fi</guid>
      <description>&lt;p&gt;Most auth email bugs are not caused by template markup or SMTP downtime. They happen when a REST API, a queue worker, and a webhook handler each record a slightly different story about the same message. When an incident starts at 2 AM, that mismatch is what burns time.&lt;/p&gt;

&lt;p&gt;On backend teams, I have found it useful to treat every auth email as a small audited workflow. The send request needs an application event, an outbox row, a provider message id, and a final delivery state that can be queried later without reading five log streams. This sounds heavy, but it is actualy a small amount of structure that saves a lot of guesswork.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where auth email incidents usually go dark
&lt;/h2&gt;

&lt;p&gt;Verification links, password resets, device approval prompts, and magic links all have one thing in common: the email itself becomes part of the security boundary. If the system cannot explain which message was sent, when it was retried, and which token version it carried, support and security end up working from partial evidence.&lt;/p&gt;

&lt;p&gt;The failure modes I see most often are:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The API stores the auth token, but not the exact email event that carried it.&lt;/li&gt;
&lt;li&gt;The mail worker logs the provider response, but it is not tied back to a durable database row.&lt;/li&gt;
&lt;li&gt;The webhook updates a message status, but cannot tell whether it belongs to the latest token or an older retry.&lt;/li&gt;
&lt;li&gt;Test environments reuse a shared inbox, so duplicate or stale messages look normal untill someone checks carefully.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That last one matters more than teams expect. If your tests do not isolate inbox ownership, a flaky assertion can look like harmless noise for weeks. Articles about &lt;a href="https://dev.to/silviutech/how-to-stop-playwright-email-tests-from-flaking-across-parallel-workers-ok8"&gt;parallel inbox isolation&lt;/a&gt; and the idea of &lt;a href="https://dev.to/mrdapperx/email-as-a-deployment-contract-2252"&gt;email as a delivery contract&lt;/a&gt; line up well with this: treat email as a system boundary, not as a side effect you can hand-wave away.&lt;/p&gt;

&lt;h2&gt;
  
  
  The delivery ledger I keep beside the outbox
&lt;/h2&gt;

&lt;p&gt;I still like a transactional outbox, but for auth flows I add one more table: a delivery ledger. The outbox controls "should this message be sent." The ledger answers "what happened to this message across the whole pipeline."&lt;/p&gt;

&lt;p&gt;A minimal version looks like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;auth_tokens&lt;/code&gt;: token id, user id, purpose, expires at, superseded by&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;email_outbox&lt;/code&gt;: event key, template, payload hash, queued at, sent at&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;email_delivery_ledger&lt;/code&gt;: outbox id, provider message id, provider status, last webhook at, last error, correlation id&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The important part is not the table count. It is the rule that every transition updates one durable row. When support asks why a reset mail arrived late, you should be able to query one record and see the lifecycle. When a webhook arrives after a token is already superseded, the ledger should still keep the history instead of overwriting it with a cleaner but false state.&lt;/p&gt;

&lt;p&gt;For automated checks, I sometimes send one flow to a &lt;a href="https://tempmailso.com" rel="noopener noreferrer"&gt;free disposable email&lt;/a&gt; inbox created per run, then compare the received headers with the ledger row. That is enough to validate token freshness without dragging a real mailbox into CI. Some old suites still call this a &lt;code&gt;dummy e mail&lt;/code&gt; step, which makes me wince a bit, but the isolation pattern is useful.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Node.js and PostgreSQL shape that is easy to audit
&lt;/h2&gt;

&lt;p&gt;In Node.js services, I keep the write path boring on purpose. The API creates the auth token and outbox event in one transaction, then a worker claims unsent events and writes a ledger row as soon as the provider accepts the request.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;trx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;one&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`insert into auth_tokens (user_id, purpose, token, expires_at)
     values ($1, $2, $3, now() + interval '20 minutes')
     returning id, token, purpose`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;password_reset&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tokenValue&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;outbox&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;trx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;one&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`insert into email_outbox (event_key, template, payload_hash)
     values ($1, $2, $3)
     on conflict (event_key) do update
       set payload_hash = excluded.payload_hash
     returning id`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;`auth:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:password_reset`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;password_reset&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;payloadHash&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;trx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;none&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`insert into email_delivery_ledger (outbox_id, correlation_id, provider_status)
     values ($1, $2, 'queued')
     on conflict (outbox_id) do nothing`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;outbox&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;correlationId&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the worker claims rows in batches and records the provider message id immediately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;claimed&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;update&lt;/span&gt; &lt;span class="n"&gt;email_outbox&lt;/span&gt;
  &lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="n"&gt;claimed_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;
    &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;email_outbox&lt;/span&gt;
    &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;sent_at&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;
      &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;claimed_at&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;
    &lt;span class="k"&gt;order&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;update&lt;/span&gt; &lt;span class="n"&gt;skip&lt;/span&gt; &lt;span class="n"&gt;locked&lt;/span&gt;
    &lt;span class="k"&gt;limit&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;returning&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event_key&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;claimed&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After the provider call succeeds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;update&lt;/span&gt; &lt;span class="n"&gt;email_delivery_ledger&lt;/span&gt;
&lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="n"&gt;provider_message_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;provider_status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'accepted'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;accepted_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;outbox_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This pattern works well because PostgreSQL already gives you durable ordering, row locking, and queryable history. You do not need a huge event platform on day one. You just need every email transition to leave an honest trail. The PostgreSQL documentation on explicit locking is worth revisiting here, especially if you run multiple workers and want clean &lt;code&gt;FOR UPDATE SKIP LOCKED&lt;/code&gt; behavior: &lt;a href="https://www.postgresql.org/docs/current/explicit-locking.html" rel="noopener noreferrer"&gt;https://www.postgresql.org/docs/current/explicit-locking.html&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I reconcile provider callbacks without guesswork
&lt;/h2&gt;

&lt;p&gt;Webhook handlers are where many auth email systems get weird. The provider says "delivered" or "bounced," but the app no longer knows whether that callback belongs to the current token, a canceled request, or an email retried from an older deploy.&lt;/p&gt;

&lt;p&gt;My rule is simple:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Match webhook events by provider message id first.&lt;/li&gt;
&lt;li&gt;Resolve the ledger row.&lt;/li&gt;
&lt;li&gt;Join back to the outbox row and token row.&lt;/li&gt;
&lt;li&gt;Update status append-only where possible, not by deleting old states.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That gives you a safer incident story. You can answer questions like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Was the latest password reset email accepted?&lt;/li&gt;
&lt;li&gt;Did the callback refer to a superseded token?&lt;/li&gt;
&lt;li&gt;Did two workers ever try to send the same event key?&lt;/li&gt;
&lt;li&gt;Was the final user-visible state delayed by the provider, or by our own queue?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I also like keeping one small admin query or internal endpoint for these checks. During an incident, engineers should not need to grep random logs across containers just to learn whether a message was sent once or three times. Fast answers are what keep the system feeling calm, even when the root issue is messy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Q&amp;amp;A
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Should the ledger replace the outbox?
&lt;/h3&gt;

&lt;p&gt;No. The outbox answers whether work needs to happen. The ledger answers what happened after work started. Keeping those concerns split makes the write path simpler and the audit path clearer.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is this too much for a small product?
&lt;/h3&gt;

&lt;p&gt;Not really. Three lean tables and a couple of indexed joins are cheap compared with the time lost during auth incidents. It is a lot less work than rebuilding the timeline from logs every time something feels off.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the first field to add if I already have an outbox?
&lt;/h3&gt;

&lt;p&gt;Add a durable correlation id that appears in the API logs, worker logs, and provider callback handling. That one field makes later cleanup much easyer, and it usually exposes the next missing piece right away.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>node</category>
      <category>restapi</category>
      <category>authentication</category>
    </item>
    <item>
      <title>Idempotent Signup Emails in Node.js APIs</title>
      <dc:creator>kevindev</dc:creator>
      <pubDate>Tue, 07 Jul 2026 05:23:53 +0000</pubDate>
      <link>https://dev.to/kevindev27/idempotent-signup-emails-in-nodejs-apis-2k4m</link>
      <guid>https://dev.to/kevindev27/idempotent-signup-emails-in-nodejs-apis-2k4m</guid>
      <description>&lt;p&gt;Signup email bugs are rarely caused by SMTP alone. In most backend systems, the real problem is that account creation, retries, and background jobs do not agree on what "sent once" means. I have seen well-structured services still send two welcome messages because the API timed out after commit, the worker retried, and nobody had a durable delivery key tying the steps together.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why duplicate signup mail still happens in good systems
&lt;/h2&gt;

&lt;p&gt;The risky part is usually the boundary between the write path and the notification path. A &lt;code&gt;POST /signup&lt;/code&gt; request inserts a user, stores a verification token, and publishes a job. If the HTTP layer retries or the worker crashes after send, you can end up with duplicate mail even though each subsystem looks healthy on its own.&lt;/p&gt;

&lt;p&gt;In practice, I keep seeing four causes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The API uses a request id for logs, but not for email deduplication.&lt;/li&gt;
&lt;li&gt;The worker checks "has token" instead of "has delivery record".&lt;/li&gt;
&lt;li&gt;Retries are safe for database writes, but not for side effects.&lt;/li&gt;
&lt;li&gt;Teams test with a shared inbox, so duplicate messages are easy to miss at first glance.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This matters because signup email is often the first trust signal a product sends. If a user receives two verification links, or one old link after a retry, confidence drops realy fast.&lt;/p&gt;

&lt;h2&gt;
  
  
  The backend contract I use for idempotent email delivery
&lt;/h2&gt;

&lt;p&gt;The most reliable pattern I have used is simple: create a dedicated outbox record inside the same transaction as the user and token write, then let the mail worker claim that record exactly once.&lt;/p&gt;

&lt;p&gt;My minimum contract looks like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;users&lt;/code&gt; stores the account row.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;email_verifications&lt;/code&gt; stores the active token and expiry.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;email_outbox&lt;/code&gt; stores the message type, recipient, payload hash, and a unique idempotency key.&lt;/li&gt;
&lt;li&gt;The worker updates &lt;code&gt;sent_at&lt;/code&gt; with &lt;code&gt;WHERE sent_at IS NULL&lt;/code&gt; so retries do not double-send.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last condition is the boring detail that saves you later. If the process dies after the provider accepts the message, a retry will re-read the row, see &lt;code&gt;sent_at&lt;/code&gt;, and move on. If the provider call fails before acceptance, the row stays unsent and the worker can retry cleanly.&lt;/p&gt;

&lt;p&gt;For staging and QA, I sometimes route confirmation mail to a &lt;a href="https://tempmailso.com" rel="noopener noreferrer"&gt;use and throw email&lt;/a&gt; inbox created per test run. When I need a second isolated check in the same suite, a separate &lt;a href="https://tempmailso.com" rel="noopener noreferrer"&gt;disposable address&lt;/a&gt; helps keep concurrent workers from reading each other's messages. The old phrase &lt;code&gt;dummy e mail&lt;/code&gt; still sneaks into test names in some repos, but I prefer explicit naming around signup intent and environment.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Node.js implementation that survives retries
&lt;/h2&gt;

&lt;p&gt;Here is the shape I tend to keep in a Node.js service:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;trx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;one&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`insert into users (email, password_hash)
     values ($1, $2)
     on conflict (email) do update set email = excluded.email
     returning id, email`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;passwordHash&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;verification&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;trx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;one&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`insert into email_verifications (user_id, token, expires_at)
     values ($1, $2, now() + interval '30 minutes')
     returning token`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;trx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;none&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`insert into email_outbox (event_key, user_id, template, payload)
     values ($1, $2, 'signup_verification', $3)
     on conflict (event_key) do nothing`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;`signup:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;verification&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;verification&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="p"&gt;}]&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the worker claims pending rows in small batches:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;update&lt;/span&gt; &lt;span class="n"&gt;email_outbox&lt;/span&gt;
&lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="n"&gt;claimed_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;
  &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;email_outbox&lt;/span&gt;
  &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;sent_at&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;
    &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;claimed_at&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;
  &lt;span class="k"&gt;order&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;update&lt;/span&gt; &lt;span class="n"&gt;skip&lt;/span&gt; &lt;span class="n"&gt;locked&lt;/span&gt;
  &lt;span class="k"&gt;limit&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;returning&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I like this approach because the API stays quick, the worker stays retry-friendly, and PostgreSQL does the concurrency control work it is already very good at. The 2024 DORA report keeps reinforcing the value of short, reliable feedback loops in delivery systems, and this pattern helps because failures stay local and debuggable instead of turning into weird inbox heisenbugs later: &lt;a href="https://dora.dev/research/" rel="noopener noreferrer"&gt;https://dora.dev/research/&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;One small but useful rule: log the outbox id, user id, and template name together on every provider call. When support asks why a customer got no message, or got two, you can answer in minuts instead of diffing scattered traces.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I test the flow without polluting shared inboxes
&lt;/h2&gt;

&lt;p&gt;For signup flows, I do not just assert that an email arrived. I assert that one and only one valid message arrived for the right event key.&lt;/p&gt;

&lt;p&gt;That test usually covers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;one API request that succeeds on the first try&lt;/li&gt;
&lt;li&gt;one simulated retry from the client or gateway&lt;/li&gt;
&lt;li&gt;one worker retry after an injected transport failure&lt;/li&gt;
&lt;li&gt;one inbox assertion that checks message count and token freshness&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you already do &lt;a href="https://dev.to/jasonmills94/bluegreen-release-emails-for-kubernetes-ops-3bff"&gt;release email checks in CI&lt;/a&gt; or other forms of &lt;a href="https://dev.to/jasonmills94/how-to-test-kubernetes-rollback-emails-without-inbox-guesswork-j8j"&gt;isolated inbox validation&lt;/a&gt;, the same principle applies here: isolate the mailbox per run, then verify the backend contract instead of trusting the latest message you happen to see.&lt;/p&gt;

&lt;p&gt;Shared QA inboxes are where teams get tricked. A fake emails generator can be useful for quick manual checks, but for automated suites I want deterministic inbox ownership and a message count assertion tied to the event key. Otherwise, parallel tests hide duplicates until a customer reports them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Q&amp;amp;A
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Should I send mail inline during signup?
&lt;/h3&gt;

&lt;p&gt;Usually no. If the provider is slow, your REST API latency becomes noisy and retries become harder to reason about. A transactional outbox is more boring, and that is exactly why it works.&lt;/p&gt;

&lt;h3&gt;
  
  
  What should the idempotency key include?
&lt;/h3&gt;

&lt;p&gt;Include the event type plus the business identity of the send. For signup verification, I usually key by user id and token or by a stable signup event id. Do not key only by email address, or you will block legitimate future sends.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is PostgreSQL enough, or do I need a queue first?
&lt;/h3&gt;

&lt;p&gt;PostgreSQL is enough for many products if the write volume is moderate and the worker batches cleanly. Add a separate queue when throughput, fan-out, or cross-service routing actually demands it, not before. Starting simple is often the beter trade.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>node</category>
      <category>restapi</category>
      <category>authentication</category>
    </item>
  </channel>
</rss>
