// blog

· 10 min read · by Alaeddine
A Cypress test that signs up with a mailQA address, waits for the verification email with cy.task and visits the link inside it.

Cypress is good at everything that happens in the browser, and has no view at all of what happens in an inbox. So the signup test stops at "check your email", the password reset is tested by hand before each release, and the one-time-code screen is the part of the login nobody automated.

This post closes that gap with @mailqa/client. Each test gets a private inbox, waits for the one email it caused, and carries on with the link or the code inside it — in the same spec, in the same run, with nothing delivered to a real person.

Why the SDK runs in cy.task, not in the spec

A Cypress spec runs inside the browser, and its commands are queued rather than awaited. The SDK is a Node client with an API key. Both facts point the same way: run the SDK in Cypress's Node process, through cy.task, and hand the spec only what it needs — an address, a link, a code.

That keeps the API key out of the browser, out of Cypress.env(), and out of the command log and the screenshots a failed run uploads. It also means the polling happens in plain async code, where waitForMessage already does it for you, instead of being rebuilt out of recursive cy.request calls.

What you need

  • A mailQA API key — Settings → API keys in the dashboard. It is shown once; put it in your CI secrets.
  • An app under test whose email reaches mailQA. Either send to the inbox's own address (every inbox has a real one, like cy-3f9a1c2e@acme.mailqa.io), or point the app's SMTP at the sandbox — smtp.mailqa.io, port 587 with STARTTLS, and your organization's credentials. The sandbox page has the setup for each common framework.
  • The client, as a dev dependency:
npm install --save-dev @mailqa/client
export MAILQA_API_KEY=mqa_live_…

The tasks

Everything mailQA-specific lives in cypress.config.ts. Each task is a thin wrapper over one SDK call:

// cypress.config.ts
import { randomUUID } from 'node:crypto';
import { defineConfig } from 'cypress';
import { MailQA } from '@mailqa/client';

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

/** What a spec may pass to a wait. Only JSON survives cy.task, so a date
 *  arrives as an ISO string and a pattern as its source text. */
interface WaitArgs {
  inbox: string;
  subjectContains?: string;
  after?: string;
  timeout?: number;
  /** For waitForLink: a substring of the URL… */
  matching?: string;
  /** …or a regular expression's source. */
  pattern?: string;
}

function waitOptions({ after, matching, pattern, ...filter }: WaitArgs) {
  return { ...filter, after: after ? new Date(after) : undefined };
}

export default defineConfig({
  e2e: {
    baseUrl: 'http://localhost:3000',
    setupNodeEvents(on) {
      on('task', {
        'mailqa:createInbox': (title: string) =>
          mailqa.inboxes.create({
            name: title.slice(0, 80),
            address: `cy-${randomUUID().slice(0, 8)}`,
          }),

        'mailqa:waitForMessage': (args: WaitArgs) => mailqa.waitForMessage(waitOptions(args)),

        'mailqa:waitForCode': (args: WaitArgs) => mailqa.waitForCode(waitOptions(args)),

        'mailqa:waitForLink': (args: WaitArgs) =>
          mailqa.waitForLink({
            ...waitOptions(args),
            matching: args.pattern ? new RegExp(args.pattern) : args.matching,
          }),

        'mailqa:deleteInbox': async (id: string) => {
          await mailqa.inboxes.delete(id);
          return null; // a task must resolve to something other than undefined
        },
      });
    },
  },
});

Three Cypress rules shape that file, and each one fails confusingly if it is missed:

  • Arguments and results are serialised. A RegExp passed to cy.task arrives as {} and a Date as a string, which is why the waits take pattern and an ISO after and rebuild them on the Node side.
  • A task must not resolve to undefined. inboxes.delete resolves to nothing, so the task returns null after it.
  • A task has its own timeout — taskTimeout, 60 seconds by default. The SDK's waits give up after 30, so the error you see is mailQA's, naming the inbox and the filter, rather than Cypress's generic one. Raise a wait past 60 seconds and pass { timeout } to cy.task too.

The address is exactly what you pass: inboxes.create adds no random suffix, and asking for an address that already exists hands back that inbox. A random one per test is what keeps parallel machines and re-runs from ever sharing an inbox.

One inbox per test

Create the inbox in beforeEach and keep it as an alias, so every test in the file starts with an empty inbox of its own and nothing to filter out:

// cypress/e2e/signup.cy.ts
import type { Inbox } from '@mailqa/client';

describe('signup', () => {
  beforeEach(() => {
    cy.task<Inbox>('mailqa:createInbox', Cypress.currentTest.title).as('inbox');
  });

  afterEach(() => {
    cy.get<Inbox>('@inbox').then((inbox) => cy.task('mailqa:deleteInbox', inbox.id));
  });

  it('verifies a new account from the emailed link', () => {
    cy.get<Inbox>('@inbox').then((inbox) => {
      cy.visit('/signup');
      cy.get('input[name=email]').type(inbox.address);
      cy.get('input[name=password]').type('correct horse battery staple');
      cy.contains('button', 'Create account').click();

      cy.task<string>('mailqa:waitForLink', {
        inbox: inbox.id,
        subjectContains: 'Verify',
        pattern: '/verify\\?token=',
      }).then((link) => cy.visit(link));

      cy.contains('Email verified').should('be.visible');
    });
  });
});

