Testing Transactional Emails with SendGrid and AWS SES: Sandbox, Mocks, and Delivery Events

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:

  1. Unit tests — does your code call the SendGrid/SES SDK with the right parameters?
  2. Sandbox tests — does the actual API call succeed without sending real email?
  3. 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/mail or @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

Read more