Nodemailer is the most widely used email library in Node.js. If your Express, Next.js, or NestJS app sends emails, there's a good chance it's using Nodemailer under the hood.
But testing those emails end-to-end in CI has always been the hard part. This guide shows how to test the complete email flow with Playwright and ZeroDrop — no Docker, no fake SMTP server, no mocking.
The typical Nodemailer setup
// lib/email.ts
import nodemailer from 'nodemailer';
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT),
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
});
export async function sendVerificationEmail(to: string, token: string) {
const verifyUrl = `${process.env.APP_URL}/verify?token=${token}`;
await transporter.sendMail({
from: 'noreply@yourapp.com',
to,
subject: 'Verify your email',
html: `<p>Click <a href="${verifyUrl}">here</a> to verify your email.</p>`,
});
}
export async function sendPasswordResetEmail(to: string, token: string) {
const resetUrl = `${process.env.APP_URL}/reset-password?token=${token}`;
await transporter.sendMail({
from: 'noreply@yourapp.com',
to,
subject: 'Reset your password',
html: `<p>Click <a href="${resetUrl}">here</a> to reset your password.</p>`,
});
}
export async function sendOTPEmail(to: string, otp: string) {
await transporter.sendMail({
from: 'noreply@yourapp.com',
to,
subject: 'Your verification code',
html: `<p>Your code is: <strong>${otp}</strong></p>`,
});
}
Stage 1 — Local development: Nodemailer test account
Nodemailer has a built-in test account generator using Ethereal Email:
// For local development only
const testAccount = await nodemailer.createTestAccount();
const transporter = nodemailer.createTransport({
host: 'smtp.ethereal.email',
port: 587,
auth: {
user: testAccount.user,
pass: testAccount.pass,
},
});
// After sending, preview the email
const info = await transporter.sendMail({ ... });
console.log('Preview URL:', nodemailer.getTestMessageUrl(info));
This is useful for inspecting emails locally — the preview URL opens in a browser. But you can't automate reading from Ethereal in a Playwright test, and it doesn't work in CI.
What it solves: Visual inspection during local development.
What it doesn't solve: Automated testing. No programmatic API to read the email in a test.
Stage 2 — CI: Nodemailer + real SMTP + ZeroDrop
For automated testing in GitHub Actions, point Nodemailer at a real SMTP provider (Resend, SendGrid, Postmark) and use ZeroDrop to catch the emails:
npm install zerodrop-client
import { test, expect } from '@playwright/test';
import { ZeroDrop } from 'zerodrop-client';
const mail = new ZeroDrop();
test('email verification flow', async ({ page }) => {
// 1. Generate a disposable inbox
const inbox = process.env.TEST_INBOX ?? mail.generateInbox();
// 2. Sign up — Nodemailer sends the verification email
await page.goto('/signup');
await page.fill('[name="email"]', inbox);
await page.fill('[name="password"]', 'TestPass123!');
await page.click('[type="submit"]');
await expect(page).toHaveURL('/check-email');
// 3. ZeroDrop catches the email — magic link auto-extracted
const email = await mail.waitForLatest(inbox, { timeout: 30000 });
expect(email.subject).toContain('Verify your email');
expect(email.magicLink).not.toBeNull();
// 4. Click the verification link
await page.goto(email.magicLink!);
await expect(page).toHaveURL('/dashboard');
});
OTP flow
test('OTP login', async ({ page }) => {
const inbox = process.env.TEST_INBOX ?? mail.generateInbox();
await page.goto('/login');
await page.fill('[name="email"]', inbox);
await page.click('[type="submit"]');
// OTP auto-extracted at the edge — no regex needed
const email = await mail.waitForLatest(inbox, { timeout: 30000 });
expect(email.otp).not.toBeNull();
await page.fill('[name="otp"]', email.otp!);
await page.click('[type="submit"]');
await expect(page).toHaveURL('/dashboard');
});
Password reset flow
test('password reset', async ({ page }) => {
const inbox = process.env.TEST_INBOX ?? mail.generateInbox();
await page.goto('/forgot-password');
await page.fill('[name="email"]', inbox);
await page.click('[type="submit"]');
const email = await mail.waitForLatest(inbox, { timeout: 30000 });
expect(email.magicLink).not.toBeNull();
await page.goto(email.magicLink!);
await page.fill('[name="password"]', 'NewPass123!');
await page.click('[type="submit"]');
await expect(page.getByText('Password updated')).toBeVisible();
});
Configuring Nodemailer for CI
The key is using a real SMTP provider in CI while keeping Ethereal for local development:
// lib/email.ts
const isCI = process.env.CI === 'true';
const transporter = isCI
? nodemailer.createTransport({
// Real SMTP in CI — emails actually delivered
host: process.env.SMTP_HOST, // e.g. smtp.resend.com
port: 587,
auth: {
user: process.env.SMTP_USER, // e.g. resend
pass: process.env.SMTP_PASS, // e.g. your Resend API key
},
})
: nodemailer.createTransport({
// Ethereal locally — visual inspection only
host: 'smtp.ethereal.email',
port: 587,
auth: {
user: process.env.ETHEREAL_USER,
pass: process.env.ETHEREAL_PASS,
},
});
Using Resend as the SMTP backend:
SMTP_HOST=smtp.resend.com
SMTP_PORT=587
SMTP_USER=resend
SMTP_PASS=re_your_api_key
Using SendGrid:
SMTP_HOST=smtp.sendgrid.net
SMTP_PORT=587
SMTP_USER=apikey
SMTP_PASS=SG.your_api_key
GitHub Actions workflow
name: E2E Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- run: npx playwright install --with-deps chromium
- name: Generate test inbox
id: inbox
uses: zerodrop-dev/create-inbox@8706a59 # v1.0.0
- name: Run E2E tests
run: npx playwright test
env:
CI: true
TEST_INBOX: ${{ steps.inbox.outputs.inbox }}
SMTP_HOST: smtp.resend.com
SMTP_PORT: 587
SMTP_USER: resend
SMTP_PASS: ${{ secrets.RESEND_API_KEY }}
APP_URL: ${{ secrets.STAGING_URL }}
The full picture
| Ethereal (local) | Real SMTP (staging) | Real SMTP + ZeroDrop (CI) | |
|---|---|---|---|
| Visual inspection | ✅ preview URL | ✅ real inbox | ✅ automated |
| No real emails sent | ✅ | ❌ | ❌ |
| Automated in CI | ❌ | ❌ | ✅ |
| Parallel test runs | ❌ | ❌ | ✅ |
| OTP auto-extraction | ❌ | ❌ | ✅ |
| Tests real delivery | ❌ | ✅ | ✅ |
Use Ethereal locally for template inspection. Use a real SMTP provider + ZeroDrop in CI for full end-to-end coverage.
Why not use MailHog?
MailHog is the traditional alternative — a fake SMTP server that catches emails locally. The problems:
- Requires a running Docker container in CI
- Adds 15-30 seconds cold start time per CI run
- Parallel tests share one inbox — race conditions when multiple tests send emails simultaneously
- Unmaintained since 2020
ZeroDrop solves all three: no Docker, no cold start, and every test gets its own isolated inbox.
ZeroDrop — disposable email inboxes for CI pipelines. Free, no signup, no Docker.
→ zerodrop.dev · docs · npm
Top comments (0)