Guide

E2E Email Testing with Playwright

Traditionally, E2E email testing means configuring Gmail catch-all, setting up forwarding, and wrestling with provider APIs to read mail. The Nylas CLI gives you a managed agent inbox and one command to poll for Gmail, Outlook, Exchange, Yahoo, iCloud, and IMAP. No OAuth, no shared inbox pollution.

Written by Hazik Director of Product Management

Reviewed by Caleb Geene

VerifiedCLI 3.1.1 · Gmail · last tested April 11, 2026

Why do traditional email tests break?

According to the Playwright team, over 3.5 million npm weekly downloads make it the fastest-growing E2E test framework. Yet testing email flows in Playwright remains painful.

Shared test inboxes are the #1 cause of flaky email tests — parallel test workers reading from the same inbox produce race conditions that are impossible to debug deterministically.

The traditional approach to E2E email testing looks like this:

  • Configure catch-all inboxes in Gmail or Microsoft
  • Set up forwarding rules and hope they don't break
  • Use provider-specific APIs (Gmail API, Microsoft Graph API) to read mail
  • Share a single inbox across tests and filter by subject tokens

This is tedious. Running tests frequently can hit Gmail rate limits or Graph API throttling limits, and pollute your domain reputation. Reading emails asynchronously and verifying content requires custom glue code for each provider.

The Nylas CLI handles all of this:

# Provision a managed inbox (one-time, or per test)
nylas agent account create e2e@your-org.nylas.email

# Poll for messages on the active agent grant
nylas email list --json

# Read full body
nylas email read msg_xyz123 --json
✓ Agent account created successfully!

  Email:      e2e@your-org.nylas.email
  Provider:   nylas
  Status:     valid

Nylas agent accounts give you managed addresses that receive mail without OAuth or third-party mailbox connections. You get JSON output you can parse in Playwright to verify subject, body, links, and automate clicking them.

Prerequisites

E2E email testing with the Nylas CLI requires three things: a Nylas application, the CLI itself, and an E2E test framework. Setup takes under 5 minutes. Homebrew is the fastest install path on macOS and Linux — a single brew install pulls the latest release.

  1. A Nylas application. Create one in the Nylas dashboard.
  2. Nylas CLI installed and authenticated. Install via Homebrew or Go, then run nylas auth config to store your API key. The CLI reads NYLAS_API_KEY from the environment when set, so CI runners can skip the interactive config step.
    brew install nylas/nylas-cli/nylas
    # or
    go install github.com/nylas/cli/cmd/nylas@latest
    
    nylas auth config
  3. Playwright (or similar) for E2E tests

How do I set up an inbox for tests?

Setting up a test inbox means creating a Nylas agent account — a managed email address that receives mail without OAuth or provider configuration. Agent accounts provision in under 2 seconds and accept mail immediately. Two strategies exist: one account per test for full isolation, or a single shared account with token-based filtering for simpler setups.

Strategy A: One agent account per test (recommended)

Create a fresh agent account at the start of each test, target it with the flow under test, and tear it down at the end. Each test gets a clean inbox — no cross-test bleed-through, no token filtering, no race conditions between parallel workers. This approach generates a UUID-based email address so every test worker gets an isolated mailbox.

import { randomUUID } from 'node:crypto'
import { execFileSync } from 'node:child_process'

// Per-test setup
const localPart = `e2e-${randomUUID()}`
const testEmail = `${localPart}@your-org.nylas.email`
const created = execFileSync(
  'nylas',
  ['agent', 'account', 'create', testEmail, '--json'],
  { encoding: 'utf-8' }
)
const grantId = JSON.parse(created).id

// In the test
await page.fill('input[name=email]', testEmail)

Strategy B: Single shared agent account + unique tokens

Use one persistent agent account (e.g., e2e@your-org.nylas.email) and embed a unique token in each test's subject or body. When polling, filter messages by that token. This strategy works well for teams running fewer than 10 parallel workers, since all tests share a single inbox.

Create the shared agent account once during CI bootstrap or manually. The account persists across test runs, so you don't need to provision and tear down on every execution.

# Create once (or via CI bootstrap)
nylas agent account create e2e@your-org.nylas.email

In your app, when sending a test email, embed a randomUUID() token in the subject line. Node's crypto.randomUUID() generates a v4 UUID — 122 bits of randomness, so collisions are statistically impossible across test runs.

import { randomUUID } from 'node:crypto'

const token = randomUUID()
// Embed token in subject: "Reset password - ${token}"
await sendPasswordResetEmail('e2e@your-org.nylas.email', { token })

How do I configure the CLI for testing?

Configuring the Nylas CLI for testing means storing an API key and optionally setting a default agent grant ID. The CLI checks the NYLAS_API_KEY environment variable first, then falls back to the stored config from nylas auth config. In CI, set both NYLAS_API_KEY and NYLAS_AGENT_GRANT_ID as pipeline secrets — the CLI picks them up automatically, so no interactive prompts block headless runners.

