Testing Transactional Emails with Mailtrap: Sandbox, API Assertions, and Spam Analysis

Testing Transactional Emails with Mailtrap: Sandbox, API Assertions, and Spam Analysis

Mailtrap provides a fake SMTP inbox that captures outgoing emails so they never reach real users during testing. Beyond just trapping emails, its API lets you fetch email content, assert on subject and body, check spam scores, and validate HTML rendering. This guide covers the full Mailtrap testing workflow for Node.js, Python, and CI environments.

Key Takeaways

Use Mailtrap's API to assert on captured emails, not just check that they were sent. The Mailtrap API returns email headers, HTML body, text body, attachments, and spam scores — assert on all of them.

Create a separate Mailtrap inbox per test suite. Multiple test suites sharing one inbox create race conditions. Use Mailtrap's API to create temporary inboxes and delete them after tests.

Check spam score in tests, not just content. Mailtrap calculates SpamAssassin scores — build this into your CI pipeline to catch deliverability regressions before production.

Test HTML email rendering. Mailtrap shows email previews in different clients. For automated rendering tests, use the HTML body from the API and check it with a CSS parser or screenshot comparison.

Assert on email headers. Reply-To, List-Unsubscribe, DKIM status, and From address format are all header-level bugs that users see. Test them explicitly.

Why Mailtrap

Testing email delivery is difficult without a specialized tool because:

  • Real SMTP sends emails to real inboxes (bad in dev/test)
  • Logging that "send was called" doesn't verify the email was correct
  • Rendering bugs are invisible without viewing the HTML
  • Spam scores are invisible without SpamAssassin running

Mailtrap solves all four by providing a fake SMTP server that captures emails and exposes them via API for automated assertions.

Setting Up Mailtrap

Sign up at mailtrap.io and create an inbox. Each inbox gets:

  • SMTP credentials (for your application to send through)
  • An inbox ID (for API assertions)
  • An API token (for the test assertion API)

Configure your application's email client to use the Mailtrap SMTP server in test/staging environments:

SMTP_HOST=sandbox.smtp.mailtrap.io
SMTP_PORT=2525
SMTP_USERNAME=<your-mailtrap-username>
SMTP_PASSWORD=<your-mailtrap-password>

Mailtrap API Overview

Base URL: https://mailtrap.io/api

Key endpoints:

GET  /inboxes                          → List all inboxes
GET  /inboxes/{inbox_id}/messages      → List captured emails
GET  /inboxes/{inbox_id}/messages/{id} → Single email details
GET  /inboxes/{inbox_id}/messages/{id}/body.html  → HTML body
GET  /inboxes/{inbox_id}/messages/{id}/body.txt   → Text body
GET  /inboxes/{inbox_id}/messages/{id}/spam_report → SpamAssassin report
DELETE /inboxes/{inbox_id}/messages    → Clear all messages

Node.js Integration Tests

Setup

// tests/email/mailtrap.js
const MAILTRAP_API_TOKEN = process.env.MAILTRAP_API_TOKEN;
const MAILTRAP_INBOX_ID = process.env.MAILTRAP_INBOX_ID;
const BASE_URL = 'https://mailtrap.io/api';

async function mailtrapRequest(path, options = {}) {
  const response = await fetch(`${BASE_URL}${path}`, {
    headers: {
      'Api-Token': MAILTRAP_API_TOKEN,
      'Content-Type': 'application/json',
    },
    ...options,
  });

  if (!response.ok) {
    throw new Error(`Mailtrap API error ${response.status}: ${await response.text()}`);
  }

  return response.json();
}

export async function clearInbox() {
  await fetch(`${BASE_URL}/inboxes/${MAILTRAP_INBOX_ID}/messages`, {
    method: 'PATCH',
    headers: { 'Api-Token': MAILTRAP_API_TOKEN, 'Content-Type': 'application/json' },
    body: JSON.stringify({ messages: [] }),
  });
}

export async function getMessages() {
  const data = await mailtrapRequest(`/inboxes/${MAILTRAP_INBOX_ID}/messages`);
  return data;
}

export async function waitForEmail(toAddress, { timeout = 10000 } = {}) {
  const start = Date.now();

  while (Date.now() - start < timeout) {
    const messages = await getMessages();
    const match = messages.find(m =>
      m.to_email === toAddress || m.to.includes(toAddress)
    );
    if (match) return match;

    await new Promise(r => setTimeout(r, 1000));
  }

  throw new Error(`No email to ${toAddress} received within ${timeout}ms`);
}

export async function getEmailBody(messageId) {
  return mailtrapRequest(`/inboxes/${MAILTRAP_INBOX_ID}/messages/${messageId}/body.html`);
}

