Source: https://cli.nylas.com/guides/e2e-email-testing

# 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](https://cli.nylas.com/authors/hazik) Director of Product Management

Reviewed by [Caleb Geene](https://cli.nylas.com/authors/caleb-geene)

Updated May 4, 2026

> **Heads up:** `nylas inbound` is deprecated. This guide uses the `nylas agent` surface — managed agent accounts replace inbound inboxes. The [agent reference](https://github.com/nylas/cli/blob/main/docs/commands/agent.md) covers the full surface.

> **TL;DR:** Provision a managed agent account per test (or one shared account with token filtering), send your app's transactional emails to it, then poll with [`nylas email list --json`](https://cli.nylas.com/docs/commands/email-list) in Playwright to verify content and automate link clicks. No Gmail config, no shared inbox pollution, no provider-specific APIs.

## Why do traditional email tests break?

According to the [Playwright](https://playwright.dev/) 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](https://developers.google.com/gmail/api), [Microsoft Graph API](https://learn.microsoft.com/en-us/graph/api/resources/mail-api-overview)) 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](https://developers.google.com/gmail/api/reference/quota) or [Graph API throttling limits](https://learn.microsoft.com/en-us/graph/throttling), 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:

```bash
# 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
```

```text
✓ 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

1. **A Nylas application**. Create one in the [Nylas dashboard](https://dashboard-v3.nylas.com/).
2. **Nylas CLI installed and authenticated**

  ```bash
  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?

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.

```typescript
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.

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

In your app, when sending a test email:

```javascript
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?

```bash
# 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?

After triggering the flow (e.g., submitting a password reset form), poll until the email arrives or a timeout is reached.

```bash
# 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
```

```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):

```typescript
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`](https://cli.nylas.com/docs/commands/email-read) with the message ID to fetch the full body, including HTML. Pass `--grant` to target a specific agent account.

```bash
# 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
```

The JSON includes `body` (HTML) and `snippet`. Parse and assert in your test:

```javascript
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.

```typescript
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.

```typescript
// 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()
})
```

> Note
>
> On May 4, 2026, this guide moved from the deprecated `nylas inbound` surface to `nylas agent`. Inbound inboxes are now agent accounts; `nylas inbound messages` is now [`nylas email list`](https://cli.nylas.com/docs/commands/email-list) against the agent grant. The shell-based `execSync` calls were also tightened to `execFileSync` with explicit arg arrays so command construction can't be hijacked by interpolated values.

## 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`](https://cli.nylas.com/docs/commands/agent-account-list) to verify:

```text
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.

### 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

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

> **Update (February 18, 2026)**: Corrected install commands and updated JavaScript/TypeScript snippets to import `randomUUID` from `node:crypto` so they run in standard Node test environments.

## Next steps

- [Send email from the terminal](https://cli.nylas.com/guides/send-email-from-terminal): full guide to sending email with the CLI
- [Email deliverability with Nylas CLI](https://cli.nylas.com/guides/email-deliverability-cli): verify your test emails actually arrive
- [Agent command reference](https://github.com/nylas/cli/blob/main/docs/commands/agent.md): full CLI docs for the agent surface (accounts, policies, rules)
- [Create an AI agent email identity](https://cli.nylas.com/guides/create-ai-agent-email-identity): same surface, focused on send + IMAP/SMTP receive
- [Extract OTP codes from email](https://cli.nylas.com/guides/extract-otp-codes-from-email): automate verification code extraction in test flows
- [Receive inbound email](https://cli.nylas.com/guides/receive-inbound-email-cli): per-test isolated agent inboxes
- [Command reference](https://cli.nylas.com/docs/commands): every flag documented