# Auth (one-time or in CI)
nylas auth config
# Paste API key

# Set default agent account for commands (optional)
export NYLAS_AGENT_GRANT_ID=11111111-1111-1111-1111-111111111111

# Or pass --grant explicitly to each command

How do I poll for test emails?

Polling for test emails means calling nylas email list --json in a loop until the expected message arrives or a timeout fires. Most transactional emails arrive within 3-5 seconds, so a 30-second timeout with 2-second intervals gives 15 retries — enough for even slow SMTP pipelines. The --json flag returns structured output that JSON.parse() can consume directly.

The nylas email list command returns up to 50 messages per call by default. Pass --limit to control how many messages the CLI fetches per poll iteration. Use --unread to skip already-processed messages and reduce noise in the response.

# List messages on the active agent grant (JSON for parsing)
nylas email list --json --limit 20

# Only unread, against a specific agent account
nylas email list \
  --grant 11111111-1111-1111-1111-111111111111 \
  --unread --json
[
  {
    "id": "msg_test_a1b2c3d4",
    "to": [{"email": "e2e-signup-test-1@your-org.nylas.email"}],
    "from": [{"name": "Acme App", "email": "noreply@acme.example.com"}],
    "subject": "Reset your password",
    "snippet": "Click the link below to reset your password. This link expires in 24 hours.",
    "date": "2026-03-25T14:30:05-04:00",
    "body": "<html><body><p>Click <a href='https://app.acme.example.com/reset?token=abc123'>here</a> to reset your password.</p></body></html>"
  }
]

Example Playwright helper using execFileSync with arg arrays (no shell, no injection risk):

import { execFileSync } from 'node:child_process'

async function pollForEmail(
  grantId: string,
  predicate: (msg: { subject?: string; snippet?: string }) => boolean,
  options = { intervalMs: 2000, timeoutMs: 30000 }
) {
  const start = Date.now()
  while (Date.now() - start < options.timeoutMs) {
    const out = execFileSync(
      'nylas',
      ['email', 'list', '--grant', grantId, '--json', '--limit', '10'],
      { encoding: 'utf-8' }
    )
    const messages = JSON.parse(out)
    const match = messages.find(predicate)
    if (match) return match
    await new Promise(r => setTimeout(r, options.intervalMs))
  }
  throw new Error('Email not received within timeout')
}

// Usage
const msg = await pollForEmail(process.env.NYLAS_AGENT_GRANT_ID!, m =>
  m.subject?.includes('Reset your password')
)

How do I read the full email body?

Reading the full email body means fetching the complete message — HTML, headers, and metadata — by its message ID. The nylas email list response includes a snippet (the first ~200 characters of plaintext), but password reset links and verification tokens live in the full HTML body. Use nylas email read with the message ID and --json to get the full content.

Pass --grant to target a specific agent account. The --raw flag returns the unprocessed body without any HTML rendering, which is useful when you need to inspect raw MIME content or headers. The JSON response includes both the body (HTML) and snippet fields.

# Full message with body
nylas email read msg_xyz123 --json

# Raw body (no HTML rendering)
nylas email read msg_xyz123 --raw --json

# Against a specific agent account
nylas email read msg_xyz123 \
  --grant 11111111-1111-1111-1111-111111111111 --json

Once you have the full message, parse the JSON and assert against the body and subject fields. The example below uses execFileSync with an explicit argument array — no shell involved, so interpolated values can't be hijacked.

const out = execFileSync(
  'nylas',
  ['email', 'read', msg.id, '--grant', grantId, '--json'],
  { encoding: 'utf-8' }
)
const email = JSON.parse(out)
expect(email.body).toContain('Click here to reset your password')
expect(email.subject).toBe('Reset your password')

Extracting email links means parsing the HTML body for href attributes and navigating to the matched URL in Playwright. Transactional emails typically contain 1-3 action links (reset, verify, confirm). The cheerio library parses HTML with a jQuery-like API and adds roughly 200 KB to your test dependencies — significantly lighter than a full DOM parser.

Use a CSS selector like a[href*="reset"] to match the link by a known path segment. Some email services wrap links in tracking redirects, so you may need to follow the redirect chain or extract the final URL from a query parameter like redirect_to.

import * as cheerio from 'cheerio'
import { execFileSync } from 'node:child_process'

function extractResetLink(htmlBody: string): string {
  const $ = cheerio.load(htmlBody)
  const link = $('a[href*="reset"], a[href*="password"]').first().attr('href')
  if (!link) throw new Error('Reset link not found in email')
  return link
}

