Test email OTP codes and magic links
Email OTP and magic-link testing
Test email OTP codes and magic links in end-to-end tests
Your test can’t open an inbox, but it can call an API. Sign in with a new Mailsac address, poll the Mailsac REST API until the email arrives, take the 6-digit code from the plain text or the magic link from the message’s links, then finish signing in with the browser.
Updated October 19, 2026. Each example on this page passed against the live Mailsac API: Playwright 1.64, Cypress 16.1, pytest with Playwright for Python, and Selenium 4 on Java 11.
The free plan includes an API key and 1,500 Ops a month.
The pattern, in any framework
- Make a new address for this test, such as
otp-<uuid>@yourteam.msdc.co. - Note the time, then click the button that sends the email.
- Poll
GET /api/addresses/{email}/messageswith backoff and a 60-second deadline. - Keep the newest message with the right subject received after that time.
- Code: read
GET /api/text/…. Link: use the message’slinks. - Type the code or open the link, check you’re signed in, delete the message.
How to read a one-time code or magic link in a test
One-time codes (OTP, verification codes, email 2FA codes) and magic links are tested the same way. Your app sends a real email through its usual provider. Mailsac receives it. Your test, running in Node.js, Python or Java rather than in the browser, reads it back over HTTPS with your API key in the Mailsac-Key header.
- Use a new address for every test. Any address at mailsac.com or at your custom domain can receive mail, so there is nothing to create first. A fresh address means the inbox holds only this test’s email.
- Note the time just before the action that sends the email. Mailsac’s
receivedfield is the time Mailsac received the message, not the email’s Date header, so compare it with your own clock. - Poll with backoff and an overall deadline. Email usually arrives within seconds, but your provider’s queue can add more. Check after 1 second, then wait a little longer each time, up to 5 seconds, and give up after about 60 seconds with a clear error.
- Filter, don’t take the first message. Keep a message only if it arrived after your timestamp and its subject matches. The list is sorted newest first, so a resent code wins over the old one.
- Extract exactly one code or link. If the email contains two different 6-digit numbers or two candidate links, fail. A test that guesses can pass for the wrong reason.
- Finish the login in the same browser session and check the signed-in page. Then delete the message.
Which endpoint has the code?
| You want | Call | What you get |
|---|---|---|
| New mail and its links | GET /api/addresses/{email}/messages?limit=20 | Messages newest first, each with _id, subject, received, inbox and links. A magic-link test needs nothing else. |
| The text with the code | GET /api/text/{email}/{messageId} | The plain-text part of the email, as the sender wrote it. If the email has no text part, Mailsac converts the HTML to text. |
| The original HTML | GET /api/dirty/{email}/{messageId} | The HTML part, unchanged. Use it when the code appears only in the HTML. |
| Display-safe HTML | GET /api/body/{email}/{messageId} | Sanitized HTML for showing the email. Link hrefs are removed, so don’t look for links here. |
| Everything | GET /api/raw/{email}/{messageId} | The original message source, still MIME-encoded (quoted-printable or base64). |
| Clean up | DELETE /api/addresses/{email}/messages/{messageId} | Deletes one message. There is no undo. |
Why the “plain text” may not match the HTML. /api/text returns the email’s own plain-text part. Most email providers build that part automatically from your HTML template, so it can look converted, with different spacing or line breaks, or it can leave the code out. Mailsac converts HTML to text itself only when there is no text part, and then links appear as Sign in [https://…]. If the code is missing or formatted differently in the text, read /api/dirty instead, or fix the template’s text part.
A reusable waitForEmail helper (TypeScript)
Save this as tests/mailsac-otp.ts. It uses Node’s built-in fetch, so there is nothing to install, and it works with Playwright, Vitest, Jest, WebdriverIO or a plain script.
tests/mailsac-otp.ts · Node.js 20+ · no dependencies
// mailsac-otp.ts: wait for an email in a Mailsac inbox, then get the one-time code or magic link.
// Node.js 20+ and no dependencies. Run it in your test runner, never in the browser: it holds your API key.
import { randomUUID } from 'node:crypto';
const API = process.env.MAILSAC_API_URL ?? 'https://mailsac.com/api';
// Public @mailsac.com inboxes are readable by anyone. In CI, set MAILSAC_DOMAIN to your private domain.
const DOMAIN = process.env.MAILSAC_DOMAIN || 'mailsac.com';
export type Message = {
_id: string;
inbox: string; // where Mailsac stored it; mail to name+tag@ is stored in name@
subject: string;
received: string; // when Mailsac received it (ISO 8601), not the email's Date header
links: string[] | null; // http(s) links Mailsac found in the text and HTML parts
};
export type WaitOptions = {
inbox: string;
subjectIncludes?: string;
after: Date | number; // take this just before the action that sends the email
timeoutMs?: number; // overall deadline; default 60 s
};
class MailsacError extends Error {
status?: number;
constructor(message: string, status?: number) { super(message); this.status = status; }
}
async function api(path: string, method = 'GET'): Promise<Response> {
const key = process.env.MAILSAC_API_KEY;
if (!key) throw new MailsacError('Set MAILSAC_API_KEY');
const res = await fetch(API + path, {
method,
headers: { 'Mailsac-Key': key },
signal: AbortSignal.timeout(10_000),
});
// 401: the key was not accepted. 429: the account used its monthly Ops. Neither fixes itself.
if (!res.ok) throw new MailsacError(`Mailsac API returned ${res.status} for ${method} ${path}`, res.status);
return res;
}
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
// A new address for every test and every retry, so parallel tests never share an inbox.
// Use a unique local part, not name+tag@: plus-addressed mail lands in the shared name@ inbox.
export function newInbox(prefix = 'login'): string {
return `${prefix}-${randomUUID()}@${DOMAIN}`;
}
// Poll the inbox until a matching email arrives or the deadline passes.
// Waits 1 s, then 1.5 s, 2.25 s ... up to 5 s between checks. Retries 5xx and network errors.
export async function waitForEmail({ inbox, subjectIncludes, after, timeoutMs = 60_000 }: WaitOptions): Promise<Message> {
const since = typeof after === 'number' ? after : after.getTime();
const deadline = Date.now() + timeoutMs;
let delay = 1_000;
let lastError: unknown;
for (;;) {
try {
// Newest first. Each item already includes `links`, so magic-link tests need no second call.
const res = await api(`/addresses/${encodeURIComponent(inbox)}/messages?limit=20`);
const messages = (await res.json()) as Message[];
const match = messages.find((m) =>
Date.parse(m.received) >= since && (!subjectIncludes || m.subject?.includes(subjectIncludes)));
if (match) return match;
} catch (err) {
const status = (err as MailsacError).status;
if (status !== undefined && status < 500) throw err; // bad key, Ops limit: fail now
lastError = err; // 5xx, timeout or network error: try again
}
const wait = Math.min(delay, deadline - Date.now());
if (wait <= 0) {
const what = subjectIncludes ? `email with "${subjectIncludes}" in the subject` : 'email';
throw new Error(`No ${what} reached ${inbox} within ${timeoutMs / 1000} s`, { cause: lastError });
}
await sleep(wait);
delay = Math.min(delay * 1.5, 5_000);
}
}
// The plain-text body: the sender's text part, or text Mailsac made from the HTML if there is none.
export async function getText(message: Message): Promise<string> {
const res = await api(`/text/${encodeURIComponent(message.inbox)}/${encodeURIComponent(message._id)}`);
return res.text();
}
// Exactly one distinct code, or an error. The default finds a standalone 6-digit number.
// For codes shown as "4 8 2 9 1 3", pass a pattern with a capture group, e.g.
// /code\D{0,40}((?:\d\s*){6})/gi; whitespace is removed from what it captures.
export function extractCode(text: string, pattern: RegExp = /\b\d{6}\b/g): string {
const re = new RegExp(pattern.source, pattern.flags.includes('g') ? pattern.flags : pattern.flags + 'g');
const codes = new Set([...text.matchAll(re)].map((m) => (m[1] ?? m[0]).replace(/\s+/g, '')));
if (codes.size !== 1) throw new Error(`Expected one code in the email, found ${codes.size}: ${[...codes].join(', ')}`);
return [...codes][0];
}
// Exactly one distinct link whose origin and path match `url` (query string ignored), or an error.
export function extractMagicLink(message: Message, url: string): string {
const want = new URL(url);
const links = (message.links ?? [])
// Links found in plain text can keep a closing > ] ) or quote, as in <https://...>. Drop it.
.map((link) => link.replace(/[>\])'"]+$/, ''))
.filter((link) => {
if (!URL.canParse(link)) return false;
const { origin, pathname } = new URL(link);
return origin === want.origin && pathname === want.pathname;
});
const found = [...new Set(links)];
if (found.length !== 1) {
// List the links without their query strings, so tokens don't end up in CI logs.
const seen = (message.links ?? []).map((link) => link.split('?')[0]).join(' ');
throw new Error(`Expected one link to ${want.origin}${want.pathname}, found ${found.length}. Links: ${seen}`);
}
return found[0];
}
// For links rewritten by click tracking (SendGrid, Mailgun, SES and others): follow the redirects
// until the URL is on your app's origin, without requesting that last URL, so a one-time token
// isn't used up before the browser opens it.
export async function resolveTrackedLink(url: string, appOrigin: string, maxHops = 5): Promise<string> {
let current = url;
for (let hop = 0; hop < maxHops; hop++) {
if (new URL(current).origin === new URL(appOrigin).origin) return current;
const res = await fetch(current, { method: 'GET', redirect: 'manual', signal: AbortSignal.timeout(10_000) });
const location = res.headers.get('location');
if (res.status < 300 || res.status > 399 || !location) return current;
current = new URL(location, current).href;
}
throw new Error(`More than ${maxHops} redirects from ${url}`);
}
// Delete one message when the test is done. Works on public inboxes and on your private domains.
export async function deleteMessage(message: Message): Promise<void> {
await api(`/addresses/${encodeURIComponent(message.inbox)}/messages/${encodeURIComponent(message._id)}`, 'DELETE');
}
waitForEmail({ inbox, subjectIncludes, after, timeoutMs })returns the newest message received at or afterafterwhose subject contains your text. It retries server errors and network failures until the deadline, but stops at once on a 401 (key not accepted) or 429 (the account has used its monthly Ops).- The list response already includes
links, so a magic-link test makes no second request. Only the code needsgetText. extractCodeandextractMagicLinkreturn exactly one match or throw, and the error message lists what they found.extractMagicLinkcompares origin and path, so help and unsubscribe links are ignored.- Keep
MAILSAC_API_KEYin your CI secrets.MAILSAC_API_URLis optional, for pointing the helper at a mock API.
Playwright: sign in with an emailed code or magic link
Two tests: one types the emailed 6-digit code, the other opens the emailed sign-in link. Change the labels, button names, subjects and signed-in text to match your app.
tests/login.spec.ts · @playwright/test
// tests/login.spec.ts
import { test, expect } from '@playwright/test';
import {
newInbox, waitForEmail, getText, extractCode, extractMagicLink, deleteMessage,
} from './mailsac-otp';
// PLACEHOLDER: your sign-in page's labels, buttons, subjects and signed-in text.
test('signs in with the emailed one-time code', async ({ page }) => {
const inbox = newInbox('otp');
await page.goto('/login');
await page.getByLabel('Email').fill(inbox);
const after = Date.now(); // just before the click that sends the email
await page.getByRole('button', { name: 'Email me a code' }).click();
const message = await waitForEmail({ inbox, subjectIncludes: 'sign-in code', after });
const code = extractCode(await getText(message));
await page.getByLabel('Code').fill(code);
await page.getByRole('button', { name: 'Verify' }).click();
await expect(page.getByText(`Signed in as ${inbox}`)).toBeVisible();
await deleteMessage(message);
});
test('signs in with the emailed magic link', async ({ page, baseURL }) => {
const inbox = newInbox('magic');
await page.goto('/login');
await page.getByLabel('Email').fill(inbox);
const after = Date.now();
await page.getByRole('button', { name: 'Email me a sign-in link' }).click();
const message = await waitForEmail({ inbox, subjectIncludes: 'sign-in link', after });
const link = extractMagicLink(message, new URL('/auth/magic', baseURL).href);
await page.goto(link); // same browser context, so the session cookie lands where the test continues
await expect(page.getByText(`Signed in as ${inbox}`)).toBeVisible();
await deleteMessage(message);
});
Give each test more time than the email wait, so a missing email fails with the helper’s message instead of Playwright’s 30-second default:
playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests',
timeout: 90_000, // longer than the 60 s email wait, so a missing email fails with a clear message
fullyParallel: true, // safe: every test uses its own inbox
use: { baseURL: process.env.BASE_URL ?? 'http://localhost:3000' }, // PLACEHOLDER: your app
});
Run it
export MAILSAC_API_KEY=your_api_key # PLACEHOLDER: from the Mailsac dashboard
export MAILSAC_DOMAIN=yourteam.msdc.co # PLACEHOLDER: your private domain; omit to try public @mailsac.com
npx playwright test
Open the magic link with page.goto in the same test, so the session cookie it sets is in the browser context the test keeps using. For a longer walkthrough with password resets and GitHub Actions, see the Playwright email testing tutorial.
Cypress: the same tests with @mailsac/cypress
Cypress commands run in the browser, so the API call has to happen in Cypress’s Node process. The @mailsac/cypress plugin does that for you: cy.mailsacWaitForMessage() polls in Node and gives the test the message’s text and links, and the key never reaches the browser. It needs Cypress 16 on Node.js 22, 24 or 26+.
npm install --save-dev cypress@16.1.0 @mailsac/cypress@0.1.0
cypress.config.js
// cypress.config.js
const { defineConfig } = require('cypress');
const { createMailsacTasks } = require('@mailsac/cypress/node');
module.exports = defineConfig({
e2e: {
baseUrl: process.env.BASE_URL || 'http://localhost:3000', // PLACEHOLDER: your app
setupNodeEvents(on, config) {
on('task', createMailsacTasks()); // reads MAILSAC_API_KEY in Node; the browser never sees it
return config;
},
},
});
cypress/e2e/login.cy.js
// cypress/e2e/login.cy.js (and add import '@mailsac/cypress/commands' to cypress/support/e2e.js)
import { extractCode, extractLink } from '@mailsac/cypress';
const DOMAIN = 'mailsac.com'; // PLACEHOLDER: your private domain, such as yourteam.msdc.co
const newInbox = (prefix) => `${prefix}-${Date.now()}-${Cypress._.random(1e6, 9e6)}@${DOMAIN}`;
// Links from an email's plain-text part can end in > or ], as in <https://...>. Trim them first.
const trimLinks = (message) => ({ ...message, links: message.links.map((l) => l.replace(/[>\])'"]+$/, '')) });
it('signs in with the emailed one-time code', () => {
const email = newInbox('otp');
cy.visit('/login');
cy.get('input[name=email]').type(email);
cy.then(() => {
const receivedAfter = new Date().toISOString(); // just before the click that sends the email
cy.contains('button', 'Email me a code').click();
return cy.mailsacWaitForMessage({ email, receivedAfter, subjectIncludes: 'sign-in code', timeoutMs: 60_000 });
}).then((message) => {
cy.get('input[name=code]').type(extractCode(message), { log: false });
cy.contains('button', 'Verify').click();
cy.contains(`Signed in as ${email}`).should('be.visible');
});
});
it('signs in with the emailed magic link', () => {
const email = newInbox('magic');
cy.visit('/login');
cy.get('input[name=email]').type(email);
cy.then(() => {
const receivedAfter = new Date().toISOString();
cy.contains('button', 'Email me a sign-in link').click();
return cy.mailsacWaitForMessage({ email, receivedAfter, subjectIncludes: 'sign-in link', timeoutMs: 60_000 });
}).then((message) => {
const origin = new URL(Cypress.config('baseUrl')).origin;
cy.visit(extractLink(trimLinks(message), { origin, pathname: '/auth/magic' }), { log: false });
cy.contains(`Signed in as ${email}`).should('be.visible');
});
});
Take receivedAfter inside cy.then(), as shown. Written at the top of the test, it would run before cy.visit() has even started. Set MAILSAC_API_KEY in the shell or CI secret store without a CYPRESS_ prefix, so it never reaches Cypress.env(). More on Cypress: the Cypress email testing guide.
Python: pytest with Playwright for Python
The same helper in Python with requests. It doesn’t depend on the browser library, so you can use it with Selenium for Python or plain API tests too.
mailsac_otp.py · Python 3.9+ · requests
# mailsac_otp.py: wait for an email in a Mailsac inbox, then get the one-time code or magic link.
# Python 3.9+ and requests. It holds your API key, so it runs in the test process, never in a page.
import os
import re
import time
import uuid
from datetime import datetime
from urllib.parse import quote, urlsplit
import requests
API = os.environ.get("MAILSAC_API_URL", "https://mailsac.com/api")
# Public @mailsac.com inboxes are readable by anyone. In CI, set MAILSAC_DOMAIN to your private domain.
DOMAIN = os.environ.get("MAILSAC_DOMAIN") or "mailsac.com"
session = requests.Session()
def _api(path, method="GET"):
key = os.environ.get("MAILSAC_API_KEY")
if not key:
raise RuntimeError("Set MAILSAC_API_KEY")
res = session.request(method, API + path, headers={"Mailsac-Key": key}, timeout=10)
res.raise_for_status() # 401: key not accepted. 429: monthly Ops used up.
return res
def new_inbox(prefix="login"):
"""A new address per test. Use a unique local part, not name+tag@ (that lands in name@)."""
return f"{prefix}-{uuid.uuid4()}@{DOMAIN}"
def wait_for_email(inbox, after, subject_includes=None, timeout_s=60):
"""Poll until an email received at or after `after` (epoch seconds) matches, or time out."""
deadline = time.time() + timeout_s
delay = 1.0
while True:
try:
# Newest first; each item already includes "links".
for m in _api(f"/addresses/{quote(inbox)}/messages?limit=20").json():
received = datetime.fromisoformat(m["received"].replace("Z", "+00:00")).timestamp()
if received >= after and (not subject_includes or subject_includes in (m.get("subject") or "")):
return m
except requests.HTTPError as err:
if err.response.status_code < 500:
raise # bad key or Ops limit: retrying won't help
except requests.RequestException:
pass # timeout or network error: try again
wait = min(delay, deadline - time.time())
if wait <= 0:
raise TimeoutError(f"No email with {subject_includes!r} in the subject reached {inbox} in {timeout_s} s")
time.sleep(wait)
delay = min(delay * 1.5, 5.0)
def get_text(message):
"""The sender's plain-text part, or text Mailsac made from the HTML if there is none."""
return _api(f"/text/{quote(message['inbox'])}/{quote(message['_id'])}").text
def extract_code(text, pattern=r"\b\d{6}\b"):
"""Exactly one distinct code. A capture group, if any, is used; whitespace in it is removed."""
codes = {re.sub(r"\s+", "", m.group(m.lastindex or 0)) for m in re.finditer(pattern, text)}
if len(codes) != 1:
raise AssertionError(f"Expected one code in the email, found {len(codes)}: {sorted(codes)}")
return codes.pop()
def extract_magic_link(message, url):
"""Exactly one distinct link with the same scheme, host and path as `url`."""
want = urlsplit(url)
found = set()
for link in message.get("links") or []:
link = link.rstrip(">])'\"") # plain-text links can keep a closing > or ]
got = urlsplit(link)
if (got.scheme, got.netloc, got.path) == (want.scheme, want.netloc, want.path):
found.add(link)
if len(found) != 1:
raise AssertionError(f"Expected one link to {want.netloc}{want.path}, found {len(found)}")
return found.pop()
def delete_message(message):
_api(f"/addresses/{quote(message['inbox'])}/messages/{quote(message['_id'])}", "DELETE")
test_login.py · pytest-playwright
# test_login.py: pip install pytest-playwright requests && playwright install chromium
import os
import time
from playwright.sync_api import Page, expect
from mailsac_otp import (delete_message, extract_code, extract_magic_link, get_text,
new_inbox, wait_for_email)
BASE_URL = os.environ.get("BASE_URL", "http://localhost:3000") # PLACEHOLDER: your app
def test_sign_in_with_emailed_code(page: Page):
inbox = new_inbox("otp")
page.goto(f"{BASE_URL}/login")
page.get_by_label("Email").fill(inbox)
after = time.time() # just before the click that sends the email
page.get_by_role("button", name="Email me a code").click()
message = wait_for_email(inbox, after, subject_includes="sign-in code")
code = extract_code(get_text(message))
page.get_by_label("Code").fill(code)
page.get_by_role("button", name="Verify").click()
expect(page.get_by_text(f"Signed in as {inbox}")).to_be_visible()
delete_message(message)
def test_sign_in_with_magic_link(page: Page):
inbox = new_inbox("magic")
page.goto(f"{BASE_URL}/login")
page.get_by_label("Email").fill(inbox)
after = time.time()
page.get_by_role("button", name="Email me a sign-in link").click()
message = wait_for_email(inbox, after, subject_includes="sign-in link")
page.goto(extract_magic_link(message, f"{BASE_URL}/auth/magic"))
expect(page.get_by_text(f"Signed in as {inbox}")).to_be_visible()
delete_message(message)
pip install pytest-playwright requests
playwright install chromium
MAILSAC_API_KEY=your_api_key BASE_URL=http://localhost:3000 pytest test_login.py # PLACEHOLDER values
Selenium (Java): automate an email 2FA code
A JUnit 5 test with Selenium 4. It uses Java’s built-in HttpClient and Jackson for JSON. The helper class is below the test.
LoginTest.java · JUnit 5 · selenium-java 4
// LoginTest.java: JUnit 5 + Selenium 4. Selenium Manager finds or downloads the browser driver.
import static org.junit.jupiter.api.Assertions.assertTrue;
import com.fasterxml.jackson.databind.JsonNode;
import java.time.Duration;
import java.time.Instant;
import java.util.UUID;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.openqa.selenium.By;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.chrome.ChromeDriver;
import org.openqa.selenium.chrome.ChromeOptions;
import org.openqa.selenium.support.ui.ExpectedConditions;
import org.openqa.selenium.support.ui.WebDriverWait;
class LoginTest {
static final String BASE_URL = System.getenv().getOrDefault("BASE_URL", "http://localhost:3000"); // PLACEHOLDER
WebDriver driver;
@BeforeEach void start() { driver = new ChromeDriver(new ChromeOptions().addArguments("--headless=new")); }
@AfterEach void stop() { driver.quit(); }
@Test void signsInWithEmailedCode() throws Exception {
String inbox = "otp-" + UUID.randomUUID() + "@mailsac.com"; // PLACEHOLDER: your private domain
driver.get(BASE_URL + "/login");
driver.findElement(By.name("email")).sendKeys(inbox);
Instant after = Instant.now(); // just before the click that sends the email
driver.findElement(By.xpath("//button[text()='Email me a code']")).click();
JsonNode message = MailsacOtp.waitForEmail(inbox, "sign-in code", after, Duration.ofSeconds(60));
driver.findElement(By.name("code")).sendKeys(MailsacOtp.extractCode(message));
driver.findElement(By.xpath("//button[text()='Verify']")).click();
new WebDriverWait(driver, Duration.ofSeconds(10)).until(
ExpectedConditions.textToBePresentInElementLocated(By.tagName("body"), "Signed in as " + inbox));
}
@Test void signsInWithMagicLink() throws Exception {
String inbox = "magic-" + UUID.randomUUID() + "@mailsac.com";
driver.get(BASE_URL + "/login");
driver.findElement(By.name("email")).sendKeys(inbox);
Instant after = Instant.now();
driver.findElement(By.xpath("//button[text()='Email me a sign-in link']")).click();
JsonNode message = MailsacOtp.waitForEmail(inbox, "sign-in link", after, Duration.ofSeconds(60));
driver.get(MailsacOtp.extractMagicLink(message, BASE_URL + "/auth/magic"));
assertTrue(driver.getPageSource().contains("Signed in as " + inbox));
}
}
MailsacOtp.java: the helper (Java 11+, jackson-databind)
// MailsacOtp.java: Java 11+ and Jackson (com.fasterxml.jackson.core:jackson-databind).
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.util.HashSet;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public final class MailsacOtp {
private static final String API = System.getenv().getOrDefault("MAILSAC_API_URL", "https://mailsac.com/api");
private static final HttpClient HTTP = HttpClient.newHttpClient();
private static final ObjectMapper JSON = new ObjectMapper();
private static String get(String path) throws Exception {
HttpRequest req = HttpRequest.newBuilder(URI.create(API + path))
.header("Mailsac-Key", System.getenv("MAILSAC_API_KEY"))
.timeout(Duration.ofSeconds(10)).build();
HttpResponse<String> res = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
if (res.statusCode() != 200) throw new IllegalStateException("Mailsac API returned " + res.statusCode());
return res.body();
}
private static String enc(String s) { return URLEncoder.encode(s, StandardCharsets.UTF_8); }
/** Polls (1 s, 1.5 s, ... up to 5 s apart) for an email received at or after `after`. */
public static JsonNode waitForEmail(String inbox, String subjectPart, Instant after, Duration timeout)
throws Exception {
Instant deadline = Instant.now().plus(timeout);
long delay = 1000;
while (true) {
for (JsonNode m : JSON.readTree(get("/addresses/" + enc(inbox) + "/messages?limit=20"))) {
boolean fresh = !Instant.parse(m.path("received").asText()).isBefore(after);
if (fresh && m.path("subject").asText().contains(subjectPart)) return m;
}
long wait = Math.min(delay, Duration.between(Instant.now(), deadline).toMillis());
if (wait <= 0) throw new AssertionError("No \"" + subjectPart + "\" email reached " + inbox);
Thread.sleep(wait);
delay = Math.min(delay * 3 / 2, 5000);
}
}
/** Exactly one standalone 6-digit code in the plain-text body. */
public static String extractCode(JsonNode message) throws Exception {
String text = get("/text/" + enc(message.path("inbox").asText()) + "/" + enc(message.path("_id").asText()));
Set<String> codes = new HashSet<>();
Matcher m = Pattern.compile("\\b\\d{6}\\b").matcher(text);
while (m.find()) codes.add(m.group());
if (codes.size() != 1) throw new AssertionError("Expected one code, found " + codes);
return codes.iterator().next();
}
/** Exactly one link whose scheme, host, port and path match `url`. */
public static String extractMagicLink(JsonNode message, String url) {
URI want = URI.create(url);
Set<String> found = new HashSet<>();
for (JsonNode node : message.path("links")) {
String link = node.asText().replaceAll("[>\\])'\"]+$", ""); // plain-text links can keep a > or ]
try {
URI got = URI.create(link);
if (want.getScheme().equals(got.getScheme()) && want.getAuthority().equals(got.getAuthority())
&& want.getPath().equals(got.getPath())) found.add(link);
} catch (IllegalArgumentException ignored) { }
}
if (found.size() != 1) throw new AssertionError("Expected one link to " + url + ", found " + found.size());
return found.iterator().next();
}
}
Getting the right code or link out of the email
Most flaky OTP tests come down to parsing. These are the cases we see most, and what Mailsac returns for each.
- Digits split up. Templates often show one digit per table cell or put spaces between digits. When Mailsac converts HTML to text, digits in adjacent table cells come out joined (
710558), but spaced digits stay spaced (3 3 0 2 7 1). Match the label and remove whitespace:extractCode(text, /code\D{0,40}((?:\d\s*){6})/gi). - Other numbers in the email. Postal codes, order numbers and years in the footer can also be six digits. The helpers fail rather than pick one. Anchor the pattern on the words before the code, as above.
- HTML entities. In
linksand in the text Mailsac returns, entities are already decoded:&in anhrefbecomes&. Only the raw HTML from/api/dirtystill contains them. - URL-encoded links. Mailsac keeps each link exactly as written, so
email=user%40example.comstays encoded. Pass the whole link to the browser unchanged. If you need one value, usenew URL(link).searchParams.get('token'), which decodes it. Don’t rundecodeURIComponentover the whole URL. - Trailing punctuation. Links found in a plain-text part keep the characters around them, so
<https://app.example.com/auth/magic?token=…>is listed with a trailing>, next to the clean copy from the HTML. Mailsac strips one trailing period. The helpers on this page trim the rest. - Click tracking. SendGrid, Mailgun, Amazon SES and other providers can replace your link with a redirect through their tracking domain, and then
extractMagicLinkfinds nothing. The best fix is to turn off click tracking for sign-in emails. Tracking links can also expire, and every test run adds clicks to your provider’s stats. If you can’t turn it off, resolve the redirect without opening your app’s URL, so the one-time token isn’t used up:
Playwright, with resolveTrackedLink from the helper
// Click tracking rewrote the link? Resolve the tracked links first, then pick the magic link.
const tracking = 'https://click.example.com/'; // PLACEHOLDER: your provider's tracking domain
const tracked = (message.links ?? []).filter((link) => link.startsWith(tracking));
const resolved = await Promise.all(tracked.map((link) => resolveTrackedLink(link, baseURL!)));
const link = extractMagicLink({ ...message, links: resolved }, new URL('/auth/magic', baseURL).href);
- Plus addressing. Mail to
qa+run42@mailsac.comis stored in theqa@mailsac.cominbox, withoriginalInboxset to the plus address. Listing the plus address returns nothing, and tests that use+tagsall share one inbox. Make the whole local part unique instead. - Magic link “already used”. Open the link once, in the browser that should end up signed in. Don’t fetch it from your test code first, and don’t follow tracking redirects all the way to your app. Some apps also expect the link to be opened on the device that asked for it, so request and open it in the same browser context.
Parallel runs, CI load and privacy
- One inbox per test. With a fresh address per test (and per retry), parallel workers, shards and separate CI jobs never read each other’s codes. Avoid a shared test account; if a few tests must share an address, run them one at a time and keep the
afterfilter. - No inbox setup on a custom domain. A custom domain receives mail at every address, so tests can invent addresses as they go. A zero-setup subdomain such as
yourteam.msdc.coworks right away with no DNS changes, or you can bring your own domain. - Throttling on the shared public domain. Mail to public
@mailsac.comaddresses is throttled at a lower volume. Under heavy CI load, delivery slows down (by about a minute at worst), then Mailsac’s SMTP server answers 421 and your provider has to retry later. Your tests see that as timeouts. Verified custom domains and private addresses have much higher limits. Most customers on them never see throttling. - Ops. Each API call is one Op, and each email received at a private address or custom domain is one more. A code test that finds its email on the first poll makes 3 calls (list, text, delete), and a magic-link test makes 2. Each extra poll adds 1. With the helper’s backoff, a test that times out after 60 seconds makes about 16 calls, not 60.
- Clean up. Delete each message after the test, as the Playwright and Python examples do. It keeps codes from lingering and keeps private domains under your plan’s stored-message limit. On an inbox at your own custom domain or private address,
DELETE /api/addresses/{email}/messagesclears the whole inbox. That call is refused for public inboxes, where you delete messages one at a time.
Public inboxes are for made-up data. Anyone can open a public @mailsac.com inbox on the website without an account, and any Mailsac API key can read it. A code or magic link there works for whoever sees it first. Use public addresses only for throwaway test accounts. For staging accounts with real data, or anything a real person could sign in to, use a private test domain, which only your account and team can read.
Some sign-up forms also reject well-known testing domains, including mailsac.com, as disposable. A domain of your own avoids that.
Troubleshooting OTP and magic-link tests
| What you see | Likely cause | Fix |
|---|---|---|
| The test times out waiting for the email | The app didn’t send, the address or subject doesn’t match, the provider queued it, or the public domain is throttling | Open the inbox on mailsac.com to see whether the email arrived and what its subject is. Check your app’s and provider’s logs. Allow 60 seconds or more. Move busy suites to a custom domain. |
| The test types an old code | The inbox is reused, there’s no time filter, or after was taken after the click | Use a new address per test. Take after just before the click. Filter on the subject. |
| The email is in the inbox but the helper ignores it | The test machine’s clock is ahead of Mailsac’s | Sync the runner’s clock (NTP). With a unique inbox per test, you can also take after a few seconds early. |
| “Expected one code, found 2” | Other 6-digit numbers in the email, such as a postal code or order number | Anchor the pattern on the label: /code\D{0,40}((?:\d\s*){6})/gi. |
| “Expected one code, found 0” | The digits are spaced out, the code is only in the HTML, or it’s an image | Use a pattern that allows whitespace. Read /api/dirty if the text part leaves the code out. Ask for the code as text in the template. |
| “Expected one link, found 0” | Click tracking rewrote the link, or the URL you compare against has a different host or path | Turn off click tracking for these emails, or use resolveTrackedLink. Print the error’s link list and compare. |
| The magic link says expired or already used | It was opened twice, fetched by test code, or changed by decoding | Open it once with page.goto or cy.visit, unchanged. |
Mail to name+tag@ never shows up | Plus-addressed mail is stored in name@ | Make the local part unique instead of adding a tag. |
| HTTP 401 | The API key is wrong or missing in this environment | Check the CI secret. Pull requests from forks usually don’t receive secrets. |
| HTTP 429 | The account has used this month’s Ops | Retrying won’t help. Upgrade, or poll less often. |
Questions developers ask
How do I get the OTP code from an email in my Playwright test?
Have the app email a new Mailsac address, then call the Mailsac API from the test (it runs in Node, not in the page). Poll GET /api/addresses/{email}/messages until a message with the right subject arrives after the click, read GET /api/text/{email}/{messageId}, match /\b\d{6}\b/, and fill() the code. The helper and Playwright example above do all of this.
How do I test a magic link login in Cypress?
Use cy.mailsacWaitForMessage() from @mailsac/cypress to wait for the email in Cypress’s Node process. Pick the link with extractLink(message, { origin, pathname }) and open it with cy.visit(). Trim trailing > or ] from the links first, as in the Cypress example.
How do I automate an email 2FA code in Selenium?
Selenium drives the browser; read the email with an HTTP call from the test code. After the step that sends the code, poll the Mailsac inbox, read the message’s plain text, extract the code with a regular expression and type it with sendKeys. See the Java example. The Python helper works with Selenium for Python too.
Why does my test sometimes pick up an old verification code?
The inbox already had an earlier email, and the test took the first message it found. Use a new address for each test, record the time just before the action that sends the email, and accept only messages received after it with the expected subject.
Should I read the code from the HTML or the plain-text body?
Start with the plain text from /api/text: it has no markup or entities. It’s the email’s own text part, which your provider may have generated from the HTML. If the code is missing or mangled there, read the original HTML from /api/dirty. For links, use the message’s links, which combines links from both parts.
Can I run email OTP tests in parallel in CI?
Yes, if every test uses its own address. On a custom domain any address receives mail, so tests can generate addresses freely. For heavy suites, use a custom domain rather than public @mailsac.com addresses, which are throttled at lower volumes.
Is it safe to receive login codes in a public temporary inbox?
Only for made-up test accounts. Anyone can read a public inbox, so a code or magic link there can be used by someone else. Use a private address or a custom domain for anything that can sign in to a real or shared account.
Add a real email check to your login tests
Create a free account, generate an API key and run the example for your framework. A private test domain for CI starts at $18 a month on Indie.
See also the email testing API overview and the API reference.