Skip to main content
Back to Blog
Tutorial
2026-09-28

Mailosaur: Email and SMS Testing for Automated Suites

Mailosaur guide for QA engineers: automate email, SMS, OTP, magic-link, and CI checks with reliable inboxes, Playwright examples, and failure diagnostics.

Mailosaur: Email and SMS Testing for Automated Suites

Mailosaur is a commercial email and SMS testing service for automated suites that need to receive real messages, inspect their content, extract links or one-time codes, and continue the browser flow. It is actively maintained: the official Node package is current, the Python package has 2026 releases, the Cypress plugin is listed in the Cypress plugin catalog, and Mailosaur's own documentation covers Playwright, Cypress, Selenium, WebdriverIO, Robot Framework, Node.js, Python, Java, .NET, Ruby, PHP, Go, and browser-side JavaScript.

The important status detail is that Mailosaur is not a disposable local SMTP catcher like Mailpit or smtp4dev. It is a paid hosted platform with inboxes, wildcard test addresses, API keys, retention controls, optional SMS/authentication add-ons, and plan limits. The official pricing page currently lists Personal from $20 per month billed annually with one inbox and 15,000 inbound emails per month, Core from $50 per month billed annually with multiple inboxes and 75,000 inbound emails per month, and SMS/authentication as an add-on starting from $37.50 per month. Treat those numbers as procurement inputs, not constants in tests, because account limits can be customized.

Use Mailosaur when your test has to leave the app, wait for a delivered email or SMS, prove that the message is addressed correctly, then use its real link, OTP, attachment, or body text. For purely local template debugging, a tool like Mailpit is often cheaper and faster. For login journeys where the link itself is the product behavior, pair this guide with a flow-level pattern such as testing passwordless email magic link flow.

Current Product Shape And Limits To Plan Around

Mailosaur calls its inboxes servers in much of the API. Each inbox has a unique server ID, and that ID becomes a wildcard email domain. Any address ending in @SERVER_ID.mailosaur.net can receive mail without pre-creating the mailbox address. This wildcard pattern is the feature that makes parallel tests practical: generate a unique address per test, submit it through the UI, then search for a message sent to that exact address.

The official Node.js guide documents messages.get(serverId, criteria, options) as the preferred retrieval method because it waits for a matching message and returns the full message object. The default wait is 10 seconds, and timeout is expressed in milliseconds. By default, searches consider messages received in the last hour; use receivedAfter when you want the test window to begin at a captured timestamp.

CapabilityConfirmed current behaviorTest design impact
Hosted inboxesInboxes have unique server IDs and wildcard domainsGenerate a new recipient per test instead of sharing one address
Message lookupmessages.get waits and returns the full messagePrefer it over list/search in E2E tests
Search criteriasentTo, sentFrom, subject, body, with match in the API referenceCombine recipient and subject to avoid stale matches
Time windowDefault lookup window is recent mail, with receivedAfter overrideCapture testStart before triggering the app action
SMSPhone numbers are assigned to inboxes and fetched with the same message API styleKeep SMS tests in a smaller tagged suite because they consume paid resources
SMTPHosted SMTP endpoint uses smtp.mailosaur.net on port 2525 with inbox credentialsUseful for staging apps that can be pointed at a test SMTP server

What people get wrong: they search by subject alone. That passes locally, then fails in CI because another run generated the same subject. Search by a unique recipient or phone number, include a subject/body discriminator when useful, and set receivedAfter to the moment before the user action. This turns the inbox from a shared bucket into a per-test queue.

Build A Playwright Email Test Around The Message, Not The Inbox

A resilient Mailosaur test has five phases: create a unique recipient, trigger the product action, retrieve the message with scoped criteria, assert the envelope and content, then continue the browser journey using the extracted link or code. Do not assert only that a message arrived. Assert that the message was sent to the user you created, came from the expected sender, contains the expected call to action, and produces the expected state change in the app.

import { test, expect } from '@playwright/test';
import MailosaurClient from 'mailosaur';

const apiKey = process.env.MAILOSAUR_API_KEY;
const serverId = process.env.MAILOSAUR_SERVER_ID;

if (!apiKey || !serverId) {
  throw new Error('MAILOSAUR_API_KEY and MAILOSAUR_SERVER_ID are required');
}

const mailosaur = new MailosaurClient(apiKey);

