TEST RUNNERS

Test email verification in GitHub Actions CI

Email verification is one of the last manual steps in an otherwise automated pipeline. The signup test runs fine on a laptop where a developer clicks the link by hand, then falls apart in CI because a GitHub Actions runner has nowhere to receive mail. Most teams paper over it: they stub the email, skip the step, or share one Gmail account and scrape it with fragile IMAP code.

There is a cleaner pattern. Give each CI job a real inbox from an API, drive the signup, poll for the code, and assert it. This post shows the full GitHub Actions setup with MailSink.

TL;DR

A GitHub Actions runner cannot receive email on its own. Instead of a mail server, call a disposable-inbox API: create an inbox, point your signup flow at its address, wait for the verification code, assert it. With MailSink that is three REST calls, no SMTP, no IMAP, and no shared account. The free tier is 50 inboxes per month, which covers most CI usage.

ApproachReceives real mailTests the real delivery pathCI-friendly
Stub the emailNoNoYes
Shared Gmail + IMAPYesPartlyFlaky
Local mail catcher (MailHog)Only same-host SMTPNo (never leaves the box)Limited
Disposable-inbox API (MailSink)YesYesYes

Why email breaks in CI

A runner is an ephemeral virtual machine with no inbox and no public MX record. When your app sends a verification email during a test, it goes to your real email provider (Postmark, Resend, SES) and out to the internet. The runner has no way to read it back.

The three common workarounds each give something up:

A disposable-inbox API sidesteps all three. Each job gets its own throwaway address that receives real mail over real MX records, and the API hands back the OTP as JSON.

The pattern: one inbox per job

The flow is the same in any language:

const API = "https://api.mailsink.dev/v1";
const auth = { Authorization: `Bearer ${process.env.MAILSINK_API_KEY}` };

// 1. Create a throwaway inbox for this job.
const inbox = await fetch(`${API}/inboxes`, { method: "POST", headers: auth })
  .then((r) => r.json());

// 2. Sign up your app using inbox.address, then wait for the code.
const { code } = await fetch(
  `${API}/inboxes/${inbox.id}/wait-for-code?timeout=30`,
  { headers: auth },
).then((r) => r.json());

// 3. Assert.
expect(code).toMatch(/^\d{6}$/);

wait-for-code is a long poll that blocks up to 30 seconds until a message with an extractable code arrives, so you do not write your own retry loop. If you need the magic link instead of a numeric code, use wait-for-link.

Full GitHub Actions workflow

Store your API key as a repository secret (Settings > Secrets and variables > Actions) and read it as an env var. Never inline the key in the YAML.

name: e2e
on: [push]

jobs:
  signup-flow:
    runs-on: ubuntu-latest
    env:
      MAILSINK_API_KEY: ${{ secrets.MAILSINK_API_KEY }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: npm ci
      - run: npm test

Your test file uses the three calls above. Because each run creates a fresh inbox, parallel jobs never share state, and there is no cleanup step to maintain: MailSink deletes inbox contents after the TTL (1 hour on the free tier).

Reading the code, not the whole inbox

The /wait-for-code and /wait-for-link endpoints parse OTPs and verification links out of the message so your test asserts a value instead of parsing an HTML body. Senders like Stripe, GitHub, Clerk, Supabase, and Auth0 are tuned; for anything unrecognised you can still read the raw message and extract the code yourself. The MailSink API docs list every endpoint.

Driving it with Playwright or Cypress

The API is runner-agnostic, so the same three calls slot into a browser E2E test. Create the inbox, type inbox.address into the signup form, wait for the code, type it into the OTP field. See the dedicated walkthroughs for Cypress and Playwright OTP.

When to stub instead

A real inbox is not always the right call. If a test only checks that your app renders the correct email template, stubbing the send and asserting the rendered HTML is faster and has no network dependency. Reach for a real inbox when the thing under test is the end-to-end path: that the provider actually delivers, that the link works, that the OTP a user would receive is correct. Those are the flows that break silently when stubbed.

FAQ

Can GitHub Actions receive email directly?

No. A runner is an ephemeral VM with no mailbox and no public MX record. To receive a verification email in CI you call an external inbox API, then read the message back over HTTP.

Do I need SMTP or a mail server to test email in CI?

No. With a disposable-inbox API you make plain HTTPS requests. There is no SMTP to configure and no IMAP parsing. MailSink provisions the address and returns the parsed code as JSON.

How do I stop email tests from being flaky?

Use a long-poll endpoint instead of a fixed sleep. wait-for-code blocks until the message arrives or the timeout hits, so the test proceeds the moment the mail lands rather than guessing a delay. Give each job its own inbox so parallel runs never read each other’s mail.

Will the free tier cover my CI usage?

Often, yes. The free tier is 50 inboxes per month. Since each run creates one inbox and discards it, a project that runs the signup suite a couple of times a day stays inside the free tier. Higher-volume pipelines move to the $15/mo Pro plan (2,000 inboxes per month).

Does this work with agents and MCP clients, not just CI?

Yes. The same inbox API is exposed as an MCP server, so Claude Code, Cursor, and other MCP clients can create an inbox and read a verification code as tool calls.

Next