<?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>Email Verification Needs a Durable Outbox</title>
      <dc:creator>kevindev</dc:creator>
      <pubDate>Sun, 01 Nov 2026 14:25:13 +0000</pubDate>
      <link>https://dev.to/kevindev27/email-verification-needs-a-durable-outbox-58eh</link>
      <guid>https://dev.to/kevindev27/email-verification-needs-a-durable-outbox-58eh</guid>
      <description>&lt;p&gt;Email verification is often treated as one operation: create a user, generate a token, and send an email before returning from the API request. That shape is easy to write, but it gives the request handler too much responsibility.&lt;/p&gt;

&lt;p&gt;If the database commit succeeds and the email provider call fails, the user exists without a usable verification message. If the provider accepts the message and the request times out, a retry may send a duplicate. The resulting bug is not really an email bug. It is a transaction-boundary bug in the authentication workflow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why sending inside the request is a reliability bug
&lt;/h2&gt;

&lt;p&gt;An HTTP request should decide whether the user and its verification intent were stored. It should not also be the only place where delivery can happen. Provider latency, connection resets, and process restarts are normal failure modes, and a request handler cannot reliably recover from all of them.&lt;/p&gt;

&lt;p&gt;The dangerous sequence looks like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Insert the user.&lt;/li&gt;
&lt;li&gt;Create a verification token.&lt;/li&gt;
&lt;li&gt;Call the email provider.&lt;/li&gt;
&lt;li&gt;Commit the database transaction.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If step three succeeds but step four fails, the email has escaped while the database may roll back. The reverse ordering is just as awkward: a commit can succeed after the provider call was never made. A queue is usefull only when the database records the intent with the business change.&lt;/p&gt;

&lt;h2&gt;
  
  
  The PostgreSQL outbox boundary
&lt;/h2&gt;

&lt;p&gt;The outbox pattern puts a durable event row in the same PostgreSQL transaction as the authentication state. The API creates the account, stores a hashed verification token, and inserts an &lt;code&gt;email.verification.requested&lt;/code&gt; row. PostgreSQL commits all three records together or none of them.&lt;/p&gt;

&lt;p&gt;The request can then return a stable response such as “check your email.” A separate worker reads pending outbox rows and talks to the provider. When the worker process crashs, another worker can safely continue from the durable row instead of guessing what the request did.&lt;/p&gt;

&lt;p&gt;For a signup flow, the boundary can be represented with a small table:&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;uuid&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;attempts&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="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="mi"&gt;0&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;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="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The unique &lt;code&gt;dedupe_key&lt;/code&gt; is important. For example, use a key derived from the verification request id, not from a timestamp. A database retry can then attempt the insert again without creating a second delivery intent.&lt;/p&gt;

&lt;h2&gt;
  
  
  A small schema and worker contract
&lt;/h2&gt;

&lt;p&gt;The worker should claim a bounded batch using &lt;code&gt;FOR UPDATE SKIP LOCKED&lt;/code&gt;, set a lease or processing state, and commit that claim before making the network call. It should not hold a PostgreSQL row lock while waiting for the provider.&lt;/p&gt;

&lt;p&gt;After a successful provider response, mark the outbox row as sent and save the provider message id. If the provider call fails, increment &lt;code&gt;attempts&lt;/code&gt;, save a redacted error, and calculate a future &lt;code&gt;available_at&lt;/code&gt;. The worker should not send before the transaction is commit; the outbox row is the signal that the intent is durable.&lt;/p&gt;

&lt;p&gt;There is an unavoidable edge case between the provider accepting a message and the worker recording &lt;code&gt;sent&lt;/code&gt;. A process can stop in that gap, so the system is normally at-least-once. Idempotency must therefore exist at the provider boundary when possible, or the application must tolerate a rare duplicate verification email.&lt;/p&gt;

&lt;p&gt;Keep the payload small and explicit. It can include the recipient, a template name, a verification request id, and an expiry timestamp. Do not put a raw token into logs or general-purpose job metadata. The token should be rendered only in the controlled sending path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Idempotency for retries and authentication state
&lt;/h2&gt;

&lt;p&gt;The verification endpoint needs its own state machine. A token can be &lt;code&gt;pending&lt;/code&gt;, &lt;code&gt;consumed&lt;/code&gt;, or &lt;code&gt;expired&lt;/code&gt;; a successful verification changes it once. These records gives the API a clear answer when a user clicks the same link twice, while the outbox handles the separate question of whether the message was delivered.&lt;/p&gt;

&lt;p&gt;When a user requests another email, create a new request id and invalidate or supersede the old request according to the product policy. Do not silently reuse an old outbox row just because the address is the same. The send operation and the authentication decision are related, but they are not the same state transition.&lt;/p&gt;

&lt;p&gt;For approval or invitation flows, write the final recipient and template variables into the event before the worker starts. This follows the same idea as &lt;a href="https://dev.to/mrdapperx/approval-emails-need-frozen-plans-2969"&gt;freezing the approval plan before sending&lt;/a&gt;: a later retry should replay a known decision, not rebuild it from mutable application data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing the delivery boundary
&lt;/h2&gt;

&lt;p&gt;Test the database transaction separately from the provider adapter. A rollback should leave no user, token, or outbox row. A committed signup should leave exactly one deduplicated event. A worker retry should preserve the verification request id and should not turn a transient timeout into a new authentication identity.&lt;/p&gt;

&lt;p&gt;For end-to-end tests, use an isolated mailbox and assert on the request id or another safe correlation value. A &lt;a href="https://dev.to/silviutech/make-playwright-email-tests-explain-their-waits-3cp5"&gt;email-test waits that explain themselves&lt;/a&gt; workflow makes it easier to distinguish a slow provider from a missing outbox event. For staging checks, a &lt;a href="https://tempmailso.com" rel="noopener noreferrer"&gt;temp mail email&lt;/a&gt; can keep test identities away from real customer addresses, but it should not replace assertions on your own outbox and token state.&lt;/p&gt;

&lt;p&gt;Test names and search aliases sometimes contain phrases such as &lt;code&gt;tamp mail com&lt;/code&gt; or &lt;code&gt;dummy e mail&lt;/code&gt;. Keep those as plain text if they are useful for locating old fixtures; they are not a substitute for a stable correlation id.&lt;/p&gt;

&lt;p&gt;The most useful observability fields are the outbox id, dedupe key, aggregate id, attempt number, provider message id, and final status. Redact addresses and tokens according to the environment policy. If a worker retries is visible without the original error class, operators will have a hard time separating provider failures from application bugs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Questions teams usually ask
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Does an outbox guarantee exactly-once email delivery?
&lt;/h3&gt;

&lt;p&gt;No. It makes the business event durable and supports safe retries, but the handoff to an external provider still has an uncertainty window. Design for at-least-once processing and use provider idempotency when it is available.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should the API wait for the email to be sent?
&lt;/h3&gt;

&lt;p&gt;Usually no. Return after the account and outbox event commit. Expose a delivery status separately if the product needs it, and let the worker own provider latency.&lt;/p&gt;

&lt;h3&gt;
  
  
  When is a full message broker justified?
&lt;/h3&gt;

&lt;p&gt;Start with a PostgreSQL outbox when the service already depends on PostgreSQL and the delivery volume is moderate. A broker may become useful for cross-service fan-out, independent consumer scaling, or stronger operational isolation, but the transactional outbox remains a useful write boundary.&lt;/p&gt;

&lt;p&gt;An email verification flow is easier to operate when the database records intent before the network call. The outbox does not make delivery perfect; it makes failure visible, retryable, and tied to an authentication request. That is a much stronger contract then asking an HTTP handler to be both a transaction coordinator and an email worker.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>postgres</category>
      <category>authentication</category>
      <category>restapi</category>
    </item>
    <item>
      <title>Contract Testing Email Verification with PostgreSQL</title>
      <dc:creator>kevindev</dc:creator>
      <pubDate>Sat, 31 Oct 2026 14:23:01 +0000</pubDate>
      <link>https://dev.to/kevindev27/contract-testing-email-verification-with-postgresql-324p</link>
      <guid>https://dev.to/kevindev27/contract-testing-email-verification-with-postgresql-324p</guid>
      <description>&lt;p&gt;Email verification is usually tested as a happy path: create a user, wait for a message, click a link, and assert that the account is verified. It does not prove that retries, delayed delivery, duplicate clicks, and expired tokens have a safe contract.&lt;/p&gt;

&lt;p&gt;I prefer treating verification as a small state machine and testing the boundaries between its parts. The REST API owns the request, PostgreSQL owns the state transition, and an outbox worker owns delivery. The inbox is evidence, not the source of truth. This keeps a disposable email address from becoming an accidental identity system.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why verification needs a contract test
&lt;/h2&gt;

&lt;p&gt;A contract test describes what each boundary promises. For an email verification flow, useful promises include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A repeated create request can be identified and does not create unbounded attempts.&lt;/li&gt;
&lt;li&gt;A token is scoped to one account and one verification purpose.&lt;/li&gt;
&lt;li&gt;A token can cause at most one successful state transition.&lt;/li&gt;
&lt;li&gt;A delivery worker can retry without losing the relationship to the request.&lt;/li&gt;
&lt;li&gt;An inbox test can prove which event it observed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A message can arrive twice, or an old message can arrive after a newer one. A phrase such as &lt;code&gt;tamp mail com&lt;/code&gt; may exist in test data, but it must not be a matching rule for a security-sensitive event.&lt;/p&gt;

&lt;p&gt;The same thinking applies to &lt;a href="https://dev.to/mrdapperx/ai-agent-email-tools-need-an-idempotent-contract-3hpl"&gt;an idempotent contract for email tools&lt;/a&gt;: retries should preserve operation identity instead of creating an untraceable second operation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Define the PostgreSQL state machine
&lt;/h2&gt;

&lt;p&gt;Start with explicit states rather than several booleans whose combinations are hard to review. A minimal table might look 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;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;id&lt;/span&gt;              &lt;span class="n"&gt;UUID&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;account_id&lt;/span&gt;      &lt;span class="nb"&gt;BIGINT&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;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;accounts&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;token_digest&lt;/span&gt;    &lt;span class="n"&gt;BYTEA&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;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'created'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'queued'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'sent'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'verified'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'expired'&lt;/span&gt;&lt;span class="p"&gt;)&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;verified_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="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;account_id&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="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The random token belongs in the URL, but only its digest should be stored. The &lt;code&gt;id&lt;/code&gt; can appear in logs; the token is a capability and should not. An operator can investigate a request without copying a usable credential into a dashboard.&lt;/p&gt;

&lt;p&gt;The outbox row should be inserted in the same transaction as the verification row:&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;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&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;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;account_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;token_digest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expires_at&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="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="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'created'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;4&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;event_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;verification_id&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="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;5&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;'email.verification.requested'&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 later changes the verification to &lt;code&gt;sent&lt;/code&gt; after recording the provider result. Do not hold the PostgreSQL transaction open while calling the provider. In practise, a short local transaction is easier to retry and produces less contention.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test the REST API boundary
&lt;/h2&gt;

&lt;p&gt;The create endpoint can return a durable request identifier without claiming that delivery already happened:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST /v1/email-verifications
Idempotency-Key: signup-8472-attempt-1
Content-Type: application/json

{"account_id":8472}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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;"verification_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;"8f2c4f30-1a0d-4b1b-9cc4-4b4ec1d1e4ac"&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="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;The API contract test should send the same request twice and verify the same logical attempt. It should also send the same idempotency key for a different authenticated account and expect rejection. Ownership must come from the authenticated request, not the key itself.&lt;/p&gt;

