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 messagesNode.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.