mailsac

Email Testing API

Disposable email for testing

Email testing API for signup, OTP and password-reset tests

Your app sends the email. Your test waits for it through the Mailsac REST API, then checks the code or link. Any temporary @mailsac.com address can receive it, with no inbox to create first.

Updated September 24, 2026

Free plan: an API key and 1,500 Ops a month, with no expiry.

For CI and teams: a private test domain, either a zero-setup yourteam.msdc.co subdomain or your own domain, on any paid plan from $18 a month. @mailsac.com inboxes are public, so keep real reset links and codes off them. Public or private?

Enterprise-ready: SAML single sign-on and team logins, vendor security reviews, and invoice or purchase-order billing.

List an inbox, then print its newest message · bash or zsh, needs curl and jq

EMAIL="signup-test-1234@mailsac.com"
MESSAGES=$(curl -fsS -H "Mailsac-Key: $MAILSAC_API_KEY" \
  "https://mailsac.com/api/addresses/$EMAIL/messages?limit=10")
jq '.[] | {_id, received, subject}' <<< "$MESSAGES"
MESSAGE_ID=$(jq -r '.[0]._id // empty' <<< "$MESSAGES")
if [ -n "$MESSAGE_ID" ]; then
  curl -fsS -H "Mailsac-Key: $MAILSAC_API_KEY" \
    "https://mailsac.com/api/text/$EMAIL/$MESSAGE_ID"
else
  echo "Nothing to read for $EMAIL yet"
fi

Set EMAIL to the address your app emailed, or email any made-up @mailsac.com address yourself to try it (that inbox is public). Keep MAILSAC_API_KEY in shell or CI secrets, never in browser code.

How the email testing API works: trigger, wait, check

Mailsac is a temporary email API for testing the email your application sends. Your app emails a Mailsac address; your test reads the message back over HTTPS with a Mailsac-Key header.

1. Trigger

Your test makes your app email a unique address, such as verify-<uuid>@mailsac.com, and notes the time. No inbox setup: any address at mailsac.com or on your custom domain can receive mail.

2. Wait

Poll GET /api/addresses/{email}/messages every second or two (newest first). Keep the message whose subject matches and that arrived after your trigger, and stop at a deadline. Private addresses can push mail to a webhook or WebSocket instead.

3. Check

Read GET /api/addresses/{email}/messages/{messageId} for the links Mailsac parsed from the text and HTML, and GET /api/text/{email}/{messageId} for the plain text your test searches for the code.

A complete example you can run

The whole loop in one script: trigger a signup, wait up to 60 seconds for the verification email, then check that it contains exactly one 6-digit code and one link back to your app.

verify-email.mjs · Node.js 18+ · no dependencies

// Node.js 18+, no dependencies. Save as verify-email.mjs and run:
//   MAILSAC_API_KEY=your_key node verify-email.mjs
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';

const API = process.env.MAILSAC_API_URL ?? 'https://mailsac.com/api';
const KEY = process.env.MAILSAC_API_KEY; // server-side only; never in browser code
if (!KEY) throw new Error('Set MAILSAC_API_KEY');
const APP_URL = 'http://localhost:3000'; // PLACEHOLDER: your app
const SUBJECT = 'Verify your email'; // PLACEHOLDER: your email's subject

async function mailsac(path, as = 'json') {
  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();
}

// 1. A unique address per run. Anyone can read a public @mailsac.com inbox.
const email = `verify-${randomUUID()}@mailsac.com`; // or your private custom domain
const receivedAfter = Date.now();

// 2. PLACEHOLDER: replace with whatever makes your app send the email.
const body = new URLSearchParams({ email });
const signup = await fetch(`${APP_URL}/signup`, { method: 'POST', body, signal: AbortSignal.timeout(10_000) });
assert.ok(signup.ok, `Signup returned ${signup.status}`);

// 3. Poll for up to 60 s. Newest first; ignore mail received before the trigger.
const isMatch = (m) =>
  Date.parse(m.received) >= receivedAfter && m.subject?.includes(SUBJECT);