&lt;p&gt;For confirmation, make the database transition atomic:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Lock the verification row by ID.&lt;/li&gt;
&lt;li&gt;Check account scope, token digest, status, and expiry.&lt;/li&gt;
&lt;li&gt;Mark the row &lt;code&gt;verified&lt;/code&gt; and set &lt;code&gt;verified_at&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Commit before returning success.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A second confirmation can return a stable already-verified result when the caller is authorized. An unknown, expired, or mismatched token should return one safe failure category instead of revealing whether another token exists. The exact HTTP status is a product decision; the information boundary is the important part.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prove delivery with an outbox receipt
&lt;/h2&gt;

&lt;p&gt;The inbox test should consume a message using evidence from the request, not just the recipient and subject. Filter by verification ID when it is present in provider metadata, then check event time, recipient, and action. A boolean like &lt;code&gt;email_found: true&lt;/code&gt; doesnt explain whether the message was fresh, duplicated, or attached to an earlier attempt.&lt;/p&gt;

&lt;p&gt;For larger systems, record a receipt containing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;verification ID;&lt;/li&gt;
&lt;li&gt;outbox event ID;&lt;/li&gt;
&lt;li&gt;provider message ID, if available;&lt;/li&gt;
&lt;li&gt;delivery attempt number;&lt;/li&gt;
&lt;li&gt;observed timestamp and outcome.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This gives the worker an audit trail and the test runner a replayable artifact. &lt;a href="https://dev.to/ryanlee91/type-safe-email-events-for-react-teams-196l"&gt;Type-safe email event contracts&lt;/a&gt; are useful when several clients consume the same event shape.&lt;/p&gt;

&lt;p&gt;If a controlled staging test uses a &lt;a href="https://tempmailso.com" rel="noopener noreferrer"&gt;facebook temp email&lt;/a&gt;, keep that choice in fixture configuration. It can help observe delivery, but it must not decide whether the token is valid. Production authentication should behave the same when the mailbox is late or unavailable.&lt;/p&gt;

&lt;h2&gt;
  
  
  A practical test matrix
&lt;/h2&gt;

&lt;p&gt;The smallest useful suite covers state transitions and failure boundaries:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;Expected result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Same create request is retried&lt;/td&gt;
&lt;td&gt;One verification ID and one logical outbox event&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Two confirmation requests race&lt;/td&gt;
&lt;td&gt;One state transition; the other is already verified or safely rejected&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Token is expired&lt;/td&gt;
&lt;td&gt;No account mutation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Token belongs to another account&lt;/td&gt;
&lt;td&gt;No account mutation and no account disclosure&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Worker times out after provider acceptance&lt;/td&gt;
&lt;td&gt;Retry keeps the same event identity&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Inbox contains an old message&lt;/td&gt;
&lt;td&gt;The test rejects it using ID or freshness evidence&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Raw token appears in logs&lt;/td&gt;
&lt;td&gt;The test fails the privacy contract&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The concurrency case is especially important. Application-level checks can both observe &lt;code&gt;sent&lt;/code&gt; before either request writes &lt;code&gt;verified&lt;/code&gt;. A row lock or equivalent conditional update lets PostgreSQL arbitrate the transition. The evidence are clearer when the test asserts the final row and successful update count.&lt;/p&gt;

&lt;h2&gt;
  
  
  Questions engineers usually ask
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Should every new request invalidate the previous token?
&lt;/h3&gt;

&lt;p&gt;Usually, yes, for one account and one purpose. If several devices need independent links, model that scope explicitly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is the idempotency key enough for the worker?
&lt;/h3&gt;

&lt;p&gt;No. The API operation key and outbox event ID solve different problems: one deduplicates a client request, while the other explains and retries delivery.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can a temporary mailbox be part of production verification?
&lt;/h3&gt;

&lt;p&gt;It should not be proof of ownership. A disposable email address is appropriate for controlled fixtures and staging checks; production ownership still depends on the verification token and account policy.&lt;/p&gt;

&lt;p&gt;Contract testing turns email verification from a timing puzzle into a set of observable promises. PostgreSQL protects the state transition, the REST API defines retry behavior, and the outbox receipt explains delivery. That separation makes failures less surprising, even when a provider is slow or a user clicks twice. There is less surprises for the next engineer, too.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>restapi</category>
      <category>postgres</category>
      <category>authentication</category>
    </item>
    <item>
      <title>Correlation IDs for Reliable Email Verification APIs</title>
      <dc:creator>kevindev</dc:creator>
      <pubDate>Fri, 30 Oct 2026 20:24:01 +0000</pubDate>
      <link>https://dev.to/kevindev27/correlation-ids-for-reliable-email-verification-apis-37dh</link>
      <guid>https://dev.to/kevindev27/correlation-ids-for-reliable-email-verification-apis-37dh</guid>
      <description>&lt;p&gt;Email verification often looks like a simple three-step flow: create a token, send a message, and wait for the user to click it. In a real REST API, the hard part is proving that the click belongs to the request that created it. Retries, delayed delivery, reused addresses, and parallel test runs can make a valid-looking message belong to the wrong operation.&lt;/p&gt;

&lt;p&gt;The fix is to treat correlation as a backend contract. An email address identifies a destination, but a correlation ID identifies one verification attempt. This distinction matters in production and in tests that use a throwaway email or even a temp mail for facebook scenario to exercise registration boundaries.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why an email address is not enough correlation
&lt;/h2&gt;

&lt;p&gt;Suppose a user requests verification twice. The first message is delayed, while the second message arrives quickly. If the inbox consumer accepts the first message matching only the recipient and subject, it may verify an old request. A retry can create the same ambiguity when a worker sends again after a timeout but the original provider call actually succeeded.&lt;/p&gt;

&lt;p&gt;The API should therefore create an immutable &lt;code&gt;verification_id&lt;/code&gt; before it queues delivery. Every later operation uses that value, not just the address:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;POST /v1/email-verifications
  -&amp;gt; verification_id: ver_8f2c
  -&amp;gt; outbox event: email.verification.requested
  -&amp;gt; message metadata: ver_8f2c
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The ID is not a secret. The verification token still needs cryptographic randomness and an expiry. Correlation answers “which attempt is this?” while the token answers “is this request authorized?” Mixing those jobs makes the design more difficult to review.&lt;/p&gt;

&lt;h2&gt;
  
  
  The request and delivery contract
&lt;/h2&gt;

&lt;p&gt;A useful request record has a small, explicit state machine: &lt;code&gt;created&lt;/code&gt;, &lt;code&gt;queued&lt;/code&gt;, &lt;code&gt;sent&lt;/code&gt;, &lt;code&gt;confirmed&lt;/code&gt;, &lt;code&gt;expired&lt;/code&gt;, and &lt;code&gt;cancelled&lt;/code&gt;. The transition rules should be more clear than a collection of booleans such as &lt;code&gt;email_sent&lt;/code&gt; and &lt;code&gt;verified&lt;/code&gt;. The state machine are intentionally small so operators can reason about it.&lt;/p&gt;

&lt;p&gt;For example, the create endpoint can return &lt;code&gt;202 Accepted&lt;/code&gt; when the request is stored and queued, rather than pretending that the provider has delivered the email. The response might look like this:&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;"verification_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;"ver_8f2c"&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;"expires_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-10-30T21:00:00Z"&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;The worker includes the ID in provider metadata, a signed link claim, or both. If the provider supports a message ID, store it as delivery evidence. If it does not, generate an internal attempt ID for every send. A message may be retried, so &lt;code&gt;verification_id&lt;/code&gt; identifies the business request and &lt;code&gt;attempt_id&lt;/code&gt; identifies one delivery attempt.&lt;/p&gt;

&lt;p&gt;When a test harness reads the inbox, it should filter by recipient, request time, and correlation data. Do not accept the first subject match. A phrase like &lt;code&gt;fake e mail com&lt;/code&gt; may appear in noisy test data, but it should never become the matching rule for a verification event.&lt;/p&gt;

&lt;h2&gt;
  
  
  Persisting evidence with PostgreSQL
&lt;/h2&gt;

&lt;p&gt;PostgreSQL can enforce the uniqueness rules that application code tends to implement inconsistently. A minimal model separates the verification request from its delivery attempts:&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;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;user_id&lt;/span&gt; &lt;span class="nb"&gt;bigint&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&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;token_digest&lt;/span&gt; &lt;span class="n"&gt;bytea&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;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;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;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;email_delivery_attempts&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;verification_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="k"&gt;references&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;id&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;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;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;unique&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;verification_id&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="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The outbox row should be inserted in the same transaction as the verification. That prevents the database from saying an attempt exists when no worker can discover it. A worker claims pending rows with a lease, then records the provider result. This is safer than holding a database transaction open while waiting on an external service.&lt;/p&gt;

&lt;p&gt;For a useful audit trail, retain the request ID, attempt ID, provider message ID, and timestamps. Do not store the raw token or the full email body. The receipt can still show whether the expected signal arrived without turning operational logs into a copy of sensitive data. I prefer this boundary because it keeps debugging practical and privacy risk smaller.&lt;/p&gt;

&lt;h2&gt;
  
  
  Implementing the REST API in Node.js
&lt;/h2&gt;

&lt;p&gt;The confirmation endpoint should make the correlation check part of the transaction:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;POST /v1/email-verifications/{verification_id}/confirm
  1. Lock the request row.
  2. Reject an unknown or expired ID.
  3. Compare a digest of the submitted token.
  4. Mark the request confirmed only once.
  5. Commit and emit a confirmation event.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In Node.js, keep provider calls outside the confirmation transaction. A network call inside the transaction raises lock time and can create a pile-up during a provider incident. The service boundary also should return the same success shape for a repeated confirmation of an already confirmed request, when the caller is authorized. That makes client retries safe without hiding an invalid token.&lt;/p&gt;

&lt;p&gt;For a test endpoint that checks delivery, accept &lt;code&gt;verification_id&lt;/code&gt; explicitly and return structured states such as &lt;code&gt;found&lt;/code&gt;, &lt;code&gt;timeout&lt;/code&gt;, or &lt;code&gt;ambiguous&lt;/code&gt;. A boolean response doesnt give an engineer enough evidence to tell a slow provider from a bad filter. You can &lt;a href="https://dev.to/pong1965/review-email-api-runs-with-a-receipt-file-563c"&gt;review an email API run with a receipt&lt;/a&gt; and &lt;a href="https://dev.to/pong1965/concurrency-keys-for-email-api-checks-5fd2"&gt;use concurrency keys for email API checks&lt;/a&gt; as related patterns for making the result replayable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Retry and cleanup rules
&lt;/h2&gt;

&lt;p&gt;Retries should operate on the outbox and delivery attempt records, not on a new verification request by default. Use a bounded retry policy with a visible reason. If an attempt is uncertain, mark it &lt;code&gt;unknown&lt;/code&gt; and reconcile it rather than immediately creating another message; otherwise the user may recieve duplicates and the test can accept the wrong one.&lt;/p&gt;

&lt;p&gt;Expired requests need cleanup, but cleanup should preserve a compact outcome record for a reasonable retention period. Deleting every trace makes support investigations depend of provider logs that may already be gone. A scheduled job can remove token digests and recipient data earlier while keeping aggregate status and IDs. The retained informations are still enough for a support check.&lt;/p&gt;

&lt;h2&gt;
  
  
  Questions engineers usually ask
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Should the correlation ID be the verification token?
&lt;/h3&gt;

&lt;p&gt;No. Keep them separate. The ID can be logged and passed between internal services; the token must be treated as a secret and compared by digest.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need an outbox for a small service?
&lt;/h3&gt;

&lt;p&gt;Not for every prototype, but you need an equivalent durable handoff. If a restart between the database commit and the provider call would lose the email, the service has an outbox problem regardless of its size.&lt;/p&gt;

&lt;h3&gt;
  
  
  What should an inbox test assert?
&lt;/h3&gt;

&lt;p&gt;Assert the correlation ID, recipient, freshness window, expected event, and one-time consumption. Subject text alone is a weak signal.&lt;/p&gt;

