<?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: Thanh Ho Van</title>
    <description>The latest articles on DEV Community by Thanh Ho Van (@thanh_hovan_29d27c74b1e0).</description>
    <link>https://dev.to/thanh_hovan_29d27c74b1e0</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%2F4116976%2F07a0ae91-d73e-4e85-b121-3279ab0fd97b.png</url>
      <title>DEV Community: Thanh Ho Van</title>
      <link>https://dev.to/thanh_hovan_29d27c74b1e0</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/thanh_hovan_29d27c74b1e0"/>
    <language>en</language>
    <item>
      <title>Receive email OTP codes in Playwright tests without mocking</title>
      <dc:creator>Thanh Ho Van</dc:creator>
      <pubDate>Thu, 08 Oct 2026 08:46:00 +0000</pubDate>
      <link>https://dev.to/thanh_hovan_29d27c74b1e0/receive-email-otp-codes-in-playwright-tests-without-mocking-3ep8</link>
      <guid>https://dev.to/thanh_hovan_29d27c74b1e0/receive-email-otp-codes-in-playwright-tests-without-mocking-3ep8</guid>
      <description>&lt;p&gt;Most sign-up flows send an email at some point. A six-digit code, a "confirm your address" link, a magic login link. And most end-to-end suites quietly skip that step: they stub the mailer, read the code from the database, or add a test-only backdoor that marks the user as verified.&lt;/p&gt;

&lt;p&gt;That works until it doesn't. A mock won't notice that the template lost the code during a refactor, that the link points at &lt;code&gt;localhost&lt;/code&gt; in staging, or that a config change means the email never goes out at all. Those are exactly the bugs users hit first.&lt;/p&gt;

&lt;p&gt;This post shows a setup where the test reads the email your app actually sent. No mocks, no database peeking, and no &lt;code&gt;sleep(10000)&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The idea
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Point a domain you control at a catch-all inbox, so any address at that domain receives mail.&lt;/li&gt;
&lt;li&gt;Generate a new address for every test run, like &lt;code&gt;signup-1728000000000@yourdomain.com&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Let the app send its normal email through its normal provider.&lt;/li&gt;
&lt;li&gt;Ask an API for the code at that address, and have the request wait until the email arrives.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I'll use Free Domain Mail for steps 1 and 4 because that's the service I build (disclosure at the end). The patterns apply to any inbox API with long-polling.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setup: a catch-all domain and an API key
&lt;/h2&gt;

&lt;p&gt;Add a domain you own as a custom domain, publish the TXT ownership record (host &lt;code&gt;_maildock-verify&lt;/code&gt;), and set its MX record to &lt;code&gt;mx.freedomainmail.com&lt;/code&gt; with priority 10. After that, every address at the domain receives mail. There is nothing to create per test, which is the whole point.&lt;/p&gt;

&lt;p&gt;Use a domain or subdomain that doesn't already receive mail you care about, since changing MX moves all of its mail. A spare domain or something like &lt;code&gt;qa.yourcompany.com&lt;/code&gt; works well.&lt;/p&gt;

&lt;p&gt;Then create an API key and store it as a CI secret:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;FDM_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;your-api-key
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every request sends it as &lt;code&gt;Authorization: Bearer &amp;lt;key&amp;gt;&lt;/code&gt;. The key only reads domains connected to its own account.&lt;/p&gt;

