Guide
Email testing recipes for Playwright, Cypress, and CI
Assert on the mail your product actually delivered instead of the mail your mock says it would have sent. One unique address per test, no inboxes to provision, no shared mailbox for twenty parallel workers to fight over. Copy-pasteable recipes for Playwright, Cypress, calendar invites, and webhooks.
What's in this guide
- The pattern: one address per test
- Setup: keys, scopes, environment
- A tiny client
- Recipe: signup and verification code (Playwright)
- Recipe: assertions on delivered mail (Cypress)
- Recipe: calendar invites that answer themselves
- Recipe: webhooks instead of polling
- Cleaning up (you probably don't need to)
- FAQ
The pattern: one address per test
Almost every flaky email test traces back to the same root cause: two things sharing one mailbox. A suite that reuses qa@yourteam.example has to answer "is this the password reset from this run, or the one from the run three minutes ago?" — and the usual answers are a beforeEach that empties the inbox (which serializes your suite) or an increasingly baroque matcher (which fails the day someone changes a subject line).
The fix is to stop sharing. Generate a fresh address inside the test:
const runId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
const address = `signup-${runId}@yourteam.reclar.io`;
That address has never existed before and will never be used again. Because a Reclario domain is a catch-all, the mailbox comes into existence the moment mail arrives at it — there is no create call, no provisioning step, and no per-address quota to manage. Three consequences follow, and they are the entire reason the pattern is worth adopting:
- Parallel-safe by construction. Twenty Playwright workers running the same spec generate twenty different addresses. They cannot read each other's mail, so you can raise concurrency without raising your flake rate.
- No cleanup step. There is nothing to empty before each test and no ordering dependency between suites. Nobody has to remember the teardown, because there isn't one.
- No cross-talk, so matchers can be loose. The mailbox holds exactly the mail this run produced, so waiting on
/verify/iis safe. You are not writing a regex to exclude yesterday's messages, which means the regex does not break when yesterday's messages change.
The contrast with a shared test inbox is stark once you write it down. A shared inbox needs cleanup, needs serialization or extremely precise matchers, and leaks state between suites; its failure mode is intermittent and shows up as "flaky CI" rather than "broken test." A unique address per run has none of those properties, and when a wait does time out, the answer is unambiguous: either mail arrived and your matcher was wrong, or nothing was delivered at all.
Make the timeout error print the mailbox. The single most useful thing a failing email test can tell you in CI is what was in the box. "Timed out waiting for /verify/i; mailbox contained 1 message: subject 'Confirm your address'" turns a twenty-minute investigation into a five-second fix. The client below does this.
Setup: keys, scopes, environment
Sign in at reclar.io, open your domain's admin area, and create an API key on the API tab. The key is shown once and starts with rcl_.
Choose the scope deliberately. Keys are either read-only or read & write:
| Scope | Can do |
|---|---|
| Read only | List mailboxes, fetch full messages, download raw .eml sources and attachment bytes. That covers the majority of assertions. |
| Read & write | Everything above, plus injecting a test message, deleting mail, flushing a delayed RSVP, and registering webhooks. |
If your tests do any of those write operations, you must create a Read & write key — a read-only key answers 403 on them, with a message saying exactly which scope was required. This is the single most common setup mistake, and it usually surfaces halfway through a suite rather than at the first request, because the reads succeed happily.
Environment variables
| Variable | Required | Description |
|---|---|---|
RECLARIO_API_KEY | yes | Your rcl_... key. |
RECLARIO_DOMAIN | yes | Your catch-all domain, e.g. yourteam.reclar.io. |
RECLARIO_BASE_URL | no | Defaults to https://reclar.io. |
Authentication accepts either header form, so use whichever your HTTP client makes easiest:
Authorization: Bearer rcl_xxxxxxxxxxxxxxxx
X-API-Key: rcl_xxxxxxxxxxxxxxxx
In CI, put the key in your secret store and expose it as an environment variable to the test job. It is a credential for a test domain, not production mail, but it still reads everything delivered to that domain — treat it accordingly, and prefer a read-only key for jobs that only assert.
A tiny client
You do not need an SDK. On Node 18+ the global fetch is enough, and the whole useful surface is about thirty lines. Drop this in tests/helpers/reclario.js and every recipe below builds on it.
// tests/helpers/reclario.js — Node 18+, zero dependencies
const BASE = process.env.RECLARIO_BASE_URL || "https://reclar.io";
const KEY = process.env.RECLARIO_API_KEY;
const DOMAIN = process.env.RECLARIO_DOMAIN;
const auth = { Authorization: `Bearer ${KEY}`, Accept: "application/json" };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// Any local part works — the domain is catch-all, nothing to create.
function address(localPart) {
return `${localPart}@${DOMAIN}`;
}
async function json(method, path) {
const res = await fetch(BASE + path, { method, headers: auth });
const text = await res.text();
if (!res.ok) throw new Error(`${method} ${path} -> ${res.status}: ${text}`);
return text ? JSON.parse(text) : null;
}
// List message SUMMARIES for an address, newest first.
const listEmails = (addr) =>
json("GET", `/api/mailbox/${encodeURIComponent(addr)}`);
// Fetch one FULL message: htmlBody, textBody, cc, headers, attachments[].
const getEmail = (addr, id) =>
json("GET", `/api/mailbox/${encodeURIComponent(addr)}/${encodeURIComponent(id)}`);
/**
* Poll until a summary matches, or throw an error that names what WAS there.
* `match` is a predicate over the summary: (e) => boolean
*/
async function waitForEmail({ address, match = () => true, timeoutMs = 30000, intervalMs = 1000 }) {
const deadline = Date.now() + timeoutMs;
let seen = [];
for (;;) {
const { emails } = await listEmails(address);
seen = emails;
const hit = emails.find(match);
if (hit) return hit;
if (Date.now() + intervalMs > deadline) break;
await sleep(intervalMs);
}
const inventory = seen.length
? seen.map((e) => ` - [${e.timestamp}] from=${e.from} subject=${JSON.stringify(e.subject)}`).join("\n")
: " (mailbox is empty — nothing was delivered at all)";
throw new Error(
`Timed out after ${timeoutMs}ms waiting for mail in ${address}.\n` +
`Mailbox contained ${seen.length} message(s):\n${inventory}`,
);
}
module.exports = { address, listEmails, getEmail, waitForEmail };
Why two calls instead of one
GET /api/mailbox/:address returns summaries, not messages. Each entry carries id, timestamp, from, to, subject, hasAttachments, and calendarInvite when the message is an invite — but no bodies and no attachment payloads. You then fetch the one you want with GET /api/mailbox/:address/:emailId, which returns the full envelope: htmlBody, textBody, cc, replyTo, date, headers, and attachments[] metadata.
That split is deliberate, and it matters for polling. A wait loop hits the list endpoint once a second; if every poll dragged down a 200 KB HTML body plus a PDF, a suite with fifty email waits would move a lot of bytes to answer a yes/no question. Summaries keep the loop cheap, and you pay for the body exactly once, after you have decided which message you want. The limit query parameter defaults to 50 and caps at 200, which is far more than a per-test mailbox will ever hold.
The one field worth knowing about on summaries is calendarInvite — it includes the full auto-RSVP plan, which is why the calendar recipe below can poll for a sent reply without ever fetching a body.
Recipe: signup and verification code (Playwright)
The canonical flow: sign up with a throwaway address, wait for the verification mail, pull the six-digit code out of it, and finish the flow. Only the Reclario half of this can be correct as written — every signup form has different markup — so the app-under-test lines are marked, and they use getByLabel/getByRole because those are the locators most likely to already work in your app.
const { test, expect } = require("@playwright/test");
const { address, getEmail, waitForEmail } = require("./helpers/reclario");
function unique(prefix) {
const runId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
return address(`${prefix}-${runId}`);
}
test("user receives a 6-digit code and can verify with it", async ({ page }) => {
const email = unique("signup");
// --- app under test: REPLACE with your app's routes and selectors ---
await page.goto("/signup");
await page.getByLabel(/email/i).fill(email);
await page.getByLabel(/password/i).fill("Correct-Horse-Battery-9!");
await page.getByRole("button", { name: /sign up|create account/i }).click();
// --- Reclario: correct as written ---
// 60s is generous; most senders deliver in under five.
const summary = await waitForEmail({
address: email,
match: (e) => /verify|verification|confirm/i.test(e.subject || ""),
timeoutMs: 60_000,
});
const message = await getEmail(email, summary.id);
expect(message.subject).toMatch(/verify|verification|confirm/i);
// Prefer textBody: HTML bodies often split digits across styled spans,
// which defeats a naive \b\d{6}\b match.
const code = (message.textBody || message.htmlBody).match(/\b\d{6}\b/)[0];
// --- app under test: REPLACE with your code-entry UI ---
await page.getByLabel(/code/i).fill(code);
await page.getByRole("button", { name: /verify|confirm|submit/i }).click();
await expect(page.getByText(/verified|welcome/i)).toBeVisible();
});
Magic links instead of codes
Same shape, different extraction. Scan the href attributes rather than regexing the whole document — HTML bodies escape ampersands in query strings, so a naive URL regex tends to return a token that is subtly wrong:
function extractLink(html, pattern) {
const hrefs = [...String(html || "").matchAll(/href\s*=\s*["']([^"']+)["']/gi)]
.map((m) => m[1].replace(/&/g, "&"));
const hit = hrefs.find((u) => pattern.test(u));
if (!hit) throw new Error(`No link matching ${pattern}. Links present: ${JSON.stringify(hrefs)}`);
return hit;
}
const message = await getEmail(email, summary.id);
await page.goto(extractLink(message.htmlBody, /\/verify/));
await expect(page.getByText(/verified|welcome/i)).toBeVisible();
If you want to assert on the wire format too — List-Unsubscribe, DKIM headers, transfer encoding — fetch the original source with GET /api/mailbox/:address/:emailId/raw, which returns the .eml as message/rfc822.
Recipe: assertions on delivered mail (Cypress)
Cypress specs run in the browser, so the mailbox calls belong in Node. Register them as tasks in your config: the polling loop stays server-side, and the API key never enters the browser context where a page could see it.
// cypress.config.js
const { defineConfig } = require("cypress");
const { address, getEmail, waitForEmail } = require("./cypress/helpers/reclario");
module.exports = defineConfig({
e2e: {
baseUrl: process.env.APP_URL || "http://localhost:3000",
setupNodeEvents(on) {
on("task", {
"reclario:address": (localPart) => address(localPart),
"reclario:getEmail": ({ address: a, id }) => getEmail(a, id),
// A RegExp can't cross the task boundary — pass {source, flags}.
"reclario:waitForEmail": ({ address: a, subject, timeoutMs }) => {
const re = new RegExp(subject.source, subject.flags);
return waitForEmail({ address: a, match: (e) => re.test(e.subject || ""), timeoutMs });
},
});
},
},
});
// cypress/e2e/email-assertions.cy.js
function unique(prefix) {
const runId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
return `${prefix}-${runId}`;
}
describe("transactional email", () => {
it("sends a verification email with a readable code", () => {
cy.task("reclario:address", unique("cypress")).then((address) => {
// ... drive your app so it sends to `address` ...
cy.task("reclario:waitForEmail", {
address,
subject: { source: "verification code", flags: "i" },
timeoutMs: 30000,
}).then((summary) => {
// Summaries carry no bodies — assert on what's there, then fetch.
expect(summary.subject).to.match(/verification code/i);
expect(summary.hasAttachments).to.equal(false);
cy.task("reclario:getEmail", { address, id: summary.id }).then((message) => {
expect(message.textBody).to.match(/\b\d{6}\b/);
expect(message.htmlBody).to.contain("Verify your email");
});
});
});
});
});
Attachments
On the full envelope, attachments[] is metadata only — {index, filename, contentType, size, contentId}. The bytes come from a separate endpoint addressed by that index: GET /api/mailbox/:address/:emailId/attachments/:index. Add a task that returns base64 (Cypress will not serialize a Node Buffer across the task boundary) and decode it in the spec:
// in setupNodeEvents
"reclario:getAttachment": async ({ address, id, index }) => {
const res = await fetch(
`${process.env.RECLARIO_BASE_URL || "https://reclar.io"}` +
`/api/mailbox/${encodeURIComponent(address)}/${id}/attachments/${index}`,
{ headers: { Authorization: `Bearer ${process.env.RECLARIO_API_KEY}` } },
);
const buf = Buffer.from(await res.arrayBuffer());
return { base64: buf.toString("base64"), contentType: res.headers.get("content-type") };
},
// in the spec
cy.task("reclario:getEmail", { address, id: summary.id }).then((message) => {
const att = message.attachments[0];
expect(att.filename).to.equal("invoice.txt");
expect(att.size).to.be.greaterThan(0);
cy.task("reclario:getAttachment", { address, id: summary.id, index: att.index })
.then((file) => {
const text = Cypress.Buffer.from(file.base64, "base64").toString("utf8");
expect(text).to.contain("INVOICE 4417");
});
});
Recipe: calendar invites that answer themselves
This is the recipe you cannot build against a generic test mailbox. An ordinary inbox stores your .ics and stops there — but the interesting half of a scheduling feature is the reply, and a reply is normally produced by a human clicking Accept in a calendar client you do not control.
Reclario parses inbound iTIP invites and answers them. You choose the answer by putting a tag on the address:
| Tag | Effect |
|---|---|
+accept | Reply ACCEPTED |
+decline | Reply DECLINED |
+maybe, +tentative | Reply TENTATIVE |
+counter, +counterN | Counter-propose (N = minutes to shift) |
+delayN | Hold the reply for N minutes, then send |
Tags compose left to right, so interview+accept+delay5@yourteam.reclar.io accepts five minutes after the invite lands — which is how you test "the candidate answered long after the user navigated away" without a sleep in your suite. With no tag at all, the domain's default auto-RSVP setting applies and the plan's source reads domain-default instead of tag.
The gotcha that costs people an afternoon: the unique id must come BEFORE the tag.
interview-${runId}+accept@yourteam.reclar.io — works.
interview+accept-${runId}@yourteam.reclar.io — does not.
Tags are matched exactly, as whole +-delimited segments. In the second form the segment is accept-k3f9x2, which is not a tag Reclario recognizes, so no auto-RSVP is planned — and nothing errors. Your test just polls until it times out with a perfectly ordinary-looking invite sitting in the mailbox. Build the local part as [base-runId, ...tags].join("+") and it is impossible to get wrong.
The test
Send a real METHOD:REQUEST to a tagged address, then poll for the plan reaching SENT. Because calendarInvite rides along on list summaries, this whole assertion runs against the cheap endpoint.
const { test, expect } = require("@playwright/test");
const { address, waitForEmail } = require("./helpers/reclario");
// Tags go AFTER the unique id — see the note above.
function inviteAddress(prefix, tags) {
const runId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
return address([`${prefix}-${runId}`, ...tags].join("+"));
}
test("an invite to a +accept address comes back ACCEPTED", async () => {
const attendee = inviteAddress("interview", ["accept"]);
const subject = `Interview: Senior QA Engineer ${Date.now()}`;
// Your product sends the invite. Reclario treats an .ics part as an invite
// only when METHOD is REQUEST or CANCEL — a bare VEVENT with no METHOD is
// stored as an ordinary attachment.
await sendInviteFromYourApp({ to: attendee, subject });
const summary = await waitForEmail({
address: attendee,
match: (e) => e.calendarInvite?.rsvpPlan?.status === "SENT",
timeoutMs: 90_000,
intervalMs: 2000,
});
const invite = summary.calendarInvite;
expect(invite.method).toBe("REQUEST");
expect(invite.summary).toBe(subject);
const plan = invite.rsvpPlan;
expect(plan.response).toBe("ACCEPTED");
expect(plan.source).toBe("tag"); // "domain-default" if it came from settings
expect(plan.tags).toContain("accept");
expect(plan.delayMinutes).toBe(0);
expect(plan.status).toBe("SENT");
expect(plan.sentAt).toBeTruthy();
// Then assert your own product ingested the REPLY and changed state.
});
The plan object is {response, source, tags, delayMinutes, counterMinutes, scheduledAt, status, sentAt, error}, and status moves PENDING → SENT (or FAILED). For a +delayN address, the first observable state is PENDING with sentAt: null and a scheduledAt roughly N minutes out; assert that, then either let the schedule fire or flush it immediately with POST /api/mailbox/:address/:emailId/rsvp/send-now, which returns the updated envelope. That flush is a write operation, so it needs a read & write key.
Two boundaries worth stating plainly. Outbound RSVP requires a paid domain — the free public sandbox parses and plans invites but is receive-only, and dispatch comes back BLOCKED with an explanation in rsvpPlan.error. And Reclario is not a calendar client: it speaks the attendee half of iTIP correctly, but it does not render your invite the way Outlook does, so pure rendering bugs still want a human eye before a release.
For the protocol underneath all of this — what UID and SEQUENCE actually control, why a reschedule gets silently ignored, how COUNTER differs from REPLY — read testing calendar invites end-to-end.
Recipe: webhooks instead of polling
Polling is the right default inside a test: it is synchronous, it needs no public URL, and the loop dies with the test. But a long-running consumer — a staging bot, a fixture recorder, a Slack relay, a soak suite that runs for an hour — is better served by being pushed to.
Register an endpoint against your domain. Note that :subdomain is the label only (yourteam), not the full host, and the call needs a read & write key plus admin rights on that domain:
curl -X POST https://reclar.io/api/subdomains/yourteam/webhooks \
-H "Authorization: Bearer $RECLARIO_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url":"https://hooks.yourteam.example/reclario",
"events":["email.received","rsvp.sent","rsvp.failed"]}'
The response returns the signing secret exactly once — save it. Listing hooks later with GET /api/subdomains/:subdomain/webhooks deliberately omits it, and DELETE /api/subdomains/:subdomain/webhooks/:id removes one. Private and localhost URLs are rejected, so during development put a tunnel in front of your listener and register the public HTTPS URL.
Every delivery is a POST with Content-Type: application/json, an X-Reclar-Event header naming the event, and an X-Reclar-Signature header holding the hex HMAC-SHA256 of the raw body keyed with your secret. The envelope is the same shape for every event:
{
"event": "email.received",
"subdomain": "yourteam",
"at": "2026-07-21T09:14:02.117Z",
"data": {
"address": "signup-k3f9x2@yourteam.reclar.io",
"id": "…",
"from": "\"Acme\" <no-reply@acme.example>",
"subject": "Verify your email",
"hasInvite": false
}
}
rsvp.sent and rsvp.failed carry {address, emailId, response, status, error} instead. Note that email.received carries no body — use data.id with GET /api/mailbox/:address/:emailId to fetch the message.
Verifying the signature
Verify against the exact bytes on the wire, before parsing. Never re-serialize the JSON and hash that — key order and whitespace will not survive the round trip, and your signatures will fail intermittently in a way that looks like an attack.
const http = require("node:http");
const crypto = require("node:crypto");
const SECRET = process.env.RECLARIO_WEBHOOK_SECRET;
function signatureIsValid(rawBody, presented) {
const expected = crypto.createHmac("sha256", SECRET).update(rawBody).digest("hex");
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(String(presented || ""), "utf8");
// Length check first: timingSafeEqual throws on a length mismatch.
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
http.createServer((req, res) => {
if (req.method !== "POST") return void res.writeHead(405).end("POST only");
const chunks = [];
req.on("data", (c) => chunks.push(c));
req.on("end", () => {
const rawBody = Buffer.concat(chunks).toString("utf8");
if (!signatureIsValid(rawBody, req.headers["x-reclar-signature"])) {
return void res.writeHead(401).end("bad signature");
}
// ACK first, then do slow work: the delivery times out after 5 seconds.
res.writeHead(200, { "Content-Type": "application/json" }).end('{"ok":true}');
const { event, data = {} } = JSON.parse(rawBody);
if (event === "email.received") {
console.log(`${data.address} got "${data.subject}"` + (data.hasInvite ? " [invite]" : ""));
}
});
}).listen(4000);
Two delivery semantics to design around. A non-2xx response (or a redirect) counts as failure and is retried with backoff up to six attempts, so your handler must be idempotent — assume every event can arrive twice. And the response is given up on after five seconds, which is why the snippet above acknowledges before it does anything interesting.
Cleaning up (you probably don't need to)
The honest answer for most suites is: don't. The unique-address pattern means no two runs share a mailbox, so there is nothing to clear and nothing that can go stale into another test. Cleanup code in an email suite is usually a leftover habit from the shared-inbox era, and it costs you — a teardown that deletes mail is one more thing that can fail, one more reason a test can't run in parallel, and one more place where a flake gets manufactured.
If you want it anyway — you are asserting on a delete flow, or you have a policy about test data lifetime — DELETE /api/mailbox/:address/:emailId removes a single message and needs a read & write key. Retention is configurable per domain, so mail from old runs ages out on its own schedule without you writing anything.
The one case where cleanup is genuinely worth writing: a soak test or long-lived staging bot that deliberately reuses one address for hours. There, bounding the mailbox keeps your list responses small and your matchers honest. Everywhere else, delete the teardown and let the addresses be disposable.
FAQ
Do I need to create inboxes before my tests can use them?
No. A Reclario domain is a catch-all, so every address on it accepts mail immediately. signup-k3f9x2@yourteam.reclar.io works the first time you send to it, with no API call to provision it, no quota to manage, and nothing to delete afterwards. That is what makes generating a fresh address inside each test practical.
Can parallel test runs collide with each other?
Not if each run generates its own address. Build the local part from a run id — for example signup-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)} — and two workers can never read each other's mail. That removes the need for a clear-the-inbox step, which is what usually forces email suites to run serially.
Does this work in CI?
Yes. The API is plain HTTPS with a bearer token, so any CI runner with outbound network access can use it — store the key as a CI secret and expose it as RECLARIO_API_KEY. Prefer a read-only key for jobs that only assert on delivered mail; you need a read & write key only if the job injects test messages, deletes mail, flushes a delayed RSVP, or registers webhooks.
Can I test calendar invites and RSVP flows?
Yes, and this is the part a generic test mailbox cannot do. Send a METHOD:REQUEST invite to a tagged address such as interview-abc123+accept@yourteam.reclar.io and Reclario sends a real iTIP METHOD:REPLY back to the organizer. Poll until calendarInvite.rsvpPlan.status is SENT, then assert on response, source, and your own product's state. Tags cover accept, decline, tentative, counter-propose, and delayed replies.
What about attachments and raw .eml sources?
Both are available. The full message carries attachments[] metadata — index, filename, content type, size — and the bytes come from GET /api/mailbox/:address/:emailId/attachments/:index. For header-level assertions such as List-Unsubscribe, DKIM, or transfer encoding, GET /api/mailbox/:address/:emailId/raw returns the original RFC-822 source as message/rfc822.
Where should I go next?
If you are evaluating rather than implementing, the category survey at email testing tools compared covers the trade-offs between SMTP sandboxes, disposable inboxes, and receiving harnesses, including tools we don't sell. The product-level view of this page is email testing for QA teams.
Test your first email in five minutes
Claim a private catch-all domain, point a test at anything@yourteam.reclar.io, and assert on mail that was actually delivered. No inboxes to provision, no shared mailbox to clean, no DNS work on your side.