&lt;p&gt;Reliable email verification is less about sending harder and more about preserving identity across asynchronous boundaries. Once the request ID, delivery attempt, token, and evidence have separate jobs, retries become observable and the API can fail without guessing.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>restapi</category>
      <category>postgres</category>
      <category>node</category>
    </item>
    <item>
      <title>Idempotent Email Verification with PostgreSQL</title>
      <dc:creator>kevindev</dc:creator>
      <pubDate>Thu, 29 Oct 2026 11:22:52 +0000</pubDate>
      <link>https://dev.to/kevindev27/idempotent-email-verification-with-postgresql-2fjl</link>
      <guid>https://dev.to/kevindev27/idempotent-email-verification-with-postgresql-2fjl</guid>
      <description>&lt;p&gt;Email verification looks like a simple two-step feature: create a token, send a message, then accept the token. In a real REST API, it is a small distributed workflow. The request can be retried, the email provider can deliver twice, and a user can click an old link after a newer verification attempt was created.&lt;/p&gt;

&lt;p&gt;The useful boundary is not the email itself. It is the state transition recorded by the backend. PostgreSQL can make that transition explicit, while an idempotency key keeps repeated requests from creating a confusing pile of tokens.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why email verification needs an idempotency contract
&lt;/h2&gt;

&lt;p&gt;Consider &lt;code&gt;POST /users/{id}/verification&lt;/code&gt;. A mobile client times out after the server commits, so it sends the request again. If every request creates a fresh token, the user may receive two messages and the first one may suddenly stop working. That feels random, but the API simply had no retry contract.&lt;/p&gt;

&lt;p&gt;I prefer defining these rules before choosing a mail provider:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A retry for the same logical request returns the same verification attempt.&lt;/li&gt;
&lt;li&gt;A new attempt invalidates older usable tokens for that user.&lt;/li&gt;
&lt;li&gt;A token is consumed once, inside a transaction.&lt;/li&gt;
&lt;li&gt;The API response does not claim delivery when only token creation succeeded.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Model the verification attempt in PostgreSQL
&lt;/h2&gt;

