← Blog

Test email verification in Playwright with saci.cc

To test a sign-up flow that emails a code or a link, your test needs a real inbox it can read. With the saci.cc npm package, the test creates an address, signs up with it, waits for the email and gets the code or link back as a string. You need a free API key and a few lines of code, and it works the same way in Playwright, Cypress or any Node script.

Why a real inbox

A common shortcut is to stub the email sender in tests. That proves your code calls the sender, but not that the email goes out, reaches an inbox and contains a code that works. A real inbox closes that gap: the test does exactly what a user does, from the form to the code.

Get an API key

The API needs a key, and keys are free:

  • Go to your account page and sign in with GitHub. The GitHub account must be at least 30 days old.
  • Create a key. It starts with saci_ and is shown only once, so copy it right away.
  • Each account can have up to 5 keys, for example one for your laptop and one for CI. You can revoke any of them.

The client reads the key from the SACI_API_KEY environment variable, so it stays out of your code:

npm install --save-dev saci.cc
export SACI_API_KEY=saci_...

In CI, save the key as a secret and pass it to the test step. On GitHub Actions:

- run: npx playwright test
  env:
    SACI_API_KEY: ${{ secrets.SACI_API_KEY }}

A sign-up test with a verification code

createInbox() creates a new random address. inbox.waitForCode() waits until an email with a verification code arrives and returns the code:

import { test, expect } from "@playwright/test";
import { createInbox } from "saci.cc";

test("sign up with a verification code", async ({ page }) => {
  test.setTimeout(90_000);

  const inbox = await createInbox();
  await page.goto("https://your-app.com/signup");
  await page.getByLabel("Email").fill(inbox.address);
  await page.getByLabel("Password").fill("correct horse battery staple");
  await page.getByRole("button", { name: "Sign up" }).click();

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

The from filter is optional. It only accepts emails whose sender contains that text, ignoring case, which helps when the inbox gets more than one email.

Raise the test timeout

Playwright gives each test 30 seconds by default. Emails usually arrive in a few seconds, but a slow provider can take longer, and the client waits up to 60 seconds before it throws SaciTimeoutError. Call test.setTimeout(90_000) in tests that wait for email, or raise timeout in your Playwright config. To change how long the client itself waits, pass timeout in milliseconds: inbox.waitForCode({ timeout: 30_000 }).

The client doesn't poll in a tight loop. It asks the API to hold the request open until an email arrives, up to 25 seconds per request, so a wait costs only a few calls. That matters because the limit is 120 requests per minute per account, across all its keys. If you go over it, the client waits and tries again.

If your app sends a link instead of a code, use waitForLink. Without options it returns the first link in the email; match picks the one that contains some text or matches a RegExp:

const requestedAt = new Date();
await page.getByRole("button", { name: "Send reset link" }).click();

const link = await inbox.waitForLink({ match: "/reset-password", after: requestedAt });
await page.goto(link);

after skips emails that were already in the inbox, such as the welcome email from earlier in the same test. For anything else, waitForEmail returns the whole email, with subject, text, html, links, attachments and verificationCode:

const email = await inbox.waitForEmail({ from: "billing@", subject: "Your receipt", after: requestedAt });
expect(email.text).toContain("Total");

Cypress

Cypress tests run in the browser, so call the client from Node with tasks in cypress.config.ts:

import { defineConfig } from "cypress";
import { Saci } from "saci.cc";

const saci = new Saci();

export default defineConfig({
  e2e: {
    setupNodeEvents(on) {
      on("task", {
        createInbox: async () => (await saci.createInbox()).address,
        waitForCode: (address: string) => saci.inbox(address).waitForCode(),
      });
    },
  },
});

In the test, call cy.task("waitForCode", address, { timeout: 90_000 }). Without that option, cy.task gives up after 60 seconds.

Your inboxes are private

Inboxes created on the saci.cc website are public: anyone who knows the address can read them. Inboxes created with your key are different. For 7 days they belong to your account: only your account's keys can read them, and nobody else can create an address with the same name. That's what you want in tests, where emails often carry links that sign someone in.

For a fixed address, pass a name: createInbox({ name: "signup-test" }) gives you signup-test@saci.cc. If another account owns that name, you get a SaciError with status 409. Emails are still deleted about a day after they arrive, so don't count on them between runs.

Webhooks instead of waiting

If you'd rather be told when an email arrives, set a webhook on your account page. Every email that reaches one of your account's inboxes is sent to your URL in a POST, with the address, sender, subject, links and verification code, signed with a secret so you can check it came from saci.cc. The URL has to be public HTTPS, so localhost won't work; for tests on your own machine, waiting with the client is simpler. The API docs show the payload and how to check the signature in Node.

In short

  • Create a free key on your account page and put it in SACI_API_KEY, locally and as a CI secret.
  • Call createInbox(), sign up with inbox.address, then waitForCode() or waitForLink().
  • Give tests that wait for email 90 seconds.
  • Questions, problems or a higher limit: write to oi@saci.cc.