Automated email testing with a temp mail API
Sign-up, password reset, magic links, invites: the flows that matter most end in an email. This guide shows how an end-to-end test gets a disposable inbox from the fake-email.pro API, waits for the email your app sends, and reads the OTP code or the confirmation link out of it – with a short example for Playwright, Cypress and Selenium.
Why a disposable inbox in an end-to-end test
- A fresh address every run. Sign-up refuses an address that is already registered; a new mailbox per test makes the test repeatable without cleaning up users in the app.
- Real delivery. The email leaves your app through its real mailer and arrives over SMTP, so the test checks what a user gets – the template, the code, the link – not a mock.
- Isolation. Each test reads its own mailbox, and parallel workers never see each other's email. Mailboxes created with your key are visible to your account only.
- No shared inbox in CI. No Gmail account, IMAP password or second-factor prompt to keep alive on a build agent – one API key in a CI secret.
The whole cycle
Five steps, three of them API calls. Every request carries Authorization: Bearer fep_...; the key and the first request are in Getting Started, every schema is in the Reference.
POST /v1/mailboxes– Create a mailbox: 201 with its address.- Hand the address to the app under test – type it into the sign-up form, the password reset, the invite.
GET /v1/mailboxes/{address}/messages/wait– Wait for the email. One request that answers the moment the email is in, with the message in short form – id, sender, subject, bodyState, code and confirmLink; after timeout seconds (60 at most) without one it answers 204.- Take the OTP code or the confirmation link: the API read them out of the email when it arrived – code and confirmLink.
DELETE /v1/mailboxes/{address}– Delete the mailbox and its messages when the test is done.
The API is server-to-server: CORS is off, so the calls come from the test runner – Playwright's request fixture, Cypress's cy.request, Python's requests – never from the page under test. Keep the key in an environment variable (FEP_KEY below), not in the repository.
Playwright (TypeScript)
The request fixture calls the API, one request to …/messages/wait waits for the email and brings its code along, and finally deletes the mailbox even when the test fails. The request and the test get timeouts longer than the wait, so the API's 204 – not Playwright's own timeout – is what says the email never came.
import { test, expect } from '@playwright/test'
const API = 'https://api.fake-email.pro/v1'
const auth = { Authorization: `Bearer ${process.env.FEP_KEY}` }
test('sign-up is confirmed with the code from the email', async ({ page, request }) => {
test.setTimeout(120_000)
// 1. A fresh mailbox for this test
const created = await request.post(`${API}/mailboxes`, { headers: auth })
expect(created.status()).toBe(201)
const mailbox = await created.json()
try {
// 2. The app under test sends its email there
await page.goto('/sign-up')
await page.getByLabel('Email').fill(mailbox.address)
await page.getByRole('button', { name: 'Sign up' }).click()
// 3. Wait for the email: one request, answered as soon as it is in
const res = await request.get(`${API}/mailboxes/${mailbox.address}/messages/wait`, {
headers: auth,
params: { timeout: 60 },
timeout: 65_000,
})
expect(res.status(), 'no email within 60 s').toBe(200)
const message = await res.json()
// 4. The API has already read the one-time code out of the email
expect(message.code, `no code in «${message.subject}»`).not.toBeNull()
await page.getByLabel('Code').fill(message.code)
await expect(page.getByText('Email confirmed')).toBeVisible()
}
finally {
// 5. Clean up
await request.delete(`${API}/mailboxes/${mailbox.address}`, { headers: auth })
}
})Cypress
cy.request runs outside the browser, so CORS does not apply. The helper is a single request with a response timeout past the wait, narrowed to the confirmation email with subject, and the test visits the confirmLink the API found in it.
const API = 'https://api.fake-email.pro/v1'
const auth = { Authorization: `Bearer ${Cypress.env('FEP_KEY')}` }
// Yields the first email that matches the filters: 200 with the message, 204 after 60 s without one.
const waitForMessage = (address, filters = {}) =>
cy.request({
url: `${API}/mailboxes/${address}/messages/wait`,
qs: { timeout: 60, ...filters },
headers: auth,
timeout: 65_000,
}).then(({ status, body }) => {
if (status !== 200) throw new Error(`No email for ${address}`)
return body
})
it('confirms the address through the link in the email', () => {
cy.request({ method: 'POST', url: `${API}/mailboxes`, headers: auth })
.its('body.address')
.then((address) => {
cy.visit('/sign-up')
cy.get('input[type=email]').type(address)
cy.contains('button', 'Sign up').click()
waitForMessage(address, { subject: 'Confirm' }).then((message) => {
// The confirmation link the API picked out of the email
cy.visit(message.confirmLink)
cy.contains('Email confirmed')
})
cy.request({ method: 'DELETE', url: `${API}/mailboxes/${address}`, headers: auth })
})
})Selenium (Python)
Selenium drives the browser, requests talks to the API. The helper also shows the one error worth handling in a test: a 429 says how many seconds to wait in Retry-After.
import os
import time
import requests
from selenium import webdriver
from selenium.webdriver.common.by import By
API = "https://api.fake-email.pro/v1"
AUTH = {"Authorization": f"Bearer {os.environ['FEP_KEY']}"}
def wait_for_message(address, **filters):
for _ in range(3):
res = requests.get(
f"{API}/mailboxes/{address}/messages/wait",
params={"timeout": 60, **filters},
headers=AUTH,
timeout=65,
)
if res.status_code == 429:
time.sleep(int(res.headers.get("Retry-After", 1)))
continue
if res.status_code == 204:
break
res.raise_for_status()
return res.json()
raise TimeoutError(f"No email for {address}")
def test_sign_up_with_one_time_code():
created = requests.post(f"{API}/mailboxes", headers=AUTH)
assert created.status_code == 201, created.json()["message"]
address = created.json()["address"]
driver = webdriver.Chrome()
try:
driver.get("https://app.example.com/sign-up")
driver.find_element(By.NAME, "email").send_keys(address)
driver.find_element(By.CSS_SELECTOR, "button[type=submit]").click()
message = wait_for_message(address)
assert message["code"], f"No code in {message['subject']!r}"
driver.find_element(By.NAME, "code").send_keys(message["code"])
finally:
driver.quit()
requests.delete(f"{API}/mailboxes/{address}", headers=AUTH)Getting the code or the link out
codeis the one-time code the API found when the email arrived – only a number or token next to a word like «code», «verification», «OTP» or «PIN», in English and ten other languages. It is a string, so a leading zero survives; years, prices, ZIPs in the footer and phone numbers are left out. No code in the email,null.confirmLinkis the link that looks like a confirmation – by its text («Verify email», «Confirm», «Sign in») or by its URL; unsubscribe links never are. The URL is exactly what the email carried, tracker redirect included – the browser follows it like a user's would. None,null.- When those two are not the ones you need,
GET /v1/mailboxes/{address}/messages/{id}with theidfrom the wait has the whole email:codesandlinkswith every candidate, most likely first, and the fulltextandhtmlbodies to match your template against. bodyStatesays what was kept.STOREDis the normal case.UNPARSED: the email arrived but could not be read – no sender, subject, bodies or code; only…/rawhas it, and it is worth a test failure of its own, since your users would not read it either.OVERFLOW: the email was larger than your account's storage, so only its headers were kept.- Wait for the right email, not the first one. The wait answers with the earliest email that matches the filters:
subjectandfromtake a substring, case-insensitive. When a flow sends two, wait for the second withafter=<id of the first>– the chain never skips an email, even one that arrived before the second request.
Staying inside the limits
A test suite hits the API in bursts, so plan for three things: a monthly quota on creating mailboxes, a per-minute limit on every call and a cap on waits held open at once. Your numbers come from GET /v1/limits; Limits explains them.
- Create only where a fresh address is the point. Creating a mailbox is the one call that spends the monthly quota; reading never does, and deleting does not give it back. A test that only needs an inbox, not a new user, can reuse one mailbox across its steps.
- Wait instead of looping. Every call counts against the per-minute limit, and a wait counts once however long it is held open – one wait replaces a loop of reads. Waits held open at the same time are capped per user; past the cap the call is
429 too_many_waits, so keep the number of parallel workers underconcurrentWaits. - Honour
429. Sleep forRetry-Afterseconds and retry. A402 quota_exhaustedstops creation until the quota resets on the first of the month; reading keeps working.
What's next
- Get a key on the temp mail API page – it is free with any account.
- Need more mailboxes a month than your plan gives? Compare the plans on pricing.