Test sign-up emails in Playwright and Cypress
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.
| Field | Description |
|---|---|
delivered_to | string | 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/sdkpip install mailsai pytest-playwrightWhere 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();
});import os
import uuid
import pytest
from mailsai import Client, plus_address
from playwright.sync_api import Page, expect
mails = Client() # reads MAILS_API_KEY
class Inbox:
"""A test's own plus address, and waits for the email sent to it."""
def __init__(self, address: str):
self.address = address
def wait_for(self, has: str, from_: str | None = None, since: str | None = None, timeout: int = 50) -> dict:
result = mails.wait_for_received(to=self.address, has=has, from_=from_, since=since, timeout=timeout)
if result["timed_out"]:
raise AssertionError(f"No email with a {has} reached {self.address} in {timeout} seconds")
return result["message"]
def wait_for_code(self, **opts) -> str:
return self.wait_for("code", **opts)["codes"][0]["value"]
def wait_for_link(self, **opts) -> str:
return self.wait_for("link", **opts)["links"][0]["url"]
@pytest.fixture
def inbox() -> Inbox:
# A new address for every test, so tests running at the same time never share mail.
return Inbox(plus_address(os.environ["MAILS_RECEIVING_ADDRESS"], f"run-{uuid.uuid4().hex[:8]}"))
def test_signs_up_with_the_emailed_code(page: Page, inbox: Inbox):
page.goto("/signup")
page.get_by_label("Email").fill(inbox.address)
page.get_by_role("button", name="Create account").click()
code = inbox.wait_for_code(from_="example.com")
page.get_by_label("Verification code").fill(code)
page.get_by_role("button", name="Verify").click()
expect(page.get_by_text("Welcome")).to_be_visible()
def test_confirms_the_address_from_the_emailed_link(page: Page, inbox: Inbox):
page.goto("/signup")
page.get_by_label("Email").fill(inbox.address)
page.get_by_role("button", name="Create account").click()
page.goto(inbox.wait_for_link(from_="example.com"))
expect(page.get_by_text("Email confirmed")).to_be_visible()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:
npm install --save-dev @mailsai/cypressimport "@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:
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:
CYPRESS_MAILS_API_KEY="$MAILS_API_KEY" \
CYPRESS_MAILS_RECEIVING_ADDRESS=qa.acme@in.mails.ai \
npx cypress runThe commands:
cy.mailsaiPlusAddress(tag?)Yields a plus address ofMAILS_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.timeoutis 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 ofkind:verify,sign_in,reset_passwordorother.
When no email arrives in time, a wait fails the test with a message that names its filters.
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.
GET /Wait for a received message. Needs thev1/ messages/ received/ wait readscope.
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 +.
| Parameter | Description |
|---|---|
to | string 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. |
from | string 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. |
has | string 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. |
since | string 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. |
timeout | integer 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 +.
| Mailosaur | mails.ai |
|---|---|
| An inbox (a server in the API), named by its server ID | An agent, named by its receiving address (qa.acme@in.mails.ai) |
Any address ending @SERVER_ID.mailosaur.net | Any plus address, qa.acme+<tag>@in.mails.ai |
new MailosaurClient(apiKey), MailosaurClient(api_key) | createClient() and Client(), which read MAILS_API_KEY |
servers., servers. | plusAddress(receivingAddress, tag), plus_address(receiving_address, tag) |
messages.get(serverId, { sentTo }); in Python, messages.get(server_id, criteria) with criteria.sent_to | received.wait({ to }), wait_for_received(to=…) |
sentFrom | from: 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.codes | message.codes[0].value, one list for the whole email |
message.html.links[0].href, message.text.links | message.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. | cy.mailsaiPlusAddress(tag?) |
cy.mailosaurGetMessage(serverId, { sentTo }) | cy.mailsaiWaitForMessage({ to }) |
MAILOSAUR_API_KEY in the Cypress environment | MAILS_API_KEY in the Cypress environment |
POST /api/messages/search?server=… with sentTo in the body, and HTTP Basic auth | GET /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?