How to test email verification flows in CI

A practical way to test signup and email-verification flows end to end in an automated pipeline, using a disposable inbox you can read over HTTP.

Most signup flows send a verification email with a link or a code. That step is easy to leave untested because it needs a real inbox — so teams stub it out, and then a broken template or a dead link ships to production. You can test it for real instead: create a throwaway address, run your signup, and read the email back over HTTP.

The idea

  1. Ask receivemail.dev for a fresh address. You get back the address and a per-mailbox secret used to read it later.
  2. Run your normal signup with that address.
  3. Poll the mailbox until the verification email shows up, then pull the link or code out of the body and finish the flow.

Plain shell (works in any CI)

# 1. create the mailbox
resp=$(curl -s -X POST https://receivemail.dev/mailboxes)
addr=$(echo "$resp" | jq -r .address)
secret=$(echo "$resp" | jq -r .secret)

# 2. run your signup against $addr (your app / your API call here)
curl -s -X POST https://your-app.example/api/signup \
  -H 'content-type: application/json' \
  -d "{\"email\":\"$addr\",\"password\":\"test-Pa55w0rd\"}"

# 3. poll for the verification mail (up to ~30s)
for i in $(seq 1 15); do
  body=$(curl -s "https://receivemail.dev/mailboxes/$addr/messages" \
    -H "Authorization: Bearer $secret")
  link=$(echo "$body" | jq -r '.messages[0].body_text // ""' \
    | grep -oE 'https://your-app\.example/verify\?token=[A-Za-z0-9._-]+' | head -1)
  [ -n "$link" ] && break
  sleep 2
done

# 4. hit the verification link
test -n "$link" && curl -s -o /dev/null -w '%{http_code}\n' "$link"

Playwright

import { test, expect, request } from '@playwright/test';

test('new user can verify their email', async ({ page }) => {
  const api = await request.newContext();

  // fresh inbox
  const mb = await (await api.post('https://receivemail.dev/mailboxes')).json();

  // sign up through the real UI
  await page.goto('/signup');
  await page.getByLabel('Email').fill(mb.address);
  await page.getByLabel('Password').fill('test-Pa55w0rd');
  await page.getByRole('button', { name: 'Create account' }).click();

  // wait for the verification email to land
  let link: string | undefined;
  await expect.poll(async () => {
    const res = await api.get(
      `https://receivemail.dev/mailboxes/${mb.address}/messages`,
      { headers: { Authorization: `Bearer ${mb.secret}` } },
    );
    const msg = (await res.json()).messages[0];
    link = msg?.body_text?.match(/https:\/\/[^\s"]+verify[^\s"]*/)?.[0];
    return Boolean(link);
  }, { timeout: 30_000 }).toBe(true);

  await page.goto(link!);
  await expect(page.getByText('Email verified')).toBeVisible();
});

Or use the fixture packages

The polling boilerplate is packaged up so you don't have to copy it:

npm i -D playwright-receivemail   # or: cypress-receivemail
import { test, expect } from 'playwright-receivemail';

test('new user can verify their email', async ({ page, mailbox }) => {
  await page.goto('/signup');
  await page.getByLabel('Email').fill(mailbox.address);
  await page.getByLabel('Password').fill('test-Pa55w0rd');
  await page.getByRole('button', { name: 'Create account' }).click();

  const link = await mailbox.waitForMatch(/https:\/\/[^\s"]+verify[^\s"]*/, {
    subjectContains: 'verify',
  });
  await page.goto(link);
  await expect(page.getByText('Email verified')).toBeVisible();
});

Cypress: cy.createMailbox(), then cy.getEmailMatch(/\d{6}/) for a code or a link regex. See playwright-receivemail / cypress-receivemail.

Notes