// In Playwright test
const out = execFileSync(
  'nylas',
  ['email', 'read', msg.id, '--grant', grantId, '--json'],
  { encoding: 'utf-8' }
)
const email = JSON.parse(out)
const resetUrl = extractResetLink(email.body)
await page.goto(resetUrl)
await expect(page).toHaveURL(/reset|password/)
// Continue with form fill, submit, etc.

Complete example

The complete example below ties together every step — provisioning, polling, body extraction, and link clicking — into a single Playwright test file. This test covers a password reset flow end-to-end in under 40 seconds: it submits the forgot-password form, polls for the reset email, extracts the link with cheerio, navigates to it, and submits a new password.

The example uses a single pre-created agent account (e2e@your-org.nylas.email) and filters by subject (Strategy B). In test mode, configure your app to send password reset emails to the agent address.

// e2e/password-reset.spec.ts
import { test, expect } from '@playwright/test'
import { execFileSync } from 'node:child_process'
import * as cheerio from 'cheerio'

const GRANT_ID = process.env.NYLAS_AGENT_GRANT_ID!
const TEST_EMAIL = 'e2e@your-org.nylas.email'

async function pollForEmail(predicate: (m: any) => boolean) {
  const timeout = 30000
  const interval = 2000
  const start = Date.now()
  while (Date.now() - start < timeout) {
    const out = execFileSync(
      'nylas',
      ['email', 'list', '--grant', GRANT_ID, '--json', '--limit', '10'],
      { encoding: 'utf-8' }
    )
    const msgs = JSON.parse(out)
    const m = msgs.find(predicate)
    if (m) return m
    await new Promise(r => setTimeout(r, interval))
  }
  throw new Error('Email not received')
}

test('password reset flow', async ({ page }) => {
  const expectedSubject = 'Reset your password'
  await page.goto('/forgot-password')
  await page.fill('input[name=email]', TEST_EMAIL)
  await page.click('button[type=submit]')
  // App (in test mode) sends subject: "Reset your password"
  await expect(page.getByText(/check your email/i)).toBeVisible()

  const msg = await pollForEmail(m => m.subject?.includes(expectedSubject))
  const fullOut = execFileSync(
    'nylas',
    ['email', 'read', msg.id, '--grant', GRANT_ID, '--json'],
    { encoding: 'utf-8' }
  )
  const full = JSON.parse(fullOut)
  const $ = cheerio.load(full.body)
  const resetLink = $('a[href*="reset"]').first().attr('href')!
  await page.goto(resetLink)
  await page.fill('input[name=password]', 'NewSecurePass123!')
  await page.click('button[type=submit]')
  await expect(page.getByText(/password updated/i)).toBeVisible()
})

Troubleshooting

Troubleshooting E2E email tests usually comes down to four issues: wrong agent account, missing --json flag, malformed HTML links, or CI auth misconfiguration. According to Playwright's GitHub discussions, email-related test flakiness accounts for roughly 15% of reported E2E failures. The fixes below address each root cause directly.

No emails received

The most common cause is a mismatched agent account: ensure NYLAS_AGENT_GRANT_ID or the explicit --grant matches the address you sent to. Use nylas agent account list to verify:

Agent Accounts (2)

1. e2e@your-org.nylas.email                active   12 messages
   ID: 11111111-1111-1111-1111-111111111111

2. smoke@your-org.nylas.email              active    3 messages
   ID: 22222222-2222-2222-2222-222222222222

Inbound delivery can take a few seconds; increase poll timeout if needed. Confirm your app sends to the correct managed address in test config.

JSON parse errors

Pass --json to CLI commands. Without it, output is human-readable text. execFileSync with encoding: 'utf-8' returns stdout only — warnings on stderr won't mix in.

Email HTML is often non-standard — many email clients generate deeply nested table layouts with inline styles. Use a specific CSS selector (e.g., a[href*="reset"]) rather than a generic a tag match to avoid hitting navigation or unsubscribe links. Some email services wrap every link in a tracking redirect, adding 1-2 extra hops. Follow redirects or extract the final URL from the redirect_to query parameter.

Rate limits and flakiness

Use one agent account per test when running many parallel workers (Strategy A above). Clean up after tests: nylas agent account delete <agent-account-id> --yes.

Auth in CI

CI runners need non-interactive authentication. The Nylas CLI reads the NYLAS_API_KEY environment variable automatically, so store your API key as a pipeline secret (GitHub Actions, GitLab CI, CircleCI all support encrypted secrets). Then run nylas auth config to persist it for subsequent commands in the same job.

# Store API key as CI secret; CLI reads NYLAS_API_KEY when set
export NYLAS_API_KEY=your_key
nylas auth config

Next steps

With E2E email testing working, expand into related workflows: sending test emails, monitoring deliverability, or extracting OTP codes. Each guide below covers a specific use case with ready-to-run CLI commands.