SendGrid powers transactional email for millions of applications. But how do you test that your SendGrid emails actually arrive, contain the right content, and work end-to-end in CI?
This guide covers the full testing progression — from local development to automated Playwright tests in GitHub Actions.
The app we're testing
A Next.js API route that sends a verification email via SendGrid:
// app/api/auth/signup/route.ts
import sgMail from '@sendgrid/mail';
sgMail.setApiKey(process.env.SENDGRID_API_KEY!);
export async function POST(req: Request) {
const { email } = await req.json();
const token = crypto.randomUUID();
const verifyUrl = `${process.env.NEXT_PUBLIC_URL}/verify?token=${token}`;
await sgMail.send({
from: 'noreply@yourapp.com',
to: email,
subject: 'Verify your email',
html: `<p>Click <a href="${verifyUrl}">here</a> to verify your email.</p>`,
});
return Response.json({ success: true });
}
Stage 1 — Local development: SendGrid sandbox mode
SendGrid has a sandbox mode that intercepts emails without delivering them. Enable it by passing mail_settings in your API call:
await sgMail.send({
from: 'noreply@yourapp.com',
to: email,
subject: 'Verify your email',
html: `<p>Click <a href="${verifyUrl}">here</a> to verify.</p>`,
mailSettings: {
sandboxMode: {
enable: process.env.NODE_ENV === 'development',
},
},
});
In sandbox mode, SendGrid processes the request and validates it but doesn't deliver the email. You can verify delivery in the SendGrid Activity Feed dashboard.
What it solves: Does my app call SendGrid correctly? Is my email template valid?
What it doesn't solve: Automated testing. You can't read emails from SendGrid's Activity Feed programmatically in a test.
Stage 2 — Staging: SendGrid live key to a real inbox
Switch sandbox mode off for staging:
mailSettings: {
sandboxMode: {
enable: false,
},
},
Emails now go through real delivery. You can manually verify they arrive, links work, and nothing lands in spam. Catches real delivery issues like domain authentication problems.
What it solves: Does the email actually reach a real inbox end-to-end?
What it doesn't solve: Automation. You can't run this in CI without access to a real inbox your test can read.
Stage 3 — CI: SendGrid live key + ZeroDrop
For automated Playwright tests in GitHub Actions:
npm install zerodrop-client
import { test, expect } from '@playwright/test';
import { ZeroDrop } from 'zerodrop-client';
const mail = new ZeroDrop();
test('user can sign up and verify email', async ({ page }) => {
// 1. Generate a disposable inbox
const inbox = mail.generateInbox();
// → "swift-x7k2m@zerodrop-sandbox.online"
// 2. Sign up — SendGrid sends a real verification email to this inbox
await page.goto('/signup');
await page.fill('[data-testid="email"]', inbox);
await page.click('[data-testid="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!);
// 5. Assert verified
await expect(page).toHaveURL('/dashboard');
});
OTP flows
If your app sends a numeric OTP via SendGrid:
await sgMail.send({
from: 'noreply@yourapp.com',
to: email,
subject: 'Your verification code',
html: `<p>Your code is: <strong>${otp}</strong></p>`,
});
const email = await mail.waitForLatest(inbox, { timeout: 30000 });
// OTP auto-extracted at the edge — no regex needed
expect(email.otp).not.toBeNull();
await page.fill('[data-testid="otp"]', email.otp!);
await page.click('[data-testid="verify"]');
Using SendGrid Dynamic Templates
If you use SendGrid's Dynamic Templates, the flow is the same — ZeroDrop catches the rendered email:
await sgMail.send({
from: 'noreply@yourapp.com',
to: email,
templateId: 'd-your-template-id',
dynamicTemplateData: {
verify_url: verifyUrl,
username: 'Test User',
},
});
// ZeroDrop catches the fully rendered template output
const email = await mail.waitForLatest(inbox, { timeout: 30000 });
expect(email.magicLink).not.toBeNull();
This tests that your Dynamic Template renders correctly and the link is valid — something sandbox mode can't verify.
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:
TEST_INBOX: ${{ steps.inbox.outputs.inbox }}
SENDGRID_API_KEY: ${{ secrets.SENDGRID_API_KEY }}
NEXT_PUBLIC_URL: ${{ secrets.STAGING_URL }}
// Use CI inbox or generate locally
const inbox = process.env.TEST_INBOX ?? mail.generateInbox();
The full picture
| Sandbox mode (local) | Live key (staging) | Live key + ZeroDrop (CI) | |
|---|---|---|---|
| Validates API call | ✅ | ✅ | ✅ |
| No real emails sent | ✅ | ❌ | ❌ |
| Tests template rendering | ❌ | ✅ | ✅ |
| Automated in CI | ❌ | ❌ | ✅ |
| Parallel test runs | ❌ | ❌ | ✅ |
| OTP auto-extraction | ❌ | ❌ | ✅ |
| Tests real delivery | ❌ | ✅ | ✅ |
Use sandbox mode during development, the live key for manual staging verification, and the live key + ZeroDrop for automated CI.
ZeroDrop — disposable email inboxes for CI pipelines. Free, no signup, no Docker.
→ zerodrop.dev · docs · npm
Top comments (1)
Same pattern applies here: the provider API success is not the user outcome.
The useful Playwright test proves the email content can complete the workflow. Subject, destination, link shape, token validity, and expiry behavior matter more than "SendGrid returned 202." That distinction saves a lot of false confidence in CI.