Skip to main content

Test sign-up emails in Playwright and Cypress

Each test gets its own plus address, waits for the email sent to it, and reads the code or link.

To test a sign-up, sign-in or password-reset flow from end to end, a test needs an email address it can read. Give each test its own plus address, type it into the form, wait for the email sent to that address, and read the code or link from it. Tests that run at the same time never read each other’s mail.

Plus addresses

An agent receives mail at its receiving address, such as qa.acme@in.mails.ai for an agent named qa in the workspace acme. Mail to any plus address of it reaches the same agent: add + and a tag before the @, as in qa.acme+run-1842@in.mails.ai. There is nothing to set up for a new one.

Each received message keeps the address it was sent to as delivered_to, and the to filter takes only the mail sent to the address you name, in any case. The SDKs build an address with plusAddress(receivingAddress, tag) in TypeScript and plus_address(receiving_address, tag) in Python. Each checks the tag: 1 to 40 letters, digits, ., _, - or +, and short enough that the part before the @ stays within 64 characters.

FieldDescription
delivered_tostring | null The address the email was sent to, in lower case: the agent's receiving_address, or a plus address of it (qa.acme+run-1842@in.mails.ai), which reaches the same agent. What the to filter matches. On a message made with POST /v1/test/inbound it is that request's to, or the receiving_address. Null on mail received before 2026-10-16.

A workspace can receive mail at a limited number of different plus addresses a day, set by its plan (see Limits). An address that has already received mail that day keeps working, so a test’s second email always arrives. Past the limit, mail to a new plus address is kept only as a record, and a wait with that address as to answers 402 plan_limit_exceeded at once.

The examples use the agent qa in the workspace acme. Any agent works, including hello, the agent every new workspace has.

Playwright

Install the SDK next to Playwright:

npm install --save-dev @mailsai/sdk

Where the tests run, set MAILS_API_KEY to an API key with the read scope, and MAILS_RECEIVING_ADDRESS to the receiving address of the agent the tests use. GET /v1/agents/{id} returns it as receiving_address:

curl https://api.mails.ai/v1/agents/qa \
  -H "Authorization: Bearer $MAILS_API_KEY"

The fixture gives each test its own plus address and two helpers that wait for the email sent to it. waitForCode() returns the first code in the email, and waitForLink() its first verify, sign-in or reset link (wait_for_code() and wait_for_link() in Python). Pass from, the domain your app sends from, to take only its mail.

import { randomUUID } from "node:crypto";
import { test as base, expect } from "@playwright/test";
import { createClient, plusAddress } from "@mailsai/sdk";

const mails = createClient(); // reads MAILS_API_KEY
const receivingAddress = process.env.MAILS_RECEIVING_ADDRESS;
if (!receivingAddress) throw new Error("Set MAILS_RECEIVING_ADDRESS to your agent's receiving address");

type WaitOptions = { from?: string; since?: string; timeout?: number };

type Inbox = {
  address: string;
  waitForCode: (opts?: WaitOptions) => Promise<string>;
  waitForLink: (opts?: WaitOptions) => Promise<string>;
};

const test = base.extend<{ inbox: Inbox }>({
  inbox: async ({}, use) => {
    // A new address for every test, so tests running at the same time never share mail.
    const address = plusAddress(receivingAddress, `run-${randomUUID().slice(0, 8)}`);

    async function waitFor(has: "code" | "link", { from, since, timeout = 50 }: WaitOptions = {}) {
      const { message } = await mails.received.wait({ to: address, has, from, since, timeout });
      if (!message) throw new Error(`No email with a ${has} reached ${address} in ${timeout} seconds`);
      return message;
    }

    await use({
      address,
      waitForCode: async (opts) => {
        const { codes = [] } = await waitFor("code", opts);
        return codes[0].value;
      },
      waitForLink: async (opts) => {
        const { links = [] } = await waitFor("link", opts);
        return links[0].url;
      },
    });
  },
});

test("signs up with the emailed code", async ({ page, inbox }) => {
  test.slow(); // an email can take longer than Playwright's default 30 seconds
  await page.goto("/signup");
  await page.getByLabel("Email").fill(inbox.address);
  await page.getByRole("button", { name: "Create account" }).click();

  const code = await inbox.waitForCode({ from: "example.com" });
  await page.getByLabel("Verification code").fill(code);
  await page.getByRole("button", { name: "Verify" }).click();
  await expect(page.getByText("Welcome")).toBeVisible();
});

test("confirms the address from the emailed link", async ({ page, inbox }) => {
  test.slow();
  await page.goto("/signup");
  await page.getByLabel("Email").fill(inbox.address);
  await page.getByRole("button", { name: "Create account" }).click();

  await page.goto(await inbox.waitForLink({ from: "example.com" }));
  await expect(page.getByText("Email confirmed")).toBeVisible();
});

The tests visit relative paths, so set baseURL in playwright.config.ts, or run pytest with --base-url. test.slow() gives a test three times Playwright’s default of 30 seconds, because a wait holds for up to 50 seconds while an email is on its way.

When several spec files use the fixture, move everything above the first test into a file of its own and export test and expect from it. In Python, move it to conftest.py.