afterEach runs when a test fails as well as when it passes. What it cannot survive is the runner itself going away — a cancelled CI job, a crashed browser — which is what the sweep at the end of this post is for.

If the verification link points at a different domain from baseUrl — a separate auth host, say — wrap the visit and the assertions after it in cy.origin(), as Cypress requires for any cross-origin page.

One-time codes

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

it('accepts the emailed sign-in code', () => {
  cy.get<Inbox>('@inbox').then((inbox) => {
    cy.signUpAs(inbox.address); // your own command
    cy.contains('button', 'Email me a code').click();

    cy.task<string>('mailqa:waitForCode', { inbox: inbox.id, subjectContains: 'code' }).then(
      (code) => cy.get('input[name=code]').type(code),
    );

    cy.location('pathname').should('eq', '/dashboard');
  });
});

A message that arrives without a code fails the task with no_code_found, so the test says what went wrong instead of typing an empty string into the form.

Asserting on the email itself

When the email is the thing under test, mailqa:waitForMessage returns all of it — plain JSON, so it crosses cy.task intact:

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

it('sends a receipt with the order total', () => {
  cy.get<Inbox>('@inbox').then((inbox) => {
    cy.checkOut({ email: inbox.address, plan: 'pro-annual' });

    cy.task<Message>('mailqa:waitForMessage', {
      inbox: inbox.id,
      subjectContains: 'receipt',
    }).then((receipt) => {
      expect(receipt.from.address).to.eq('billing@acme.dev');
      expect(receipt.text).to.contain('$240.00');
      expect(receipt.attachments.map((a) => a.filename)).to.include('invoice.pdf');
    });
  });
});

html is the sanitised markup mailQA renders in its dashboard; assert against rawHtml when the exact markup you sent is what matters. links and codes are there too, already extracted.

Several emails in one test

A flow that sends more than one email to the same person — verification, then welcome, then a password reset — needs each wait to pick the right one. Filter on the subject, and pass after so a wait ignores everything that arrived before the step it is checking:

it('signs in with a reset password', () => {
  cy.get<Inbox>('@inbox').then((inbox) => {
    cy.signUpAndVerify(inbox.address).then(() => {
      // Taken here, once the commands above have run — not beside them.
      const requested = new Date().toISOString();

      cy.visit('/forgot-password');
      cy.get('input[name=email]').type(inbox.address);
      cy.contains('button', 'Send reset link').click();

      cy.task<string>('mailqa:waitForLink', {
        inbox: inbox.id,
        after: requested,
        subjectContains: 'reset',
        matching: '/reset-password',
      }).then((link) => cy.visit(link));
    });

    cy.get('input[name=password]').type('a brand new passphrase');
    cy.contains('button', 'Save').click();
    cy.location('pathname').should('eq', '/dashboard');
  });
});

Where the timestamp is taken matters more in Cypress than anywhere else. A line like const requested = new Date() runs when the test body runs, and the commands written above it have only been queued by then, not executed — so a timestamp taken beside them predates the signup it is meant to come after, and the wait matches the verification email instead. Taking it inside the .then() of the last command before the step is what puts it in the right place.

The subject filter is the second half. A wait returns the newest matching message, and a welcome email that happens to arrive a moment after the reset was requested is newer; the filter is what keeps it out.

Waits never default to "after now". By the time a test asks, the email has often already arrived, and a default time filter would hide it — intermittently, which is the worst kind of failure. Fresh inboxes make after unnecessary most of the time.

Running it in CI

The API key is the only secret the suite needs. With the official GitHub Action:

- uses: cypress-io/github-action@v6
  with:
    start: npm start
    wait-on: 'http://localhost:3000'
  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 }}

Inboxes count against your plan's limit while they exist. A run that is cancelled never reaches its afterEach, so sweep up once at the end of every cypress run — anything the suite created more than an hour ago, which never touches a run still going on another machine:

// in setupNodeEvents, beside the tasks
on('after:run', async () => {
  const hourAgo = Date.now() - 60 * 60 * 1000;
  const stale = (await mailqa.inboxes.list()).filter(
    (inbox) => inbox.address.startsWith('cy-') && Date.parse(inbox.createdAt) < hourAgo,
  );
  await Promise.all(stale.map((inbox) => mailqa.inboxes.delete(inbox.id)));
});

When a wait times out

Work through it 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 inbox is in the mailQA dashboard for as long as the test is running, and listing it without a filter usually settles the question in one look.

Where to go next

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.