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 across supported providers. 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 replaces every step above with three commands:
# 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
E2E email testing with the Nylas CLI requires three things: a Nylas application, the CLI itself, and an E2E test framework.
- A Nylas application. Create one in the Nylas dashboard.
- Nylas CLI installed and authenticated. See the getting-started guide for install and setup. The CLI reads
NYLAS_API_KEYfrom the environment when set, so CI runners can skip the interactive config step. - 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.emailIn 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?
Once the CLI is authenticated (see the getting-started guide), point it at a default agent grant for your tests. Set NYLAS_AGENT_GRANT_ID so commands target the test inbox automatically, or pass the grant ID as a positional argument to each command. 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.
# Set default agent account for commands (optional)
export NYLAS_AGENT_GRANT_ID=11111111-1111-1111-1111-111111111111
# Or pass explicitly to each commandHow 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 \
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', 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 the grant ID as a positional argument 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 \
11111111-1111-1111-1111-111111111111 --jsonOnce 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, 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?
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, 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_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_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 positional grant ID 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 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 configNext 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.
- Send email from the terminal: full guide to sending email with the CLI
- Cypress Email Testing: same inbox polling pattern for Cypress tasks
- Mailtrap alternative for real inbox testing: choose between SMTP capture and delivered inbox checks
- 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
- Gmail API sandbox: test email integrations — demo mode, test accounts, and the SES sandbox compared
- Command reference: every flag documented