Start/Email testing

Markdown

Email testing

2 min read

Test signup, password reset and magic-link emails in CI, end to end, whichever email provider your app sends with.

Your team has a test domain, <team>.test.groundmail.dev. Mail sent to any address there lands in your test inbox and is never delivered to anyone. Your test signs up with a fresh address, waits for the email, and reads the code or the link out of it.

It works with any provider (SendGrid, Amazon SES, Postmark, Mailgun, your own SMTP server or Groundmail), from local runs, CI, preview deploys and staging. Your app needs no changes: it sends to an address like any other.

How it works

  1. Get a fresh address for each test. Any local part works: signup-8f3a@acme.test.groundmail.dev. A unique address per test means parallel runs never pick up each other's email.
  2. Have your app send to it. Sign up, reset a password, invite a user: whatever sends the email.
  3. Wait for the email. One call waits until it arrives and returns it with the code and links already extracted.

Find your test domain on the Test inbox page in the dashboard, or with GET /test-inbox. You need an API key with the emails:read scope; a test key (gm_test_…) works.

Playwright

TypeScript
import { test, expect } from '@playwright/test'
import { Groundmail } from 'groundmail'

const groundmail = new Groundmail(process.env.GROUNDMAIL_API_KEY!)

test('sign up with an email code', async ({ page }) => {
  const email = await groundmail.testInbox.address({ prefix: 'signup' })

  await page.goto('/signup')
  await page.getByLabel('Email').fill(email)
  await page.getByRole('button', { name: 'Continue' }).click()

  const message = await groundmail.testInbox.waitFor({ to: email, timeout: 30_000 })
  await page.getByLabel('Code').fill(message.code!)
  await page.getByRole('button', { name: 'Verify' }).click()

  await expect(page.getByText('Welcome')).toBeVisible()
})

For a magic link or a verification link, open one of message.links:

TypeScript
const link = message.links.find((url) => url.includes('/verify'))!
await page.goto(link)

Vitest and Jest

No browser needed: call your app's signup endpoint, then wait for the email. Jest uses the same code without the vitest import.

TypeScript
import { describe, expect, it } from 'vitest' // Jest: remove this line
import { Groundmail } from 'groundmail'

const gm = new Groundmail(process.env.GROUNDMAIL_API_KEY!)
const APP_URL = process.env.APP_URL ?? 'http://localhost:3000'

describe('signup', () => {
  it('emails a verification code', async () => {
    // A fresh address per test, e.g. signup-k3m9x2pq@acme.test.groundmail.dev
    const to = await gm.testInbox.address({ prefix: 'signup' })

    const res = await fetch(`${APP_URL}/api/signup`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email: to }),
    })
    expect(res.ok).toBe(true)

    const email = await gm.testInbox.waitFor({ to, timeout: 30_000 })
    expect(email.subject).toContain('Verify')
    expect(email.code).toMatch(/^\d{6}$/)
  }, 40_000)
})

Cypress

Call the API from a task (Node side), so the API key never reaches the browser:

TypeScript
// cypress.config.ts
import { defineConfig } from 'cypress'
import { Groundmail } from 'groundmail'

const groundmail = new Groundmail(process.env.GROUNDMAIL_API_KEY!)

export default defineConfig({
  e2e: {
    setupNodeEvents(on) {
      on('task', {
        testAddress: (prefix: string) => groundmail.testInbox.address({ prefix }),
        waitForEmail: (to: string) => groundmail.testInbox.waitFor({ to, timeout: 30_000 }),
      })
    },
  },
})
TypeScript
// cypress/e2e/reset.cy.ts
it('resets a password', () => {
  cy.task<string>('testAddress', 'reset').then((email) => {
    cy.visit('/forgot-password')
    cy.get('input[name=email]').type(email)
    cy.contains('button', 'Send reset link').click()

    cy.task<{ links: string[] }>('waitForEmail', email, { timeout: 35_000 }).then((message) => {
      cy.visit(message.links.find((url) => url.includes('/reset'))!)
    })
  })
})

pytest

Any HTTP client works. With requests:

Python
import os, secrets, requests

API = "https://api.groundmail.dev"
HEADERS = {"Authorization": f"Bearer {os.environ['GROUNDMAIL_API_KEY']}"}


def test_address(prefix="run"):
    domain = requests.get(f"{API}/test-inbox", headers=HEADERS).json()["test_inbox"]["domain"]
    return f"{prefix}-{secrets.token_hex(4)}@{domain}"


def wait_for_email(to, timeout=30):
    r = requests.get(f"{API}/test-inbox/wait", params={"to": to, "timeout": timeout}, headers=HEADERS, timeout=timeout + 10)
    r.raise_for_status()  # 408 when nothing arrived in time
    return r.json()["email"]


def test_signup_sends_a_code(client):
    email = test_address("signup")
    client.post("/signup", json={"email": email})

    message = wait_for_email(email)
    assert message["code"]
    assert client.post("/verify", json={"email": email, "code": message["code"]}).ok

Plain HTTP

Terminal
# Your test domain
curl https://api.groundmail.dev/test-inbox -H "Authorization: Bearer $GROUNDMAIL_API_KEY"

# Wait up to 30 seconds for an email to this address
curl "https://api.groundmail.dev/test-inbox/wait?to=signup-8f3a@acme.test.groundmail.dev&timeout=30" \
  -H "Authorization: Bearer $GROUNDMAIL_API_KEY"

wait returns at once when the email already arrived, and 408 timeout when nothing arrives in time; calling it again is safe. Each call waits at most 60 seconds. Full reference: Test inbox API.

CLI

Terminal
TO=$(groundmail test-inbox address --prefix signup)
# …trigger the email…
groundmail test-inbox wait --to "$TO" --json | jq -r .email.code

wait exits 2 when nothing arrives in time.

Emails sent with a test key

Emails you send through Groundmail with a gm_test_ key are never delivered (Testing), and they land in the same test inbox, so waitFor works for them too, whatever address they were sent to. They are free and don't count toward the monthly limit.

What you see in the dashboard

The Test inbox page (under Monitor) lists every caught email with search by address and subject, and updates as mail arrives. Each email shows the extracted code and links at the top, then the HTML, text, raw source and headers.

Limits and retention

  • 100 test emails a month free on every account. At the limit, new test emails are refused until the first of next month.
  • Email testing add-on: $10/mo with 5,000 test emails a month included, then $2 per 1,000. Available on any plan, Free included. Turn it on from the Test inbox page or pricing.
  • Test emails never count toward your sending volume.
  • Emails are kept for 14 days, then deleted.
  • Messages over 10 MB are dropped.

Was this page helpful?