const deadline = Date.now() + 60_000;
let message;
while (!message) {
  if (Date.now() > deadline) throw new Error(`No matching email for ${email} in 60 s`);
  message = (await mailsac(`/addresses/${email}/messages?limit=10`)).find(isMatch);
  if (!message) await new Promise((resolve) => setTimeout(resolve, 2_000));
}

// 4. Check the code (plain text) and the link (the metadata's links[], parsed by Mailsac).
const meta = await mailsac(`/addresses/${email}/messages/${message._id}`);
const text = await mailsac(`/text/${email}/${message._id}`, 'text');
const codes = [...new Set(text.match(/\b\d{6}\b/g))];
// Plain-text links can keep trailing punctuation, e.g. <url> or [url]; drop it before comparing.
const links = [...new Set((meta.links ?? []).map((url) => url.replace(/[>\]).,;:!?'"*]+$/, '')))]
  .filter((url) => url.startsWith(`${APP_URL}/verify`));
assert.equal(codes.length, 1, `Expected one 6-digit code, found ${codes.length}`);
assert.equal(links.length, 1, `Expected one verification link, found ${links.length}`);
const [code] = codes;
const [link] = links;
console.log('Verification email OK', { code, link });

What to change

  • APP_URL, SUBJECT and the step 2 trigger, to match your app.
  • The code pattern and link filter in step 4, if your email differs.
  • The address: a private address or verified custom domain if the email’s link or code works on a real account.
  • Keep the test machine’s clock in sync; the example ignores mail received before it started.
  • It stops at the first HTTP error; in CI, consider retrying 5xx responses within your deadline, as @mailsac/cypress does.

What it costs to run

Each API call is one Op. A test that finds its email on the first poll uses 3 (list, metadata, text); each extra poll adds 1, and a private address or custom domain adds 1 for the inbound message. A 60-second timeout at 2-second polls uses about 30. At 3 to 6 Ops per passing test, the free plan covers roughly 250 to 500 runs a month and Indie roughly 4,000 to 8,000.

Using Cypress?

The @mailsac/cypress plugin waits for the matching email with cy.mailsacWaitForMessage(), and extractLink() or extractCode() pulls out the link or code. Your API key stays in Cypress’s Node process. Requires Cypress 16 on Node.js 22, 24 or 26+.

npm install --save-dev cypress@16.1.0 @mailsac/cypress@0.1.0

Public or private inboxes

Mail sent to a mailsac.com address is public by default. 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.

Which should I use? A public address to try things out with synthetic data. A private address for one fixed test account whose mail must stay private; the free plan includes one. A custom domain or msdc.co subdomain when every test needs its own private address (Indie and up).

Public address

No inbox setup

Who can read it: anyone who knows or guesses the address.

Setup: none. Make up any @mailsac.com address.

Retention: temporary; messages may be recycled quickly.

Push: none; poll the API.

Plans: every plan, including free.

Private address

Reserved for your account

Who can read it: only your account.

Setup: reserve it in the dashboard or with POST /api/addresses/{email}.

Retention: up to your plan’s message storage, oldest recycled first.

Push: webhook, WebSocket, Slack, or another Mailsac private address.

Plans: Free 1 · Indie 50 · Business 250 · Enterprise 480.

Custom domain

Your own domain, or a subdomain of Mailsac’s msdc.co

Who can read it: only your account, once the domain is verified (until then its mail is public). Enterprise can make a domain public.

Setup: a zero-setup yourteam.msdc.co subdomain, or your own domain: verify it with a TXT record, then point MX records to Mailsac.

Retention: up to your plan’s message storage, oldest recycled first.

Push: a catch-all address forwards to a webhook, WebSocket, Slack or private address; domain-wide WebSockets on Business and up.

Plans: Indie 1 · Business 5 · Enterprise 12+ (not Free).

Public inboxes are for synthetic test data. If a reset link or code would work on a real account, or the email holds real personal data, use a private address or a verified private custom domain.

What your tests can check

Beyond codes and links, every part of the message is available through the API.

Works with any test framework or CI

Mailsac’s test email API is plain REST and JSON, so Playwright, Selenium, Jest, pytest or a shell step in your pipeline can use it with nothing to install. Official client packages exist only for JavaScript; for Python, Java, Go, C#, PHP or Ruby, the API reference has copy-paste request snippets.

The @mailsac/cypress plugin’s documentation is also available to AI coding assistants through Context7.

In CI, keep MAILSAC_API_KEY in your secret store and use it only in server-side test code. Keys are shown once, so treat them like passwords; GET /api/me is a quick way to confirm one works.

Can’t change who your staging app emails? Point its SMTP settings at Email Capture (capture.mailsac.com). Mailsac keeps the mail instead of delivering it, and your tests read it with the same API. Each captured message is one Op; captured mail is public unless you turn on private capture or use a private custom domain.

A temporary email API for testing, not for sending mail

Looking for the other kind of disposable email API, one that flags disposable addresses at signup? That’s Mailsac’s separate email validation API.

Pricing and usage limits

The free plan includes API access, public inboxes, one private address and 1,500 Ops a month, and it doesn’t expire. Paid plans start at $18 a month:

FreeIndieBusinessEnterprise
Ops a month1,50025,0002 million8 million
Private addresses150250480
Custom domains, incl. msdc.conone1512+
Message storage501,0005,00012,000
API keys11multiple, namedmultiple, named
Team loginsnonenone515+

Business and Enterprise add SAML single sign-on and an IP allowlist; Enterprise adds a vendor security review and can pay by invoice or purchase order. See pricing for all prices and add-ons.

An Op is a REST API call, an inbound message to a private address or custom domain, or a message pushed to a webhook, WebSocket or Slack. Reading mail on the website is free, apart from a few buttons such as Download and View Original. Ops reset on the first of each month (UTC).

The limit is soft: Mailsac emails warnings first. If usage keeps going, API requests return HTTP 429 until the next month, which stops your email tests, so add Ops or move to a larger plan before then.

Service status: status.mailsac.com. Forum support on every plan; email support from Indie up.

Frequently asked questions

Do I need to create an inbox before my app sends to it?

No. Any address at mailsac.com, or on your custom domain, can receive mail without setup. Listing an address that has no mail returns an empty array.

Is the temp mail API free?

Yes, for testing apps you control. The free plan gives you an API key and 1,500 Ops a month for Mailsac’s disposable email API, with no expiry. Reading @mailsac.com mail through the API needs that key; public inboxes can also be viewed on the website without an account.

How do I keep test email private?

Reserve a private address in the dashboard or with POST /api/addresses/{email}; the free plan includes one. For a new private address in every test, add a custom domain; on Indie and up, a zero-setup msdc.co subdomain works right away, with no DNS changes. Only your account can read that mail, except on a domain that isn’t verified yet, where mail is public.

Can I run email tests in parallel?

Yes. Give each test its own random address, as the example does, so tests never see each other’s mail; they do share your account’s monthly Ops. Public addresses are throttled at lower volumes: delivery slows by up to about a minute, then mail is deferred. Busy suites belong on a custom domain, and Business and Enterprise can also allowlist your staging app’s sending IPs and domains.

How long are messages kept?

Public inboxes are temporary, and their messages may be recycled quickly. Private addresses and custom domains keep mail up to your plan’s message storage, oldest recycled first; star a message to keep it. Addresses don’t expire, but a released private address becomes public.

Can Mailsac push new email instead of polling?

Yes, for private addresses and custom domains: turn on webhook or WebSocket forwarding for a private address (every plan; free includes one) or a catch-all on your domain. Domain-wide WebSockets need Business or Enterprise, and pushed messages use Ops. Push also saves polling calls on long waits.

Does Mailsac send email?

No, Mailsac only receives mail. To stop a staging app from emailing real people, point its SMTP settings at Email Capture, described above.

Why didn’t my test email arrive?

Check that the test polls the exact address your app emailed and that the time filter isn’t skipping the message. Public inbox messages are recycled quickly, so older mail to a reused address can disappear before your test reads it, and busy public addresses can be throttled. The missing-mail guide covers other causes.

Try the email testing API on the free plan

Create a free account, generate a key under API Keys & Users in the dashboard, and run the example above.

Comparing tools? See Mailsac vs Mailtrap.