Testing Transactional Emails with SendGrid and AWS SES: Sandbox, Mocks, and Delivery Events
SendGrid and AWS SES both offer sandbox modes for testing without sending real emails. Beyond sandbox, you need to test delivery event webhooks (bounce, complaint, open, click) and mock the SDK in unit tests. This guide covers the full testing stack for both providers.
What Transactional Email Testing Covers
Testing transactional email has three layers:
- Unit tests — does your code call the SendGrid/SES SDK with the right parameters?
- Sandbox tests — does the actual API call succeed without sending real email?
- Webhook tests — does your app handle delivery events (bounce, complaint, open, unsubscribe) correctly?
Most teams only do layer 1. Skipping layers 2 and 3 means you'll miss API credential issues, malformed templates, and broken webhook handlers until production breaks.
SendGrid Testing
Sandbox Mode
SendGrid's Mail Send API supports a sandbox flag that processes the request (validates it, checks template rendering, applies personalizations) but does not deliver the email:
const client = require('@sendgrid/mail');
client.setApiKey(process.env.SENDGRID_API_KEY);
async function sendWelcomeEmail(user) {
const msg = {
to: user.email,
from: 'no-reply@yourapp.com',
subject: `Welcome, ${user.name}!`,
templateId: process.env.SENDGRID_WELCOME_TEMPLATE_ID,
dynamicTemplateData: {
name: user.name,
verificationUrl: `${process.env.APP_URL}/verify?token=${user.token}`,
},
mailSettings: {
sandboxMode: {
enable: process.env.NODE_ENV === 'test',
},
},
};
const [response] = await client.send(msg);
return response.statusCode;
}In sandbox mode, a successful call returns 200 and an empty body instead of 202 Accepted. The response is otherwise identical.
// test/email-service.test.js
process.env.NODE_ENV = 'test';
const { sendWelcomeEmail } = require('../src/email-service');
it('sends welcome email via SendGrid', async () => {
const user = { email: 'jane@example.com', name: 'Jane', token: 'tok123' };
const statusCode = await sendWelcomeEmail(user);
// Sandbox returns 200 (vs 202 in production)
expect(statusCode).toBe(200);
});Mocking the SendGrid SDK
Sandbox mode still requires a real API key and network call. For unit tests that should be fast and isolated, mock the SDK:
// test/email-service.test.js
jest.mock('@sendgrid/mail', () => ({
setApiKey: jest.fn(),
send: jest.fn().mockResolvedValue([{ statusCode: 200 }]),
}));
const sgMail = require('@sendgrid/mail');
const { sendWelcomeEmail } = require('../src/email-service');
it('sends email to the correct address', async () => {
await sendWelcomeEmail({ email: 'jane@example.com', name: 'Jane', token: 'tok' });
expect(sgMail.send).toHaveBeenCalledWith(
expect.objectContaining({
to: 'jane@example.com',
templateId: expect.any(String),
})
);
});
it('includes dynamic template data', async () => {
await sendWelcomeEmail({ email: 'jane@example.com', name: 'Jane', token: 'mytoken' });
const [call] = sgMail.send.mock.calls;
const msg = call[0];
expect(msg.dynamicTemplateData.name).toBe('Jane');
expect(msg.dynamicTemplateData.verificationUrl).toContain('mytoken');
});Testing SendGrid Webhooks
SendGrid sends POST requests to your webhook endpoint for delivery events: delivered, bounce, spam_report, open, click, unsubscribe. These drive critical business logic (suppressing bounced addresses, tracking engagement), so test them:
// src/webhooks/sendgrid.js
async function handleSendGridWebhook(events) {
for (const event of events) {
switch (event.event) {
case 'bounce':
await markEmailAsBounced(event.email);
break;
case 'spam_report':
await suppressEmail(event.email);
break;
case 'unsubscribe':
await unsubscribeUser(event.email);
break;
}
}
}// test/webhooks/sendgrid.test.js
const request = require('supertest');
const app = require('../../src/app');
const db = require('../../src/db');
describe('SendGrid webhook handler', () => {
it('marks bounced emails as undeliverable', async () => {
const payload = [
{
event: 'bounce',
email: 'bounce@example.com',
type: 'bounce',
timestamp: Date.now() / 1000,
},
];
const response = await request(app)
.post('/webhooks/sendgrid')
.send(payload)
.set('Content-Type', 'application/json');
expect(response.status).toBe(200);
const user = await db.users.findByEmail('bounce@example.com');
expect(user.emailBounced).toBe(true);
});
it('suppresses spam-reported addresses', async () => {
const payload = [{ event: 'spam_report', email: 'spam@example.com' }];
await request(app)
.post('/webhooks/sendgrid')
.send(payload)
.set('Content-Type', 'application/json');
const suppressed = await db.suppressions.find('spam@example.com');
expect(suppressed).toBeTruthy();
});
});Verifying webhook signatures: SendGrid signs webhooks with a public key. Test that your handler rejects invalid signatures:
it('rejects requests with invalid signature', async () => {
const response = await request(app)
.post('/webhooks/sendgrid')
.send([{ event: 'bounce', email: 'test@example.com' }])
.set('X-Twilio-Email-Event-Webhook-Signature', 'invalid')
.set('X-Twilio-Email-Event-Webhook-Timestamp', Date.now().toString());
expect(response.status).toBe(403);
});AWS SES Testing
SES Simulator Addresses
AWS SES provides a set of special email addresses that trigger different delivery outcomes without consuming send quota:
| Address | Behavior |
|---|---|
success@simulator.amazonses.com |
Accepted and delivered |
bounce@simulator.amazonses.com |
Hard bounce notification |
ooto@simulator.amazonses.com |
Out-of-office auto-reply |
complaint@simulator.amazonses.com |
Spam complaint notification |
suppressionlist@simulator.amazonses.com |
Suppression list bounce |
Use these in integration tests:
const { SESClient, SendEmailCommand } = require('@aws-sdk/client-ses');
const ses = new SESClient({ region: process.env.AWS_REGION });
it('sends successfully to verified simulator address', async () => {
const command = new SendEmailCommand({
Source: 'test@yourapp.com',
Destination: {
ToAddresses: ['success@simulator.amazonses.com'],
},
Message: {
Subject: { Data: 'Integration test' },
Body: { Text: { Data: 'Test message' } },
},
});
const response = await ses.send(command);
expect(response.MessageId).toBeTruthy();
});Mocking the AWS SDK
For unit tests that don't hit the real AWS API:
jest.mock('@aws-sdk/client-ses', () => {
const mockSend = jest.fn().mockResolvedValue({ MessageId: 'test-message-id' });
return {
SESClient: jest.fn(() => ({ send: mockSend })),
SendEmailCommand: jest.fn(params => ({ _params: params })),
__mockSend: mockSend,
};
});
const { SESClient, SendEmailCommand, __mockSend } = require('@aws-sdk/client-ses');
const { sendPasswordResetEmail } = require('../src/email-service');
it('sends password reset email with correct parameters', async () => {
await sendPasswordResetEmail('user@example.com', 'reset-token-xyz');
expect(SendEmailCommand).toHaveBeenCalledWith(
expect.objectContaining({
Destination: { ToAddresses: ['user@example.com'] },
})
);
});Testing SES SNS Notifications
SES delivers bounce and complaint notifications via SNS. Your application subscribes to an SNS topic and receives HTTP POST requests. Test the handler:
// test/webhooks/ses.test.js
const request = require('supertest');
const app = require('../../src/app');
const bounceNotification = {
Type: 'Notification',
TopicArn: 'arn:aws:sns:us-east-1:123456789:ses-notifications',
Message: JSON.stringify({
notificationType: 'Bounce',
bounce: {
bounceType: 'Permanent',
bounceSubType: 'General',
bouncedRecipients: [
{ emailAddress: 'hard-bounce@example.com', action: 'failed' },
],
timestamp: new Date().toISOString(),
},
mail: {
source: 'no-reply@yourapp.com',
destination: ['hard-bounce@example.com'],
},
}),
};
const complaintNotification = {
Type: 'Notification',
Message: JSON.stringify({
notificationType: 'Complaint',
complaint: {
complainedRecipients: [{ emailAddress: 'complaint@example.com' }],
complaintFeedbackType: 'abuse',
},
}),
};
it('handles hard bounce notification', async () => {
const response = await request(app)
.post('/webhooks/ses')
.send(bounceNotification)
.set('Content-Type', 'application/json');
expect(response.status).toBe(200);
// Assert user was added to suppression list
const suppressed = await db.suppressions.find('hard-bounce@example.com');
expect(suppressed).toBeTruthy();
});
it('handles complaint notification', async () => {
const response = await request(app)
.post('/webhooks/ses')
.send(complaintNotification)
.set('Content-Type', 'application/json');
expect(response.status).toBe(200);
const user = await db.users.findByEmail('complaint@example.com');
expect(user.emailOptOut).toBe(true);
});
it('handles SNS subscription confirmation', async () => {
const confirmPayload = {
Type: 'SubscriptionConfirmation',
SubscribeURL: 'https://sns.amazonaws.com/...',
Token: 'abc123',
};
// Your handler should auto-confirm subscriptions
const response = await request(app)
.post('/webhooks/ses')
.send(confirmPayload)
.set('Content-Type', 'application/json');
expect(response.status).toBe(200);
});Comparing Provider Test Coverage
| Test type | SendGrid | AWS SES |
|---|---|---|
| API call (unit) | Mock @sendgrid/mail |
Mock @aws-sdk/client-ses |
| Real API (sandbox) | mailSettings.sandboxMode: true |
Simulator email addresses |
| Delivery event webhooks | SNS/webhook POST handler tests | SNS notification handler tests |
| Template rendering | Sandbox API validates templates | N/A (SES uses raw HTML/templates) |
| Bounce handling | Webhook test with bounce event | SNS notification with bounce type |
| Complaint handling | Webhook test with spam_report event | SNS notification with complaint type |
Running in CI
# .github/workflows/email-tests.yml
name: Email Service Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
env:
SENDGRID_API_KEY: ${{ secrets.SENDGRID_API_KEY }}
AWS_REGION: us-east-1
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- run: npm test -- --testPathPattern="email|webhook"For the sandbox/integration tests, you need real API credentials—store them in CI secrets. For unit tests with mocked SDKs, no credentials are needed.
Full-Stack Email Flow Testing
Unit and webhook tests cover the server-side email path. For true end-to-end coverage—user submits signup form, receives welcome email, clicks verification link, account is activated—use an end-to-end test suite. HelpMeTest supports multi-step tests that drive a real browser through signup workflows and assert on the resulting state, including email receipt via Mailtrap integration.
Summary
- Use SendGrid's sandbox mode (
mailSettings.sandboxMode: true) for integration tests without real delivery - Use AWS SES simulator addresses (
success@simulator.amazonses.com,bounce@simulator.amazonses.com) for integration tests - Mock the SDK (
@sendgrid/mailor@aws-sdk/client-ses) for fast, isolated unit tests - Test delivery event webhooks with real HTTP POST requests and assert on database state changes
- Always test bounce and complaint handlers—they protect your sender reputation in production
- Store API credentials in CI secrets; never hardcode them