test('new user verifies an email address', async ({ page }) => {
  const testStart = new Date();
  const emailAddress = mailosaur.servers.generateEmailAddress(serverId);

  await page.goto('/signup');
  await page.getByLabel('Email').fill(emailAddress);
  await page.getByRole('button', { name: 'Create account' }).click();
  await expect(page.getByText('Check your email')).toBeVisible();

  const message = await mailosaur.messages.get(
    serverId,
    {
      sentTo: emailAddress,
      subject: 'Verify your email'
    },
    {
      receivedAfter: testStart,
      timeout: 30000
    }
  );

  expect(message.to[0].email).toBe(emailAddress);
  expect(message.from[0].email).toBe('noreply@example.com');
  expect(message.subject).toBe('Verify your email');
  expect(message.html.body).toContain('Confirm your account');

  const verifyLink = message.html.links.find((link) => link.text.includes('Verify'));
  if (!verifyLink?.href) throw new Error('Verification link missing from the email');
  expect(verifyLink.href).toMatch(/^https:\/\/app\.example\.com\/verify/);

  await page.goto(verifyLink.href);
  await expect(page.getByRole('heading', { name: 'Email verified' })).toBeVisible();
});

Notice the order. The test captures testStart before clicking the product action. It searches by the generated recipient and subject. It checks sender and recipient before opening the link. It asserts a page-level outcome after following the link. Those details prevent the common false positive where the test finds an old message, follows an old link, and still reports success because the page happened to load.

Extract Links And OTP Codes Without Fragile Parsing

Mailosaur parses email content and exposes links in html.links and text.links. It also extracts verification codes into html.codes and text.codes. Use those arrays before writing your own parser. When the product sends both HTML and plain text, assert both are usable. A plain text part matters for accessibility, deliverability, and fallback clients.

Content typeMailosaur fieldAssertion that catches real defects
HTML bodymessage.html.bodyRequired CTA text appears, hidden fallback text is not the only content
Plain text bodymessage.text.bodySame destination and instructions exist without HTML
Linksmessage.html.links, message.text.linksURL host, path, and token parameters match the expected environment
Codesmessage.html.codes, message.text.codesCode exists once and has the expected length or format
Attachmentsmessage.attachments plus files.getAttachmentFile name, content type, byte length, and decoded content are checked
import { expect } from '@playwright/test';

type MailosaurLink = { text?: string; href: string };
type MailosaurCode = { value: string };
type MailosaurMessage = {
  html?: { links?: MailosaurLink[]; codes?: MailosaurCode[]; body?: string };
  text?: { links?: MailosaurLink[]; codes?: MailosaurCode[]; body?: string };
};

export function getLoginLink(message: MailosaurMessage): string {
  const htmlLinks = message.html?.links ?? [];
  const textLinks = message.text?.links ?? [];
  const allLinks = [...htmlLinks, ...textLinks];
  const loginLink = allLinks.find((link) => {
    const url = new URL(link.href);
    return url.hostname === 'app.example.com' && url.pathname === '/login/magic';
  });

  if (!loginLink) throw new Error('magic login link should be present');
  return loginLink.href;
}

export function getSixDigitCode(message: MailosaurMessage): string {
  const allCodes = [...(message.html?.codes ?? []), ...(message.text?.codes ?? [])];
  const matchingCodes = allCodes.filter((code) => /^[0-9]{6}$/.test(code.value));

  expect(matchingCodes, 'exactly one six digit code should be present').toHaveLength(1);
  return matchingCodes[0].value;
}

The helper above still uses a regex, but it uses Mailosaur's extracted code values as input. That is different from scraping the entire email body with a permissive pattern that might capture an invoice number, support ticket ID, or year. For links, parse with URL and assert the host and path. Substring matching on the entire href is too easy to fool when environments share domains or redirect paths.

SMS And 2FA Tests Need Their Own Budget

Mailosaur SMS testing works by assigning a phone number to an inbox, sending SMS to that number, and retrieving the resulting message through the API. The official SMS docs say the code for SMS automation is almost identical to email automation, but the operational model is not identical. Email addresses are wildcard and cheap to generate. Phone numbers are allocated resources, and SMS traffic usually has tighter cost and throughput constraints.

For CI, put SMS tests behind a tag or project that runs on release branches, nightly builds, or explicit workflow dispatch. Run fast email flows on every pull request, but do not make every contributor wait on external SMS delivery unless the changed area affects authentication or messaging.

import { test, expect } from '@playwright/test';
import MailosaurClient from 'mailosaur';

const apiKey = process.env.MAILOSAUR_API_KEY;
const serverId = process.env.MAILOSAUR_SERVER_ID;
const smsNumber = process.env.MAILOSAUR_SMS_NUMBER;

if (!apiKey || !serverId || !smsNumber) {
  throw new Error('MAILOSAUR_API_KEY, MAILOSAUR_SERVER_ID, and MAILOSAUR_SMS_NUMBER are required');
}

const mailosaur = new MailosaurClient(apiKey);