A wait answers with the newest matching email at the address. When a test asks for a second email at the same address, such as a second code, pass since: the time just before it asked, so the first email cannot answer. since is compared with the time mails.ai received the email, so it needs a clock that keeps good time.

Cypress

The @mailsai/cypress plugin adds four commands to Cypress 13 or later. Install it, then load it in your support file:

Terminal
npm install --save-dev @mailsai/cypress
cypress/support/e2e.ts
import "@mailsai/cypress";

The plugin reads its settings from the Cypress environment. The wait commands need MAILS_API_KEY, and cy.mailsaiPlusAddress() needs MAILS_RECEIVING_ADDRESS, the receiving address its plus addresses are made from. MAILS_BASE_URL is optional, and so is MAILS_AGENT_ID, which limits the waits to one agent’s mail. Put them in the env block of your Cypress config, read from your environment, so no key is written into the repository:

cypress.config.ts
import { defineConfig } from "cypress";

export default defineConfig({
  e2e: {
    baseUrl: "http://localhost:3000",
  },
  env: {
    MAILS_API_KEY: process.env.MAILS_API_KEY,
    MAILS_RECEIVING_ADDRESS: process.env.MAILS_RECEIVING_ADDRESS,
  },
});

Or leave the block out and set each one as an environment variable with the CYPRESS_ prefix, which Cypress takes off:

Terminal
CYPRESS_MAILS_API_KEY="$MAILS_API_KEY" \
CYPRESS_MAILS_RECEIVING_ADDRESS=qa.acme@in.mails.ai \
npx cypress run

The commands:

  • cy.mailsaiPlusAddress(tag?) Yields a plus address of MAILS_RECEIVING_ADDRESS. Without a tag, a new one each time.
  • cy.mailsaiWaitForMessage({ to, from, has, since, timeout, agentId }) Waits for an email that matches and yields the newest one. timeout is the whole wait in seconds, 60 by default.
  • cy.mailsaiWaitForCode(options) Waits the same way for an email with a code, and yields its first code, message.codes[0].value.
  • cy.mailsaiWaitForLink({ ...options, kind }) Waits the same way for an email with a verify, sign-in or password-reset link, and yields the URL of its first such link, or of its first link of kind: verify, sign_in, reset_password or other.

When no email arrives in time, a wait fails the test with a message that names its filters.

cypress/e2e/signup.cy.ts
describe("sign-up", () => {
  it("verifies the address with the emailed code", () => {
    cy.mailsaiPlusAddress().then((email) => {
      cy.visit("/signup");
      cy.get("[data-cy=email]").type(email);
      cy.get("[data-cy=create-account]").click();

      cy.mailsaiWaitForCode({ to: email, from: "example.com" }).then((code) => {
        cy.get("[data-cy=code]").type(code);
      });
      cy.get("[data-cy=verify]").click();
      cy.contains("Welcome").should("be.visible");
    });
  });

  it("confirms the address from the emailed link", () => {
    cy.mailsaiPlusAddress().then((email) => {
      cy.visit("/signup");
      cy.get("[data-cy=email]").type(email);
      cy.get("[data-cy=create-account]").click();

      cy.mailsaiWaitForLink({ to: email, kind: "verify" }).then((link) => cy.visit(link));
      cy.contains("Email confirmed").should("be.visible");
    });
  });
});

Without the plugin

The wait is one request, so cy.request can make it. Give the request a timeout longer than the wait’s, since Cypress gives up on a request after 30 seconds by default. The key is read with cy.env(), so this needs Cypress 15.10 or later; the plugin works from Cypress 13.

log: false keeps the key out of the Command Log. failOnStatusCode: false keeps it out of the error Cypress prints for a failed status, which lists the request’s headers, so the test checks the status itself.

it("verifies the address with the emailed code", () => {
  cy.env(["MAILS_API_KEY", "MAILS_RECEIVING_ADDRESS"]).then(({ MAILS_API_KEY, MAILS_RECEIVING_ADDRESS }) => {
    const [local, domain] = MAILS_RECEIVING_ADDRESS.split("@");
    const email = `${local}+run-${Date.now()}@${domain}`;

    cy.visit("/signup");
    cy.get("[data-cy=email]").type(email);
    cy.get("[data-cy=create-account]").click();

    cy.request({
      url: "https://api.mails.ai/v1/messages/received/wait",
      qs: { to: email, has: "code", timeout: 50 },
      headers: { Authorization: `Bearer ${MAILS_API_KEY}` },
      timeout: 60_000, // longer than the 50-second wait
      log: false,
      failOnStatusCode: false,
    }).then(({ status, body }) => {
      if (status !== 200) throw new Error(`mails.ai answered ${status}: ${JSON.stringify(body)}`);
      expect(body.timed_out, "an email arrived").to.equal(false);
      cy.get("[data-cy=code]").type(body.message.codes[0].value);
    });
  });
});

qs encodes the + in the address. Under both Playwright and Cypress, the call is the same one, below.

Any test runner

One request waits for the email. It answers as soon as an email sent to to arrives, or after timeout seconds with message null and timed_out true.

