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
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: validNylas 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
- A Nylas application. Create one in the Nylas dashboard.
- Nylas CLI installed and authenticated
brew install nylas/nylas-cli/nylas # or go install github.com/nylas/cli/cmd/nylas@latest nylas auth config - Playwright (or similar) for E2E tests
How do I set up an inbox for tests?
You have two strategies. Pick one based on how many parallel tests you run.
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.
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.
# Create once (or via CI bootstrap)
nylas agent account create e2e@your-org.nylas.emailIn your app, when sending a test email:
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?
# 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 commandHow do I poll for test emails?
After triggering the flow (e.g., submitting a password reset form), poll until the email arrives or a timeout is reached.
# 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?
Use nylas email read with the message ID to fetch the full body, including HTML. Pass --grant to target a specific agent account.
# 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 --jsonThe JSON includes body (HTML) and snippet. Parse and assert in your test:
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')How do I extract and click email links?
To automate clicking a link (e.g., password reset URL), parse the HTML body for href attributes.
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
Uses a single pre-created agent account (e2e@your-org.nylas.email) and a unique token in the subject for filtering. In test mode, configure your app to append the token to the email subject.
// 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
No emails received
Wrong 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-222222222222Inbound 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.
Link extraction fails
Email HTML can be complex. Use a reliable selector (e.g., a[href*="reset"]) or search for a known query param. Some emails use tracking redirect URLs. Follow redirects or look for the final redirect_to param in the href.
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
# Store API key as CI secret; CLI reads NYLAS_API_KEY when set
export NYLAS_API_KEY=your_key
nylas auth configNext steps
- Send email from the terminal: full guide to sending email with the CLI
- Email deliverability with Nylas CLI: verify your test emails actually arrive
- Agent command reference: full CLI docs for the agent surface (accounts, policies, rules)
- Create an AI agent email identity: same surface, focused on send + IMAP/SMTP receive
- Extract OTP codes from email: automate verification code extraction in test flows
- Receive inbound email: per-test isolated agent inboxes
- Command reference: every flag documented