// blog

· 11 min read · by Alaeddine
A Playwright test signs up on a web form, and the welcome email it triggers arrives in mailQA for the test to assert on.

Signup verification, one-time codes, password resets, magic links: the flows that decide whether a user gets into your product at all are the ones an end-to-end suite most often skips. The browser half is easy to automate. The inbox half is where it goes wrong — a shared Gmail account read over IMAP, a page.waitForTimeout(5000) that is too short in CI and too long everywhere else, a test that passes on a leftover email from yesterday's run.

This post wires @mailqa/client into a Playwright suite so every test gets a private inbox of its own, waits for exactly the message it triggered, and reads the link or code straight out of it. No sleeps, no shared mailbox, and nothing ever delivered to a real person.

What you need

  • A mailQA API key — Settings → API keys in the dashboard. It is shown once, so put it straight into your CI secrets.
  • An app under test whose email reaches mailQA, which is the next section.
  • @playwright/test and the client:
npm install --save-dev @mailqa/client
export MAILQA_API_KEY=mqa_live_…

The client depends on nothing and uses the global fetch, so any Node version Playwright supports will run it.

Getting the app's email into mailQA

There are two ways in, and a test suite does not care which one you use — both land in the same inbox, and the SDK reads them identically.

Send it to the inbox's address. Every inbox has a real address on your organization's subdomain, like pw-3f9a1c2e@acme.mailqa.io. If your staging environment already sends through a real provider, type that address into the signup form and the mail arrives over the public internet, exactly as it would for a customer. Nothing in the app changes.

Point the app's SMTP at the sandbox. For local runs and CI, where you would rather the app sent nothing out at all, give it your organization's SMTP credentials:

SMTP_HOST=smtp.mailqa.io
SMTP_PORT=587          # STARTTLS
SMTP_USER=acme         # from the dashboard, or mailqa.smtp.credentials()
SMTP_PASS=…

Everything the app sends is then captured and filed by recipient. Mail to an inbox's own address lands in that inbox, so the tests below work unchanged; mail to anyone else — customer@example.com — lands in an inbox named after that address, created on first use. Either way, no one receives it. The sandbox page has the same configuration for Rails, Django, Laravel and the other common mailers.

One inbox per test, as a fixture

The whole approach rests on isolation: if each test owns its inbox, the only email in it is the one that test caused, and there is nothing to filter out. Playwright fixtures are the natural place for that, because their teardown runs even when the test fails.

// tests/fixtures.ts
import { randomUUID } from 'node:crypto';
import { test as base, expect } from '@playwright/test';
import { MailQA, type Inbox } from '@mailqa/client';

export const mailqa = new MailQA({ apiKey: process.env.MAILQA_API_KEY! });

export const test = base.extend<{ inbox: Inbox }>({
  inbox: async ({}, use, testInfo) => {
    const inbox = await mailqa.inboxes.create({
      // What the dashboard shows. Up to 80 characters.
      name: testInfo.title.slice(0, 80),
      // What the address is. Unique per run, so parallel workers,
      // retries and --repeat-each never share an inbox.
      address: `pw-${randomUUID().slice(0, 8)}`,
    });

    await use(inbox);

    // A failed test gets the inbox's contents in its report, beside
    // the trace — usually the fastest way to see what went wrong.
    if (testInfo.status !== testInfo.expectedStatus) {
      const { data } = await mailqa.messages.list(inbox.id, { limit: 10 });
      await testInfo.attach('mailqa-inbox.json', {
        body: JSON.stringify(data, null, 2),
        contentType: 'application/json',
      });
    }

    await mailqa.inboxes.delete(inbox.id);
  },
});

export { expect };

Two details are worth keeping. The address is exactly what you pass — inboxes.create does not add a random suffix, and asking for an address that already exists hands back that inbox rather than failing — so generate one per test instead of deriving it from the test's name. And the inbox is deleted in teardown rather than at the end of the test body, where a failing assertion would skip it and leave the inbox counting against your plan.

