---
title: "Email Testing with Cypress"
description: "Wait for a signup or password reset email in a Cypress test, pull the link or code out of it, and finish the flow. The API key stays in Node."
canonical_url: "https://smtp.dev/docs/guides/cypress/"
---

> Index of every page on this site: [llms.txt](https://smtp.dev/llms.txt)

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](https://smtp.dev/docs/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](https://smtp.dev/docs/guides/send-test-email/#from-your-app)), or let it send through its usual provider to an address on a [domain whose MX points at the sandbox](https://smtp.dev/docs/setup/domain/).

## The Task

Register it in your Cypress config:

```js
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](https://smtp.dev/docs/api/#get-a-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

```js
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

```js
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:

```js
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](https://smtp.dev/docs/features/account/#plus-sign-aliasing) give every test its own address without creating accounts, on every plan. On Pro and Max, a [catch-all account](https://smtp.dev/docs/guides/catch-all/) accepts any address on the domain.
- **Keep the key in Node.** `cy.request` can call the API too, but the key would have to sit in `Cypress.env`, which the browser can read. The task keeps it in `process.env`.
- **Links to another origin.** If the link opens a different origin than `baseUrl`, run the steps that follow inside `cy.origin`.
- **Rate limits.** One request a second per waiting test stays far below the [4096 requests per minute limit](https://smtp.dev/docs/api/#rate-limiting).

## 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](https://smtp.dev/docs/guides/ci/) has a GitHub Actions workflow with both.

## Sitemap

See the full [sitemap](https://smtp.dev/sitemap.md) for all pages.
