Testing email in Playwright without an API key or signup
Your Playwright test clicks "Send reset link" and the app sends an email. The test needs to get that email and open the link in it. Email Blackhole does this with no account and no API key: your app sends through a dummy SMTP server, and the test asks the HTTP API to wait for the message.
1. Get the settings
Open the sandbox and press Copy as .env. Paste the result into your project's .env (and keep .env out of git). It holds the SMTP settings for your app and the EBH_* variables for your test:
SMTP_HOST=smtp.emailblackhole.io
SMTP_PORT=2525
SMTP_USER=k3x9w2m8q1
SMTP_PASS=the-session-secret
SMTP_SECURE=false
EBH_URL=https://emailblackhole.io
EBH_ID=k3x9w2m8q1
EBH_SECRET=the-session-secret
Local runs and CI are different. A free session is for working on your own machine: it lives in memory and ends after 60 minutes without use, so when it expires, press Copy as .env again and replace the block in your .env. A pipeline cannot do that (creating a session needs a person at a browser), so CI needs a permanent inbox, a paid plan whose values never expire. Store them as CI secrets.
2. Point your app at it
Your app sends mail the way it always does; only its SMTP settings change. With nodemailer:
import nodemailer from 'nodemailer'
export const transport = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT),
secure: false, // STARTTLS: start in plain text, upgrade, refuse to continue if the upgrade fails
requireTLS: true,
auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS },
})
Any recipient is accepted and nothing is delivered, so a test can invent an address for every run.
3. A helper that waits for the email
tests/email.ts:
import { expect, type APIRequestContext } from '@playwright/test'
const base = `${process.env.EBH_URL}/api/sessions/${process.env.EBH_ID}`
const headers = { Authorization: `Bearer ${process.env.EBH_SECRET}` }
/** The position of the inbox right now. Take it BEFORE the action that sends the email. */
export async function mark(request: APIRequestContext): Promise<number> {
const res = await request.get(`${base}/messages`, { headers })
const { messages } = await res.json()
return Math.max(0, ...messages.map((m: { seq: number }) => m.seq))
}
/** Waits (up to 60 s) for a message newer than `after`, sent to `to`, whose subject contains `subject`. */
export async function waitForEmail(request: APIRequestContext, after: number, to: string, subject = '') {
const q = new URLSearchParams({ after: String(after), to, subject, timeout: '60' })
const res = await request.get(`${base}/wait?${q}`, { headers, timeout: 70_000 })
expect(res.status(), 'the email should arrive').toBe(200)
const { messages } = await res.json()
return messages[0] as { subject: string; text: string; html: string; links: string[]; codes: string[] }
}
Load .env with import 'dotenv/config' at the top of playwright.config.ts.
4. The test
import { test, expect } from '@playwright/test'
import { mark, waitForEmail } from './email'
test('a new user confirms their email address', async ({ page, request }) => {
const email = `user-${Date.now()}@example.com`
const after = await mark(request)
await page.goto('/signup')
await page.getByLabel('Email').fill(email)
await page.getByRole('button', { name: 'Create account' }).click()
const message = await waitForEmail(request, after, email, 'confirm')
await page.goto(message.links[0]) // or message.codes[0] for a one-time code
await expect(page.getByText('Your email is confirmed')).toBeVisible()
})
Or use the ready-made helper
The helper above is also published as a zero-dependency npm package, emailblackhole, and there is a runnable starter project:
npm install --save-dev emailblackhole
import { inbox } from 'emailblackhole' // reads EBH_URL, EBH_ID, EBH_SECRET (and loads .env)
const ebh = inbox()
const after = await ebh.mark()
// ... trigger the email ...
const message = await ebh.waitForEmail({ after, to: email, subject: 'confirm' })
- Package: npmjs.com/package/emailblackhole (source, MIT)
- Starter with a working forgot-password test and CI workflow: emailblackhole-playwright-starter (MIT)
Why a mark
The wait only looks at mail newer than the mark, so an email left over from an earlier run can never satisfy this run. Mail that arrives between the action and the wait still counts, so there is no race the other way either. No sleep, no polling loop.
What else you get
message.linksandmessage.codesare already extracted, so you rarely need a regular expression.- The HTML of every message can be looked at in the browser, in a sandbox with scripts off and remote images blocked.
- Mail from a real provider (SendGrid, SES, Mailgun) can be sent to the inbox's public address, and the message shows whether SPF, DKIM and DMARC passed. See Getting started.
Related: Test email in CI, Cypress, password-reset testing, API reference.