export async function getSpamReport(messageId) {
  return mailtrapRequest(`/inboxes/${MAILTRAP_INBOX_ID}/messages/${messageId}/spam_report`);
}

Writing Email Assertion Tests

// tests/email/welcome-email.test.js
import { clearInbox, waitForEmail, getEmailBody, getSpamReport } from './mailtrap.js';
import { registerUser } from '../helpers/api.js';

describe('Welcome email', () => {
  beforeEach(async () => {
    await clearInbox();
  });

  it('sends welcome email on registration', async () => {
    await registerUser({
      email: 'newuser@example.com',
      name: 'New User',
    });

    const email = await waitForEmail('newuser@example.com', { timeout: 10000 });

    expect(email).toBeDefined();
    expect(email.subject).toBe('Welcome to HelpMeTest!');
  });

  it('welcome email has correct sender', async () => {
    await registerUser({ email: 'test@example.com', name: 'Test' });

    const email = await waitForEmail('test@example.com');

    expect(email.from_email).toBe('noreply@helpmetest.com');
    expect(email.from_name).toBe('HelpMeTest');
  });

  it('welcome email is addressed to the registered user', async () => {
    await registerUser({ email: 'jane@example.com', name: 'Jane Smith' });

    const email = await waitForEmail('jane@example.com');

    expect(email.to_email).toBe('jane@example.com');
    expect(email.to_name).toBe('Jane Smith');
  });

  it('welcome email contains activation link', async () => {
    await registerUser({ email: 'activate@example.com', name: 'Activate' });

    const email = await waitForEmail('activate@example.com');
    const htmlBody = await getEmailBody(email.id);

    expect(htmlBody).toContain('activate');
    expect(htmlBody).toMatch(/https?:\/\/[^\s"]+\/activate\/[^\s"]+/);
  });

  it('welcome email has acceptable spam score', async () => {
    await registerUser({ email: 'spam@example.com', name: 'Spam Test' });

    const email = await waitForEmail('spam@example.com');
    const spamReport = await getSpamReport(email.id);

    // SpamAssassin: score < 5.0 is not spam
    expect(spamReport.result.score).toBeLessThan(5.0);
  });
});

Testing Password Reset Emails

describe('Password reset email', () => {
  beforeEach(() => clearInbox());

  it('sends reset email with expiring link', async () => {
    await requestPasswordReset('user@example.com');

    const email = await waitForEmail('user@example.com');

    expect(email.subject).toContain('Password Reset');

    const html = await getEmailBody(email.id);
    // Reset link should contain a token
    expect(html).toMatch(/\/reset-password\/[a-f0-9-]{36}/);
  });

  it('does not send email for non-existent address', async () => {
    await requestPasswordReset('nobody@example.com');

    // Wait a short time, then assert no email was sent
    await new Promise(r => setTimeout(r, 3000));
    const messages = await getMessages();

    expect(messages).toHaveLength(0);
  });

  it('reset email has Reply-To header set', async () => {
    await requestPasswordReset('user@example.com');

    const email = await waitForEmail('user@example.com');

    // Reply-To should point to support, not noreply
    expect(email.raw_headers).toContain('Reply-To: support@helpmetest.com');
  });
});

Python Integration Tests

# tests/email/test_mailtrap.py
import os
import time
import requests
import pytest

MAILTRAP_API_TOKEN = os.environ['MAILTRAP_API_TOKEN']
MAILTRAP_INBOX_ID = os.environ['MAILTRAP_INBOX_ID']
BASE_URL = "https://mailtrap.io/api"

HEADERS = {"Api-Token": MAILTRAP_API_TOKEN}


def clear_inbox():
    requests.patch(
        f"{BASE_URL}/inboxes/{MAILTRAP_INBOX_ID}/messages",
        headers=HEADERS,
        json={"messages": []},
    )


def get_messages():
    r = requests.get(f"{BASE_URL}/inboxes/{MAILTRAP_INBOX_ID}/messages", headers=HEADERS)
    r.raise_for_status()
    return r.json()


def wait_for_email(to_address: str, timeout: float = 10.0):
    deadline = time.time() + timeout
    while time.time() < deadline:
        for msg in get_messages():
            if to_address in (msg.get("to_email", "") + msg.get("to", "")):
                return msg
        time.sleep(1)
    raise TimeoutError(f"No email to {to_address} within {timeout}s")


def get_email_body_html(message_id: int) -> str:
    r = requests.get(
        f"{BASE_URL}/inboxes/{MAILTRAP_INBOX_ID}/messages/{message_id}/body.html",
        headers=HEADERS,
    )
    return r.text