test('user signs in with an SMS verification code', async ({ page }) => {
  const testStart = new Date();

  await page.goto('/login');
  await page.getByLabel('Phone number').fill(smsNumber);
  await page.getByRole('button', { name: 'Send code' }).click();

  const sms = await mailosaur.messages.get(
    serverId,
    { sentTo: smsNumber, body: 'Your ExampleApp code' },
    { receivedAfter: testStart, timeout: 45000 }
  );

  const code = sms.text.codes.find((entry) => /^[0-9]{6}$/.test(entry.value));
  if (!code?.value) throw new Error('SMS should contain one six digit verification code');

  await page.getByLabel('Verification code').fill(code.value);
  await page.getByRole('button', { name: 'Continue' }).click();
  await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});

If this test fails intermittently, diagnose by looking at three things in order: whether the SMS is visible in the Mailosaur dashboard, whether the app sent to the exact allocated number, and whether your search window began too late. Do not immediately increase the timeout to two minutes. Longer waits hide routing mistakes and turn a deterministic configuration bug into a slow failure.

Cypress, SDKs, And When To Use The Raw API

Mailosaur's Cypress quickstart documents the official cypress-mailosaur package, imported from cypress/support/e2e.js, with CYPRESS_MAILOSAUR_API_KEY available as the environment variable alternative to hardcoding the key. For Cypress-heavy teams, those commands keep test code idiomatic. For Playwright and other Node-based suites, the mailosaur Node client is usually simpler because it can be used directly in test fixtures and helpers.

StackRecommended integrationGood fit
Playwrightmailosaur Node SDK in fixtures/helpersBrowser flows that continue after email or SMS
Cypresscypress-mailosaur custom commandsTeams that keep all test actions inside Cypress command chains
PythonOfficial mailosaur package from PyPIPytest suites, service checks, backend test jobs
Java/.NETOfficial SDKs listed in Mailosaur docsEnterprise stacks that already drive Selenium or API tests there
Raw REST APIGET /api/servers, GET /api/messages/:id, search/list endpointsDiagnostics, cross-language glue, or tools without SDK support
const { defineConfig } = require('cypress');

module.exports = defineConfig({
  e2e: {
    baseUrl: 'https://app.example.com',
    setupNodeEvents() {
      return undefined;
    }
  },
  env: {
    MAILOSAUR_SERVER_ID: 'SERVER_ID'
  }
});
import 'cypress-mailosaur';
// cy.findByLabelText and cy.findByRole come from Cypress Testing Library (@testing-library/cypress).
import '@testing-library/cypress/add-commands';

describe('email verification', () => {
  it('receives a verification email', () => {
    // Cypress 16 removed Cypress.env(); cy.env() reads CYPRESS_MAILOSAUR_SERVER_ID without exposing it to the browser.
    cy.env(['MAILOSAUR_SERVER_ID']).then(({ MAILOSAUR_SERVER_ID: serverId }) => {
      const email = `signup-${Date.now()}@${serverId}.mailosaur.net`;

      cy.visit('/signup');
      cy.findByLabelText('Email').type(email);
      cy.findByRole('button', { name: 'Create account' }).click();

      cy.mailosaurGetMessage(serverId, {
        sentTo: email,
        subject: 'Verify your email'
      }).then((message) => {
        expect(message.to[0].email).to.equal(email);
        expect(message.html.links.length).to.be.greaterThan(0);
      });
    });
  });
});

Use cypress-mailosaur 4.0 or later with Cypress 16: the plugin moved from Cypress.env to cy.env. Make sure the API key is supplied by CYPRESS_MAILOSAUR_API_KEY in the runner environment, not in cypress.config.js committed to source control. Keep the server ID in non-secret config if the inbox is dedicated to test, but treat the API key as a credential because it can read and delete messages.

CI Wiring For Pull Requests And Release Gates

CI should separate fast email checks from slower paid-channel checks. The example below runs Playwright tests tagged @email on pull requests and makes an SMS job manual by using workflow_dispatch. It pins current action majors, stores traces only on failure, and keeps Mailosaur secrets in GitHub Actions secrets.

name: messaging-tests

on:
  pull_request:
  workflow_dispatch:

jobs:
  email:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npx playwright install --with-deps chromium
      - name: Run email tests
        env:
          MAILOSAUR_API_KEY: ${{ secrets.MAILOSAUR_API_KEY }}
          MAILOSAUR_SERVER_ID: ${{ secrets.MAILOSAUR_SERVER_ID }}
        run: npx playwright test --grep @email
      - uses: actions/upload-artifact@v7
        if: failure()
        with:
          name: playwright-report-${{ github.run_id }}
          path: playwright-report

  sms:
    if: github.event_name == 'workflow_dispatch'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npx playwright install --with-deps chromium
      - name: Run SMS tests
        env:
          MAILOSAUR_API_KEY: ${{ secrets.MAILOSAUR_API_KEY }}
          MAILOSAUR_SERVER_ID: ${{ secrets.MAILOSAUR_SERVER_ID }}
          MAILOSAUR_SMS_NUMBER: ${{ secrets.MAILOSAUR_SMS_NUMBER }}
        run: npx playwright test --grep @sms

