Selenium Email Testing: Verify Signup Emails with Node.js

Updated September 2026. Tested with selenium-webdriver 4.49, headless Chrome 153 and Node.js 22, 24 and 26.

Selenium email testing covers the part of a user journey that leaves the browser. Your app sends an email, and the test has to read it before it can continue. Selenium WebDriver can fill in a signup form, but the email lands somewhere else. This guide adds that missing piece with the Mailsac email testing API, in JavaScript with selenium-webdriver and headless Chrome.

You’ll build an end-to-end signup test. It signs up with a new address, waits for the verification email, follows its link and checks that the account is verified. The same helper then reads a 6-digit sign-in code, and the whole suite runs in GitHub Actions.

How Selenium email testing works

  1. Trigger. Selenium signs up with a new, unique address at mailsac.com. Your app sends the verification email through its usual email provider; Mailsac only receives it. There’s no inbox to create first: any address at mailsac.com, or on your own custom domain, can receive mail.
  2. Wait. The test asks the Mailsac REST API for that address’s messages every 2 seconds. It gives up after 60 seconds, and it ignores anything that arrived before the signup.
  3. Check. It reads the links Mailsac found in the email, opens the verification link in the same browser, and checks the page your app shows.

What you need

  • Node.js 22 or later. selenium-webdriver 4.47 and later declare Node.js 22 as their minimum. The Mailsac helper on its own runs on Node.js 18 and later.
  • Google Chrome. Selenium Manager ships with selenium-webdriver and downloads a matching ChromeDriver when none is on your PATH, so there’s no driver to install.
  • A Mailsac API key. Create a free account; API access is included on every plan, including free. Generate a key under API Keys & Users in the dashboard. It’s shown only once, so treat it like a password.
  • Your app, running where Chrome can reach it and able to send real email. This guide assumes http://localhost:3000.

Set up Selenium 4 with headless Chrome

Add selenium-webdriver to your app’s repository, or to a new folder if your tests live on their own. 4.49.0 is the version tested here.

# In your app's repository, or in a new folder after running: npm init -y
npm install --save-dev selenium-webdriver@4.49.0
npm pkg set scripts.test:email="node --test e2e/*.test.mjs"
mkdir e2e

The test:email script uses Node’s built-in test runner, so there’s no test framework to install. If your suite uses Mocha or Jest, keep the Selenium and Mailsac code below and adapt the imports, the before/after hooks (Jest calls them beforeAll/afterAll) and the timeout setting.

Add a small Mailsac helper

Save this as e2e/mailsac.mjs. It uses Node’s built-in fetch and has no dependencies. It reads your API key from the MAILSAC_API_KEY environment variable and sends it only in the Mailsac-Key header.

// e2e/mailsac.mjs: wait for an email with the Mailsac REST API. Node 18+, no dependencies.
import { randomUUID } from 'node:crypto';

const API = process.env.MAILSAC_API_URL || 'https://mailsac.com/api'; // override only for a mock

// A new address for every test, so reruns and parallel jobs never share an inbox.
// Public @mailsac.com by default; set MAILSAC_TEST_DOMAIN to use your private domain.
export function uniqueAddress(prefix) {
  const domain = process.env.MAILSAC_TEST_DOMAIN || 'mailsac.com';
  return `${prefix}-${Date.now()}-${randomUUID().slice(0, 8)}@${domain}`;
}

async function mailsac(path, as = 'json') {
  const key = process.env.MAILSAC_API_KEY; // a CI secret; never in browser code
  if (!key) throw new Error('Set MAILSAC_API_KEY');
  const headers = { 'Mailsac-Key': key };
  const res = await fetch(API + path, { headers, signal: AbortSignal.timeout(10_000) });
  // Fail fast on errors. A 429 means the account hit its monthly Ops limit; retrying won't help.
  if (!res.ok) throw new Error(`Mailsac API returned ${res.status} for ${path}`);
  return as === 'json' ? res.json() : res.text();
}