A signup verification test

With the fixture in place a test reads like the flow it checks. waitForLink polls until a matching message arrives, then returns the first link in it that matches:

// tests/signup.spec.ts
import { test, expect, mailqa } from './fixtures';

test('a new user verifies their email address', async ({ page, inbox }) => {
  await page.goto('/signup');
  await page.getByLabel('Email').fill(inbox.address);
  await page.getByLabel('Password').fill('correct horse battery staple');
  await page.getByRole('button', { name: 'Create account' }).click();

  const link = await mailqa.waitForLink({
    inbox: inbox.id,
    subjectContains: 'Verify',
    matching: /\/verify\?token=/,
  });

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

matching takes a substring or a regular expression. Without it you get the first link in the message, which in most templates is the logo pointing at your home page — so it is worth being specific.

One-time codes

waitForCode returns the first code mailQA extracted from the message. Extraction looks for a keyword near the digits — "code", "OTP", "verification" and the like — so an order number or a date in the same email is not mistaken for one.

test('two-factor sign-in accepts the emailed code', async ({ page, inbox }) => {
  await signUpAs(page, inbox.address);   // your own helper
  await page.getByRole('button', { name: 'Email me a code' }).click();

  const code = await mailqa.waitForCode({ inbox: inbox.id, subjectContains: 'code' });

  await page.getByLabel('Code').fill(code);
  await expect(page).toHaveURL(/\/dashboard/);
});

When a message arrives but has no code in it, waitForCode throws a MailQAError with the code no_code_found rather than returning an empty string, so the failure names the problem.

Asserting on the email itself

The link and the code are usually all a flow test needs, but the whole message is there when the email is the thing under test. waitForMessage returns it in full:

test('the receipt shows the order total', async ({ page, inbox }) => {
  await checkOut(page, { email: inbox.address, items: ['pro-annual'] });

  const receipt = await mailqa.waitForMessage({
    inbox: inbox.id,
    subjectContains: 'receipt',
  });

  expect(receipt.from.address).toBe('billing@acme.dev');
  expect(receipt.text).toContain('$240.00');
  expect(receipt.attachments.map((a) => a.filename)).toContain('invoice.pdf');
});

html is the sanitised version the dashboard renders; assert against rawHtml when the exact markup you sent is what matters. You can also open the rendered email in the browser you already have, which turns "does the button in the email work" into an ordinary page test:

await page.setContent(receipt.rawHtml ?? '');
await page.getByRole('link', { name: 'View your invoice' }).click();

Reusing an inbox on purpose

Some flows send more than one email to the same person — a verification now, a welcome later, a password reset at the end. They all go to the one inbox the test owns, and each wait needs to pick out the right one. Filter on the subject, and pass after to ignore anything that arrived before the step you are testing:

test('a password reset link signs the user in', async ({ page, inbox }) => {
  await signUpAndVerify(page, inbox.address);   // this already sent two emails

  const requested = new Date();
  await page.goto('/forgot-password');
  await page.getByLabel('Email').fill(inbox.address);
  await page.getByRole('button', { name: 'Send reset link' }).click();

  const link = await mailqa.waitForLink({
    inbox: inbox.id,
    after: requested,
    subjectContains: 'reset',
    matching: '/reset-password',
  });
  await page.goto(link);
  await page.getByLabel('New password').fill('a brand new passphrase');
  await page.getByRole('button', { name: 'Save' }).click();
  await expect(page).toHaveURL(/\/dashboard/);
});

Waits never default to "after now". By the time your test calls one, the email has often already arrived, and a default time filter would hide the very message you are waiting for — intermittently, which is worse than always. Fresh inboxes make it unnecessary; after is for when you share one deliberately.

Parallel workers on a tight inbox limit

An inbox per test is the simplest thing that works, and inboxes are deleted as fast as they are made. But a large suite running fullyParallel across many workers has many alive at once, and each one counts against your plan's inbox limit while it exists. If you hit quota_exceeded, give each worker an inbox and each test a tag within it — the part after a + in the address:

import type { Inbox, Message, WaitForMessageOptions } from '@mailqa/client';

interface Mailbox {
  address: string;
  waitForMessage(filter?: Omit<WaitForMessageOptions, 'inbox' | 'tag'>): Promise<Message>;
}

export const test = base.extend<{ mailbox: Mailbox }, { workerInbox: Inbox }>({
  workerInbox: [
    async ({}, use, workerInfo) => {
      const inbox = await mailqa.inboxes.create({
        name: `playwright worker ${workerInfo.workerIndex}`,
        address: `pw-w${workerInfo.workerIndex}-${randomUUID().slice(0, 8)}`,
      });
      await use(inbox);
      await mailqa.inboxes.delete(inbox.id);
    },
    { scope: 'worker' },
  ],

  mailbox: async ({ workerInbox }, use) => {
    const tag = randomUUID().slice(0, 8);
    const [local, domain] = workerInbox.address.split('@');
    await use({
      address: `${local}+${tag}@${domain}`,
      waitForMessage: (filter = {}) =>
        mailqa.waitForMessage({ ...filter, inbox: workerInbox.id, tag }),
    });
  },
});

A test then fills in mailbox.address and calls mailbox.waitForMessage(), and reads the link or code from the message's links and codes.

Mail to pw-w3-…+9f2c1d0a@acme.mailqa.io lands in worker 3's inbox with the tag 9f2c1d0a, and a wait filtered on that tag sees only that test's mail. Workers run one test at a time, so the inbox is never shared by two tests at once.

Timeouts: make the email wait the one that fails

waitForMessage and its two siblings give up after 30 seconds by default — and so does a Playwright test. When the two are equal, Playwright's timer usually wins, and the report says "Test timeout of 30000ms exceeded" instead of the MessageTimeoutError that names the inbox and the filter that matched nothing.

Keep the email wait comfortably inside the test budget:

// playwright.config.ts
export default defineConfig({
  timeout: 60_000,
  // …
});
const link = await mailqa.waitForLink({ inbox: inbox.id, matching: '/verify', timeout: 20_000 });

When a wait does time out, check three things in order: did the app actually send (its logs), did it send here (the recipient address, or the SMTP credentials), and is the filter right — subjectContains is a case-insensitive substring, not a pattern. The attachment the fixture adds to failed tests usually answers all three at a glance.

Running it in CI

The API key is the only secret the suite needs. In GitHub Actions:

- name: End-to-end tests
  run: npx playwright test
  env:
    MAILQA_API_KEY: ${{ secrets.MAILQA_API_KEY }}
    # Only if the app under test sends through the sandbox:
    SMTP_HOST: smtp.mailqa.io
    SMTP_PORT: 587
    SMTP_USER: ${{ secrets.MAILQA_SMTP_USER }}
    SMTP_PASS: ${{ secrets.MAILQA_SMTP_PASS }}

Teardown deletes each inbox, but a job that is cancelled mid-run never reaches it. A global teardown that sweeps up anything the suite left behind more than an hour ago keeps the count honest without touching a run that is still going:

// tests/sweep.ts — `globalTeardown: './tests/sweep.ts'` in playwright.config.ts
import { mailqa } from './fixtures';

export default async function sweep() {
  const hourAgo = Date.now() - 60 * 60 * 1000;
  const stale = (await mailqa.inboxes.list()).filter(
    (inbox) => inbox.address.startsWith('pw-') && Date.parse(inbox.createdAt) < hourAgo,
  );
  await Promise.all(stale.map((inbox) => mailqa.inboxes.delete(inbox.id)));
}

Where to go next

  • The SDK reference covers every method and option, and the error codes a test can branch on.
  • The quickstart does the same flow with curl, for a suite in a language the SDK does not cover.
  • The API reference documents the endpoints the client calls, if you would rather write your own wrapper.

Try it on your own suite

Create an inbox, point a test at it, and watch the email arrive. Every plan starts with a 14-day free trial.