Automated Email Testing for Developers

Test verification flows, transactional mail, and OTPs from your CI pipeline.

your address

-

OTP codessignup confirmationspassword resetsmagic linksverification emailsnewsletter testswebhook receipts
OTP codessignup confirmationspassword resetsmagic linksverification emailsnewsletter testswebhook receipts

Live inbox

Auto-refreshing every 10 seconds.

Nothing here yet

Send a test mail or use the address on a signup form. New messages land here automatically.

Why email is the hardest part of E2E testing

Most of an end-to-end suite is straightforward: drive the browser, assert on the DOM. Then you reach the signup flow, and the next assertion lives in an email you have no way to open. The test stalls at exactly the step that matters most.

Teams usually try one of four workarounds, and each has a cost worth knowing before you pick one.

The four usual approaches

  • Automate a real mail provider. Sign in to Gmail with a test account and read the message. This breaks constantly. Providers treat headless browsers as suspicious, and you will spend more time fighting CAPTCHAs and two-factor prompts than testing your own product.
  • Run a local SMTP catcher. Tools in this category are excellent for local development. The limitation is that they only see mail your application hands directly to that SMTP server, so they cannot test a staging environment that sends through a real provider, and they need somewhere to run in CI.
  • Stub the mail layer entirely. Fast and reliable, and it genuinely proves your code called the send function. It does not prove the template rendered, the link was correct, or the message left the building. Worth having, but it is a unit test wearing an integration test costume.
  • Read from a disposable inbox over HTTP. Your application sends mail exactly as it does in production, and your test reads it with a plain HTTP request. No browser automation, no local infrastructure. This is what this service provides.

None of these is universally correct. Stubbing is the right call for a fast unit suite. A disposable inbox is the right call when you want to prove the whole path works, template and delivery included.

What a disposable inbox gives you

  • Isolation. A fresh inbox per test means no cross-contamination and no order dependence between tests.
  • Real delivery. The message travels the same path it does in production, so a broken template or a malformed link fails the test instead of reaching a customer.
  • No account management. No credentials to rotate, no shared test mailbox slowly filling with thousands of messages.
  • Parallel-friendly. Each worker provisions its own address, so tests can run concurrently without fighting over one inbox.

Playwright

Playwright's request fixture makes this straightforward. Create the inbox, run the UI flow, then poll with a deadline rather than reading once.

const BASE = 'https://test-emails.com/hcgi/api';

async function waitForMessage(request, token, match, timeoutMs = 30000) {
  const deadline = Date.now() + timeoutMs;
  while (Date.now() < deadline) {
    const res = await request.get(BASE + '/mailbox/messages', {
      headers: { 'x-mail-token': token }
    });
    if (res.ok()) {
      const { messages } = await res.json();
      const hit = messages.find(match);
      if (hit) return hit;
    }
    await new Promise(r => setTimeout(r, 1000));
  }
  throw new Error('verification email never arrived');
}

test('user can sign up and verify their email', async ({ page, request }) => {
  const inbox = await (await request.post(BASE + '/mailbox')).json();

  await page.goto('https://staging.myapp.com/signup');
  await page.fill('input[name="email"]', inbox.address);
  await page.click('button[type="submit"]');

  const msg = await waitForMessage(request, inbox.token, m => /verify/i.test(m.subject));
  const otp = msg.intro.match(/\b(\d{6})\b/)[1];

  await page.fill('input[name="otp"]', otp);
  await page.click('button:has-text("Verify")');
  await expect(page.locator('[data-testid="dashboard"]')).toBeVisible();
});

Cypress

Cypress cannot open a second tab or navigate cross-origin mid-test, which is exactly why reading email over HTTP matters here. Use cy.request and recursion, since Cypress commands are not promises and a while loop will not work as written.

const BASE = 'https://test-emails.com/hcgi/api';

Cypress.Commands.add('waitForMessage', (token, attempts = 30) => {
  if (attempts === 0) throw new Error('verification email never arrived');
  return cy.request({
    url: BASE + '/mailbox/messages',
    headers: { 'x-mail-token': token }
  }).then(res => {
    const hit = res.body.messages.find(m => /verify/i.test(m.subject));
    if (hit) return hit;
    return cy.wait(1000).then(() => cy.waitForMessage(token, attempts - 1));
  });
});

it('verifies a new account', () => {
  cy.request('POST', BASE + '/mailbox').then(({ body: inbox }) => {
    cy.visit('/signup');
    cy.get('input[name="email"]').type(inbox.address);
    cy.get('button[type="submit"]').click();

    cy.waitForMessage(inbox.token).then(msg => {
      const otp = msg.intro.match(/\b(\d{6})\b/)[1];
      cy.get('input[name="otp"]').type(otp);
    });
  });
});

Five things that make email tests flaky

Almost every unreliable email test we have seen fails for one of these reasons.

  • Reading the inbox once, immediately. Delivery is asynchronous and takes anywhere from under a second to several. A single read right after form submission is a race you will lose on a loaded CI runner. Always poll against a deadline.
  • Asserting on messages[0]. The newest message is not necessarily yours. Match on subject, sender, or a token you injected into the flow.
  • Regexing the HTML part. Markup changes whenever a designer touches the template. Read text when a plain-text alternative exists.
  • Sharing one inbox across tests. This couples tests together and makes failures depend on execution order. Provisioning is one cheap call, so do it per test.
  • Ignoring rate limits in parallel runs. The limit is 100 requests per 5 minutes per IP, and your entire CI runner pool likely shares one egress IP. Ten workers polling once a second will exhaust that in under a minute. Use a sensible interval and handle 429 with backoff.

Where this approach does not fit

Being straight about the limits saves you debugging time later.

  • Testing outbound mail from the address. The service is receive-only, so it cannot help you test parsing of inbound replies.
  • Applications that reject disposable domains. Many do, deliberately, and that is a legitimate product decision. If the system under test blocks these domains, use a mailbox on a domain you control. Do not treat it as an obstacle to route around.
  • Long-lived fixtures. Inboxes are disposable and retention is controlled by our upstream provider. Anything that must survive past a single run needs real infrastructure.
  • Anything sensitive. Never send real customer data, production credentials, or unmasked PII to a disposable inbox, even in staging.

Getting started

The generator at the top of this page is the same service the examples above talk to, so you can try a flow by hand before writing the test. When you are ready to automate, the verification flow walkthrough covers the whole path step by step.