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: '[email protected]',
to: email,
subject: 'Verify your email',
html: `<p>Click <a href="${verifyUrl}">here</a> to verify your email.</p>`,
});
return Response.json({ success: true });
}
Enter fullscreen mode Exit fullscreen mode
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: '[email protected]',
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',
},
},
});
Enter fullscreen mode Exit fullscreen mode
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,
},
},
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
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();
// → "[email protected]"
// 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');
});
Enter fullscreen mode Exit fullscreen mode
OTP flows
If your app sends a numeric OTP via SendGrid:
await sgMail.send({
from: '[email protected]',
to: email,
subject: 'Your verification code',
html: `<p>Your code is: <strong>${otp}</strong></p>`,
});
Enter fullscreen mode Exit fullscreen mode
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"]');
Enter fullscreen mode Exit fullscreen mode
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: '[email protected]',
to: email,
templateId: 'd-your-template-id',
dynamicTemplateData: {
verify_url: verifyUrl,
username: 'Test User',
},
});
Enter fullscreen mode Exit fullscreen mode
// ZeroDrop catches the fully rendered template output
const email = await mail.waitForLatest(inbox, { timeout: 30000 });
expect(email.magicLink).not.toBeNull();
Enter fullscreen mode Exit fullscreen mode
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 }}
Enter fullscreen mode Exit fullscreen mode
// Use CI inbox or generate locally
const inbox = process.env.TEST_INBOX ?? mail.generateInbox();
Enter fullscreen mode Exit fullscreen mode
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
답글 남기기