// Poll every 2 s until the deadline. The list is newest first; skip anything received
// before `receivedAfter`, a Date.now() taken just before your app sends the email.
export async function waitForEmail(email, { subject, receivedAfter, timeoutMs = 60_000 }) {
  const deadline = Date.now() + timeoutMs;
  while (Date.now() <= deadline) {
    const list = await mailsac(`/addresses/${email}/messages?limit=10`);
    const match = list.find((m) =>
      Date.parse(m.received) >= receivedAfter && m.subject?.includes(subject));
    if (match) {
      // Metadata has links[], the URLs Mailsac found in the email. Plain-text links can keep
      // trailing punctuation, e.g. <url> or [url]; drop it, then de-duplicate.
      const message = await mailsac(`/addresses/${email}/messages/${match._id}`);
      const links = (message.links ?? []).map((url) => url.replace(/[>\]).,;:!?'"*]+$/, ''));
      return { ...message, links: [...new Set(links)] };
    }
    await new Promise((resolve) => setTimeout(resolve, 2_000));
  }
  throw new Error(`No "${subject}" email for ${email} within ${timeoutMs / 1000} s`);
}

// The message as plain text. Mailsac generates it for HTML-only email.
export const readText = (email, messageId) => mailsac(`/text/${email}/${messageId}`, 'text');

What it does:

  • A unique address per test. uniqueAddress('signup') returns a new address each time, so reruns, retries and parallel CI jobs never read each other’s mail.
  • Bounded polling. waitForEmail() lists the inbox every 2 seconds, newest first, and throws when timeoutMs (60 seconds by default) runs out. Each request also has a 10-second timeout, so a stalled connection can’t hang your suite.
  • Only mail received after the trigger. The list endpoint has no “received after” filter, so the helper compares each message’s received time with receivedAfter and also matches the subject. An older verification email, or a welcome email that arrives first, is skipped. Tests that just take the newest message can end up clicking yesterday’s link.
  • Links parsed by Mailsac. The message metadata includes links, the HTTP(S) URLs Mailsac found in the email’s text and HTML. The helper trims punctuation that plain-text emails can leave on the end of a link, such as > or ].
  • Plain text for codes. readText() returns the message as plain text. Mailsac generates it for HTML-only email.
  • Fails fast on API errors. Any non-2xx response throws with the status and path, never the key. In CI, consider retrying 5xx responses within your deadline. MAILSAC_API_URL is only for pointing the helper at a mock server.

Each API call counts as one Mailsac Op. The signup test uses 2 Ops when the email is there on the first poll (list, then metadata), plus 1 for each extra poll. The 2FA test below also reads the text, so it starts at 3. Each message that arrives at a private address or custom domain adds 1 more. A test that times out after 60 seconds uses about 30. The free plan includes 1,500 Ops a month; here’s how Ops are counted.

Test the signup verification email

Save this as e2e/signup.test.mjs. Change the lines marked PLACEHOLDER to match your app’s signup page, field names, confirmation text and email subject.

// e2e/signup.test.mjs: sign up in headless Chrome, then verify the address from the email.
import assert from 'node:assert/strict';
import { after, before, describe, it } from 'node:test';
import { Browser, Builder, By, until } from 'selenium-webdriver';
import chrome from 'selenium-webdriver/chrome.js';
import { readText, uniqueAddress, waitForEmail } from './mailsac.mjs';

const APP_URL = process.env.APP_URL || 'http://localhost:3000'; // PLACEHOLDER: your app

describe('signup email', () => {
  let driver;
  before(async () => {
    const options = new chrome.Options().addArguments('--headless=new', '--window-size=1280,1000');
    driver = await new Builder().forBrowser(Browser.CHROME).setChromeOptions(options).build();
  });
  after(() => driver?.quit());

  it('verifies a new account from the link in the email', { timeout: 90_000 }, async () => {
    const email = uniqueAddress('signup');

    // PLACEHOLDER: your signup page, field names, confirmation text and email subject.
    await driver.get(`${APP_URL}/signup`);
    await driver.findElement(By.name('email')).sendKeys(email);
    await driver.findElement(By.name('password')).sendKeys('a-test-password-123');
    const receivedAfter = Date.now(); // just before the app sends the email
    await driver.findElement(By.css('button[type=submit]')).click();
    const status = await driver.wait(until.elementLocated(By.css('[role=status]')), 10_000);
    await driver.wait(until.elementTextMatches(status, /check your email/i), 10_000,
      'Signup not confirmed');

    const message = await waitForEmail(email, { subject: 'Verify your email', receivedAfter });
    const links = message.links.filter((url) => url.startsWith(`${APP_URL}/verify`));
    assert.equal(links.length, 1, `Expected one verification link, found ${links.length}`);

    await driver.get(links[0]);
    const heading = await driver.wait(until.elementLocated(By.css('h1')), 10_000);
    await driver.wait(until.elementTextIs(heading, 'Email verified'), 10_000, 'Email not verified');
  });
});