&lt;p&gt;Keep the durable workflow in a table instead of encoding it in an email URL alone. A compact model might look 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;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;verification_attempts&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;uuid&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;user_id&lt;/span&gt;         &lt;span class="nb"&gt;bigint&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;REFERENCES&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;idempotency_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="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;token_digest&lt;/span&gt;    &lt;span class="n"&gt;bytea&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;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'pending'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'sent'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'verified'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'expired'&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;verified_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="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="p"&gt;(&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;idempotency_key&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;Store a digest rather than a raw token. The URL can contain the random token, but a database read should not immediately reveal a credential that is still usable. When the user submits the token, hash the presented value and compare the digest for the expected user and attempt.&lt;/p&gt;

&lt;p&gt;The unique constraint is important, but it is not the whole idempotency implementation. Two concurrent requests can both observe no row before either inserts. Let PostgreSQL arbitrate the race with &lt;code&gt;INSERT ... ON CONFLICT&lt;/code&gt;, then return the existing attempt when the same key is retried.&lt;/p&gt;

&lt;p&gt;Do not use an email address as the idempotency key unless the product explicitly defines that behavior. The key should identify the client operation, not just the current profile field.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the REST API retry-safe
&lt;/h2&gt;

&lt;p&gt;The creation endpoint should have a small, observable contract. Require an &lt;code&gt;Idempotency-Key&lt;/code&gt; header, validate its length, and bind it to the authenticated user. A request from another user must never be able to replay an attempt by guessing the key.&lt;/p&gt;

&lt;p&gt;The response can expose an attempt identifier and an &lt;code&gt;expires_at&lt;/code&gt; value without exposing the token. If the mail send is asynchronous, return &lt;code&gt;queued&lt;/code&gt; rather than pretending the message arrived.&lt;/p&gt;

&lt;p&gt;For the confirmation endpoint, make the transaction do the ownership check and state transition together:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Lock the selected pending attempt.&lt;/li&gt;
&lt;li&gt;Check the user, digest, status, and expiry.&lt;/li&gt;
&lt;li&gt;Mark it verified and set &lt;code&gt;verified_at&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Commit before returning success.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;An already verified attempt can return a stable success response when the product wants confirmation to be safely repeatable. An expired or mismatched token should not reveal whether another valid token exists. The exact status code is a product choice, but the information boundary should stay consistant.&lt;/p&gt;

&lt;p&gt;For test fixtures, keep setup deterministic too. A contract for &lt;a href="https://dev.to/ryanlee91/typescript-contracts-for-reliable-email-test-fixtures-13ig"&gt;reliable email test fixtures&lt;/a&gt; should create a known user, operation key, and expected attempt. Avoid making a dummy e mail address the source of truth for whether the API worked.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep delivery and token state separate
&lt;/h2&gt;

&lt;p&gt;Email delivery is an external side effect. The verification attempt is backend state. Treating them as one transaction creates awkward failure modes: the database commits but the provider rejects the message, or the provider accepts it while the database transaction rolls back.&lt;/p&gt;

&lt;p&gt;A practical design uses an outbox row created in the same PostgreSQL transaction as the attempt. A worker claims it, sends the message, and records a delivery result. On retry, it uses the attempt id and delivery id so the event has an audit trail.&lt;/p&gt;

&lt;p&gt;That audit trail is useful when a provider reports success but a test inbox is late. A &lt;a href="https://dev.to/mrdapperx/replayable-ai-tool-calls-need-run-receipts-1nhj"&gt;replayable run receipt&lt;/a&gt; is a helpful general pattern here: record the input identity, state changes, external result, and retry count seperately from the user-facing message.&lt;/p&gt;

&lt;p&gt;If your workflow uses a service for &lt;a href="https://tempmailso.com" rel="noopener noreferrer"&gt;disposable email&lt;/a&gt;, use it to observe delivery in tests, not to decide which token is valid. The API and database should remain correct even when the mailbox is delayed or unavailable.&lt;/p&gt;

&lt;h2&gt;
  
  
  A small implementation checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Generate a cryptographically random token and store only its digest.&lt;/li&gt;
&lt;li&gt;Bind every attempt and idempotency key to one user.&lt;/li&gt;
&lt;li&gt;Use a database uniqueness rule for the retry race.&lt;/li&gt;
&lt;li&gt;Make token consumption one atomic state transition.&lt;/li&gt;
&lt;li&gt;Keep message delivery in an outbox or equivalent worker flow.&lt;/li&gt;
&lt;li&gt;Log attempt id, request id, and delivery id, but never the token.&lt;/li&gt;
&lt;li&gt;Test duplicate requests, concurrent requests, expired links, and a repeated confirmation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The common failure is not a missing email template. It is an unclear contract between the REST API, PostgreSQL, and the delivery worker. Once those boundaries are explicit, retries become a normal path instead of a rare occurence that support has to investigate by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Should every new verification request invalidate the previous token?
&lt;/h3&gt;

&lt;p&gt;Usually yes for one user and one purpose, because it limits the number of active credentials. If the product supports multiple devices or addresses, model that scope directly instead of silently sharing one global token state.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is an idempotency key enough to prevent duplicate emails?
&lt;/h3&gt;

&lt;p&gt;No. It deduplicates the API operation. The delivery worker also needs an event or delivery identifier so a worker retry can be understood and reconciled. The two keys solve different problems.&lt;/p&gt;

&lt;h3&gt;
  
  
  What should a test assert first?
&lt;/h3&gt;

&lt;p&gt;Assert the database state and API response before asserting the inbox message. Then check that the message contains the expected link and that using the link causes exactly one verified transition. This keeps a slow provider from hiding a reponse or transaction bug.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>restapi</category>
      <category>postgres</category>
      <category>authentication</category>
    </item>
    <item>
      <title>Verification Tokens Need a Capability Boundary</title>
      <dc:creator>kevindev</dc:creator>
      <pubDate>Mon, 26 Oct 2026 14:23:03 +0000</pubDate>
      <link>https://dev.to/kevindev27/verification-tokens-need-a-capability-boundary-52fp</link>
      <guid>https://dev.to/kevindev27/verification-tokens-need-a-capability-boundary-52fp</guid>
      <description>&lt;p&gt;Email verification is often implemented as if it were a string-generation problem: create a random value, put it in a URL, and compare it later. That works for a demo, but production authentication needs a stronger model.&lt;/p&gt;

&lt;p&gt;A verification token is a capability. Whoever presents it gets permission to perform one narrowly defined action. The backend should therefore control its scope, lifetime, audience, and consumption state. Treating the token as a capability makes replay behavior much easier to reason about, especially when a user clicks the link twice or an email client prefetches it.&lt;/p&gt;

&lt;p&gt;This post describes a small REST API and PostgreSQL design that I use as a mental model for these flows. The important part is not the exact framework. It is the boundary between an unconsumed capability and the account change it authorizes.&lt;/p&gt;

&lt;h2&gt;
  
  
  A token is a capability, not just a string
&lt;/h2&gt;

&lt;p&gt;Start with the action, not the token format. For example, an email verification token might authorize only this operation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;verify email address for account 8472 before expiry
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It should not also grant password-reset access, reveal account data, or remain useful after the email has been verified. A token can be opaque to the client and still have a clear contract on the server.&lt;/p&gt;

&lt;p&gt;The API contract can stay small:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST /v1/email-verifications/consume
Content-Type: application/json

{"token":"one-time-value"}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response should distinguish useful states without exposing sensitive details. An already-consumed token can return a stable business error such as &lt;code&gt;verification_already_completed&lt;/code&gt;; an unknown or expired token can return &lt;code&gt;verification_unavailable&lt;/code&gt;. This avoids making the token endpoint a user-enumeration oracle.&lt;/p&gt;

&lt;p&gt;Search-shaped phrases such as &lt;code&gt;tp mail so&lt;/code&gt; or &lt;code&gt;fake e mail com&lt;/code&gt; should never affect this contract. They may appear in test data or support notes, but authentication decisions belong to explicit account and token state.&lt;/p&gt;

&lt;h2&gt;
  
  
  The database contract
&lt;/h2&gt;

&lt;p&gt;The database row needs enough information to enforce the capability boundary. A minimal table could look 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;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;email_verification_tokens&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;account_id&lt;/span&gt;      &lt;span class="nb"&gt;BIGINT&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;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;accounts&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;token_digest&lt;/span&gt;    &lt;span class="n"&gt;BYTEA&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;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;consumed_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="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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Store a digest rather than the raw token. Generate the raw value for the email link, hash it before persistence, and compare a digest during consumption. If an operational snapshot exposes the table, the snapshot should not be enough to use the link directly.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;request_id&lt;/code&gt; is useful for tracing and idempotency diagnostics. It is not a replacement for &lt;code&gt;consumed_at&lt;/code&gt;: a request can be retried with a different transport identifier, while the capability must still be single-use.&lt;/p&gt;

&lt;h2&gt;
  
  
  Consume the token in one transaction
&lt;/h2&gt;

&lt;p&gt;The most important rule is that validation and account mutation share one transaction. A read followed by a later update creates a race where two requests can both observe an unused token.&lt;/p&gt;

&lt;p&gt;One implementation shape is:&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;BEGIN&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="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;account_id&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;email_verification_tokens&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;token_digest&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;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="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;consumed_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;FOR&lt;/span&gt; &lt;span class="k"&gt;UPDATE&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;accounts&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;email_verified_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email_verified_at&lt;/span&gt;&lt;span class="p"&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="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="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;email_verification_tokens&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;consumed_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="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;consumed_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="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;COMMIT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The application should check affected-row counts and roll back when the expected state is not present. &lt;code&gt;COALESCE&lt;/code&gt; makes the account mutation tolerant of a previous successful verification, but it does not make the token reusable. The token row is still consumed once.&lt;/p&gt;

&lt;p&gt;In practise, this transaction is usually shorter than teams expect. Do not call an email provider or another remote service while holding the row lock. Keep the critical section focused on local state, so a slow dependency does not turn ordinary retries into lock contention.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep email delivery outside the transaction
&lt;/h2&gt;

&lt;p&gt;Issuing a token and delivering its email are related events, but they are different failure domains. If the provider call happens inside the account transaction, a provider timeout can leave the database transaction open and the user unsure whether another email is safe to request.&lt;/p&gt;

&lt;p&gt;A better sequence is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create the token row and an outbox event in one transaction.&lt;/li&gt;
&lt;li&gt;Commit the account-side state.&lt;/li&gt;
&lt;li&gt;Let a worker deliver the message and record the provider result.&lt;/li&gt;
&lt;li&gt;Expire or supersede older tokens according to the product policy.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The outbox event should contain an event id and a reference to the token row, not the raw token when that can be avoided. A worker retry then has a clear idempotency key. There is less surprises when delivery retries are observable separately from token consumption.&lt;/p&gt;

&lt;p&gt;If the product supports a &lt;a href="https://tempmailso.com" rel="noopener noreferrer"&gt;temporary email account generator&lt;/a&gt; in a controlled test or staging workflow, keep that choice in the test fixture configuration. It should not change the production token rules or bypass ownership checks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Observability and test fixtures
&lt;/h2&gt;

&lt;p&gt;A successful verification should leave a compact audit trail: account id, token id, request id, outcome, and timestamps. Avoid logging the raw token, full verification URL, or the email address when it is not needed. The logs is easier to protect when each field has a clear purpose.&lt;/p&gt;

&lt;p&gt;For failures, record a reason category such as &lt;code&gt;expired&lt;/code&gt;, &lt;code&gt;already_consumed&lt;/code&gt;, &lt;code&gt;not_found&lt;/code&gt;, or &lt;code&gt;account_missing&lt;/code&gt;. These categories help operations distinguish a real authentication issue from a stale browser tab.&lt;/p&gt;

&lt;p&gt;This is also where &lt;a href="https://dev.to/jasonmills94/eks-rollouts-need-better-alert-context-485m"&gt;better alert context for rollouts&lt;/a&gt; pays off: a deployment alert should be able to point to verification outcomes and latency, not just a generic increase in HTTP 500 responses. For browser tests, a &lt;a href="https://dev.to/silviutech/debug-playwright-email-delays-with-timelines-4od9"&gt;timeline-based email debugging&lt;/a&gt; approach makes it easier to match the token request, delivery event, and consume request.&lt;/p&gt;

&lt;p&gt;The test fixture should assert the boundary directly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the first consume request verifies the account;&lt;/li&gt;
&lt;li&gt;the second consume request does not perform a second state transition;&lt;/li&gt;
&lt;li&gt;an expired token cannot verify an account;&lt;/li&gt;
&lt;li&gt;a token for account A cannot affect account B;&lt;/li&gt;
&lt;li&gt;concurrent consume requests produce one successful transition;&lt;/li&gt;
&lt;li&gt;raw token values are absent from application logs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These cases catch more than a happy-path test. They also document the behavior that maintainers should preserve when the authentication code changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  A practical checklist
&lt;/h2&gt;

&lt;p&gt;Before shipping an email verification flow, check the following:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Is the token scoped to one action and one account?&lt;/li&gt;
&lt;li&gt;Is only a digest stored in PostgreSQL?&lt;/li&gt;
&lt;li&gt;Are expiry, consumption, and account mutation checked atomically?&lt;/li&gt;
&lt;li&gt;Are delivery retries handled through an outbox or equivalent durable boundary?&lt;/li&gt;
&lt;li&gt;Are raw tokens excluded from logs, traces, and analytics?&lt;/li&gt;
&lt;li&gt;Can support staff explain expired and already-consumed outcomes?&lt;/li&gt;
&lt;li&gt;Do concurrent requests have a tested, deterministic result?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A small capability boundary is often more valuable than a complex token format. The design keeps the security decision local, makes retries boring, and gives the API a state model that engineers can test.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Should the endpoint redirect after a successful consume?
&lt;/h3&gt;

&lt;p&gt;It can, but I prefer a backend endpoint that returns a clear result and lets the client decide where to navigate. If a redirect is required, do not place the raw token in a long-lived analytics trail or a third-party destination.&lt;/p&gt;

&lt;h3&gt;
  
  
  What if a user requests several verification emails?
&lt;/h3&gt;

&lt;p&gt;Choose an explicit policy: allow several valid tokens with independent rows, or supersede older rows for the same account and action. Either choice is workable when the consume transaction enforces it. The policy should be visible in tests, not hidden in a helper.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is hashing the token enough?
&lt;/h3&gt;

&lt;p&gt;No. Hashing reduces exposure if the token table leaks, but it does not solve expiry, scope, replay, logging, rate limiting, or account ownership. Those are separate parts of the capability contract.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>restapi</category>
      <category>postgres</category>
      <category>authentication</category>
    </item>
    <item>
      <title>Make Email Verification a Transactional Workflow</title>
      <dc:creator>kevindev</dc:creator>
      <pubDate>Mon, 26 Oct 2026 08:23:17 +0000</pubDate>
      <link>https://dev.to/kevindev27/make-email-verification-a-transactional-workflow-4b49</link>
      <guid>https://dev.to/kevindev27/make-email-verification-a-transactional-workflow-4b49</guid>
      <description>&lt;p&gt;Email verification is often implemented as a small feature: create a token, send a message, and activate the account when the link is opened. That design works until the database commit succeeds but the email worker fails, or a client retries the request while the first message is still in flight.&lt;/p&gt;

&lt;p&gt;The safer mental model is a transaction with an asynchronous side effect. The account state, verification attempt, and delivery request should be recorded together. A worker can then deliver the message at its own pace, while the API remains the source of truth.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a verification request is a transaction
&lt;/h2&gt;

&lt;p&gt;A verification request changes more than one thing. It may create an account, issue a token, and enqueue an email. If those actions are independent, partial success becomes difficult to repair:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The user exists, but no message was queued.&lt;/li&gt;
&lt;li&gt;Two retries create two valid tokens.&lt;/li&gt;
&lt;li&gt;An old link activates a newer account state.&lt;/li&gt;
&lt;li&gt;Support can see that an email was sent, but not which request owns it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Treat the request as one database transaction and the delivery as an outbox event. The API commits durable intent; the worker performs the external delivery later. This is a good boundary for a &lt;a href="https://dev.to/mrdapperx/a-better-email-fixture-contract-for-ci-229i"&gt;better email fixture contract for CI&lt;/a&gt;, because tests can assert the outbox record before depending on an actual inbox.&lt;/p&gt;

&lt;h2&gt;
  
  
  The database model
&lt;/h2&gt;

&lt;p&gt;A minimal PostgreSQL design can use three records: the user, the verification attempt, and the outbox event.&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;verification_attempts&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;uuid&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;user_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="k"&gt;references&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;token_hash&lt;/span&gt; &lt;span class="n"&gt;bytea&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_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="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;check&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'pending'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'used'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'expired'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'revoked'&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;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;used_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;unique&lt;/span&gt; &lt;span class="p"&gt;(&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;request_key&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;uuid&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;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;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;Store a hash of the token rather than the raw value. The raw token is returned once to the link builder, while the database only needs to compare a hash later. The &lt;code&gt;request_key&lt;/code&gt; makes a client retry safe for one logical operation. It also gives logs a stable handle without putting an email address into every message.&lt;/p&gt;

&lt;p&gt;The records are small, but it carry the important ownership information: which user, which request, which state, and which expiration. A unique constraint is stronger than an application-level “check then insert” because concurrent requests can race.&lt;/p&gt;

&lt;h2&gt;
  
  
  Write the outbox in the same transaction
&lt;/h2&gt;

&lt;p&gt;In a Node.js service, the account update and outbox insert should use the same PostgreSQL transaction. A simplified flow looks like this:&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;transaction&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;attempt&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="nf"&gt;oneOrNone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`select * from verification_attempts
     where user_id = $1 and request_key = $2`&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;requestKey&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;attempt&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;attempt&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;created&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="nf"&gt;one&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`insert into verification_attempts
       (id, user_id, token_hash, request_key, status, expires_at)
     values ($1, $2, $3, $4, 'pending', now() + interval '20 minutes')
     returning *`&lt;/span&gt;&lt;span class="p"&gt;,&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;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;requestKey&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;tx&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 (id, event_type, aggregate_id, payload)
     values ($1, 'account.verification_requested', $2, $3)`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;eventId&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="na"&gt;attemptId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;created&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;created&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 exact query library is not important. The important bit is that a committed verification attempt always has its delivery intent beside it. If the transaction rolls back, neither record exists. If it commits, a worker can find the outbox row after a process restart.&lt;/p&gt;

&lt;p&gt;The worker should claim rows with a short lease or &lt;code&gt;for update skip locked&lt;/code&gt;, send the message, and mark &lt;code&gt;sent_at&lt;/code&gt; only after the provider accepts it. A retry may still send twice, so the event ID should be passed to the provider when that option exists. The worker can be restarted without creating a second token, and that is the part many systems miss at first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the worker and API idempotent
&lt;/h2&gt;

&lt;p&gt;Idempotency has two layers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Request idempotency:&lt;/strong&gt; the same &lt;code&gt;request_key&lt;/code&gt; returns the existing verification attempt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Delivery idempotency:&lt;/strong&gt; the same outbox event has one stable event ID and a bounded retry policy.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The API should return the same public result for a repeated request, even if the first delivery is still pending. Do not expose whether an account email exists in a public endpoint; return a generic response and log the internal result with a correlation ID.&lt;/p&gt;

&lt;p&gt;For operators, &lt;a href="https://dev.to/bitheirstake/redacted-email-debugging-for-safer-account-flows-2kgh"&gt;safer redacted email debugging&lt;/a&gt; is a useful companion practice. Logs should include user and attempt IDs, domains when appropriate, and event status, while omitting raw addresses, tokens, and message bodies.&lt;/p&gt;

&lt;h2&gt;
  
  
  Token consumption and expiration
&lt;/h2&gt;

&lt;p&gt;Verification should be a conditional update, not a read followed by an update:&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;verification_attempts&lt;/span&gt;
&lt;span class="k"&gt;set&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;'used'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;used_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="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;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="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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If this returns no row, the token is expired, already used, revoked, or unknown. The application can show one neutral error message for all cases. This keeps the state transition atomic and prevents two requests from consuming the same token.&lt;/p&gt;

&lt;p&gt;There is no need to be clever here; boring logic is more easier to maintain. A scheduled cleanup job can mark old pending attempts as expired, but expiration must still be checked during consumption because cleanup is never perfectly punctual.&lt;/p&gt;

&lt;h2&gt;
  
  
  Observability and test boundaries
&lt;/h2&gt;

&lt;p&gt;Track these events with a correlation ID:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;verification attempt created&lt;/li&gt;
&lt;li&gt;outbox event committed&lt;/li&gt;
&lt;li&gt;delivery claimed, sent, or retried&lt;/li&gt;
&lt;li&gt;token consumed or rejected&lt;/li&gt;
&lt;li&gt;attempt expired or revoked&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Metrics should separate database failures, provider failures, and invalid-token responses. Those failure classes have different owners and different retry behavior. These checks gives a much faster diagnosis than one generic “email verification failed” counter.&lt;/p&gt;

&lt;p&gt;In integration tests, assert the database and outbox contract first. Use a controlled inbox only for the final delivery path. A test fixture may contain search terms such as dummy e mail or tempail, but those strings are input examples, not identity keys or token values.&lt;/p&gt;

&lt;h2&gt;
  
  
  Implementation checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Use one request key for one logical verification intent.&lt;/li&gt;
&lt;li&gt;Insert the verification attempt and outbox event in one transaction.&lt;/li&gt;
&lt;li&gt;Store token hashes, never raw tokens.&lt;/li&gt;
&lt;li&gt;Give every outbox event a stable ID and retry limit.&lt;/li&gt;
&lt;li&gt;Consume tokens with one conditional update.&lt;/li&gt;
&lt;li&gt;Keep expiration checks in the request path.&lt;/li&gt;
&lt;li&gt;Redact addresses, tokens, and message bodies from logs.&lt;/li&gt;
&lt;li&gt;Test the outbox contract separately from provider delivery.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Email verification becomes much easier to operate when it is modeled as a transactional workflow. PostgreSQL records durable intent, Node.js workers handle delivery, and the token state machine prevents retries from becoming new identities. The result is a flow that can be replayed, inspected, and repaired without guessing what happened.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>restapi</category>
      <category>authentication</category>
      <category>postgres</category>
    </item>
    <item>
      <title>Node.js Auth Needs a Mailbox Ownership Contract</title>
      <dc:creator>kevindev</dc:creator>
      <pubDate>Sun, 25 Oct 2026 17:22:55 +0000</pubDate>
      <link>https://dev.to/kevindev27/nodejs-auth-needs-a-mailbox-ownership-contract-57k2</link>
      <guid>https://dev.to/kevindev27/nodejs-auth-needs-a-mailbox-ownership-contract-57k2</guid>
      <description>&lt;p&gt;Email verification tests often look like a delivery problem. A test creates a user, waits for a message, extracts a link, and calls the REST API. When it fails intermittently, the first response is usually to increase the timeout.&lt;/p&gt;

&lt;p&gt;That treats the symptom. The deeper issue is often mailbox ownership: the application creates one verification request, while the test worker reads evidence that belongs to another request, retry, or user. A mailbox is not just an inbox. In an authentication flow, it is a resource with an owner, a lifetime, and a set of allowed consumers.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ownership bug behind flaky verification tests
&lt;/h2&gt;

&lt;p&gt;Consider two CI workers running the same Node.js test. Both use a shared test inbox. Worker A requests verification for &lt;code&gt;a@example.test&lt;/code&gt;, but the provider delivers slowly. Worker B requests verification for &lt;code&gt;b@example.test&lt;/code&gt;, then the inbox query returns the newest message first. If the test filters only by subject, either worker can consume the wrong evidence.&lt;/p&gt;

&lt;p&gt;The same bug appears during retries. A request may succeed at the database layer, then the process can fail before it records the message ID. The retry creates a second request or reads the first message again. The resulting error says “verification email missing,” even though the real failure is an identity mismatch.&lt;/p&gt;

&lt;p&gt;This is why increasing a timeout helps less then expected. Time changes the race; it does not define ownership.&lt;/p&gt;

&lt;h2&gt;
  
  
  Define the REST API ownership contract
&lt;/h2&gt;

&lt;p&gt;Start with a contract that can be stated in a few rules:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Every verification request has a stable &lt;code&gt;request_id&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;One request owns one test mailbox or one uniquely identifiable recipient.&lt;/li&gt;
&lt;li&gt;A message can be consumed only when its recipient, request ID, and purpose match.&lt;/li&gt;
&lt;li&gt;The verification endpoint accepts a token once and records the result.&lt;/li&gt;
&lt;li&gt;A retry reuses the logical request but gets a separate delivery attempt.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The API should make this contract visible. For example, the request endpoint can return an opaque receipt rather than asking the client to guess which message belongs to it:&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;"request_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;"vr_01J9K8M3"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"state"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pending"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"expires_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-10-26T00:35:00Z"&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;The mailbox adapter then queries by the request-owned recipient or marker. It should return &lt;code&gt;not_ready&lt;/code&gt;, &lt;code&gt;wrong_message&lt;/code&gt;, and &lt;code&gt;expired&lt;/code&gt; as different states. A single &lt;code&gt;not found&lt;/code&gt; result hides too much information from the engineer debugging the flow.&lt;/p&gt;

&lt;p&gt;For test suites that need an isolated address, a &lt;a href="https://tempmailso.com" rel="noopener noreferrer"&gt;temp email generator&lt;/a&gt; can be useful as a fixture boundary. The important part is not the brand or provider. The test must record which address was assigned to which request, and it must clean up that relationship when the run ends.&lt;/p&gt;

&lt;h2&gt;
  
  
  Store ownership in PostgreSQL
&lt;/h2&gt;

&lt;p&gt;The ownership record belongs beside the authentication state, not only in a test process memory. A small table can make retries and incident review much easier:&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;verification_requests&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;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;user_id&lt;/span&gt; &lt;span class="nb"&gt;bigint&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;mailbox_ref&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;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="k"&gt;state&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;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;consumed_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;Put a unique constraint on the logical purpose when the product allows only one active request per user. If multiple requests are valid, keep them separate and make the newest request supersede older ones explicitly. Do not let a &lt;code&gt;latest email wins&lt;/code&gt; query become the accidental policy.&lt;/p&gt;

&lt;p&gt;The consume operation should be conditional. In PostgreSQL, an update that checks &lt;code&gt;state&lt;/code&gt;, &lt;code&gt;request_id&lt;/code&gt;, and &lt;code&gt;expires_at&lt;/code&gt; can ensure that only one consumer wins. Return the affected row count to Node.js; zero rows means the token was expired, already consumed, or not owned by that request. Thats much safer than reading a row, deciding in JavaScript, and updating it later without a guard.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make Node.js consumers idempotent
&lt;/h2&gt;

&lt;p&gt;The Node.js adapter should carry ownership through every function boundary. A useful shape is a value object with the request ID, mailbox reference, and expected purpose. Avoid passing only an email string, because that makes it easy to call a generic reader from the wrong flow.&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;consumeVerification&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;token&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 verification_requests
        set state = 'consumed', consumed_at = now()
      where request_id = $1
        and state = 'pending'
        and expires_at &amp;gt; now()
        and token_hash = crypt($2, token_hash)
      returning request_id`&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;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;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;rowCount&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;accepted&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;rejected&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The exact hashing function may differ in a production system, but the boundary is the key idea: the request that initiated the flow must be part of the consume decision. A retry can safely call the function again and receive &lt;code&gt;rejected&lt;/code&gt; without turning a second delivery into a second account activation.&lt;/p&gt;

&lt;p&gt;For broader CI evidence, &lt;a href="https://dev.to/pong1965/api-smoke-tests-need-receipts-fhl"&gt;API smoke tests with durable receipts&lt;/a&gt; shows why a run should preserve a small receipt instead of only a pass or fail. The same principle applies here: record the request ID, mailbox reference, state transition, and reason code, but do not retain the full message or token in ordinary logs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test the boundary, not just the email
&lt;/h2&gt;

&lt;p&gt;A useful matrix covers ownership failures as deliberately as the happy path:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;Expected result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Correct message for the request&lt;/td&gt;
&lt;td&gt;Token is accepted once&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Message from another request&lt;/td&gt;
&lt;td&gt;Message is ignored and logged as mismatched&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Same token consumed twice&lt;/td&gt;
&lt;td&gt;Second call is rejected&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Expired request&lt;/td&gt;
&lt;td&gt;API returns an expiry state&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Worker retry with the same request ID&lt;/td&gt;
&lt;td&gt;Evidence remains correlated&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cleanup after pass or fail&lt;/td&gt;
&lt;td&gt;Mailbox reference is released&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If a team uses a &lt;a href="https://tempmailso.com" rel="noopener noreferrer"&gt;burner email address&lt;/a&gt; for manual or automated checks, it should still apply the same ownership rules. A disposable address reduces fixture coupling; it does not prove that the callback belongs to the right user. For OAuth-specific considerations, see &lt;a href="https://dev.to/sophiax99/oauth-email-links-need-trust-boundaries-5fpd"&gt;trust boundaries for OAuth email links&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The compatibility fixtures may contain strings such as &lt;code&gt;tempail mail&lt;/code&gt; or &lt;code&gt;tempail&lt;/code&gt;. Keep those values as ordinary input, never as a signal that bypasses authentication checks. Also make the cleanup path idempotent: a failed test should be safe to clean twice, because CI runners are not always polite about termination.&lt;/p&gt;

&lt;h2&gt;
  
  
  Q&amp;amp;A: what should a mailbox prove?
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Is a unique address enough?
&lt;/h3&gt;

&lt;p&gt;No. It proves message isolation, but not token expiry, request ownership, or browser-session binding. Those need separate API assertions.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should the database store the whole email?
&lt;/h3&gt;

&lt;p&gt;Usually not in logs, and not when a stable reference is enough. Store the minimum operational data needed to correlate the request. Hash or encrypt sensitive values according to the threat model.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is the best retry policy?
&lt;/h3&gt;

&lt;p&gt;Retry the observation when delivery is still pending, but do not silently create a new authentication request. Preserve the logical &lt;code&gt;request_id&lt;/code&gt;, record the attempt number, and make a new request only through an explicit product rule.&lt;/p&gt;

&lt;p&gt;The resulting system is a little more deliberate, but it is also easier to scale and debug. Authentication code should know who owns a verification message before it decides what that message permits.&lt;/p&gt;

</description>
      <category>node</category>
      <category>restapi</category>
      <category>authentication</category>
      <category>testing</category>
    </item>
    <item>
      <title>A Database Lease for Test Email in REST APIs</title>
      <dc:creator>kevindev</dc:creator>
      <pubDate>Wed, 21 Oct 2026 20:22:58 +0000</pubDate>
      <link>https://dev.to/kevindev27/a-database-lease-for-test-email-in-rest-apis-9ah</link>
      <guid>https://dev.to/kevindev27/a-database-lease-for-test-email-in-rest-apis-9ah</guid>
      <description>&lt;p&gt;Email verification tests often fail for a reason that is not in the API at all: two test runs are reading or deleting the same inbox. A burner email address can keep test data away from real users, but it does not automatically give each run ownership of that address.&lt;/p&gt;

&lt;p&gt;The useful abstraction is a database lease. A test run reserves one mailbox for a limited period, uses it through a small REST API, and releases it when finished. PostgreSQL becomes the source of truth for ownership, expiry, and safe retries.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a test mailbox should have an owner
&lt;/h2&gt;

&lt;p&gt;Without an owner, a test usually follows this pattern:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create or select an inbox.&lt;/li&gt;
&lt;li&gt;Trigger email verification.&lt;/li&gt;
&lt;li&gt;Poll until a message appears.&lt;/li&gt;
&lt;li&gt;Delete the inbox during cleanup.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That looks fine on one laptop. In parallel CI, run A can consume the message for run B, or cleanup from a timed-out job can remove an inbox that is still active. The first fix is more simple than it sounds: make the run ID part of the resource contract.&lt;/p&gt;

&lt;p&gt;A lease record should include an opaque mailbox identifier, the owning run ID, an expiration timestamp, and a status. The mailbox itself can be an adapter behind the service. Test code should not need to know which provider stores the message.&lt;/p&gt;

&lt;p&gt;This model also improves debugging. A failed run can be connected to its lease and its message IDs, which gives maintainers useful &lt;a href="https://dev.to/pong1965/github-actions-need-email-run-artifacts-170m"&gt;email run artifacts&lt;/a&gt; instead of a collection of screenshots.&lt;/p&gt;

&lt;h2&gt;
  
  
  The REST API contract
&lt;/h2&gt;

&lt;p&gt;Keep the public API small. A minimal version might expose these operations:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST /test-mailboxes
Idempotency-Key: run-8f1c
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response returns the mailbox address, a lease ID, and &lt;code&gt;expires_at&lt;/code&gt;. Repeating the request with the same run ID should return the existing active lease, not create a second mailbox. This is easy to miss when a test is fast, but it become painful when a CI runner retries after a network timeout.&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;"lease_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;"lease_01J9..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"address"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"run-8f1c@example.test"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"expires_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-10-22T04:00:00Z"&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;Message reads should also be scoped by the lease:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET /test-mailboxes/lease_01J9/messages?after=2026-10-22T03:20:00Z
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The server checks that the lease belongs to the caller's run and has not expired. The client can poll with a bounded timeout, while the service translates provider-specific message states into stable API states such as &lt;code&gt;pending&lt;/code&gt;, &lt;code&gt;received&lt;/code&gt;, or &lt;code&gt;expired&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;For teams formalizing these checks, &lt;a href="https://dev.to/pong1965/contract-test-api-emails-in-github-actions-45f8"&gt;contract-testing API emails in GitHub Actions&lt;/a&gt; is a useful adjacent pattern. The important boundary is that the test verifies the API contract, not an undocumented provider response.&lt;/p&gt;

&lt;h2&gt;
  
  
  Persisting the lease in PostgreSQL
&lt;/h2&gt;

&lt;p&gt;A compact table is enough for the first version:&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;test_mailbox_leases&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;lease_id&lt;/span&gt; &lt;span class="n"&gt;uuid&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;run_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;mailbox_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="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;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'active'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'released'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'expired'&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;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;released_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;CREATE&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;one_active_lease_per_run&lt;/span&gt;
    &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;test_mailbox_leases&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run_id&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;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'active'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The partial unique index prevents duplicate active leases for one run. During allocation, select an available mailbox and update its ownership in one transaction. If several workers compete, use &lt;code&gt;FOR UPDATE SKIP LOCKED&lt;/code&gt; so a worker does not wait behind a mailbox already being assigned.&lt;/p&gt;

&lt;p&gt;The lease is a database fact, not just a value cached by the test runner. That matters when a worker crashes or a retry lands on another process. It also gives operators a place to inspect stuck resources without searching application logs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Retries, expiry, and cleanup
&lt;/h2&gt;

&lt;p&gt;There is two useful boundaries: request idempotency and lease expiration. The idempotency key protects a single allocation request. The expiration timestamp protects the system when the client disappears.&lt;/p&gt;

&lt;p&gt;A cleanup worker can periodically mark old active rows as &lt;code&gt;expired&lt;/code&gt;, then ask the mailbox adapter to remove or quarantine the corresponding inbox. Do not make cleanup depend on the happy path only; test jobs crash, and that are normal. The cleanup query should be repeatable and safe to run more than 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;UPDATE&lt;/span&gt; &lt;span class="n"&gt;test_mailbox_leases&lt;/span&gt;
&lt;span class="k"&gt;SET&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;'expired'&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;'active'&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;lt;&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;lease_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mailbox_key&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The adapter should treat an already-removed mailbox as a successful cleanup outcome. If provider deletion fails, retain the lease row and record an operational error. Removing the database evidence too early makes the leak harder to diagnose.&lt;/p&gt;

&lt;p&gt;Keep malformed input fixtures separate from ownership tests. A string such as temp gamil com can be useful for validation coverage, but it should never be confused with a mailbox lease or used as a resource key.&lt;/p&gt;

&lt;h2&gt;
  
  
  Operational checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Require a run ID and an idempotency key for allocation.&lt;/li&gt;
&lt;li&gt;Enforce one active lease per run in PostgreSQL.&lt;/li&gt;
&lt;li&gt;Scope message reads to the lease owner.&lt;/li&gt;
&lt;li&gt;Give every lease a short, explicit expiration time.&lt;/li&gt;
&lt;li&gt;Make release and expiry cleanup idempotent.&lt;/li&gt;
&lt;li&gt;Attach lease IDs and message IDs to CI artifacts.&lt;/li&gt;
&lt;li&gt;Alert on active leases older than the normal test duration.&lt;/li&gt;
&lt;li&gt;Keep provider credentials and real user addresses out of test payloads.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Small spelling or grammar differences in logs can make searches annoying, so normalize provider errors at the adapter boundary. The API response should stay stable even when the underlying email system is little noisy.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Should every test get a new mailbox?
&lt;/h3&gt;

&lt;p&gt;Every isolated run should get an owned lease. Tests inside one run can share it when their message assertions are deliberately coordinated. Sharing across unrelated runs creates hidden coupling.&lt;/p&gt;

&lt;h3&gt;
  
  
  How long should a lease live?
&lt;/h3&gt;

&lt;p&gt;Start with the expected test duration plus a modest recovery window. A lease that is too short causes false expiry; one that is too long increases cleanup cost. Measure actual run times and adjust from observed data.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is PostgreSQL enough for high parallelism?
&lt;/h3&gt;

&lt;p&gt;For many CI workloads, yes. Use short transactions, indexed status and expiry fields, and &lt;code&gt;SKIP LOCKED&lt;/code&gt; when allocating from a pool. If allocation becomes a bottleneck, measure database wait time before adding another coordination system.&lt;/p&gt;

&lt;p&gt;The main design decision is ownership. Once the REST API and PostgreSQL model make ownership explicit, verification messages become test data with a lifecycle, instead of an unreliable side channel.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>restapi</category>
      <category>postgres</category>
      <category>authentication</category>
    </item>
    <item>
      <title>REST API Idempotency for Email Verification Leases</title>
      <dc:creator>kevindev</dc:creator>
      <pubDate>Wed, 21 Oct 2026 17:23:18 +0000</pubDate>
      <link>https://dev.to/kevindev27/rest-api-idempotency-for-email-verification-leases-49cg</link>
      <guid>https://dev.to/kevindev27/rest-api-idempotency-for-email-verification-leases-49cg</guid>
      <description>&lt;p&gt;Email verification looks like a simple two-request feature: create a user, send a code, then accept the code. In a real REST API, the hard part is the boundary between those requests. Email delivery can be delayed, clients retry after a timeout, and a user can open two verification links at nearly the same time.&lt;/p&gt;

&lt;p&gt;I model that boundary as a short-lived verification lease. The lease gives one verification attempt an owner, an expiration time, and an idempotency key. This makes retries boring: the API can return the same result for the same operation instead of creating more codes or moving the account through an unexpected state.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why email verification needs a lease
&lt;/h2&gt;

&lt;p&gt;A verification token is not only a secret. It is also a claim about state: which account requested it, when it was created, whether it was consumed, and which request is allowed to consume it. Without those fields, a delayed message can be accepted after a newer request has already changed the account.&lt;/p&gt;

&lt;p&gt;The lease should contain at least:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;account_id&lt;/code&gt; and a hash of the verification token&lt;/li&gt;
&lt;li&gt;an idempotency key for the request that created it&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;created_at&lt;/code&gt;, &lt;code&gt;expires_at&lt;/code&gt;, and &lt;code&gt;consumed_at&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;the current status, such as &lt;code&gt;pending&lt;/code&gt;, &lt;code&gt;verified&lt;/code&gt;, or &lt;code&gt;expired&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;a delivery reference for support and retry diagnosis&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is useful even when the message goes to a &lt;a href="https://tempmailso.com" rel="noopener noreferrer"&gt;free temporary email&lt;/a&gt; inbox during an integration test. The test address does not make the workflow deterministic; ownership and time boundaries do.&lt;/p&gt;

&lt;p&gt;Teams often search for “email temporary free” when they are trying to set up a quick test. That can solve the mailbox part, but it does not solve replay protection. A test should still prove that an old token is rejected and that a repeated request has a stable response.&lt;/p&gt;

&lt;p&gt;For related design context, &lt;a href="https://dev.to/sophiax99/passwordless-otp-needs-inbox-boundaries-1079"&gt;OTP inbox boundaries&lt;/a&gt; are a useful way to think about separating mailbox ownership from authentication state.&lt;/p&gt;

&lt;h2&gt;
  
  
  The REST API contract
&lt;/h2&gt;

&lt;p&gt;The endpoint that creates a verification lease should accept an idempotency key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST /v1/accounts/42/verifications
Idempotency-Key: signup-42-3f6b
Content-Type: application/json

{"delivery":"email"}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the first request, the service creates the lease and queues a message. On a retry with the same key, it returns the stored response for that lease. It should not generate another token just because the first HTTP response was lost.&lt;/p&gt;

&lt;p&gt;The response can expose a non-secret reference and the expiration time:&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;"verification_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;"ver_8d2f"&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;"pending"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"expires_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-10-21T18:00:00Z"&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;The confirm endpoint should have equally strict rules. A successful confirmation moves &lt;code&gt;pending&lt;/code&gt; to &lt;code&gt;verified&lt;/code&gt; once. A repeated confirmation can return the already-verified state, while a different token must fail without revealing whether another token exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  Persisting idempotency in PostgreSQL
&lt;/h2&gt;

&lt;p&gt;The database is the right place to serialize the state transition. An in-memory lock disappears during a restart and does not coordinate multiple API instances.&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;UNIQUE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;verification_request_key&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;verification_leases&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;account_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;idempotency_key&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;UNIQUE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;one_active_lease_per_account&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;verification_leases&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;account_id&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;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'pending'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The create handler can insert with &lt;code&gt;ON CONFLICT&lt;/code&gt;, then read the existing row and return its stored response. The confirm handler should lock the selected row, check the expiry, compare the token hash, and update the status in one transaction.&lt;/p&gt;

&lt;p&gt;Do not store the raw token. Hash it before persistence, and keep the token out of application logs, metrics labels, and exception messages. The database record needs enough information to explain a failure, not enough information to impersonate the account.&lt;/p&gt;

&lt;p&gt;There is a small detail that gets missed: the outbox event should be committed with the lease. Otherwise the API can report &lt;code&gt;pending&lt;/code&gt; while the delivery job never receives a durable event. A worker may retry delivery, but it must keep the same verification id and lease metadata.&lt;/p&gt;

&lt;h2&gt;
  
  
  Retry and expiration behavior
&lt;/h2&gt;

&lt;p&gt;Separate three kinds of retry. A client retry with the same idempotency key should return the same lease. A delivery retry should reuse the same token and message reference where the provider supports it. A user retry after expiry should create a new lease, not revive the old one.&lt;/p&gt;

&lt;p&gt;The expiry check belongs in the confirmation transaction, not only in a background cleanup job. Cleanup is eventually consistent; authorization cannot be. A cleanup worker can mark old rows as &lt;code&gt;expired&lt;/code&gt; and remove message data later, but the API must reject an expired lease immediately.&lt;/p&gt;

&lt;p&gt;This is also where a failure taxonomy helps. A missing message, an expired lease, a provider rejection, and an invalid token are different failures with different owners. The &lt;a href="https://dev.to/silviutech/playwright-email-tests-need-a-failure-taxonomy-575n"&gt;failure taxonomy for email tests&lt;/a&gt; offers a practical testing perspective for keeping those cases distinct.&lt;/p&gt;

&lt;h2&gt;
  
  
  Operational checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Require an idempotency key on lease creation.&lt;/li&gt;
&lt;li&gt;Put a unique constraint around the account and key.&lt;/li&gt;
&lt;li&gt;Hash tokens and redact them from logs.&lt;/li&gt;
&lt;li&gt;Check expiration inside the confirmation transaction.&lt;/li&gt;
&lt;li&gt;Commit the outbox event with the lease row.&lt;/li&gt;
&lt;li&gt;Keep delivery, verification, and cleanup metrics separate.&lt;/li&gt;
&lt;li&gt;Test duplicate requests, delayed messages, and concurrent confirmations.&lt;/li&gt;
&lt;li&gt;Keep &lt;code&gt;temp org mail&lt;/code&gt; as plain test text only; it is not a safe identifier or an anchor.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Five minutes spent on the state model usually saves hours of debugging later. The useful signal is not merely that an email arrived. It is that the right lease accepted one valid transition, exactly once, with evidence that can be inspected after the test runner has gone away.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Should a retry create a new verification email?
&lt;/h3&gt;

&lt;p&gt;Not when it carries the same idempotency key. Return the existing lease and let the delivery layer decide whether a resend is needed. A new user action can use a new key and a new lease.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can Redis replace PostgreSQL for this state?
&lt;/h3&gt;

&lt;p&gt;Redis can help with short-lived coordination, but the durable account state and idempotency result belong in a transactional store. Using both is reasonable when their responsibilities are explicit.&lt;/p&gt;

&lt;h3&gt;
  
  
  What should an integration test assert?
&lt;/h3&gt;

&lt;p&gt;Assert the API state, message ownership, token expiry, duplicate-request response, and final database transition. The inbox is evidence in the flow, not the source of truth.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>restapi</category>
      <category>postgres</category>
      <category>authentication</category>
    </item>
    <item>
      <title>Idempotent Email Verification for REST APIs</title>
      <dc:creator>kevindev</dc:creator>
      <pubDate>Wed, 21 Oct 2026 05:22:56 +0000</pubDate>
      <link>https://dev.to/kevindev27/idempotent-email-verification-for-rest-apis-41op</link>
      <guid>https://dev.to/kevindev27/idempotent-email-verification-for-rest-apis-41op</guid>
      <description>&lt;p&gt;Email verification is often implemented as a single request: create a user, send a message, and wait for a click. That model breaks down when the client times out after the server has already accepted the request. A retry can create a second token, send a second message, or attach the wrong result to the user.&lt;/p&gt;

&lt;p&gt;The fix is to make verification intent idempotent. The API should treat repeated requests as a lookup for the same operation, while PostgreSQL enforces the uniqueness that application code alone can miss.&lt;/p&gt;

&lt;p&gt;This matters in both production authentication flows and automated tests that use a free throwaway email or a throwaway email address. A test mailbox is useful only when the API can explain which verification attempt produced the message.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why verification retries create duplicate work
&lt;/h2&gt;

&lt;p&gt;Suppose a client sends &lt;code&gt;POST /verification-requests&lt;/code&gt; and waits six seconds. The server commits the request, but the response is lost. The client sends the same request again without an idempotency key.&lt;/p&gt;

&lt;p&gt;The server cannot know whether this is a new request or a retry. It may issue two tokens and deliver two emails. The first retry is mostly harmless; the second one is where things gets confusing. A user clicks the older link, while the database considers the newer token to be current.&lt;/p&gt;

&lt;p&gt;Email delivery makes the timing less predictable. A provider can queue messages, a worker can restart, and an inbox can expose messages in a different order than they were created. The REST API needs its own record of intent instead of using inbox arrival time as the source of truth.&lt;/p&gt;

&lt;p&gt;For integration tests, &lt;a href="https://dev.to/ryanlee91/react-signup-tests-need-a-run-scoped-email-contract-3cfg"&gt;a run-scoped email contract&lt;/a&gt; is a useful companion to this design. Each run should know which verification request and mailbox it owns.&lt;/p&gt;

&lt;h2&gt;
  
  
  Define the idempotency contract
&lt;/h2&gt;

&lt;p&gt;Require the client to send an &lt;code&gt;Idempotency-Key&lt;/code&gt; header when it starts verification. Scope that key to the authenticated user and operation, then persist the request fingerprint with it.&lt;/p&gt;

&lt;p&gt;The endpoint contract can be simple:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The first request creates a verification intent and returns &lt;code&gt;202 Accepted&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A retry with the same key and the same payload returns the existing intent.&lt;/li&gt;
&lt;li&gt;The same key with a different payload returns &lt;code&gt;409 Conflict&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A new key creates a new intent only when the account is allowed another attempt.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last rule is important. Idempotency prevents accidental duplicates, but it should not bypass rate limits or account security policy. The API still needs a separate decision about how often a user may request a new message.&lt;/p&gt;

&lt;h2&gt;
  
  
  Store intent and delivery separately
&lt;/h2&gt;

&lt;p&gt;The verification intent describes what the user asked the system to do. Delivery attempts describe what a worker tried to send. Keeping them separate makes the states less ambiguous:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;verification_intent: pending -&amp;gt; sent -&amp;gt; consumed
delivery_attempt:   queued  -&amp;gt; accepted -&amp;gt; delivered
                                  \-&amp;gt; failed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An intent can remain &lt;code&gt;pending&lt;/code&gt; while a delivery attempt is retried. It should not be marked verified just because a provider accepted a message. Verification happens only after the user presents a valid, unexpired token.&lt;/p&gt;

&lt;p&gt;The database record should include a user ID, an idempotency key, a hash of the request parameters, an expiry time, and a token hash. Do not store the raw token in logs. A test can assert the resulting state without exposing a credential in CI output.&lt;/p&gt;

&lt;h2&gt;
  
  
  Enforce uniqueness in PostgreSQL
&lt;/h2&gt;

&lt;p&gt;Application checks are not enough when two workers or two HTTP requests run concurrently. Use a unique constraint for the idempotency boundary:&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;verification_intents&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;intent_id&lt;/span&gt; &lt;span class="n"&gt;uuid&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;user_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;idempotency_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="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;request_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="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'pending'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'sent'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'consumed'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'expired'&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="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;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="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="p"&gt;(&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;idempotency_key&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;On conflict, fetch the existing row and compare &lt;code&gt;request_hash&lt;/code&gt;. If the hash differs, return &lt;code&gt;409&lt;/code&gt;; if it matches, return the existing intent. The transaction should not send the email itself. Commit the intent first, then let an outbox worker handle delivery.&lt;/p&gt;

&lt;p&gt;This separation also supports operational controls. Deployment workflows can use &lt;a href="https://dev.to/jasonmills94/kubernetes-deploys-need-email-failure-budgets-2l02"&gt;email failure budgets in deployment workflows&lt;/a&gt; to decide when provider failures should stop a rollout rather than making every API request wait for the provider.&lt;/p&gt;

&lt;h2&gt;
  
  
  Return useful states from the REST API
&lt;/h2&gt;

&lt;p&gt;The client needs a stable status resource instead of guessing from a button spinner:&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;"intent_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;"3d1d0fb8-0b6a-4b91-ae49-7a3559e0bf2e"&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;"sent"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"expires_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-10-21T06:00:00Z"&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;Keep provider details out of the public response. A &lt;code&gt;sent&lt;/code&gt; status means the delivery workflow accepted the intent, not that the user has read the message. An &lt;code&gt;expired&lt;/code&gt; status should be terminal for that intent, while a new request may create another intent if policy allows it.&lt;/p&gt;

&lt;p&gt;The API response are easier to debug when each state has one meaning. In local notes, someone might search for &lt;code&gt;temp gamil com&lt;/code&gt; or &lt;code&gt;tempail mail&lt;/code&gt;; those phrases can appear as plain text, but they should never be used as identifiers or routing logic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test the failure paths
&lt;/h2&gt;

&lt;p&gt;The most valuable tests are not the happy-path click. Test a lost response followed by an identical retry, a reused key with a changed email, concurrent requests, an expired token, and a delivery worker that fails after the intent commits.&lt;/p&gt;

&lt;p&gt;For an automated test, assert the intent ID and state, then inspect a message matched to that intent. The test does not need to knows the provider's internal timing. It only needs evidence that the right message belongs to the right request.&lt;/p&gt;

&lt;p&gt;A useful checklist is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Require and persist an idempotency key.&lt;/li&gt;
&lt;li&gt;Hash request parameters and reject changed retries.&lt;/li&gt;
&lt;li&gt;Enforce uniqueness in PostgreSQL.&lt;/li&gt;
&lt;li&gt;Commit intent before attempting delivery.&lt;/li&gt;
&lt;li&gt;Hash tokens and redact them from logs.&lt;/li&gt;
&lt;li&gt;Make expiry and resend policy explicit.&lt;/li&gt;
&lt;li&gt;Correlate test messages with an intent ID or run ID.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Idempotent verification turns a fragile email side effect into a recoverable workflow. The provider may still be delayed, and a worker may still fail, but a retry will have a known meaning. That is the boundary an authentication service needs to stay correct under ordinary network failures.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>restapi</category>
      <category>postgres</category>
      <category>authentication</category>
    </item>
    <item>
      <title>Model Test Email as a Lease in a REST API</title>
      <dc:creator>kevindev</dc:creator>
      <pubDate>Tue, 20 Oct 2026 23:22:51 +0000</pubDate>
      <link>https://dev.to/kevindev27/model-test-email-as-a-lease-in-a-rest-api-8hf</link>
      <guid>https://dev.to/kevindev27/model-test-email-as-a-lease-in-a-rest-api-8hf</guid>
      <description>&lt;p&gt;Email verification tests often fail for a reason that is not really email. A test creates an account, requests a verification message, waits for an inbox, and then asserts that a link arrived. If the inbox is shared or its lifetime is undefined, the test has no reliable answer to a simple question: does this message belong to this run?&lt;/p&gt;

&lt;p&gt;I treat a test inbox as a leased resource. The lease has an owner, an expiration time, and a state. That small model makes a REST API easier to retry and gives PostgreSQL enough information to reject stale or competing work. It also keeps temporary disposable mail in its proper place: a controlled fixture for a test environment, not a substitute for an application's identity model.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why an inbox should have a lease
&lt;/h2&gt;

&lt;p&gt;A mailbox address by itself is not a test fixture. It is only a location. Without ownership and expiry, several jobs can read the same verification email, or a new job can consume a message left by an older one.&lt;/p&gt;

&lt;p&gt;The usual workaround is to add a longer &lt;code&gt;sleep&lt;/code&gt;. That can hide the race for a while, but it does not identify the message or prevent a different job from reading it. A lease creates a boundary around the resource:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;lease_id&lt;/code&gt; identifies one reservation.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;run_id&lt;/code&gt; connects the reservation to the test execution.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;address&lt;/code&gt; is the mailbox location, ideally generated for this run.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;status&lt;/code&gt; moves through &lt;code&gt;active&lt;/code&gt;, &lt;code&gt;released&lt;/code&gt;, or &lt;code&gt;expired&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;expires_at&lt;/code&gt; tells readers when the reservation is no longer valid.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The boundary matters most when a CI worker is interrupted. The lease can expire without requiring a cleanup process to run perfectly. That is a more dependable contract than assuming every job reaches its finalizer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Separate verification intent from mailbox ownership
&lt;/h2&gt;

&lt;p&gt;The verification request and the mailbox lease should be related, but they should not be the same row. A verification intent answers, “which account needs a token?” A lease answers, “which test run may inspect this inbox?”&lt;/p&gt;

&lt;p&gt;This separation also makes the API states clearer. The signup endpoint may return &lt;code&gt;202 Accepted&lt;/code&gt; after creating an intent. A worker sends the message. A test reader later records evidence that a matching message was observed. The &lt;a href="https://dev.to/ryanlee91/react-signup-flows-need-email-states-5c45"&gt;explicit email states in signup flows&lt;/a&gt; are useful here because the UI and the test can distinguish pending, verified, expired, and failed instead of treating every non-success response as the same thing.&lt;/p&gt;

&lt;p&gt;Do not put the verification token in a log line just to make the test easier to debug. Store a correlation ID and a hash of the expected message attributes. You can then prove that the subject, recipient, and request ID matched without making credentials or live links searchable in CI logs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Implement the lease with PostgreSQL
&lt;/h2&gt;

&lt;p&gt;A minimal schema can look 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;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;email_test_leases&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;lease_id&lt;/span&gt; &lt;span class="n"&gt;uuid&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;run_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;address&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="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'active'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'released'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'expired'&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;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;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;email_test_leases_active_idx&lt;/span&gt;
    &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;email_test_leases&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;address&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expires_at&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;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'active'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When allocating an address, use a transaction and lock the candidate row before changing its status. The allocation query should verify &lt;code&gt;expires_at &amp;gt; now()&lt;/code&gt; and the caller's &lt;code&gt;run_id&lt;/code&gt;. If the row is expired, mark it expired before reusing the address. That ordering is important; otherwise a slow reader may still look like the current owner.&lt;/p&gt;

&lt;p&gt;The API that creates the lease should accept an idempotency key. A retry with the same key returns the original lease instead of creating a second reservation. This is where a REST API and the database cooperate: the endpoint owns the contract, while PostgreSQL enforces uniqueness and locking.&lt;/p&gt;

&lt;p&gt;For larger suites, keep the lease table small by deleting old rows asynchronously. Do not delete active rows as part of routine cleanup. A cleanup task should first select only leases with &lt;code&gt;expires_at &amp;lt; now()&lt;/code&gt; and then update them conditionally with &lt;code&gt;status = 'active'&lt;/code&gt;. The condition prevents cleanup from releasing a lease that was renewed between selection and update.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make REST API retries boring
&lt;/h2&gt;

&lt;p&gt;Every operation in the flow should have a stable idempotency key: allocate, request verification, and record observation. A timeout after the server commits is then recoverable. The client repeats the request, and the server returns the existing result.&lt;/p&gt;

&lt;p&gt;The response should expose state, not implementation detail. For example:&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;"lease_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;"6f7b3d2c-2d2d-4f91-9e77-6bd7d5d0a4bf"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"state"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"active"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"expires_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-10-21T07:00:00Z"&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;A client must still handle &lt;code&gt;410 Gone&lt;/code&gt; after expiry. Retrying forever is not resilience; it is noise. The caller should create a new fixture and attach it to a new test attempt, while keeping the old lease ID in the failure record.&lt;/p&gt;

&lt;p&gt;The same principle applies to &lt;a href="https://dev.to/pong1965/reusable-workflows-for-email-api-checks-393n"&gt;reusable workflows for email API checks&lt;/a&gt;: make the setup and evidence steps repeatable, but do not silently reuse state from a previous run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use the inbox as evidence, not a timing oracle
&lt;/h2&gt;

&lt;p&gt;Polling should query for evidence that matches the current run: lease ID when supported, otherwise a correlation value in the recipient alias, subject, or message header. A message that merely arrived recently is not enough.&lt;/p&gt;

&lt;p&gt;For short-lived integration tests, a &lt;a href="https://tempmailso.com" rel="noopener noreferrer"&gt;temp mail so&lt;/a&gt; inbox can provide that controlled boundary. Keep the provider call behind a small adapter so the application test does not depend on provider-specific polling details. In one test note, a developer might type &lt;code&gt;temp mailid&lt;/code&gt; or &lt;code&gt;tepm mail com&lt;/code&gt; while searching for a fixture; those phrases should never become production identifiers or query semantics.&lt;/p&gt;

&lt;p&gt;When the provider cannot return a stable message ID, record the observation time, a normalized subject, and a digest of the relevant headers. The test can then explain why it accepted a message. It may sound slightly extra, but it save hours when a provider delivers messages out of order.&lt;/p&gt;

&lt;h2&gt;
  
  
  Operational checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Give every test run a unique &lt;code&gt;run_id&lt;/code&gt; and every mailbox a &lt;code&gt;lease_id&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Enforce one active lease per address with a partial index or equivalent constraint.&lt;/li&gt;
&lt;li&gt;Use idempotency keys for allocation and verification requests.&lt;/li&gt;
&lt;li&gt;Reject expired leases before reading or releasing them.&lt;/li&gt;
&lt;li&gt;Match messages to run-specific evidence, not arrival time alone.&lt;/li&gt;
&lt;li&gt;Redact tokens, full addresses, and provider credentials from logs.&lt;/li&gt;
&lt;li&gt;Record the last provider message ID and polling error for diagnosis.&lt;/li&gt;
&lt;li&gt;Release leases on success, but rely on expiry for interrupted jobs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This design turns email from an unpredictable external wait into a small state machine. The provider can still be slow, and a worker can still crash, but the API response, database row, and test evidence retain the same meaning after a retry.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>restapi</category>
      <category>postgres</category>
      <category>authentication</category>
    </item>
    <item>
      <title>Replay-Safe Email Verification Receipts</title>
      <dc:creator>kevindev</dc:creator>
      <pubDate>Tue, 20 Oct 2026 17:23:01 +0000</pubDate>
      <link>https://dev.to/kevindev27/replay-safe-email-verification-receipts-lia</link>
      <guid>https://dev.to/kevindev27/replay-safe-email-verification-receipts-lia</guid>
      <description>&lt;p&gt;Email verification looks like a short request: create a token, send a message, and return success. The difficult case starts when the provider accepts the message but the network timeout reaches your service first. The worker cannot know whether it is safe to retry, and the user may receive two links.&lt;/p&gt;

&lt;p&gt;I prefer treating the delivery result as a replay-safe receipt. The application records one logical verification, gives it a stable delivery ID, and lets a worker update that record with provider evidence. A retry can then repeat the operation without creating a second verification intent.&lt;/p&gt;

&lt;p&gt;This is a backend boundary, not just an email feature. It keeps a REST API predictable, makes PostgreSQL state useful during incidents, and gives operators something better than “the request timed out.”&lt;/p&gt;

&lt;h2&gt;
  
  
  The timeout that creates two verification emails
&lt;/h2&gt;

&lt;p&gt;The first implementation usually look simple:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Insert a verification token.&lt;/li&gt;
&lt;li&gt;Call the mail provider.&lt;/li&gt;
&lt;li&gt;Mark the token as delivered.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If step 2 times out, the caller may retry the API request. A second token or outbox row is created, even though the first provider call might have succeeded. The result is confusing for users and hard to diagnose from logs.&lt;/p&gt;

&lt;p&gt;The fix is to separate intent from attempt. A verification intent says which account is being verified and when the token expires. A delivery attempt says which worker tried to send it, with what idempotency key, and what the provider returned. Those records can be related without being the same thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a delivery receipt should represent
&lt;/h2&gt;

&lt;p&gt;A receipt is not proof that the user clicked the link. It is a durable statement about the delivery boundary:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;delivery_id&lt;/code&gt; identifies one logical message.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;idempotency_key&lt;/code&gt; identifies the provider operation.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;status&lt;/code&gt; distinguishes &lt;code&gt;pending&lt;/code&gt;, &lt;code&gt;accepted&lt;/code&gt;, &lt;code&gt;failed&lt;/code&gt;, and &lt;code&gt;unknown&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;provider_message_id&lt;/code&gt; stores external evidence when available.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;attempt_count&lt;/code&gt;, &lt;code&gt;last_error&lt;/code&gt;, and &lt;code&gt;next_attempt_at&lt;/code&gt; explain retry decisions.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;expires_at&lt;/code&gt; prevents an old token from being sent after it is useless.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;code&gt;unknown&lt;/code&gt; status is important. A timeout is not always a failure. Marking it as failed and immediately sending again can produce duplicates; marking it accepted without evidence can hide a real outage. A bounded retry policy can resolve unknown outcomes while preserving the original delivery ID.&lt;/p&gt;

&lt;p&gt;The receipt should also carry a correlation ID, but not the token or the full email address in ordinary logs. A tempail mail address appearing in a debug line may look harmless, yet retained logs often have a wider audience than the test inbox.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use PostgreSQL to make replay safe
&lt;/h2&gt;

&lt;p&gt;Create one outbox row per logical verification and enforce that rule in the database:&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;UNIQUE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;one_active_verification_delivery&lt;/span&gt;
&lt;span class="k"&gt;ON&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;verification_id&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;status&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'pending'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'sending'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'accepted'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'unknown'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For the request path, use an idempotency key and a unique constraint scoped to the account or client. If the same request arrives again, return the existing verification response. If the payload differs, return a conflict instead of silently changing the recipient.&lt;/p&gt;

&lt;p&gt;Workers can claim pending rows without waiting behind one another:&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_delivery&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;status&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'pending'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'unknown'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;next_attempt_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;AND&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lease_until&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_until&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;next_attempt_at&lt;/span&gt;&lt;span class="p"&gt;,&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;20&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;AS&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;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'sending'&lt;/span&gt;&lt;span class="p"&gt;,&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_until&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;'90 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_delivery&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_delivery&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="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;Commit the claim before making the network call. When the response arrives, update by both &lt;code&gt;id&lt;/code&gt; and &lt;code&gt;lease_owner&lt;/code&gt;. A worker whose lease expired must not mark a row accepted after another worker has reclaimed it. This condition matter more than the exact queue product.&lt;/p&gt;

&lt;p&gt;For transient failures, use backoff and a ceiling. For permanent failures, record the provider category and stop retrying. A clean failure record make an incident searchable without replaying the whole request.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep the REST API contract stable
&lt;/h2&gt;

&lt;p&gt;The API should expose the state that a client can act on, not internal provider details. For example:&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;"verification_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;"ver_123"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"delivery_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;"accepted"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"expires_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-10-21T01:00:00Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"retryable"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&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;When delivery is &lt;code&gt;unknown&lt;/code&gt;, the client can show that the message is being checked and poll a status endpoint. It should not create another verification just because the original HTTP request had a timeout. Teams often does the opposite because the retry path is easier to add than the state model.&lt;/p&gt;

&lt;p&gt;For controlled staging checks, a &lt;a href="https://tempmailso.com" rel="noopener noreferrer"&gt;tempmailso&lt;/a&gt; inbox can isolate test traffic from real customer mail, but it does not replace idempotency or provider-side evidence. The useful contract remains in your database.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test the receipt, not only the inbox
&lt;/h2&gt;

&lt;p&gt;End-to-end tests should simulate the ambiguous cases: provider accepts then times out, worker crashes after claiming, lease expires during a slow send, and the same API request arrives twice. Assert the number of logical deliveries as well as the visible message.&lt;/p&gt;

&lt;p&gt;A &lt;a href="https://dev.to/ryanlee91/react-email-tests-need-a-fixture-factory"&gt;fixture factory for email tests&lt;/a&gt; can make those scenarios repeatable across test cases. At the deployment layer, keep the same evidence mindset as &lt;a href="https://dev.to/jasonmills94/ecr-to-kubernetes-keep-a-deploy-receipt"&gt;a deploy receipt that explains a CI result&lt;/a&gt;: record what happened, which run owned it, and what should happen next.&lt;/p&gt;

&lt;p&gt;Do not assert only that an inbox eventually contains a message. Assert that the message belongs to the expected &lt;code&gt;delivery_id&lt;/code&gt;, that a late worker cannot overwrite a newer state, and that retry exhaustion produces an actionable error. The test is more useful when it can explain why a delivery was accepted or retried.&lt;/p&gt;

&lt;h2&gt;
  
  
  Operational checklist
&lt;/h2&gt;

&lt;p&gt;Before shipping this flow, verify:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;One unique delivery exists for each logical verification.&lt;/li&gt;
&lt;li&gt;Repeated API requests return the same result for the same idempotency key.&lt;/li&gt;
&lt;li&gt;Worker claims use short transactions and &lt;code&gt;SKIP LOCKED&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Result updates require a current lease owner.&lt;/li&gt;
&lt;li&gt;Unknown provider outcomes have bounded retries and alerts.&lt;/li&gt;
&lt;li&gt;Expired tokens cannot be sent by an old worker.&lt;/li&gt;
&lt;li&gt;Logs contain correlation IDs instead of tokens or raw message bodies.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The main design decision is to make uncertainty a stored state. Once the delivery receipt is durable, retries become controlled replays instead of guesses, and a provider timeout becomes a case the system can explain.&lt;/p&gt;

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