<?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: GrabMail</title>
    <description>The latest articles on DEV Community by GrabMail (@grabmail).</description>
    <link>https://dev.to/grabmail</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%2F4137820%2F12748c67-58e1-4cf6-8294-8ac1be57689e.png</url>
      <title>DEV Community: GrabMail</title>
      <link>https://dev.to/grabmail</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/grabmail"/>
    <language>en</language>
    <item>
      <title>Reading a verification e-mail in GitHub Actions without mocking anything</title>
      <dc:creator>GrabMail</dc:creator>
      <pubDate>Tue, 22 Sep 2026 14:33:09 +0000</pubDate>
      <link>https://dev.to/grabmail/reading-a-verification-e-mail-in-github-actions-without-mocking-anything-3cfn</link>
      <guid>https://dev.to/grabmail/reading-a-verification-e-mail-in-github-actions-without-mocking-anything-3cfn</guid>
      <description>&lt;p&gt;&lt;em&gt;A workflow that registers a user on staging, waits for the real confirmation e-mail and follows the link, in bash, with no secrets.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Here is the end-to-end check most projects wish they had: on every deploy to staging, register a brand-new user, wait for the confirmation e-mail the application really sends, follow the link inside it, and assert that the account is active. No mocked mailer, no test-only code path that skips verification.&lt;/p&gt;

&lt;p&gt;What made this hard was the mailbox. It needs to be reachable from a CI runner, it needs to be readable without a password stored as a secret, and it must not collide when two workflows run at once. A disposable inbox with an HTTP API solves all three, so the whole job is bash, curl and jq.&lt;/p&gt;

&lt;h2&gt;
  
  
  The workflow
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;e2e-signup&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;workflow_run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;workflows&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;deploy-staging&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;types&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;completed&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;signup&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Invent an address&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;echo "BOX=ci-${GITHUB_RUN_ID}-${RANDOM}@grabmail.io" &amp;gt;&amp;gt; "$GITHUB_ENV"&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Register on staging&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;curl -sf -X POST https://staging.example.com/api/register \&lt;/span&gt;
            &lt;span class="s"&gt;-H 'Content-Type: application/json' \&lt;/span&gt;
            &lt;span class="s"&gt;-d "{\"email\":\"$BOX\",\"password\":\"Ci-${RANDOM}-pass!\"}"&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Wait for the e-mail&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;for i in $(seq 1 30); do&lt;/span&gt;
            &lt;span class="s"&gt;LIST=$(curl -sG https://grabmail.io/api/v1/mailbox --data-urlencode "address=$BOX")&lt;/span&gt;
            &lt;span class="s"&gt;ID=$(echo "$LIST" | jq -r '.messages[0].id // empty')&lt;/span&gt;
            &lt;span class="s"&gt;[ -n "$ID" ] &amp;amp;&amp;amp; { echo "MSG_ID=$ID" &amp;gt;&amp;gt; "$GITHUB_ENV"; exit 0; }&lt;/span&gt;
            &lt;span class="s"&gt;sleep 2&lt;/span&gt;
          &lt;span class="s"&gt;done&lt;/span&gt;
          &lt;span class="s"&gt;echo 'no verification e-mail within 60 s' &amp;gt;&amp;amp;2; exit 1&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Follow the link&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;MSG=$(curl -sG "https://grabmail.io/api/v1/message/$MSG_ID" --data-urlencode "mailbox=$BOX")&lt;/span&gt;
          &lt;span class="s"&gt;LINK=$(echo "$MSG" | jq -r .text | grep -oE 'https://staging\.example\.com/verify[^ &amp;gt;"]*' | head -1)&lt;/span&gt;
          &lt;span class="s"&gt;test -n "$LINK"&lt;/span&gt;
          &lt;span class="s"&gt;curl -sf "$LINK" &amp;gt; /dev/null&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Assert the account is active&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;STATE=$(curl -sf "https://staging.example.com/api/users/by-email?email=$BOX" | jq -r .state)&lt;/span&gt;
          &lt;span class="s"&gt;test "$STATE" = active&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why it holds up
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;No secrets.&lt;/strong&gt; The public domains on &lt;a href="https://grabmail.io/" rel="noopener noreferrer"&gt;GrabMail&lt;/a&gt; need no key, so the workflow has nothing to leak and nothing to rotate. The only thing that identifies the mailbox is the address, and the address is unguessable because it carries the run id and a random number.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No collisions.&lt;/strong&gt; Two workflows running at the same time invent two different addresses. There is no shared account and no shared state.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No cleanup.&lt;/strong&gt; Messages are deleted after five days by the service, so the job never has to delete anything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Honest failures.&lt;/strong&gt; If the mail does not arrive in a minute, the job fails with a sentence that says so. That is the bug you want to catch: a mailer misconfigured on staging is exactly what this test exists to notice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Details worth knowing
&lt;/h2&gt;

&lt;p&gt;The mailbox endpoint is rate-limited to one request per second per address, and answers &lt;code&gt;429&lt;/code&gt; with a &lt;code&gt;Retry-After&lt;/code&gt; header if you go faster. Two seconds between polls is a comfortable margin. The &lt;a href="https://grabmail.io/docs/api" rel="noopener noreferrer"&gt;API reference&lt;/a&gt; lists the response fields; the &lt;code&gt;preview&lt;/code&gt; in the list response is often enough to spot a six-digit code without fetching the full message.&lt;/p&gt;

&lt;p&gt;If the application refuses the address because it sits on a disposable-mail blocklist, the fix is not a different local part. Point a domain you own at the service instead: one MX record makes every address on it a catch-all inbox, and it is not on anybody's list.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://grabmail.io/guides/github-actions-email-e2e" rel="noopener noreferrer"&gt;full GitHub Actions guide&lt;/a&gt; covers the variants: extracting a one-time code instead of a link, retrying across a re-deploy, and reporting the e-mail body as a job artifact when the assertion fails.&lt;/p&gt;

</description>
      <category>testing</category>
      <category>githubactions</category>
      <category>devops</category>
      <category>email</category>
    </item>
  </channel>
</rss>