Start your app, then run the test with your key in the environment. Keep the key out of committed files.

MAILSAC_API_KEY=your_api_key npm run test:email

The test fails with a clear message if the email doesn’t arrive within 60 seconds, or if it doesn’t contain exactly one distinct link to your app’s /verify page. Filtering on your app’s origin and path means the test can’t pass by following a help or unsubscribe link, or a link to another site.

Take receivedAfter just before the click that sends the email. Taken earlier, it can let an older email through. Taken after the click, it can skip an email that arrived quickly.

Read a 6-digit OTP or 2FA code

Codes work the same way, except the test reads the plain text and finds the code itself; Mailsac doesn’t extract codes for you. Two-factor tests usually sign in to a seeded account, so the address is fixed and older codes pile up in its inbox. Because the code signs in to an account, give that account a private address; the free plan includes one.

Add this test inside the describe block in e2e/signup.test.mjs (readText is already imported), and set TWO_FACTOR_EMAIL and TWO_FACTOR_PASSWORD for the seeded account. It clears the browser’s cookies first, because many apps sign you in as soon as you verify an email.

  it('signs in with the 6-digit code from the email', { timeout: 90_000 }, async () => {
    // PLACEHOLDER: a seeded test account with email 2FA turned on. Its address is fixed, so
    // older codes stay in the inbox; receivedAfter makes the test read only the new one.
    const { TWO_FACTOR_EMAIL: email, TWO_FACTOR_PASSWORD: password } = process.env;
    assert.ok(email && password, 'Set TWO_FACTOR_EMAIL and TWO_FACTOR_PASSWORD');

    await driver.manage().deleteAllCookies(); // the signup test may have left you signed in
    await driver.get(`${APP_URL}/login`);
    await driver.findElement(By.name('email')).sendKeys(email);
    await driver.findElement(By.name('password')).sendKeys(password);
    const receivedAfter = Date.now();
    await driver.findElement(By.css('button[type=submit]')).click();
    const codeInput = await driver.wait(until.elementLocated(By.name('code')), 10_000);

    const message = await waitForEmail(email, { subject: 'Your sign-in code', receivedAfter });
    const codes = [...new Set((await readText(email, message._id)).match(/\b\d{6}\b/g))];
    assert.equal(codes.length, 1, `Expected one 6-digit code, found ${codes.length}`);

    await codeInput.sendKeys(codes[0]);
    await driver.findElement(By.css('button[type=submit]')).click();
    await driver.wait(until.urlContains('/account'), 10_000); // PLACEHOLDER: your signed-in page
  });

Requiring exactly one distinct 6-digit number keeps the test honest. If the email also contains an order number or a postcode with six digits, the test fails instead of typing the wrong one. In that case, match the words around the code instead, such as /code is (\d{6})/, and use its first capture group. For a longer walkthrough of a login form with an emailed 2FA code, see Verifying 2FA code delivery via email with Selenium.

Run the email tests in CI

In CI, the app under test has to send real email to the test address, just as it does locally. This GitHub Actions workflow assumes the tests live in your app’s repository. It starts the app, waits until it answers, then runs the tests in headless Chrome. The ubuntu-latest runner image includes Google Chrome and a matching ChromeDriver, so there’s nothing to install.

# .github/workflows/email-tests.yml
name: Email tests
on: [push, pull_request]
permissions:
  contents: read
jobs:
  selenium:
    runs-on: ubuntu-latest # includes Chrome and a matching ChromeDriver
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: 24
          cache: npm
      - run: npm ci
      - name: Start the app, then run the Selenium email tests
        env:
          MAILSAC_API_KEY: ${{ secrets.MAILSAC_API_KEY }}
          MAILSAC_TEST_DOMAIN: ${{ vars.MAILSAC_TEST_DOMAIN }} # e.g. yourteam.msdc.co
        run: |
          npm start & # PLACEHOLDER: start your app; it must really send its email
          timeout 60 bash -c 'until curl -s -o /dev/null http://localhost:3000; do sleep 1; done'
          npm run test:email

Add MAILSAC_API_KEY as a repository secret under Settings, then Secrets and variables, then Actions. Optionally add a MAILSAC_TEST_DOMAIN variable for a private domain (see below). If you added the 2FA test, store TWO_FACTOR_EMAIL and TWO_FACTOR_PASSWORD as secrets and add them to the step’s env, like MAILSAC_API_KEY. Its seeded inbox is shared, so keep two runs from overlapping: add concurrency: email-tests at the top level of the workflow.

