Playwright email testing with Mailsac

Playwright Email Testing: Test Signup and Password-Reset Emails with Mailsac

By Mailsac Engineering. Tested on September 24, 2026 with @playwright/test 1.63 on Node.js 22, 24 and 26.

Playwright can fill in your signup and password-reset forms, but the step that proves they work happens outside the browser: an email arrives with a 6-digit code or a link. This tutorial adds that step to your Playwright tests. Your app sends mail through its usual provider, Mailsac receives it at a unique test address, and the test reads it through the Mailsac REST API before typing the code or opening the link.

You will build a small helper, two signup-verification tests (one types the emailed code, one follows the emailed link) and a password-reset test that signs in with the new password. The tests run side by side in parallel workers and in GitHub Actions. Every code block below was run with real Playwright against a local test app and a mock of the Mailsac API.

How Playwright email testing works

Each test follows three steps:

  • Trigger. The test signs up, or asks for a reset, with a brand-new address such as signup-3-1790000000000-1a2b3c4d@yourteam.msdc.co, and notes the time just before it clicks. There is no inbox to create first: any address at mailsac.com or on your custom domain can receive mail.
  • Wait. The test polls GET /api/addresses/{email}/messages, which lists the inbox newest first, until a message with the expected subject arrives after that time, or a 60-second deadline passes.
  • Check. The test reads the links Mailsac found in the message from GET /api/addresses/{email}/messages/{messageId}, or its plain text from GET /api/text/{email}/{messageId}. Then it types the code or opens the link in the browser.

Every request sends your API key in the Mailsac-Key header. The helper runs in Playwright’s Node.js test runner, so the key never reaches the page under test.

Before you start

  • Node.js 22, 24 or 26, the versions Playwright currently supports.
  • A Mailsac API key. Create a free account, then generate a key under API Keys & Users in the dashboard. Every plan includes API access; the free plan includes 1,500 Ops a month. A key is shown only once, so store it like a password.
  • An app that sends real email, running on your machine or in a test environment. Mailsac receives and inspects test email; it doesn’t send it. To stop a staging app from emailing real people, you can point its SMTP settings at Mailsac’s Email Capture instead; your tests read captured mail by recipient address with the same API. Captured mail is public unless you turn on private capture.
  • A place for the mail. Public @mailsac.com addresses are fine for a first try with made-up accounts. For reset links and codes that would work on a real account, use a verified private custom domain, available on Indie and higher plans. See public vs private addresses below.

Set up the project

Create a project with Playwright Test and its Chromium browser. In an existing Node.js project, skip the first two lines. @types/node gives your editor types for process and node:crypto.

mkdir email-tests && cd email-tests
npm init -y
npm install --save-dev @playwright/test @types/node
npx playwright install chromium
mkdir -p tests

Already using Playwright? Keep your config, but make sure its test timeout is longer than the email wait, as below. The config that npm init playwright generates also sets workers: 1 on CI, which runs your email tests one at a time.

Save this as playwright.config.ts in the project root:

import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  timeout: 90_000, // longer than the 60 s email wait, so a missing email gets a clear error
  fullyParallel: true, // every test has its own address, so tests can run side by side
  workers: process.env.CI ? 4 : undefined,
  use: { baseURL: process.env.BASE_URL ?? 'http://localhost:3000' }, // PLACEHOLDER: your app
});

  • timeout: 90_000 gives each test more time than the helper’s 60-second wait, so a missing email fails with the helper’s message instead of Playwright’s default 30-second test timeout.
  • fullyParallel, and 4 workers on CI, are safe because every test gets its own address. See parallel workers below.
  • baseURL comes from BASE_URL, so the same tests can target your laptop or a staging deployment.

Add a Mailsac helper

Save this as tests/mailsac.ts. It uses Node’s built-in fetch, so there is nothing else to install.

// tests/mailsac.ts: read test email with the Mailsac REST API and Node's built-in fetch.
// It runs in the Playwright test runner, not in the browser, so the key never reaches a page.
import { randomUUID } from 'node:crypto';
import type { TestInfo } from '@playwright/test';

const API = process.env.MAILSAC_API_URL ?? 'https://mailsac.com/api';
// @mailsac.com inboxes are public unless reserved. Set MAILSAC_DOMAIN to your private domain.
const DOMAIN = process.env.MAILSAC_DOMAIN || 'mailsac.com';

async function mailsac(path: string) {
  const key = process.env.MAILSAC_API_KEY; // a secret: keep it in the runner's environment
  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.
  if (!res.ok) throw new Error(`Mailsac API returned ${res.status} for ${path}`);
  return res;
}

// A new address for every test (and every retry), so workers never share an inbox.
export function newAddress(testInfo: TestInfo, prefix: string) {
  const id = `${testInfo.workerIndex}-${Date.now()}-${randomUUID().slice(0, 8)}`;
  return `${prefix}-${id}@${DOMAIN}`;
}

type Message = { _id: string; subject: string; received: string };
type WaitOptions = { subject: string; receivedAfter: number; timeoutMs?: number };

// Poll every 2 s until the deadline. Ignore mail received before `receivedAfter`.
export async function waitForEmail(email: string, options: WaitOptions) {
  const { subject, receivedAfter, timeoutMs = 60_000 } = options;
  const deadline = Date.now() + timeoutMs;
  while (Date.now() <= deadline) {
    const res = await mailsac(`/addresses/${email}/messages?limit=10`); // newest first
    const messages = (await res.json()) as Message[];
    const match = messages.find((m) =>
      Date.parse(m.received) >= receivedAfter && m.subject?.includes(subject));
    if (match) return match;
    await new Promise((resolve) => setTimeout(resolve, 2_000));
  }
  throw new Error(`No "${subject}" email for ${email} in ${timeoutMs / 1000} s`);
}

