Guide

How to test E2E email flows with Nylas CLI and 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 catch-all inbox and one command to poll. No OAuth, no shared inbox pollution.

The old way vs the new way

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, Graph API) to read mail
  • Share a single inbox across tests and filter by subject tokens

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

The Nylas CLI handles all of this:

# Create catch-all inbox (one-time)
nylas inbound create "e2e-*@your-org.nylas.email"

# Poll for messages
nylas inbound messages inbox_abc123 --json

# Read full body
nylas email read msg_xyz123 inbox_abc123 --json

Nylas Inbound gives 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 account with Inbound enabled. Beta access; join the waitlist at inbound.nylas.com if needed.
  2. Nylas CLI installed and authenticated
    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

Set up a catch-all inbox

You have three strategies. Pick one based on how many parallel tests you run.

Strategy A: Catch-all with wildcard (recommended)

Create a catch-all inbox that receives all emails matching a pattern. Generate unique addresses per test (e.g., e2e-${uuid}@your-org.nylas.email) without creating new inboxes.

# Create catch-all inbox (one-time)
nylas inbound create "e2e-*@your-org.nylas.email"
# No need to create an inbox per test; all e2e-* addresses are valid

In your tests, generate unique addresses; all deliver to the same inbox:

const testEmail = `e2e-${crypto.randomUUID()}@your-org.nylas.email`
await page.fill('input[name=email]', testEmail)

Strategy B: Single inbox + unique tokens

Use one inbox (e.g., e2e@your-org.nylas.email) and include a unique token in every test email (subject or body). When polling, filter messages by that token.

# Create once (or via CI env)
nylas inbound create e2e
# => e2e@your-org.nylas.email

In your app, when sending a test email:

const token = crypto.randomUUID()
await sendPasswordResetEmail(`e2e+${token}@your-org.nylas.email`, { token })
// Or embed token in subject: "Reset password - ${token}"

Strategy C: Dynamic inbox per test

Create a new inbox for each test run with a unique prefix. Every address is valid; no filtering needed.

# Create inbox with random prefix
PREFIX="test-$(openssl rand -hex 8)"
OUTPUT=$(nylas inbound create "$PREFIX" --json)
INBOX_ID=$(echo "$OUTPUT" | jq -r '.id')
# => inbox_abc123

# Use this address when triggering the flow under test
TEST_EMAIL="${PREFIX}@your-org.nylas.email"
# Use INBOX_ID for polling: nylas inbound messages $INBOX_ID --json

Configure the CLI

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

# Set default inbox for commands (optional)
export NYLAS_INBOUND_GRANT_ID=inbox_abc123

# Or pass inbox ID explicitly to each command

Poll for emails

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

# List messages (JSON for parsing)
nylas inbound messages inbox_abc123 --json --limit 20

# Only unread
nylas inbound messages inbox_abc123 --unread --json

Example Playwright helper:

import { execSync } from 'child_process'

async function pollForEmail(
  inboxId: 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 = execSync(
      `nylas inbound messages ${inboxId} --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_INBOUND_GRANT_ID!, m =>
  m.subject?.includes('Reset your password')
)

Read full email body

Use nylas email read with the message ID and grant (inbox) ID to get the full body, including HTML.

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

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

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

const out = execSync(
  `nylas email read ${msg.id} ${inboxId} --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')

To automate clicking a link (e.g., password reset URL), parse the HTML body for href attributes.

import * as cheerio from 'cheerio'

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 email = JSON.parse(execSync(`nylas email read ${msg.id} ${inboxId} --json`, { encoding: 'utf-8' }))
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 inbox (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 { execSync } from 'child_process'
import * as cheerio from 'cheerio'

const INBOX_ID = process.env.NYLAS_INBOUND_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 = execSync(`nylas inbound messages ${INBOX_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 token = crypto.randomUUID()
  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 - ${token}"
  await expect(page.getByText(/check your email/i)).toBeVisible()

  const msg = await pollForEmail(m => m.subject?.includes(token))
  const full = JSON.parse(execSync(`nylas email read ${msg.id} ${INBOX_ID} --json`, { encoding: 'utf-8' }))
  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 inbox ID: ensure NYLAS_INBOUND_GRANT_ID or the inbox ID matches where you sent. Use nylas inbound list to verify. Inbound can take a few seconds; increase poll timeout if needed. Confirm your app sends to the correct Inbound address in test config.

JSON parse errors

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

Email HTML can be complex. Use a robust 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 unique inboxes per test when running many parallel workers. Clean up after tests: nylas inbound delete inbox_xyz --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 config

Next steps