Pull requests from forks don’t receive repository secrets, so the helper fails there with Set MAILSAC_API_KEY. To skip the job for those pull requests, add if: ${{ !github.event.pull_request.head.repo.fork }} to it. For a walkthrough of the GitHub settings with screenshots, see Run Selenium tests in GitHub Actions: email testing.

Don’t want a staging app delivering mail at all? Point its SMTP settings at Email Capture (capture.mailsac.com). Mailsac keeps each message instead of delivering it, and the same helper reads it by recipient address. Each captured message is one Op, and captured mail is public unless you turn on private capture or use a private custom domain.

Public vs private test addresses

By default, mail sent to a mailsac.com address is public. Anyone can view a public inbox on the Mailsac website without an account, and any Mailsac API key can read it or delete individual messages from it through the API. Public inboxes are temporary: messages may be recycled quickly. Public addresses also have lower inbound throttling thresholds.

A random address makes it unlikely that anyone stumbles on your test mail, but it isn’t access control. Use public addresses for synthetic test data only. If a verification link or code would work on a real account, or the email contains real personal data, use a private address or a private custom domain:

  • Private address. Reserve an address in the dashboard or with POST /api/addresses/{email}, and only your account can read its mail. The free plan includes one; it suits a fixed test account like the 2FA example. Private address docs
  • Custom domain. Every address on the domain can receive mail without setup, and its mail is private once the domain is verified. A zero-setup subdomain of msdc.co, such as yourteam.msdc.co, works right away with no DNS changes. Or bring your own domain: verify it with a TXT record, then point its MX records to Mailsac. Paid plans include custom domains (Indie 1, Business 5, Enterprise 12+); the free plan doesn’t. Custom domain docs

With a custom domain, set MAILSAC_TEST_DOMAIN=yourteam.msdc.co and every uniqueAddress() is on your private domain, so each test still gets its own private inbox. It’s the setup we recommend for team CI suites. Compare plans on the pricing page.

Troubleshooting

  • No "Verify your email" email for … within 60 s. Check that your app really sends email in this environment (many dev and CI setups log mail or use a sandbox instead), that the test polls the exact address the app emailed, and that the subject in the test matches; the match is case-sensitive. Keep the test machine’s clock in sync, because the helper skips mail received before receivedAfter. Busy public addresses can be throttled. The missing-mail guide covers other causes.
  • Expected one verification link, found 0. No link in the email starts with your APP_URL and /verify. Check that APP_URL matches the host the email uses, for example localhost versus 127.0.0.1. If your email provider rewrites links for click tracking, turn tracking off for test mail, or match the tracking link and let Chrome follow the redirect.
  • Expected one verification link, found 2 means the email has two different matching links, such as a plain-text part that broke a long URL across lines. Fix the template, or make the filter stricter.
  • Mailsac API returned 401 means the key is wrong; Set MAILSAC_API_KEY means it’s empty. In CI, pass the secret in the step’s env. 403 means the address is another account’s private address: pick another name or use your own domain. 429 means your account went past its monthly Ops and API access is paused; Mailsac emails warnings before that happens. Retrying won’t help: add Ops or move to a larger plan.
  • SessionNotCreatedError: session not created: This version of ChromeDriver only supports Chrome version …. An old ChromeDriver on your PATH takes precedence over Selenium Manager. Remove it, and Selenium Manager downloads the driver that matches your Chrome.
  • ERR_MODULE_NOT_FOUND for selenium-webdriver/chrome. In an ES module, import selenium-webdriver/chrome.js, with the extension.
  • npm warn EBADENGINE during install. Your Node.js is older than 22, the minimum that current selenium-webdriver declares. Upgrade Node.js.

Next steps

  • See the same trigger, wait and check loop with curl and plain Node.js on the Email Testing API page.
  • Browse every endpoint, including attachments, headers and cleanup, in the API reference, or start with the Mailsac docs.
  • For long waits, turn on webhook or WebSocket forwarding for a private address, or for a catch-all on your custom domain, instead of polling. Domain-wide WebSockets need Business or Enterprise, and pushed messages use Ops.
  • Using Cypress too? Read the Cypress email testing guide.

Create a free Mailsac account to get an API key, then run the signup test against your app.