&lt;h2&gt;
  
  
  Playwright: wait for the code
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;GET /api/v1/code&lt;/code&gt; takes the &lt;code&gt;address&lt;/code&gt; and a &lt;code&gt;wait&lt;/code&gt; value in seconds (up to 60). The request stays open until a message arrives at that address, then returns the extracted code along with &lt;code&gt;message_id&lt;/code&gt;, &lt;code&gt;from&lt;/code&gt;, &lt;code&gt;subject&lt;/code&gt; and &lt;code&gt;received_at&lt;/code&gt;. If nothing arrives within &lt;code&gt;wait&lt;/code&gt;, you get a 404, so always check &lt;code&gt;res.ok&lt;/code&gt; before reading the body.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// tests/email.ts&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;API&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://freedomainmail.com/api/v1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;authHeaders&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;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;FDM_API_KEY&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;FDM_API_KEY is not set&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;newAddress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prefix&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;signup&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="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;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="s2"&gt;-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;36&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;@yourdomain.com`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;waitForCode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;address&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&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;params&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URLSearchParams&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;address&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;wait&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;60&lt;/span&gt;&lt;span class="dl"&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;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;API&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/code?&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;authHeaders&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`No code for &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;address&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: HTTP &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="p"&gt;}&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;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&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;code&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the test:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// tests/signup.spec.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;test&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;expect&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@playwright/test&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;newAddress&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;waitForCode&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./email&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sign up with an email code&lt;/span&gt;&lt;span class="dl"&gt;"&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;page&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="c1"&gt;// The default test timeout (30s) is shorter than a 60s wait.&lt;/span&gt;
  &lt;span class="nx"&gt;test&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="nx"&gt;_000&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;address&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;newAddress&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;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://your-app.example/signup&lt;/span&gt;&lt;span class="dl"&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;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByLabel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Email&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;fill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;address&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;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;button&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Send code&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;click&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;code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;waitForCode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;address&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;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByLabel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Verification code&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;fill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;code&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;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;button&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Verify&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;click&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Welcome&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toBeVisible&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details matter here. First, &lt;code&gt;test.setTimeout(120_000)&lt;/code&gt; is longer than the 60-second wait. If your test timeout is shorter than the wait, Playwright kills the test before the API has had its chance to answer, and you get a confusing timeout instead of a clear "no code" error. Second, the address is unique per run, which I'll come back to in the flakiness section.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the email has other numbers in it
&lt;/h2&gt;

&lt;p&gt;By default the API detects the code on its own, which is fine for most templates. But if your email says something like "Order 48213. Your code is 912044. Expires in 10 minutes," you want to be explicit.&lt;/p&gt;

&lt;p&gt;Pass &lt;code&gt;pattern&lt;/code&gt; with the literal text around the code and exactly one placeholder: &lt;code&gt;{code}&lt;/code&gt; for any code, or &lt;code&gt;{digits}&lt;/code&gt; when it's digits only. The pattern needs some literal text besides the placeholder. Build the query with &lt;code&gt;URLSearchParams&lt;/code&gt; so the braces and spaces get encoded:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;waitForExactCode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;address&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&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;params&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URLSearchParams&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;address&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;wait&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;60&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;pattern&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Your code is {digits}&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;API&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/code?&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;authHeaders&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`No code for &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;address&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: HTTP &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="p"&gt;}&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;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&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;code&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;If your app sends more than one kind of email to the same address, you can also narrow by &lt;code&gt;from&lt;/code&gt; or &lt;code&gt;subject&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Magic links
&lt;/h2&gt;