def get_spam_report(message_id: int) -> dict:
    r = requests.get(
        f"{BASE_URL}/inboxes/{MAILTRAP_INBOX_ID}/messages/{message_id}/spam_report",
        headers=HEADERS,
    )
    return r.json()


@pytest.fixture(autouse=True)
def fresh_inbox():
    clear_inbox()
    yield
    clear_inbox()


class TestWelcomeEmail:

    def test_welcome_email_is_sent(self, register_user):
        register_user(email="test@example.com", name="Test User")

        email = wait_for_email("test@example.com")

        assert email is not None
        assert "Welcome" in email["subject"]

    def test_welcome_email_from_address(self, register_user):
        register_user(email="test@example.com", name="Test")

        email = wait_for_email("test@example.com")

        assert email["from_email"] == "noreply@helpmetest.com"

    def test_welcome_email_spam_score(self, register_user):
        register_user(email="spamtest@example.com", name="Spam Test")

        email = wait_for_email("spamtest@example.com")
        report = get_spam_report(email["id"])

        assert report["result"]["score"] < 5.0, \
            f"Spam score too high: {report['result']['score']}"

    def test_welcome_email_html_structure(self, register_user):
        register_user(email="html@example.com", name="HTML Test")

        email = wait_for_email("html@example.com")
        html = get_email_body_html(email["id"])

        assert "<html" in html.lower()
        assert "HTML Test" in html
        assert "helpmetest.com" in html.lower()

Testing Email Attachments

it('invoice email includes PDF attachment', async () => {
  await sendInvoiceEmail({
    to: 'client@example.com',
    invoiceId: 'INV-2026-001',
  });

  const email = await waitForEmail('client@example.com');

  expect(email.attachments_count).toBe(1);

  // Fetch attachment details
  const attachments = await mailtrapRequest(
    `/inboxes/${MAILTRAP_INBOX_ID}/messages/${email.id}/attachments`
  );

  expect(attachments[0].filename).toBe('invoice-INV-2026-001.pdf');
  expect(attachments[0].attachment_type).toBe('attachment');
  expect(attachments[0].content_type).toBe('application/pdf');
  expect(attachments[0].transfer_encoding).toBe('base64');

  // Verify attachment size is reasonable
  const sizeKb = attachments[0].attachment_size / 1024;
  expect(sizeKb).toBeGreaterThan(10);   // At least 10KB
  expect(sizeKb).toBeLessThan(5000);    // Under 5MB
});

Creating Isolated Inboxes Per Test Suite

For parallel test suites, create and destroy inboxes via API:

export async function createTestInbox(name) {
  const data = await mailtrapRequest('/inboxes', {
    method: 'POST',
    body: JSON.stringify({ inbox: { name, max_size: 50 } }),
  });
  return data.id;
}

export async function deleteTestInbox(inboxId) {
  await fetch(`${BASE_URL}/inboxes/${inboxId}`, {
    method: 'DELETE',
    headers: { 'Api-Token': MAILTRAP_API_TOKEN },
  });
}

// In tests:
let testInboxId;
beforeAll(async () => {
  testInboxId = await createTestInbox(`test-suite-${Date.now()}`);
});
afterAll(async () => {
  await deleteTestInbox(testInboxId);
});

CI Configuration

name: Email Tests
on: [push, pull_request]

jobs:
  email-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20' }
      - run: npm ci
      - run: npm test -- --testPathPattern='email'
        env:
          MAILTRAP_API_TOKEN: ${{ secrets.MAILTRAP_API_TOKEN }}
          MAILTRAP_INBOX_ID: ${{ secrets.MAILTRAP_INBOX_ID }}
          # Point your app's SMTP config to Mailtrap
          SMTP_HOST: sandbox.smtp.mailtrap.io
          SMTP_PORT: 2525
          SMTP_USERNAME: ${{ secrets.MAILTRAP_SMTP_USERNAME }}
          SMTP_PASSWORD: ${{ secrets.MAILTRAP_SMTP_PASSWORD }}

What to Test

Cover these email scenarios:

  • Trigger verification — email is sent when the expected event occurs
  • Recipient — To, CC, BCC addresses are correct
  • Sender — From address and name match your brand
  • Subject — exact string or pattern
  • Content — personalization fields, action URLs, unsubscribe links
  • Attachments — filename, MIME type, approximate size
  • Headers — Reply-To, List-Unsubscribe, Precedence
  • Spam score — SpamAssassin score below your threshold
  • Negative cases — no email sent when it shouldn't be

For end-to-end email flow testing that validates that clicking an email's CTA link opens the correct page, HelpMeTest adds browser-level coverage that complements Mailtrap's inbox assertions.

Read more