curl -G "https://api.mails.ai/v1/messages/received/wait" \
  -H "Authorization: Bearer $MAILS_API_KEY" \
  --data-urlencode "to=qa.acme+run-1842@in.mails.ai" \
  --data-urlencode "has=code" \
  --data-urlencode "timeout=50"

The answer, shortened:

{
  "message": {
    "id": "rcv_01JZXA2B4C6D8E0F2G4H6J8K0M",
    "from": { "address": "noreply@example.com" },
    "delivered_to": "qa.acme+run-1842@in.mails.ai",
    "subject": "Your verification code",
    "codes": [{ "value": "482913" }],
    "links": [],
    "received_at": "2026-10-16T09:30:04.512Z"
  },
  "timed_out": false
}

Read the code with jq -r '.message.codes[0].value'. In a query string, write the + as %2B, as --data-urlencode does. A + left as it is arrives as a space, which mails.ai reads back as +.

ParameterDescription
tostring Only mail sent to this address, in any case: the message's delivered_to. Give each test run or sign-up its own plus address (qa.acme+run-1842@in.mails.ai) and pass it here, so runs that overlap never read each other's mail. In a query string, write its + as %2B: one left as it is arrives as a space, which is read back as +. Mail received before 2026-10-16 has no delivered_to and never matches. Anything that is not an address answers 400 invalid_param_value.
fromstring Only mail from this sender: an address (noreply@github.com, in any case) or a domain (github.com, which takes its subdomains such as mail.github.com and never github.com.evil.example). It is the From header's address, proven only when the message's dmarc is pass. Anything else answers 400 invalid_param_value.
hasstring code: only a message with at least one entry in codes. link: only one with a verify, sign_in or reset_password link. Absent: any message that matches the other filters. One of: code, link.
sincestring Only mail received at or after this time: an ISO 8601 time such as 2026-10-05T07:00:00Z, or a date (midnight UTC). Any other value answers 400 invalid_param_value. Default: the last 5 minutes. Pass the time you asked the service to send, so an older code never answers.
timeoutinteger Seconds to wait for a match, 0 to 50 (default 30). 0 looks once and answers at once.

A wait never answers with mail judged forged (auth_failed) or quarantined. If a test times out although its email was sent, list the mail sent to the address with GET /v1/messages/received and to, and read each message’s dmarc and auth_failed. Waits are limited per workspace each minute, as Codes and links says.

Test without a real email

To test the code that waits before your app can send anything, deliver a made-up email with POST /v1/test/inbound. It takes a test key (mk_test_…) with the send scope, and to can be any plus address of the agent’s receiving address:

curl -X POST https://api.mails.ai/v1/test/inbound \
  -H "Authorization: Bearer $MAILS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "agent": "qa",
    "to": "qa.acme+run-1842@in.mails.ai",
    "from": "noreply@example.com",
    "subject": "Your verification code",
    "body_text": "Your verification code is 482913."
  }'

A wait for qa.acme+run-1842@in.mails.ai then answers with it, and its code is 482913. Without to, the email counts as sent to the receiving address itself. Made-up email does not count toward the plan’s plus addresses a day.

Coming from Mailosaur

Each Mailosaur name below is the one in Mailosaur’s own documentation, next to the closest one here. Mailosaur gives each inbox a domain of its own; here, each agent’s receiving address takes a tag after a +.

Mailosaurmails.ai
An inbox (a server in the API), named by its server IDAn agent, named by its receiving address (qa.acme@in.mails.ai)
Any address ending @SERVER_ID.mailosaur.netAny plus address, qa.acme+<tag>@in.mails.ai
new MailosaurClient(apiKey), MailosaurClient(api_key)createClient() and Client(), which read MAILS_API_KEY
servers.generateEmailAddress(serverId), servers.generate_email_address(server_id)plusAddress(receivingAddress, tag), plus_address(receiving_address, tag)
messages.get(serverId, { sentTo }); in Python, messages.get(server_id, criteria) with criteria.sent_toreceived.wait({ to }), wait_for_received(to=…)
sentFromfrom: an address, or a domain and its subdomains
timeout, in milliseconds (10 seconds by default)timeout, in seconds from 0 to 50 (30 by default)
receivedAfter, received_after (the last hour by default)since (the last 5 minutes by default)
message.html.codes[0].value, message.text.codesmessage.codes[0].value, one list for the whole email
message.html.links[0].href, message.text.linksmessage.links[0].url, one list for the whole email, each link with a kind
cypress-mailosaur, loaded with import "cypress-mailosaur";@mailsai/cypress, loaded with import "@mailsai/cypress";
cy.mailosaurGenerateEmailAddress(serverId)cy.mailsaiPlusAddress(tag?)
cy.mailosaurGetMessage(serverId, { sentTo })cy.mailsaiWaitForMessage({ to })
MAILOSAUR_API_KEY in the Cypress environmentMAILS_API_KEY in the Cypress environment
POST /api/messages/search?server=… with sentTo in the body, and HTTP Basic authGET /v1/messages/received?to=… to list and GET /v1/messages/received/wait?to=… to wait, with a Bearer key

Next steps

Was this page helpful?