&lt;p&gt;For "click to log in" or "confirm your email" links, use &lt;code&gt;GET /api/v1/links&lt;/code&gt; with the same &lt;code&gt;address&lt;/code&gt; and &lt;code&gt;wait&lt;/code&gt;. It returns &lt;code&gt;primary&lt;/code&gt;, the main verification link, plus &lt;code&gt;links&lt;/code&gt; with the other verification links it found.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;waitForLink&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;address&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&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;params&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URLSearchParams&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;address&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;wait&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;60&lt;/span&gt;&lt;span class="dl"&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;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;API&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/links?&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;authHeaders&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`No link for &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;address&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: HTTP &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;primary&lt;/span&gt; &lt;span class="p"&gt;}&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;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&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;primary&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;log in with a magic link&lt;/span&gt;&lt;span class="dl"&gt;"&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;page&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="nx"&gt;test&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="nx"&gt;_000&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;address&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;newAddress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;login&lt;/span&gt;&lt;span class="dl"&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;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://your-app.example/login&lt;/span&gt;&lt;span class="dl"&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;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByLabel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Email&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;fill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;address&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;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;button&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Email me a link&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;click&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;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;waitForLink&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;address&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Signed in&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toBeVisible&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;If you need to assert on the whole email (subject line, a footer, the sender), &lt;code&gt;/api/v1/messages&lt;/code&gt; lists and reads full messages.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cypress variant
&lt;/h2&gt;

&lt;p&gt;In Cypress, &lt;code&gt;cy.request&lt;/code&gt; does the same job. Set its &lt;code&gt;timeout&lt;/code&gt; above the 60-second wait so Cypress doesn't give up first. &lt;code&gt;cy.request&lt;/code&gt; already fails the test on a non-2xx response, so a 404 for a missing email shows up as a clear failure without extra code.&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="c1"&gt;// cypress/e2e/signup.cy.js&lt;/span&gt;
&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;signs up with an email code&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="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;address&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`signup-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="s2"&gt;@yourdomain.com`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="nx"&gt;cy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;visit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/signup&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;cy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;input[name=email]&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;type&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;address&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;cy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;button&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="s2"&gt;Send code&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;click&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="nx"&gt;cy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;request&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://freedomainmail.com/api/v1/code&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;qs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;address&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;wait&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;Cypress&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;FDM_API_KEY&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;70000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;its&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;body.code&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;code&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="nx"&gt;cy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;input[name=code]&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;type&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="nx"&gt;cy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;button&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="s2"&gt;Verify&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;click&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;Pass the key in with &lt;code&gt;CYPRESS_FDM_API_KEY=...&lt;/code&gt; in CI, or in &lt;code&gt;cypress.env.json&lt;/code&gt; locally (and keep that file out of git).&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping it from getting flaky
&lt;/h2&gt;

&lt;p&gt;Email tests have a reputation for flakiness. Most of it comes from a few habits that are easy to avoid.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use a unique address per run.&lt;/strong&gt; The API returns the newest matching message for an address. If every run uses &lt;code&gt;test@yourdomain.com&lt;/code&gt;, a retry can pick up the code from the previous attempt, which is already expired. A timestamp plus a short random suffix is enough. If you really must reuse an address, pass &lt;code&gt;since&lt;/code&gt; with the time the test started (ISO 8601 or Unix time) so older messages are ignored.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Wait, don't sleep.&lt;/strong&gt; A fixed &lt;code&gt;sleep&lt;/code&gt; is wrong in both directions: too short when your email provider is slow, wasted time when it's fast. A long-poll returns the moment the email lands.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keep timeouts larger than the wait.&lt;/strong&gt; Test timeout, &lt;code&gt;cy.request&lt;/code&gt; timeout and any HTTP client timeout should all be above 60 seconds if you use &lt;code&gt;wait=60&lt;/code&gt;. Otherwise your tooling fails before the API does.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Limit parallel email tests.&lt;/strong&gt; Each key allows 60 requests per minute, and a single long-poll covers one email, so you don't need to poll in a loop. A key can also hold only a few waiting requests at the same time; extra concurrent waits get HTTP 429. If you run a big parallel suite, put the email tests in their own project with fewer workers, or tag them and run them serially:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// playwright.config.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;defineConfig&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@playwright/test&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;defineConfig&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;projects&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;default&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;testIgnore&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sr"&gt;/email&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;.*&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="sr"&gt;spec&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="sr"&gt;ts/&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;email&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;testMatch&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sr"&gt;/email&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;.*&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="sr"&gt;spec&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="sr"&gt;ts/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;fullyParallel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then run that project with a small worker count, for example &lt;code&gt;npx playwright test --project=email --workers=2&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Treat a 404 as a real failure.&lt;/strong&gt; When the wait expires with nothing, the useful question is "did the app send the email?" That's a bug you want the test to catch, not retry around.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this doesn't cover
&lt;/h2&gt;

&lt;p&gt;The inbox is receive-only. It won't send or forward mail, so it's for verifying what your app sends, not for testing replies. And the API reads only custom domains you've connected, not the public temp mail addresses on the homepage.&lt;/p&gt;

&lt;p&gt;API reference: &lt;a href="https://freedomainmail.com/docs/api" rel="noopener noreferrer"&gt;freedomainmail.com/docs/api&lt;/a&gt;. There's also a longer walkthrough of how catch-all works: &lt;a href="https://freedomainmail.com/guides/catch-all-email" rel="noopener noreferrer"&gt;catch-all email guide&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you test OTP flows a different way, I'd like to hear how in the comments.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Disclosure: I build Free Domain Mail – Temp Mail, the service used in this post.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>testing</category>
      <category>playwright</category>
      <category>cypress</category>
      <category>javascript</category>
    </item>
    <item>
      <title>How to Set Up Custom-Domain Email with SPF, DKIM, and DMARC</title>
      <dc:creator>Thanh Ho Van</dc:creator>
      <pubDate>Thu, 10 Sep 2026 08:16:47 +0000</pubDate>
      <link>https://dev.to/thanh_hovan_29d27c74b1e0/how-to-set-up-custom-domain-email-with-spf-dkim-and-dmarc-1pf8</link>
      <guid>https://dev.to/thanh_hovan_29d27c74b1e0/how-to-set-up-custom-domain-email-with-spf-dkim-and-dmarc-1pf8</guid>
      <description>&lt;p&gt;Setting up email on a domain you own is mostly a DNS task. The confusing part is that several records solve different problems: receiving mail, proving domain ownership, authorizing senders, signing messages, and publishing an anti-spoofing policy.&lt;/p&gt;

&lt;p&gt;This guide explains the five record groups used by &lt;strong&gt;Free Domain Mail&lt;/strong&gt;, a lightweight browser-based mailbox service for freelancers, developers, solo founders, personal projects, and small teams with low email volume.&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/1EQELQDziyw" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;h2&gt;
  
  
  What you need before starting
&lt;/h2&gt;

&lt;p&gt;You need:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A domain that you own&lt;/li&gt;
&lt;li&gt;Access to its authoritative DNS provider&lt;/li&gt;
&lt;li&gt;A Free Domain Mail account&lt;/li&gt;
&lt;li&gt;A few minutes for DNS caches to refresh&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You do not need to transfer your domain or operate your own mail server.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Verify domain ownership with TXT
&lt;/h2&gt;

&lt;p&gt;The first TXT record proves that you control the domain. Its value is generated specifically for your setup.&lt;/p&gt;

&lt;p&gt;Add the exact host and value shown in the setup screen. Do not remove unrelated TXT records already used for site verification or other services.&lt;/p&gt;

&lt;p&gt;After publishing the record, run the verification check again. DNS propagation may be fast, but some providers and resolvers cache results longer.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Route incoming email with MX
&lt;/h2&gt;

&lt;p&gt;An MX record tells other mail systems where messages for your domain should be delivered.&lt;/p&gt;

&lt;p&gt;Before changing MX, review any existing records. Replacing the current MX records will change where new mail is delivered. If the domain already uses another email provider, plan the migration instead of editing records blindly.&lt;/p&gt;

&lt;p&gt;The priority number matters when multiple MX records exist: a lower number normally has higher priority.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Authorize sending with SPF
&lt;/h2&gt;

&lt;p&gt;SPF is a TXT policy that lists the services allowed to send mail for the domain.&lt;/p&gt;

&lt;p&gt;A domain should normally publish &lt;strong&gt;one SPF record&lt;/strong&gt;. If one already exists, merge the required authorization into the existing policy. Publishing two independent records beginning with v=spf1 can cause SPF evaluation errors.&lt;/p&gt;

&lt;p&gt;SPF helps receiving systems detect unauthorized infrastructure, but it does not cryptographically sign the message.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Authenticate messages with DKIM
&lt;/h2&gt;

&lt;p&gt;DKIM adds a cryptographic signature to outgoing messages. Receiving systems use DNS to obtain the public key and verify that the signed parts of the message were not altered.&lt;/p&gt;

&lt;p&gt;Free Domain Mail displays the required DKIM CNAME records during setup. Copy each hostname and target exactly. Some DNS dashboards automatically append the domain name, so check the final public record if verification fails.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Publish a DMARC policy
&lt;/h2&gt;

&lt;p&gt;DMARC connects SPF and DKIM results to the domain visible in the From address. It also tells receivers what to do when authentication fails.&lt;/p&gt;

&lt;p&gt;A cautious starting policy is often:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;v=DMARC1; p=none; rua=mailto:dmarc@example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Replace the reporting address with a mailbox you control. A monitoring policy does not directly reject mail. Move toward quarantine or reject only after reviewing legitimate sending sources and alignment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verify the public result
&lt;/h2&gt;

&lt;p&gt;After saving all records, check what the public DNS actually returns—not only what the provider dashboard displays.&lt;/p&gt;

&lt;p&gt;The free &lt;a href="https://freedomainmail.com/tools/dns-checker" rel="noopener noreferrer"&gt;DNS Checker&lt;/a&gt; inspects MX, SPF, DMARC, A, AAAA, and nameserver records. You can also follow the complete &lt;a href="https://freedomainmail.com/guide" rel="noopener noreferrer"&gt;five-step setup guide&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Common causes of failed verification include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Checking before caches have refreshed&lt;/li&gt;
&lt;li&gt;Adding the full domain twice in the hostname field&lt;/li&gt;
&lt;li&gt;Publishing multiple SPF records&lt;/li&gt;
&lt;li&gt;Leaving an old MX record active&lt;/li&gt;
&lt;li&gt;Copying whitespace or punctuation incorrectly&lt;/li&gt;
&lt;li&gt;Editing DNS at a provider that is not authoritative for the domain&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What Free Domain Mail currently provides
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://freedomainmail.com/" rel="noopener noreferrer"&gt;Free Domain Mail&lt;/a&gt; is currently a free beta with a simple webmail interface. It stores received and sent messages and supports reading, composing, replying, manual forwarding, archiving, spam marking, and deletion.&lt;/p&gt;

&lt;p&gt;Current free limits include up to three mailboxes per domain, five sent messages per domain per day, and ten received messages per domain per day. No credit card is required.&lt;/p&gt;

&lt;p&gt;It is designed for low-volume personal and small-business use. It does &lt;strong&gt;not currently provide IMAP, SMTP/POP3 client access, aliases, catch-all addresses, shared mailboxes, automatic forwarding, mobile apps, attachments, or bulk sending&lt;/strong&gt;. Sending availability is also subject to Amazon SES service restrictions.&lt;/p&gt;

&lt;p&gt;These limits are intentional: the product focuses on guided DNS setup, a minimal browser inbox, and strong anti-abuse controls—not marketing campaigns or cold outreach.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final checklist
&lt;/h2&gt;

&lt;p&gt;Before creating your first mailbox, confirm that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Domain ownership TXT passes&lt;/li&gt;
&lt;li&gt;MX points to the intended inbound service&lt;/li&gt;
&lt;li&gt;Only one valid SPF policy is published&lt;/li&gt;
&lt;li&gt;Every DKIM CNAME resolves correctly&lt;/li&gt;
&lt;li&gt;DMARC exists at _dmarc.yourdomain.com&lt;/li&gt;
&lt;li&gt;Existing website A, AAAA, and CNAME records remain unchanged&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Once all five record groups pass, the domain is ready for a custom-domain web mailbox.&lt;/p&gt;

&lt;p&gt;Have you encountered a DNS-provider-specific issue while configuring email authentication? Share the provider and the symptom in the comments—without posting private tokens or account credentials.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>tutorial</category>
      <category>dns</category>
      <category>showdev</category>
    </item>
    <item>
      <title>How to Set Up SPF, DKIM and DMARC for a Custom Domain</title>
      <dc:creator>Thanh Ho Van</dc:creator>
      <pubDate>Wed, 09 Sep 2026 07:03:32 +0000</pubDate>
      <link>https://dev.to/thanh_hovan_29d27c74b1e0/how-to-set-up-spf-dkim-and-dmarc-for-a-custom-domain-367i</link>
      <guid>https://dev.to/thanh_hovan_29d27c74b1e0/how-to-set-up-spf-dkim-and-dmarc-for-a-custom-domain-367i</guid>
      <description>&lt;p&gt;Email authentication can look like a collection of unrelated DNS records. In practice, each record answers a different question:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;SPF:&lt;/strong&gt; Which servers may send mail for this domain?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DKIM:&lt;/strong&gt; Was this message signed by an authorized sender, and did it change in transit?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DMARC:&lt;/strong&gt; Does the visible From domain align with SPF or DKIM, and what should receivers do when authentication fails?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This guide shows a safe, provider-neutral setup sequence for a custom domain.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The DNS values below are examples. Always copy the actual hostnames, selectors, and targets supplied by your email provider.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Before changing DNS
&lt;/h2&gt;

&lt;p&gt;Confirm three things first:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;You control the domain's authoritative DNS zone.&lt;/li&gt;
&lt;li&gt;You know every service that currently sends mail for the domain.&lt;/li&gt;
&lt;li&gt;You have saved a copy of the existing MX and TXT records.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Do not delete website A, AAAA, or CNAME records while configuring email. Email DNS and website DNS normally coexist.&lt;/p&gt;

&lt;p&gt;You can inspect the records currently visible on the public internet with the &lt;a href="https://freedomainmail.com/tools/dns-checker" rel="noopener noreferrer"&gt;Free Domain Mail DNS Checker&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Configure MX for incoming mail
&lt;/h2&gt;

&lt;p&gt;MX records tell other mail systems where to deliver messages addressed to your domain.&lt;/p&gt;

&lt;p&gt;A simplified example looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Type: MX
Name: @
Priority: 10
Value: inbound.mail-provider.example
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;@&lt;/code&gt; symbol usually means the root domain, but DNS dashboards differ. Some expect a blank name, while others expect the complete domain.&lt;/p&gt;

&lt;p&gt;Important rules:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Use the exact MX destination supplied by the receiving provider.&lt;/li&gt;
&lt;li&gt;Lower priority numbers are tried first.&lt;/li&gt;
&lt;li&gt;Remove an old MX record only when you intentionally stop using the old receiver.&lt;/li&gt;
&lt;li&gt;Do not point MX to an ordinary website IP address.&lt;/li&gt;
&lt;li&gt;If you use Cloudflare, mail hostnames generally need to remain &lt;strong&gt;DNS only&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;MX controls receiving. It does not authorize outgoing mail.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Publish one SPF policy
&lt;/h2&gt;

&lt;p&gt;SPF is stored as a TXT record. It lists the infrastructure allowed to send using a particular envelope domain.&lt;/p&gt;

&lt;p&gt;Example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Type: TXT
Name: @
Value: v=spf1 include:spf.mail-provider.example -all
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The most common SPF mistake is publishing two records such as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;v=spf1 include:service-a.example -all
v=spf1 include:service-b.example -all
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That does not create two valid policies. It normally produces an SPF permanent error. Combine legitimate senders into a single record instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;v=spf1 include:service-a.example include:service-b.example -all
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Also remember:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;SPF has a DNS-lookup limit.&lt;/li&gt;
&lt;li&gt;An &lt;code&gt;include&lt;/code&gt; authorizes another provider's policy; it is not a redirect.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;~all&lt;/code&gt; is a soft-fail qualifier and &lt;code&gt;-all&lt;/code&gt; is a hard-fail qualifier.&lt;/li&gt;
&lt;li&gt;Changing to &lt;code&gt;-all&lt;/code&gt; before identifying every sender may break legitimate mail.&lt;/li&gt;
&lt;li&gt;SPF alone may not satisfy DMARC when the authenticated envelope domain does not align with the visible From domain.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 3: Enable DKIM signing
&lt;/h2&gt;

&lt;p&gt;DKIM adds a cryptographic signature to outgoing messages. The sending service holds the private key, while DNS publishes the public key or a CNAME pointing to it.&lt;/p&gt;

&lt;p&gt;A provider may give you records like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Type: CNAME
Name: selector1._domainkey
Value: selector1.dkim.mail-provider.example
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Some services provide one selector; others provide several. Publish every record shown in the provider dashboard.&lt;/p&gt;

&lt;p&gt;Common DKIM problems include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Entering the whole hostname when the DNS dashboard automatically appends the domain.&lt;/li&gt;
&lt;li&gt;Checking &lt;code&gt;default._domainkey&lt;/code&gt; when the provider uses another selector.&lt;/li&gt;
&lt;li&gt;Creating a TXT record when the provider requested a CNAME.&lt;/li&gt;
&lt;li&gt;Copying a target with missing characters.&lt;/li&gt;
&lt;li&gt;Assuming that a published key means the sender has started signing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After DNS verification, send a real test message and inspect its headers. Look for &lt;code&gt;dkim=pass&lt;/code&gt; and verify that the signing domain aligns with the From domain.&lt;/p&gt;

&lt;p&gt;For a more detailed walkthrough, see this &lt;a href="https://freedomainmail.com/guides/dkim" rel="noopener noreferrer"&gt;DKIM setup guide&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Start DMARC in monitoring mode
&lt;/h2&gt;

&lt;p&gt;DMARC is a TXT record at &lt;code&gt;_dmarc.yourdomain.com&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A sensible starting policy is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Type: TXT
Name: _dmarc
Value: v=DMARC1; p=none; rua=mailto:dmarc@yourdomain.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;p=none&lt;/code&gt;, receivers can send aggregate reports without being asked to quarantine or reject failures. This gives you time to find forgotten senders and alignment problems.&lt;/p&gt;

&lt;p&gt;A safe rollout is usually:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Begin with &lt;code&gt;p=none&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Review reports and fix legitimate failures.&lt;/li&gt;
&lt;li&gt;Move a small percentage to &lt;code&gt;p=quarantine&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Increase enforcement gradually.&lt;/li&gt;
&lt;li&gt;Use &lt;code&gt;p=reject&lt;/code&gt; only when authorized mail is consistently aligned.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The reporting address must be able to receive and process DMARC aggregate reports. These are XML files and can arrive in significant volume.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Verify alignment, not only individual passes
&lt;/h2&gt;

&lt;p&gt;A message can show &lt;code&gt;spf=pass&lt;/code&gt; and still fail DMARC if SPF authenticated a different organizational domain. Likewise, DKIM must pass with an aligned signing domain.&lt;/p&gt;

&lt;p&gt;DMARC passes when at least one aligned mechanism passes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;SPF passes &lt;strong&gt;and&lt;/strong&gt; the authenticated SPF domain aligns with the From domain, or&lt;/li&gt;
&lt;li&gt;DKIM passes &lt;strong&gt;and&lt;/strong&gt; the DKIM signing domain aligns with the From domain.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This distinction matters with ticketing systems, newsletters, transactional platforms, and forwarding services.&lt;/p&gt;

&lt;h2&gt;
  
  
  Useful DNS checks
&lt;/h2&gt;

&lt;p&gt;Replace the example domain and selector:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dig +short MX example.com
dig +short TXT example.com
dig +short TXT _dmarc.example.com
dig +short CNAME selector1._domainkey.example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If results differ between tools, query the authoritative nameserver directly. That helps distinguish a bad record from a resolver cache that has not refreshed yet.&lt;/p&gt;

&lt;p&gt;DNS changes can appear quickly or take longer depending on TTL values and resolver caches. Repeatedly saving the same record does not make propagation faster.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final validation checklist
&lt;/h2&gt;

&lt;p&gt;Before declaring the configuration complete:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] The intended MX destinations are publicly visible.&lt;/li&gt;
&lt;li&gt;[ ] There is exactly one SPF record at each sending hostname.&lt;/li&gt;
&lt;li&gt;[ ] Every legitimate sender is included in the SPF design.&lt;/li&gt;
&lt;li&gt;[ ] All provider-issued DKIM selectors resolve.&lt;/li&gt;
&lt;li&gt;[ ] Test messages show DKIM signatures.&lt;/li&gt;
&lt;li&gt;[ ] DMARC exists at &lt;code&gt;_dmarc&lt;/code&gt;, not at the root.&lt;/li&gt;
&lt;li&gt;[ ] SPF or DKIM passes with DMARC alignment.&lt;/li&gt;
&lt;li&gt;[ ] Bounce and complaint handling is active.&lt;/li&gt;
&lt;li&gt;[ ] DMARC reports are being reviewed.&lt;/li&gt;
&lt;li&gt;[ ] No API keys, SMTP passwords, or private keys were published.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A practical order of operations
&lt;/h2&gt;

&lt;p&gt;For a new domain, I prefer this order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Verify ownership with the email provider.&lt;/li&gt;
&lt;li&gt;Publish DKIM.&lt;/li&gt;
&lt;li&gt;Configure SPF for known senders.&lt;/li&gt;
&lt;li&gt;Point MX to the receiving service.&lt;/li&gt;
&lt;li&gt;Add DMARC with &lt;code&gt;p=none&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Send and receive test messages.&lt;/li&gt;
&lt;li&gt;Review headers and reports.&lt;/li&gt;
&lt;li&gt;Tighten the DMARC policy gradually.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This order reduces the chance of enforcing a policy before authentication is actually working.&lt;/p&gt;

&lt;p&gt;If you want a concise version you can keep beside your DNS dashboard, the public &lt;a href="https://github.com/thanh743/email-dns-checklist" rel="noopener noreferrer"&gt;email DNS checklist on GitHub&lt;/a&gt; contains provider-neutral examples and an Amazon SES-specific companion guide.&lt;/p&gt;

&lt;p&gt;What DNS provider or email service are you configuring? Share the record types—not credentials—in the comments, and I can help identify the usual pitfalls.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>webdev</category>
      <category>security</category>
      <category>tutorial</category>
      <category>networking</category>
    </item>
  </channel>
</rss>
