All posts
    Tutorials

    Playwright Email Testing: Wait for a Verification Email and Extract the Code

    The Devmailr Team7 min read

    Playwright is great at driving a browser, but it can’t read your email — so the verification step in a signup flow usually gets stubbed or skipped. Here’s a complete, real Playwright test that signs a user up, waits for the verification email through a disposable inbox API, extracts the code, and finishes the flow. No polling boilerplate.

    The approach

    We’ll give each test run its own disposable inbox, drive the signup form with that address, then pull the code (or link) out of the email that arrives. Two ingredients: a tiny Devmailr client, and a Playwright fixture that creates an inbox before the test and deletes it after.

    A small Devmailr client

    devmailr.ts
    const BASE = 'https://api.devmailr.app/api'
    const headers = { Authorization: `Bearer ${process.env.DEVMAILR_API_KEY}` }
    
    export async function createInbox(prefix: string) {
      const res = await fetch(`${BASE}/mailboxes`, {
        method: 'POST',
        headers: { ...headers, 'Content-Type': 'application/json' },
        body: JSON.stringify({ prefix }),
      })
      if (!res.ok) throw new Error(`createInbox failed: ${res.status}`)
      return res.json() as Promise<{ _id: string; address: string }>
    }
    
    export async function waitForEmail(mailboxId: string, query = '') {
      const res = await fetch(`${BASE}/mailboxes/${mailboxId}/wait?timeout=60&${query}`, { headers })
      if (res.status === 404) throw new Error('No matching email arrived in time')
      if (!res.ok) throw new Error(`waitForEmail failed: ${res.status}`)
      return res.json()
    }
    
    export async function deleteInbox(mailboxId: string) {
      await fetch(`${BASE}/mailboxes/${mailboxId}`, { method: 'DELETE', headers })
    }

    A Playwright fixture for a per-test inbox

    Extend Playwright’s test with an inboxfixture, so every test gets a clean address and it’s torn down automatically — even if the test fails:

    fixtures.ts
    import { test as base } from '@playwright/test'
    import { createInbox, deleteInbox } from './devmailr'
    
    export const test = base.extend<{ inbox: { _id: string; address: string } }>({
      inbox: async ({}, use) => {
        const inbox = await createInbox(`pw-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`)
        await use(inbox)
        await deleteInbox(inbox._id)
      },
    })
    
    export { expect } from '@playwright/test'

    The test: signup + email verification

    signup.spec.ts
    import { test, expect } from './fixtures'
    import { waitForEmail } from './devmailr'
    
    test('user can sign up and verify their email', async ({ page, inbox }) => {
      // 1. Drive the signup form with the disposable address
      await page.goto('https://staging.myapp.com/signup')
      await page.getByLabel('Email').fill(inbox.address)
      await page.getByLabel('Password').fill('correct-horse-battery-staple')
      await page.getByRole('button', { name: 'Create account' }).click()
    
      // 2. Block until the verification email arrives (no sleeps, no polling)
      const email = await waitForEmail(inbox._id, 'has_code=true')
    
      // 3. Enter the code your app emailed
      await page.getByLabel('Verification code').fill(email.extractedCodes[0])
      await page.getByRole('button', { name: 'Verify' }).click()
    
      await expect(page.getByText('Welcome')).toBeVisible()
    })

    If your app sends a click-to-verify link rather than a code, use primaryLink — the best-guess call-to-action URL — and navigate straight to it:

    typescript
    const email = await waitForEmail(inbox._id, 'subject=' + encodeURIComponent('Confirm your email'))
    await page.goto(email.primaryLink)
    await expect(page.getByText('Email confirmed')).toBeVisible()

    Tips for reliable runs

    • Unique inbox per test.The fixture keys on a timestamp + random suffix, so parallel tests never read each other’s mail.
    • Reusing one inbox across steps? Capture a timestamp before the action and pass received_after=<ms> so /wait ignores older emails.
    • Keep the key secret. Set DEVMAILR_API_KEY as a CI secret, never in the repo.
    • On Cypress? The same client works from a cy.task or cy.request — the API is just HTTP.

    Why /wait and not polling: A single /waitrequest holds the connection open until the email lands, so your test reads as a straight line instead of a retry loop — and it doesn’t burn ~30 list calls against your rate limit.

    Related reading

    Try it in two minutes

    Create a disposable prefix@devmailr.app inbox over the API, send mail to it from your app, and read it in your tests. Free plan, no card required.