Email Testing with Cypress
Cypress runs your test in the browser, and the email lands in the sandbox. A task bridges the two. It runs in Node, polls the API until the message arrives, and hands it to the test as JSON.
Get the app's mail into the sandbox first. Point its SMTP settings at send.smtp.dev (settings per framework), or let it send through its usual provider to an address on a domain whose MX points at the sandbox.
The Task
Register it in your Cypress config:
import { defineConfig } from 'cypress'
const API = 'https://api.smtp.dev'
const headers = { 'X-API-KEY': process.env.SMTP_DEV_API_KEY }
async function api(path) {
const res = await fetch(API + path, { headers })
if (!res.ok)
throw new Error(`${path} returned ${res.status}`)
return res.json()
}
async function waitForEmail({ inbox, to, timeout = 30_000 }) {
const { member: accounts } = await api(`/accounts?address=${encodeURIComponent(inbox)}`)
const account = accounts.find(a => a.address === inbox)
const { mailboxes } = await api(`/accounts/${account.id}`)
const messages = `/accounts/${account.id}/mailboxes/${mailboxes.find(m => m.path === 'INBOX').id}/messages`
const deadline = Date.now() + timeout
while (Date.now() < deadline) {
const { member } = await api(messages)
const found = member.find(m => m.to.some(t => t.address === to))
if (found)
return api(`${messages}/${found.id}`)
await new Promise(resolve => setTimeout(resolve, 1000))
}
throw new Error(`no email to ${to} after ${timeout}ms`)
}
export default defineConfig({
e2e: {
baseUrl: 'http://localhost:3000',
setupNodeEvents(on) {
on('task', { waitForEmail })
},
},
})
inbox is the account that receives the mail. to is the exact address the test typed into the form. The task resolves with the full message: subject, from, to, text, html as an array of HTML parts, and attachments.
cy.task waits up to taskTimeout, 60 seconds by default, so the 30-second poll fails first, with a message that says which address never got mail.
Password Reset
describe('password reset', () => {
it('emails a working reset link', () => {
const address = `reset+${crypto.randomUUID()}@test.example.com`
cy.visit('/forgot-password')
cy.get('input[type=email]').type(address)
cy.contains('button', 'Send reset link').click()
cy.task('waitForEmail', { inbox: 'reset@test.example.com', to: address })
.then((message) => {
expect(message.subject).to.equal('Reset your password')
cy.visit(message.text.match(/https?:\/\/\S*reset\S*/)[0])
})
cy.get('input[name=password]').type('correct-horse-battery')
cy.contains('button', 'Set password').click()
cy.location('pathname').should('eq', '/signin')
})
})
reset+…@test.example.com delivers to the reset@test.example.com account, and the full alias stays in to. That's what the task matches on, so every test reads its own email.
One-Time Codes
cy.task('waitForEmail', { inbox: 'signup@test.example.com', to: address })
.its('text')
.then((text) => {
cy.get('input[name=code]').type(text.match(/\b\d{6}\b/)[0])
})
HTML-Only Emails
When the template has no plain text part, text can be empty. The .then callback runs in the browser, so parse the HTML with DOMParser and read the button's link:
const doc = new DOMParser().parseFromString(message.html.join(''), 'text/html')
const link = doc.querySelector('a[href*="/reset"]').getAttribute('href')
Keeping It Stable
- A fresh address per test. Plus aliases give every test its own address without creating accounts, on every plan. On Pro and Max, a catch-all account accepts any address on the domain.
- Keep the key in Node.
cy.requestcan call the API too, but the key would have to sit inCypress.env, which the browser can read. The task keeps it inprocess.env. - Links to another origin. If the link opens a different origin than
baseUrl, run the steps that follow insidecy.origin. - Rate limits. One request a second per waiting test stays far below the 4096 requests per minute limit.
In CI
Add SMTP_DEV_API_KEY as a secret and expose it to the step that runs cypress run. The app under test needs its own SMTP settings pointed at the sandbox. The CI guide has a GitHub Actions workflow with both.
Playwright
Playwright tests that trigger a password reset or signup, wait for the real email with expect.poll, follow its link or read its code, and finish the flow.
Email Clients
Connect a real client over IMAP to see how multipart emails render outside the browser. Host, port, and login settings for any client.