// The links Mailsac found in the message's text and HTML bodies.
export async function getLinks(email: string, id: string) {
  const res = await mailsac(`/addresses/${email}/messages/${id}`);
  const { links } = (await res.json()) as { links?: string[] | null };
  // Plain-text links can keep trailing punctuation, e.g. <url> or [url]; drop it.
  return [...new Set((links ?? []).map((url) => url.replace(/[>\]).,;:!?'"*]+$/, '')))];
}

// The plain-text body. Mailsac generates one when the email is HTML-only.
export async function getText(email: string, id: string) {
  const res = await mailsac(`/text/${email}/${id}`);
  return res.text();
}

// Exactly one distinct link with the same origin and path as `url`, or an error.
export function extractLink(links: string[], url: string) {
  const want = new URL(url);
  const found = new Set(links.filter((link) => {
    if (!URL.canParse(link)) return false;
    const { origin, pathname } = new URL(link);
    return origin === want.origin && pathname === want.pathname;
  }));
  if (found.size !== 1) throw new Error(`Expected 1 link to ${url}, found ${found.size}`);
  return [...found][0];
}

// Exactly one distinct standalone 6-digit code, or an error.
export function extractCode(text: string) {
  const codes = new Set(text.match(/\b\d{6}\b/g) ?? []);
  if (codes.size !== 1) throw new Error(`Expected 1 six-digit code, found ${codes.size}`);
  return [...codes][0];
}

What each part does:

  • The API key comes from the environment. MAILSAC_API_KEY is read inside the test runner process. Never put it in browser code, in a variable your frontend build embeds, or in your repository. MAILSAC_API_URL is optional; leave it unset unless you point the helper at a mock API in your own tests.
  • newAddress builds a new address for every test from Playwright’s testInfo.workerIndex, a timestamp and a random suffix. The worker index and timestamp make addresses easy to find in logs, and the random part keeps addresses unique even when two CI jobs start in the same millisecond. A retry runs the test body again, so it gets a new address too.
  • waitForEmail lists the 10 newest messages every 2 seconds. It returns the first one whose subject contains your text and whose received time is at or after receivedAfter. The inbox endpoint can’t return only the mail received after a given time, so the helper filters in the test. After 60 seconds it throws an error that names the subject and the address.
  • getLinks reads the message metadata, where Mailsac lists the HTTP(S) links it found in the text and HTML bodies. A link in plain text can keep punctuation such as a closing >, so the helper trims it and removes duplicates.
  • getText returns the plain-text body. If the email only has HTML, Mailsac generates the text.
  • extractLink and extractCode return exactly one match or throw. A test that picks one of two codes can pass for the wrong reason; a test that fails loudly tells you the email changed.
  • Errors stop the test at once. A 401 means Mailsac didn’t accept the key. A 429 means the account has used its monthly Ops, so retrying won’t help.

Test signup verification with a code or a link

Save this as tests/signup.spec.ts. Both tests sign up with a new address. The first reads the 6-digit code from the email and types it in; the second opens the verification link instead. Keep whichever matches your app, or both if your email offers both.

import { test, expect, type Page } from '@playwright/test';
import {
  newAddress, waitForEmail, getText, getLinks, extractCode, extractLink,
} from './mailsac';

// PLACEHOLDER: your signup page's labels, button and heading.
async function signUp(page: Page, email: string) {
  await page.goto('/signup');
  await page.getByLabel('Email').fill(email);
  await page.getByLabel('Password').fill('a-test-password-123');
  const receivedAfter = Date.now(); // just before the app sends the email
  await page.getByRole('button', { name: 'Create account' }).click();
  await expect(page.getByRole('heading', { name: 'Check your email' })).toBeVisible();
  return receivedAfter;
}

test('signup: the emailed 6-digit code verifies the account', async ({ page }, testInfo) => {
  const email = newAddress(testInfo, 'signup');
  const receivedAfter = await signUp(page, email);

  const message = await waitForEmail(email, { subject: 'Verify your email', receivedAfter });
  const code = extractCode(await getText(email, message._id));

  await page.getByLabel('Verification code').fill(code);
  await page.getByRole('button', { name: 'Verify' }).click();
  await expect(page.getByRole('heading', { name: 'Email verified' })).toBeVisible();
});

test('signup: the emailed link verifies the account', async ({ page, baseURL }, testInfo) => {
  const email = newAddress(testInfo, 'signup');
  const receivedAfter = await signUp(page, email);

  const message = await waitForEmail(email, { subject: 'Verify your email', receivedAfter });
  const links = await getLinks(email, message._id);

  await page.goto(extractLink(links, new URL('/verify', baseURL).href));
  await expect(page.getByRole('heading', { name: 'Email verified' })).toBeVisible();
});

Change the paths, labels, button names, headings and email subject to match your app. Two details matter more than they look:

  • Take receivedAfter just before the click that makes your app send the email. Taken after the click, it could skip an email that arrives quickly.
  • extractLink compares origin and path. It keeps only links whose origin and path match new URL('/verify', baseURL), so footer links, help pages and links to other sites are ignored.

Test the full password-reset flow

Save this as tests/password-reset.spec.ts. It creates an account, requests a reset, opens the emailed link, sets a new password and signs in with it.

import { test, expect } from '@playwright/test';
import { newAddress, waitForEmail, getLinks, extractLink } from './mailsac';

test('password reset: the emailed link works', async ({ page, baseURL }, testInfo) => {
  const email = newAddress(testInfo, 'reset');

  // An account to reset. PLACEHOLDER: or create one through your app's API or seed data.
  await page.goto('/signup');
  await page.getByLabel('Email').fill(email);
  await page.getByLabel('Password').fill('an-old-test-password');
  await page.getByRole('button', { name: 'Create account' }).click();
  await expect(page.getByRole('heading', { name: 'Check your email' })).toBeVisible();

  await page.goto('/forgot-password');
  await page.getByLabel('Email').fill(email);
  const receivedAfter = Date.now(); // just before the app sends the email
  await page.getByRole('button', { name: 'Send reset link' }).click();
  await expect(page.getByRole('heading', { name: 'Check your email' })).toBeVisible();

  // The inbox also holds the signup email; the subject and time filter skip it.
  const message = await waitForEmail(email, { subject: 'Reset your password', receivedAfter });
  const links = await getLinks(email, message._id);

  await page.goto(extractLink(links, new URL('/reset', baseURL).href));
  await page.getByLabel('New password').fill('a-new-test-password');
  await page.getByRole('button', { name: 'Update password' }).click();
  await expect(page.getByRole('heading', { name: 'Password updated' })).toBeVisible();

  // The new password works.
  await page.goto('/login');
  await page.getByLabel('Email').fill(email);
  await page.getByLabel('Password').fill('a-new-test-password');
  await page.getByRole('button', { name: 'Sign in' }).click();
  await expect(page.getByRole('heading', { name: 'Signed in' })).toBeVisible();
});

By the time the reset email arrives, the inbox also holds the signup email. The subject and receivedAfter filters skip it, just as they would skip older messages in a real inbox. If your app can create accounts through an API or seed data, use that instead of the signup form; the reset part stays the same. If your app only allows a reset or sign-in after the email is verified, verify the account first with the steps from the signup test.

Run the tests

Set your key, your private domain and your app’s URL, then run the suite with four workers:

export MAILSAC_API_KEY=your_api_key      # PLACEHOLDER: from your Mailsac dashboard
export MAILSAC_DOMAIN=yourteam.msdc.co   # PLACEHOLDER: your private domain
export BASE_URL=http://localhost:3000    # PLACEHOLDER: an app that sends real email
npx playwright test --workers=4

When each test finds its email, Playwright reports 3 passed. To try the helper without a custom domain, leave MAILSAC_DOMAIN unset: it then uses public @mailsac.com addresses, which suit made-up accounts only.

Run email tests in parallel workers

Email tests run in parallel safely when no two tests share an inbox:

  • One address per test. Each test calls newAddress, so its inbox only receives mail for that test. Workers, retries, shards and separate CI jobs never see each other’s email.
  • Parallel inside files too. fullyParallel: true lets the two signup tests in one file run at the same time, and --workers=4 (or the CI setting in the config) runs up to four tests at once.
  • Shared Ops. Parallel tests share your account’s monthly Ops. Each API call is one Op, and each email received by a private address or custom domain is one more. On a private domain, when the email is already there on the first poll, the code test and the link test use 3 Ops each, and the reset test uses 4 because it receives two emails. Mail to public @mailsac.com addresses isn’t counted, so there each test uses 2. Each extra 2-second poll adds 1, so a test that times out uses about 30.
  • Throttling. Public addresses are throttled at lower volumes: delivery slows by up to about a minute, then mail is deferred. Busy suites belong on a verified custom domain.
  • Fixed accounts. If some tests must share one address, such as a pre-seeded account, give them the same Playwright test lock, for example { lock: 'seeded-account' }. They then never run at the same time, while the rest of the suite stays parallel. receivedAfter still stops them from reading older mail in that inbox.

Run the tests in GitHub Actions

Add your key as a repository secret: in your repository’s Settings, open Secrets and variables, then Actions, choose New repository secret and name it MAILSAC_API_KEY. Then save this workflow as .github/workflows/email-tests.yml:

name: Email tests
on:
  push:
    branches: [main]
  pull_request:
permissions:
  contents: read
jobs:
  playwright:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@v6
      - uses: actions/setup-node@v6
        with:
          node-version: lts/*
      - run: npm ci
      - run: npx playwright install --with-deps chromium
      - run: npx playwright test
        env:
          MAILSAC_API_KEY: ${{ secrets.MAILSAC_API_KEY }}
          MAILSAC_DOMAIN: yourteam.msdc.co # PLACEHOLDER: your private domain
          BASE_URL: https://staging.example.com # PLACEHOLDER: your deployed test app

  • GitHub Actions sets CI, so the config runs 4 workers.
  • The workflow runs on pushes to main (change it to your default branch) and on pull requests, so a pull request doesn’t run the suite, and spend Ops, twice.
  • BASE_URL points at a deployed test environment whose mail provider sends real email. To test an app started inside the job instead, start it before the test step, or use Playwright’s webServer option, and make sure it can send mail.
  • MAILSAC_DOMAIN isn’t secret, so it can live in the file. The key stays in GitHub’s secret store.
  • GitHub doesn’t pass Actions secrets to workflows triggered from a forked repository or by Dependabot, so those pull requests fail with Set MAILSAC_API_KEY. Run email tests on branches in your own repository, and add the key as a Dependabot secret too if you want Dependabot’s pull requests tested.
  • This config doesn’t record traces, screenshots or videos. The config that npm init playwright generates records a trace on the first retry, and traces record the codes your tests type and the URLs they open, including reset links. Keep reports and artifacts private.

Public vs private addresses and custom domains

Where the test email goes decides who else can read it.

  • Public @mailsac.com addresses need no setup and work on every plan, including the free plan. 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. Public inboxes are temporary, and their messages may be recycled quickly.
  • Private addresses are reserved in the dashboard or with POST /api/addresses/{email}. Only your account and team can read their mail. The free plan includes one. A private address suits one fixed test account, not a new address for every test.
  • Custom domains, on Indie and higher plans (not the free plan), give every test its own private address. A zero-setup subdomain of msdc.co, such as yourteam.msdc.co, receives mail right away with no DNS changes. You can also bring your own domain: verify it with a TXT record and point its MX records to Mailsac. Mail to a domain that isn’t verified yet is public. Every address on the domain receives mail without setup, so newAddress works unchanged.

@mailsac.com inboxes are public unless you reserve them as private addresses. Anyone who knows or guesses a public address can read its mail, including reset links and codes. The helper falls back to public addresses only so you can try it on the free plan with made-up accounts. Before you test links or codes that would work on a real account, or emails that contain real personal data, set MAILSAC_DOMAIN to your verified private domain. Plan limits for private addresses and custom domains are on the pricing page.

Troubleshooting

  • Set MAILSAC_API_KEY: the variable isn’t set in the process that runs Playwright. In GitHub Actions, check the secret’s name, and remember that pull requests from forks and from Dependabot don’t receive Actions secrets.
  • Mailsac API returned 401: Mailsac didn’t accept the key. Look for extra spaces or an old key. A key is shown only once, so create a new one if you’ve lost it.
  • Mailsac API returned 403: the address is a private address owned by another Mailsac account. Check MAILSAC_DOMAIN, and use a key from the account that owns your private addresses and domain.
  • Mailsac API returned 429: the account has used its monthly Ops. The limit is soft: Mailsac emails warnings first, but if usage keeps going, API requests return 429 until the next month, which stops your email tests. Ops reset on the first of each month (UTC). To keep testing, add Ops or move to a larger plan.
  • Other HTTP errors, such as a 5xx: the helper stops at the first error. If they show up in CI, retry them within your deadline.
  • No "…" email for … in 60 s: compare the address in the error with the one your app actually sent to, and check that the subject text matches (the match is case-sensitive). Check your email provider’s logs too, because some test or sandbox modes accept mail without delivering it. Keep the test machine’s clock in sync, because the helper compares its own time with Mailsac’s received time. On public @mailsac.com addresses, throttling can delay a busy suite’s mail by up to about a minute, so run busy suites on a verified custom domain. The missing mail guide covers other causes.
  • Expected 1 link to …, found 0: no link in the email has that origin and path. Common causes: your email provider’s click tracking rewrote the link (turn tracking off for test mail), the token is part of the path, as in /verify/abc123, or BASE_URL doesn’t match the host in your emails.
  • Expected 1 link to …, found 2: the email has two different links to that path, for example because the sender hard-wrapped a long link in the plain-text part. Fix the template, or filter the links before calling extractLink.
  • Expected 1 six-digit code: found 0 usually means the code is formatted differently, such as 482 913. Found 2 means another 6-digit number, such as an order number or postcode, is in the email. Make the pattern in extractCode match your email’s wording.
  • The link opens but your app rejects it: check the plain-text part of the email for HTML leftovers such as &amp; inside the link.
  • Tests pass alone but fail in parallel: two tests share an address or a fixed account. Call newAddress in every test.
  • Reading mail through the Mailsac website instead of the API? Since September 17, 2026, the website shows email HTML in a sandboxed iframe, so page-level selectors won’t find content inside it. The API calls in this tutorial need no selectors. If you automate the website anyway, use Playwright’s frame locators with the preview’s iframe title, as described in the email preview update.

Next steps

  • Using Cypress? The Cypress email testing tutorial covers the same password-reset flow with the @mailsac/cypress plugin.
  • See the whole workflow. The Email Testing API page explains the trigger, wait and check loop, how Ops add up, and includes a dependency-free Node.js example.
  • Look up endpoints. The API reference lists every endpoint, and the Mailsac documentation covers private addresses, custom domains, webhooks and WebSockets.
  • Skip polling. Turn on webhook or WebSocket forwarding for a private address, or for a catch-all address on your custom domain, and Mailsac pushes each new email to you. Domain-wide WebSockets need a Business or Enterprise plan, and pushed messages use Ops.
  • Record the UI steps. Playwright Codegen writes the form-filling part of these tests for you.

Create a free Mailsac account to get an API key, then point these tests at your own app.

Email preview security update for browser automation

On September 17, 2026, we updated Mailsac’s email previews to isolate email HTML from the surrounding application. This was an intentional change to improve Mailsac’s security. If your automated tests inspect email content through the Mailsac website, you may need to update how they locate that content.

What changed

  • Expanded HTML messages in the standard inbox now render inside a sandboxed iframe.
  • The full HTML preview at /dirty/{inbox}/{id} now also uses a sandboxed iframe.
  • Unified Inbox already used an iframe; its preview now has additional sandbox restrictions.

The sandbox blocks scripts inside emails and prevents the surrounding page from directly accessing the email’s document. This keeps email content separate from the Mailsac application.

Updating browser tests

Use your test framework’s iframe support to locate elements inside the email preview. A selector that searches only the top-level page will no longer find content inside the frame.

The current preview iframe titles are:

  • Standard inbox: Email message
  • Full HTML preview: Email HTML preview
  • Unified Inbox: Email message body

Select the relevant preview frame, then apply your existing email-content locators within it. If several messages are expanded, scope the frame selector to the message you are testing or keep only that message expanded.

For framework-specific guidance, see Playwright’s frame locators or Selenium’s frame switching. Direct access through the parent page’s contentDocument is intentionally restricted by the sandbox.

Tests that retrieve email content through the API do not need iframe selectors; the frame-selection steps above apply to browser UI tests.

Communication and support

We should have communicated this compatibility change when it shipped. We’re sorry for the extra investigation this caused.

If you need help adapting a test, contact Mailsac support with the view you use and your automation framework.

May 2026 Major Release

This release focuses on improving team account management, inbox performance, and frontend reliability across the Mailsac web app.

Summary

  • New “elevate to root” permission for safer admin delegation
  • Inbox UI rewritten with improved pagination and clarity
  • Faster debugging tools and message analytics
  • MFA enforcement tightened for account security

Features

Elevate to Root User

Team users can now be granted an “elevate to root” permission. This allows a user to temporarily assume root-level privileges within their session without exposing root account credentials.

Use cases:

  • Safer offboarding (no shared root access to rotate)
  • Delegating admin tasks without sharing credentials

Elevation is session-scoped and requires re-authentication.

Individual Inbox Refresh

The inbox UI has been rewritten on the new frontend framework.

Improvements:

  • Faster, more complete pagination and message navigation
  • Clearer indication of public vs private inboxes
  • Reduced UI latency when loading large inboxes

General Improvements

Continued migration to the new frontend framework across multiple app pages.

Faster loading for inbound message debugging and message count charts.

Direct access to forwarded inboxes from the Enhanced Addresses list.

POP3 settings consolidated into the Enhanced Address Management screen.

Security & Fixes

MFA is now required, as an additional security step, to remove MFA from both team and root accounts.

Fixed an issue where some inbound mail was not tracked correctly. As a result, Ops usage may increase for affected accounts.

Removed the spam feature due to low usage.

Upgraded frontend dependencies to address known security vulnerabilities.

Behavior Changes

Spam feature removed – account.disableSpam property no longer present.

Inbound message tracking corrected (may increase Ops usage as noted above).

March 2026 Release

We continue to enhance the Mailsac platform and have improved the performance of the throttling engine that is at the heart of our inbound email service. It helps us separate non-customer email from spam.

Test your 2FA with Selenium and Mailsac

Verifying 2FA Code Delivery via Email with Selenium

Login failures lock users out of your app. From forgotten passwords to broken authentication flows, a bad login experience is a nightmare for both users and support teams.

While a simple login screen seems easy to test, complexity grows fast when you introduce two-factor authentication (2FA), magic links, SSO, or team-based accounts. Can your tests keep up?

In this guide, we’ll show you how to automate 2FA email verification using Selenium and Mailsac. You’ll send a real email, extract the one-time password (OTP) programmatically, and validate authentication—ensuring your app’s login works flawlessly.

New to Selenium email testing? Start with our Selenium email testing guide. It covers headless Chrome setup, a unique test address for every run, waiting only for mail that arrives after the test acts, and running the suite in CI. This post goes deeper on the 2FA login flow.

Selenium & Mailsac Prep

You’re already familiar with Selenium—after all, it’s a core tool for browser automation. (Fun fact: we’re a proud Selenium sponsor!)

Have you worked directly with WebDriver? WebDriver is what allows Selenium to control a browser just like a real user—clicking buttons, filling out forms, and navigating pages. For our automated 2FA test, we’ll use WebDriver to interact with the login flow, retrieve the OTP from an email, and complete authentication.

Step 1: Install Selenium for Javascript

Let’s start by installing Selenium via npm:

npm init
npm install selenium-webdriver chromedriver dotenv

You’ll also need to install chromedriver by manually downloading it from Google or from your package manager. For example for macOS:

brew install chromedriver

Step 2: Sign Up At Mailsac for Email Testing

Mailsac provides disposable email addresses and an API to fetch incoming emails. We’ll use it to verify that a 2FA email is received and contains the correct code.

Why Use Mailsac?

  • Eliminate reliance on real email accounts → No need to clutter your inbox with test emails or use shared email accounts.
  • Prevents rate-limiting & spam issues → Disposable email addresses is the core feature and is extremely quick.
  • Automates email verification → You can fetch emails instantly via API or receive them over web sockets.

Setting Up Mailsac API Access

1. Create a Free Mailsac Account:

  • Go to mailsac.com and sign up.
  • Navigate to your Dashboard then Credentials and API Keys to generate an API key.

2. Install Mailsac’s API Client

Install the official mailsac API client:

npm install @mailsac/api dotenv

3. Store Your API Key Securely

Never hardcode your API key. Use environment variables by creating a .env file:

MAILSAC_API_KEY=your-api-key-here

Now that we’ve installed Selenium and set up our Mailsac account, we can start writing our test script to automatically log in, retrieve the 2FA code, and verify it in our application.

Our Sample App

Let’s introduce our sample application that we’re going to test, called “Active Forums”.
It’s an early version of a forum application. It’s got the basic logic of Sign In, the landing page that shows you all your posts, and the Admin Panel

Pages We’re Interested In Testing

Sign In

Our sample app login screen.

One Time Password

Our sample app otp screen.

Landing Page

Our sample app dashboard screen.

Automating The Entire Login Process

Let’s automate the process of logging in, retrieving the 2FA code, and verifying it.

Step 1: Automate the Login Process

Our first task is to navigate to the login page, enter the username and password, and submit the form.

1.1 Initialize Selenium WebDriver

const { Builder, By, until } = require('selenium-webdriver');
require('dotenv').config();
const { Mailsac } = require('@mailsac/api');

async function login(emailAddress,accountPassword) {
    let driver = await new Builder().forBrowser('chrome').build();
    try {
        await driver.get("http://localhost:3000/users/sign_in");
        await driver.findElement(By.id("email")).sendKeys(emailAddress);
        await driver.findElement(By.id("password")).sendKeys(accountPassword);
        await driver.findElement(By.id("sign-in")).click();

    } catch (error) {
        console.error("Login test failed:", error);
    }
    return driver;
}

Here we:

  • Open the login page.
  • Enter the incorrect username and password into the form fields.
  • Click the login button.
  • Enter the correct login details
  • Return the driver back for the 2FA check.

Step 2: Retrieve the 2FA Code via Mailsac API

Once the user is on the 2FA screen, we need to fetch the one-time code from the email inbox.

Load the API in the script:

require('dotenv').config();
const { Mailsac } = require('@mailsac/api');

Now, we write a function to check the inbox and extract the 6-digit code from the email.

async function get2FACode(emailAddress) {
    try {
        console.log(`Checking inbox for: ${emailAddress}`);

        // Fetch the latest messages from the inbox
        const mailsac = new Mailsac({ headers: { "Mailsac-Key": process.env.MAILSAC_API_KEY } });
        const results = await mailsac.messages.listMessages(emailAddress);
        const messages = results.data;

        if (!messages.length) {
            console.log("No 2FA email received yet.");
            return null;
        }

        // Get the latest message ID
        const latestMessageId = messages[0]._id;

        // Fetch email body
        // const emailBody = await mailsac.messages.getMessage(latestMessageId);
        const emailBody = await mailsac.messages.getBodyPlainText(emailAddress,latestMessageId,{ download : true });
        // Extract 6-digit 2FA code using regex
        const match = emailBody.data.match(/\b\d{6}\b/);
        if (match) {
            console.log(`2FA Code found: ${match[0]}`);
            return match[0];
        } else {
            console.log("No 2FA code found in email.");
            return null;
        }
    } catch (error) {
        console.error("Error retrieving 2FA email:", error);
    }
}

What This Does:

  • Checks the Mailsac inbox for a new email.
  • Extracts the latest email’s content.
  • Uses a regex pattern to find the 6-digit 2FA code inside the email body.

Step 3: Input the 2FA Code in the Login Form

Now that we have the 2FA code, we return to Selenium to enter it into the form.

async function enter2FACode(driver, emailAddress) {
    let attempts = 0;
    let otpCode = null;

    // Polling for 2FA code with a maximum of 5 attempts
    while (!otpCode && attempts < 5) {
        otpCode = await get2FACode(emailAddress);
        if (!otpCode) {
            console.log("Waiting for 2FA code...");
            await new Promise(resolve => setTimeout(resolve, 5000)); // Wait 5 seconds before retrying
            attempts++;
        }
    }

    if (!otpCode) {
        console.error("Failed to retrieve 2FA code after multiple attempts.");
        return;
    }

    try {
        // Enter the 2FA code into the form
        await driver.findElement(By.id("otp_code")).sendKeys(otpCode);

        // Submit the form
        await driver.findElement(By.name("commit")).click();
        console.log("2FA code submitted, verifying login...");

        // Wait for successful login redirect
        await driver.wait(until.urlContains("/"), 10000);
        console.log("Login successful!");
    } catch (error) {
        console.error("2FA verification failed:", error);
    }
}

What This Does:

  • Retries up to 5 times to fetch the 2FA code from the email inbox.
  • If no code is found, waits 5 seconds before trying again.
  • Once the code is found, inputs it into the form and submits it.
  • Waits for the dashboard page to confirm successful login.

Step 4: Run the Full Automated Test

Now, we combine everything into a single test flow.

(async function testLogin2FA() {
    const testEmail = "[email protected]";
    const testCreds = "password123";

    // Step 1: Log in
    const driver = await login(testEmail,testCreds);

    // Step 2: Retrieve and enter 2FA code
    if (driver) {
        await enter2FACode(driver, testEmail);
        await driver.quit();
    }
})();

Full main.js File

Here’s what our full file now looks like:

const { Builder, By, until } = require('selenium-webdriver');

require('dotenv').config();
const { Mailsac } = require('@mailsac/api');

async function login(emailAddress,accountPassword) {
    let driver = await new Builder().forBrowser('chrome').build();

    try {
        await driver.get("http://localhost:3000/users/sign_in");
        await driver.findElement(By.id("email")).sendKeys(emailAddress);
        await driver.findElement(By.id("password")).sendKeys(accountPassword);
        await driver.findElement(By.id("sign-in")).click();

    } catch (error) {
        console.error("Login test failed:", error);
    }
    return driver;
}

async function get2FACode(emailAddress) {
    try {
        console.log(`Checking inbox for: ${emailAddress}`);

        // Fetch the latest messages from the inbox
        const mailsac = new Mailsac({ headers: { "Mailsac-Key": process.env.MAILSAC_API_KEY } });
        const results = await mailsac.messages.listMessages(emailAddress);
        const messages = results.data;

        if (!messages.length) {
            console.log("No 2FA email received yet.");
            return null;
        }

        // Get the latest message ID
        const latestMessageId = messages[0]._id;

        // Fetch email body
        // const emailBody = await mailsac.messages.getMessage(latestMessageId);
        const emailBody = await mailsac.messages.getBodyPlainText(emailAddress,latestMessageId,{ download : true });
        // Extract 6-digit 2FA code using regex
        const match = emailBody.data.match(/\b\d{6}\b/);
        if (match) {
            console.log(`2FA Code found: ${match[0]}`);
            return match[0];
        } else {
            console.log("No 2FA code found in email.");
            return null;
        }
    } catch (error) {
        console.error("Error retrieving 2FA email:", error);
    }
}

async function enter2FACode(driver, emailAddress) {
    let attempts = 0;
    let otpCode = null;

    // Polling for 2FA code with a maximum of 5 attempts
    while (!otpCode && attempts < 5) {
        otpCode = await get2FACode(emailAddress);
        if (!otpCode) {
            console.log("Waiting for 2FA code...");
            await new Promise(resolve => setTimeout(resolve, 5000)); // Wait 5 seconds before retrying
            attempts++;
        }
    }

    if (!otpCode) {
        console.error("Failed to retrieve 2FA code after multiple attempts.");
        return;
    }

    try {
        // Enter the 2FA code into the form
        await driver.findElement(By.id("otp_code")).sendKeys(otpCode);

        // Submit the form
        await driver.findElement(By.name("commit")).click();
        console.log("2FA code submitted, verifying login...");

        // Wait for successful login redirect
        await driver.wait(until.urlContains("/"), 10000);
        console.log("Login successful!");
    } catch (error) {
        console.error("2FA verification failed:", error);
    }
}

// Main Test Logic
(async function testLogin2FA() {
    const testEmail = "[email protected]";
    const testCreds = "password123";

    // Step 1: Log in
    const driver = await login(testEmail,testCreds);

    // Step 2: Retrieve and enter 2FA code
    if (driver) {
        await enter2FACode(driver, testEmail);
        await driver.quit();
    }
})();

Now try running the whole login test:

node main.js

And just like that we now have an automated way to test outbound emails, logins, one time passwords and correct landing page location!

Automate Your 2FA Testing with Confidence

Login authentication is a critical part of any web application, and failing to test it properly can lead to frustrated users and costly support issues. By combining Selenium for browser automation and Mailsac for email-based 2FA verification, we’ve built a fully automated test that ensures your app’s login process works smoothly.

With this setup, your testing team can:

  • Programmatically retrieve real one-time passwords (OTPs) from email
  • Automate the entire login flow, including 2FA verification
  • Reduce reliance on manual testing and catch authentication issues early

This approach not only saves time but also improves the reliability of your authentication system. You can now integrate this script into your CI/CD pipeline to ensure every update maintains seamless login functionality.

Visit our forums if you have any questions. Now it’s your turn—try running the script, tweak it for your specific use case, and start automating your login tests today!

Test Smarter : Load Testing Your Email Systems with Mailsac

Email performance can make or break your app—especially during high-demand events like Black Friday sales, onboarding campaigns, or critical notifications. That’s why we’re excited to introduce our new Email Load Testing feature: a stress-testing tool designed to help you simulate real-world email loads quickly, efficiently, and affordably.

Now you can confidently validate your email systems without impacting production environments or breaking the bank. We’ll walk you through the essentials of email load testing and show you how to use a simple script to make the most of our new feature.


Why Use Mailsac’s Email Load Testing?

Traditional load testing often comes with challenges:

  • Expensive email credits for large-scale tests.
  • Limited metrics that fail to offer actionable insights.
  • Risk of production impact during testing.

We specifically focused on solving these problems. By isolating your tests with unique, disposable subdomains, it makes sure that you have:

  • Accurate, real-time metrics without interference in production.
  • No email storage concerns—Mailsac only counts and measures emails, we don’t store them.
  • Easy-to-use functionality that requires no prior experience with complex testing frameworks.

Getting Started with Email Load Testing

Setting up your first load test is quick and easy:

  1. Log in to your Mailsac account and navigate to the Load Testing section.
  2. Create a new load test. Assign it a name like “holiday-promo-test.”
  3. Mailsac generates a unique subdomain for your test, e.g., test-load.loadtester.msdc.co. Any email sent to this subdomain will be tracked in real time.

This subdomain lets you simulate thousands of email deliveries without polluting your production environment.


A Sample Script for Load Testing

Let’s walk through a load test. Here’s an example of how a customer might simulate email traffic using our load testing feature. This script, written in node with nodemailer, helps you generate and send thousands of emails to your test subdomain.

const nodemailer = require('nodemailer');

// SMTP Configuration
const transporter = nodemailer.createTransport({
  host: process.env.SMTP_HOST,
  port: process.env.SMTP_PORT,
  auth: {
    user: process.env.SMTP_USER,
    pass: process.env.SMTP_PASSWORD,
  },
});

// Generate Random Emails
const generateRandomEmail = () => {
  const randomString = Math.random().toString(36).substring(2, 15);
  return `test-${randomString}@test-load.loadtester.msdc.co`;
};

// Send Email Function
const sendEmail = async (index) => {
  try {
    const mailOptions = {
      from: process.env.FROM_EMAIL,
      to: generateRandomEmail(),
      subject: `Test Email #${index}`,
      text: `This is email number ${index}`,
    };

    await transporter.sendMail(mailOptions);
    console.log(`Successfully sent email #${index}`);
  } catch (error) {
    console.error(`Failed to send email #${index}:`, error.message);
  }
};

// Run Load Test
const runLoadTest = async () => {
  const totalEmails = 500; // Number of emails to send
  for (let i = 0; i < totalEmails; i++) {
    await sendEmail(i);
  }
};

runLoadTest();

How This Script Works

  1. Generate Random Addresses: Emails are dynamically created using your test subdomain (e.g., test-load.loadtester.msdc.co).
  2. Send in Batches: The script sends emails sequentially, allowing you to control the pace of your test.
  3. Real-Time Metrics: As emails arrive, Mailsac tracks delivery rates.

Get Insights from Mailsac’s Dashboard

Once your test is running, log into the Mailsac dashboard to monitor:

  • Total emails received.
  • Delivery success rates.
  • Any failures.
  • Time-based performance analytics.

These insights help you pinpoint weaknesses in your email infrastructure, allowing you to make improvements before scaling up.


What’s Next for Your Email Testing?

Our Email Load Testing is designed to help you stress-test your email systems with ease, but it’s only the beginning. Here are some ways to take your testing further:

  1. Simulate peak loads: Experiment with higher volumes to mirror real-world events like product launches.
  2. Vary email content: Test different subject lines, body content, and attachments to ensure consistent performance.
  3. Collaborate with your team: Use shared subdomains to make testing easier across departments.

Remember, Mailsac’s Email Load Testing is designed for manual or semi-automated testing scenarios—not for full CI/CD integration. This keeps the focus on controlled, actionable insights.


With Mailsac, you can confidently validate your email systems without unnecessary complexity or expense. Ready to see it in action? Sign up today and let’s make email testing smarter, faster, and more reliable.

November Release: Load Testing Feature + Performance Boosts

We are excited to announce upgrades to the Mailsac Platform, recently deployed in November 2024.

Load Testing Feature

Mailsac is the first QA Disposable Email Platform which allows Load Testing or Burn Testing of SMTP sender servers. You can safely send enormous amounts of test email to Mailsac after creating a Load Test Subdomain in the Mailsac Dashboard. When we receive these emails to a special subdomain, they are not subject to the (already high) throttling limits of the main platform. Note that emails are not saved/indexed as normal, so this is a feature specifically for testing your capacity to send high volume email campaigns.

More tutorials for Load Testing email will be coming soon.

Improved Account Analytics Performance

We overhauled the backend systems for usage and analytics. You may have noticed sluggishness in the past on these features, but that should be gone for good. There’s also an updated user interface for debugging inbound mail and webhooks.

Will AI Replace Your Software Testing Job?


Today, we’re tackling a big question: With the rise of code generators and AI that can make UI elements based on a single drawing, will AI take your software testing job?

The short answer is no. Today we can safely tell you that no, AI won’t take your job. Instead, we think it will enhance the way you work. Let’s see how we can make AI a powerful ally for software testers.

Role of AI in Software Testing

We like to think of AI in the testing world’s context as a controlled chaos maker. We can simulate some parts of being human to introduce some unpredictability in our tests and see if our app mitigates against the chaos.

Instead of fighting AI, let’s embrace it. One of the ways we can do that is if we can have it write test cases for us, execute it, then tell us if it passed or not.

We can then easily move those generated tests to a framework like cypress or to a continuous integration environment like GitHub Actions.

So for the rest of the article, we’ll show you how you can have ai make a test case that:

  1. Generates and sends an email.
  2. Reads the contents of said email from the user’s inbox.
  3. Ensures that contents of 1 and 2 are the same.

To be safe and not risk sending any emails out to customers, we’ll use Mailsac and its API.

Making the Assistant

So for this walkthough we’re going to be using OpenAI’s “Assistant” feature. Assistants are a little bit more of a halfway point between the chat AI we all know, and an AI agent that is more autonomous.

We want to give this AI the ability to generate and run some code and read files, but not necessarily make any autonomous decisions.

For the instructions we’ll give it some system prompts to let it know under what context it should be operating under.

We’ll use the latest gpt-4o model and enable the code interpreter tools. That should allow it to read, run and execute some code for us.

Now let’s go ahead and start generating our test cases.

We’ll use the prompt

You are creating a code test that will return true of false if a certain criteria passes. The language for > all tests should be in javascript. Use dotenv for your API keys.

The criteria is:

  1. Generate an email message and send it via nodemailer using google’s SMTP servers. Send it to [email protected].
  2. Wait 2 seconds for the email to send from Google then use the mailsac API to check the subject and
    content of the email.
  3. Make the test pass if it matches what was sent in 1 and fail if it doesn’t match.

Keep in mind that you can easily tweak the SMTP to use your own service. We’ll stick with Google’s as its the most accessible for this walkthrough.

Generating the Test Case

Fire it off to the AI Assistant and let it generate the test case. In this run it generated a mocha test for me. In previous iterations it didn’t generate a whole mocha test so I’ll ask it to just use node.

As you can see it pretty much walks you through the whole project creation process. Let’s go ahead and do our setup and install.

mkdir email_test
cd email_test
npm init -y
npm install nodemailer dotenv axios

The libraries it wants to use are pretty standard. (You could make an argument against axios but hey, let’s roll with it).

And now we’ll use a .env file to hold our API keys. You’ll need an application password for the google SMTP.

You can find where to add that in your Google account.

You’ll also need a mailsac key.

The .env file is now:

MAILSAC_API_KEY=your_mailsac_api_key
GOOGLE_EMAIL=your_google_email
GOOGLE_PASSWORD=your_google_email_password

Now copy all of your keys and email onto the .env file.

Let’s start working through the code itself

Code

Looking through the code it looks fine, with 2 exceptions: It needs to be guided on the API endpoint (to use /api/text instead of /api/messages) and it the object retrived is the text itself, so we can remove the nonexistant child textBody

Now let’s go ahead and run the test.

Check on mailsac to visually make sure an email was sent.

You can even make the test fail on purpose to ensure it’s working as intended.

It works!

Extending AI

From here it’s easy to incorporate these kinds of tests into specific continuous integration frameworks of your choice.

You could use cypress and work with existing libraries or an existing framework to run your tests.

Or you could use GitHub actions to fully run your now AI generated tests in the cloud. We have a guide showing you how to do just that.

Conclusion

As you can see it still took a human element to know what to want to test, frame it, and even tweak the AI’s output a bit to make sure that what it was generating made sense.

Tester’s aren’t going anywhere anytime soon, especially if you leverage AI to do the work for you while you focus on whats important to test and why.

How You Can Avoid CC Errors That Could Violate GDPR

Developing and testing an application is difficult enough without the stress of GDPR, CAN-SPAM, accidental information leak, etc. But it doesn’t have to be.

In this article I’ll walk you through how our email platform could’ve helped prevent Running Warehouse from exposing thousands of their customer’s emails.

Background on the Running Warehouse Incident

Now, this isn’t meant to poke fun at a company’s mistake but really to highlight just how easy these mistakes are to miss. Last month, I received two emails from “Running Warehouse” about an update to their terms of service. Nothing really unusual at first glance… Until you notice that about 1000 other customer’s email addresses had been sent along in the CC field. And to add even more insult to injury, they did this TWICE. So now I have a list of about 2000 of Running Warehouse’s customers that I’m sure the customers themselves are not happy about.

Really not a good look for a company who could be facing a class action lawsuit for a previous data breach back in 2021. 

So let’s look at the implications of this easy to do mistake:

  1. Potential GDPR violations
  2. Loss of customer trust
  3. Company reputation lost via social media, youtube videos about you, forums, etc.

Prevent these errors with Mailsac

Obviously no company wants that! This is where mailsac steps in. We help prevent these kinds of easy mistakes, but at the application level. We have a set of features where you can hook up your continuous integration with our API to ensure the email you want to send is the one the recipient actually received.

So let’s walk through how we’d prevent this if this email was coming from our application.

Technical Deep-Dive

Here’s what we’ll do:

  1. Setup a sample application that is meant to simulate sending emails.
  2. Setup our Mailsac API Key.
  3. Send a sample email.
  4. Use the API to ensure our cc and email fields match what we sent.

Let’s get started.

Sample Application

The sample application we'll be using
  • To stand in place of your application, we’ll use this dead simple node app whose sole purpose is to just send an email based on what you place in a form. 
  • To simulate our email service API key, we’ll use our Application Key we generated for our gmail account. You can do this too if you want to step through this part. You can find it under your Google account -> security -> 2 FA -> App Password or just search for app passwords.
  • Add your credentials in the config.js file directly.
    • Again, while we embed the code here, you should really use a dot-env library.
  • Most importantly, for our example here, let’s use a mailsac temporary address. We have ad hoc inbox creation abilities so for now, let’s say we’ll send it to [email protected]
  • Fire up the app

Setup our Mailsac API Key

Before we fire off an email, let’s add some quick code to check whether what we send is the same or not once it’s received.

Mailsac API Dashboard

The Email CC Checker Snippet

  await setTimeout( async () => {
        const mailsac = new Mailsac({ headers: { "Mailsac-Key":  process.env.MAILSAC_API } })
        const results = await mailsac.messages.listMessages("[email protected]")
        const messages = results.data;
        if (messages[0].cc.length <= 0){
            console.error("This email has no CC. That's Good!")
        } 
        if (messages[0].subject === req.body.subject){
            console.error("This has the correct subject. That's Good!")
        }
      }, 1000);

Normally we’d place it in an .env file, but for our purposes here, we’ll just place it directly in the headers.

Send Sample Email

Alright now let’s fire this up and fire off an email

  • Let’s make the content and subject something specific like:
    • Subject: This email is intended only for emailcontentcheck
    • Content: Hey, this email is strictly for [email protected]. No others on cc field.
  • Fire it off
  • Wait for the check to fire off and print out comparison / success
Showing a successful email cc check.
Showing a successful email cc check.

Use the API to ensure our cc and email fields match what we sent

Right after our code fires off, it should print out the message comparison result. But now, let’s make sure our CC fields are also empty

      await setTimeout( async () => {
        const mailsac = new Mailsac({ headers: { "Mailsac-Key": process.env.MAILSAC_KEY } })
        const results = await mailsac.messages.listMessages("[email protected]")
        const messages = results.data;
        if (messages[0].cc.length &lt;= 0){
            console.error("This email has no CC. That's Good!")
        } 
        if (messages[0].subject === req.body.subject){
            console.error("This has the correct subject. That's Good!")
        }
      }, 1000);

We’re even throwing in a subject line check. You can check out the full API documentation to see how you can check for empty BCCs, content checks, and more.

Fire it off again and enjoy having an automated way test your email content, cc fields, and more!

Wrap Up

Finding the right words to use in an email is already hard enough, don’t complicate it by worrying about whether the email went through,  if it works right, if it accidentally cc’s people, etc. 

Just let mailsac handle the double checking for you.

And if you want to dive even deeper into email testing, we have a full set of articles on integrating with Cypress or integrating other testing tools like Selenium with GitHub Actions for a fully automated testing pipeline.