Ready-made QA skills can install from qaskills.sh with the qaskills CLI, but the important design principle is independent of the runner: the agent or human who changes an auth flow should have a small, named messaging suite that can be executed locally and in CI without reading a long wiki page.

Failure Mode: The Message Arrives, But The Test Times Out

A realistic failure looks like this: the app shows "Check your email", Mailosaur dashboard shows a fresh message, but messages.get times out. The root cause is usually a mismatch in search criteria, not delivery. Maybe the app lowercased the recipient, the test generated user+run@example but the product rejected plus addressing, or the subject changed from "Verify your email" to "Confirm your email address".

Diagnose it with a temporary list/search step that prints summaries for messages received after the test start. Do this in a branch or local run, not as permanent noisy CI logging.

import MailosaurClient from 'mailosaur';

const apiKey = process.env.MAILOSAUR_API_KEY;
const serverId = process.env.MAILOSAUR_SERVER_ID;

if (!apiKey || !serverId) {
  throw new Error('MAILOSAUR_API_KEY and MAILOSAUR_SERVER_ID are required');
}

const mailosaur = new MailosaurClient(apiKey);

export async function printRecentSubjects(): Promise<void> {
  const result = await mailosaur.messages.search(
    serverId,
    { sentTo: 'debug@example.test' },
    { timeout: 10000, errorOnTimeout: false }
  );

  for (const item of result.items) {
    console.log([item.id, item.subject, item.received].join(' | '));
  }
}

Once you identify the mismatch, fix the criteria. Do not leave a broad body search in place because it can match marketing footers, legal text, or a previous email in a multi-step journey. If you need to prove absence, use messages.search with errorOnTimeout: false, then assert the result count is zero for the exact recipient and subject.

Choosing Mailosaur Versus Local Mail Capture

Mailosaur is strongest when hosted delivery behavior matters: password resets in staging, SMS OTP, email replies, external address forwarding, POP3/IMAP connectivity, spam analysis, and tests run by distributed CI agents. Local capture tools are strongest when you want zero SaaS dependency, free local development, and full control inside Docker Compose.

Decision pointChoose MailosaurChoose a local catcher
SMS or 2FA by phoneYes, with allocated numbersUsually no
Pull request email smoke testsYes, if staging is hostedYes, if app runs entirely in CI
Template iterationUseful, but paidBetter default for rapid local loops
Compliance or procurementNeeds vendor reviewSimpler for local-only testing
Agent-driven setupStable API and SDKs help coding agentsDocker Compose is easier for isolated sandboxes
Production-like SMTP credentialsHosted SMTP and inbox passwordsGood only if your app can target local network services

For AI coding agents, Mailosaur's advantage is explicit API shape. An agent can add a test helper around messages.get, wire secrets into CI, and assert links or codes without reverse engineering a web UI. The risk is cost and secret handling. Give agents a narrow task, a dedicated inbox/server ID, and a clear rule that destructive cleanup such as messages.deleteAll belongs only in isolated test inboxes.

Frequently Asked Questions

Is Mailosaur only for email testing?

No. Mailosaur covers email and SMS testing, and its docs also include authentication workflows such as one-time codes and TOTP-related testing. Email is the easiest starting point because wildcard addresses let you generate a unique recipient for every test. SMS needs more planning because phone numbers are allocated to inboxes and SMS/authentication is priced as an add-on on current plans.

Should every pull request run Mailosaur SMS tests?

Usually no. Run email smoke tests on pull requests when they are stable and cheap enough for your team. Keep SMS tests tagged separately and run them on release branches, nightly builds, manual workflow dispatch, or changes that touch authentication. SMS delivery has external routing, cost, and phone-number constraints, so it should guard important flows without slowing every UI copy change.

How do I stop Mailosaur tests from reading old emails?

Generate a unique recipient per test, capture a testStart timestamp before triggering the product action, and pass receivedAfter: testStart to messages.get. Also search by sentTo plus a meaningful subject or body fragment. Avoid shared addresses and broad subject-only searches. If the suite is highly parallel, dedicate separate inboxes to noisy test groups.

Can AI coding agents safely add Mailosaur tests?

Yes, if you constrain the task. Ask the agent to use the official SDK, keep API keys in environment variables, create a unique address per test, and assert the post-click product state. Review any cleanup code carefully. Deleting a single known message in a disposable inbox is different from deleting all messages in a shared QA inbox.