Local Email Testing with MailHog in Docker: Capturing, Inspecting, and Asserting Emails
MailHog is an open-source local SMTP server that captures all outgoing emails and exposes them via a web UI and HTTP API. No signups, no internet connection required, and no risk of emails reaching real users. This guide covers running MailHog in Docker, asserting on captured emails via its HTTP API, and integrating it into Node.js, Python, and Go test suites.
Key Takeaways
MailHog requires zero configuration and no internet access. Run it in Docker, point your SMTP config to port 1025, and all emails are captured immediately.
Use MailHog's v2 API for programmatic assertions. The /api/v2/messages endpoint returns structured JSON with headers, body, attachments — everything you need for assertions.
Reset the message store before each test. MailHog persists messages in memory across tests. Call DELETE /api/v1/messages between tests to prevent cross-test interference.
Use Docker Compose for consistent dev and CI environments. MailHog + your application in one docker-compose.yml gives every developer the same email testing setup without manual configuration.
MailHog is for local/CI testing only — not production. MailHog captures all emails to all recipients. Never run MailHog in a production environment.
Starting MailHog
Docker (recommended)
docker run -d \
--name mailhog \
-p 1025:1025 \ # SMTP port
-p 8025:8025 \ <span class="hljs-comment"># Web UI port
mailhog/mailhogAccess the web UI at http://localhost:8025.
Docker Compose
# docker-compose.yml
version: '3.8'
services:
app:
build: .
environment:
SMTP_HOST: mailhog
SMTP_PORT: 1025
SMTP_USERNAME: ""
SMTP_PASSWORD: ""
depends_on:
- mailhog
mailhog:
image: mailhog/mailhog
ports:
- "1025:1025" # SMTP
- "8025:8025" # Web UI + HTTP APIMailHog accepts any username/password combination, so no credentials are needed.
MailHog HTTP API
MailHog exposes two API versions:
GET /api/v2/messages → List all captured messages (paginated)
GET /api/v2/messages?start=0&limit=10 → Paginated
GET /api/v1/messages/{id} → Single message by ID
DELETE /api/v1/messages → Delete all messages
DELETE /api/v1/messages/{id} → Delete one messageThe v2 response format:
{
"total": 1,
"count": 1,
"start": 0,
"items": [
{
"ID": "abc123",
"From": { "Relays": null, "Mailbox": "noreply", "Domain": "helpmetest.com", "Params": "" },
"To": [{ "Relays": null, "Mailbox": "user", "Domain": "example.com", "Params": "" }],
"Content": {
"Headers": {
"Subject": ["Welcome to HelpMeTest!"],
"Content-Type": ["text/html; charset=utf-8"],
"From": ["HelpMeTest <noreply@helpmetest.com>"],
"To": ["user@example.com"]
},
"Body": "<html>...</html>",
"Size": 2048,
"MIME": null
},
"Created": "2026-05-17T10:00:00Z",
"MIME": { "Parts": [] },
"Raw": { "From": "noreply@helpmetest.com", "To": ["user@example.com"], "Data": "..." }
}
]
}Node.js Integration Tests
MailHog Client Helper
// tests/helpers/mailhog.js
const MAILHOG_URL = process.env.MAILHOG_URL ?? 'http://localhost:8025';
export async function clearMessages() {
await fetch(`${MAILHOG_URL}/api/v1/messages`, { method: 'DELETE' });
}
export async function getMessages() {
const r = await fetch(`${MAILHOG_URL}/api/v2/messages?limit=50`);
const data = await r.json();
return data.items ?? [];
}
export async function waitForEmail(toAddress, { timeout = 10000 } = {}) {
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
const messages = await getMessages();
const match = messages.find(msg =>
msg.To.some(to => `${to.Mailbox}@${to.Domain}` === toAddress)
);
if (match) return match;
await new Promise(r => setTimeout(r, 500));
}
throw new Error(`No email to ${toAddress} received within ${timeout}ms`);
}
export function getSubject(message) {
return message.Content.Headers.Subject?.[0] ?? '';
}
export function getBody(message) {
// For multipart emails, find the HTML part
if (message.MIME?.Parts?.length > 0) {
const htmlPart = message.MIME.Parts.find(p =>
p.Headers['Content-Type']?.[0]?.includes('text/html')
);
return htmlPart?.Body ?? message.Content.Body;
}
return message.Content.Body;
}
export function getHeader(message, name) {
return message.Content.Headers[name]?.[0] ?? null;
}Test Examples
// tests/email/welcome.test.js
import { clearMessages, waitForEmail, getSubject, getBody, getHeader } from '../helpers/mailhog.js';
import { registerUser } from '../helpers/api.js';
beforeEach(async () => {
await clearMessages();
});
describe('Welcome email', () => {
it('sends email on successful registration', async () => {
await registerUser({ email: 'welcome@example.com', name: 'Welcome User' });
const email = await waitForEmail('welcome@example.com');
expect(email).toBeDefined();
});
it('has correct subject', async () => {
await registerUser({ email: 'subject@example.com', name: 'Subject Test' });
const email = await waitForEmail('subject@example.com');
expect(getSubject(email)).toBe('Welcome to HelpMeTest!');
});
it('body contains personalized greeting', async () => {
await registerUser({ email: 'greet@example.com', name: 'Jane Doe' });
const email = await waitForEmail('greet@example.com');
const body = getBody(email);
expect(body).toContain('Jane Doe');
});
it('body contains activation link', async () => {
await registerUser({ email: 'activate@example.com', name: 'Activate' });
const email = await waitForEmail('activate@example.com');
const body = getBody(email);
expect(body).toMatch(/\/activate\/[a-f0-9]{32,}/);
});
it('from address is branded', async () => {
await registerUser({ email: 'from@example.com', name: 'From Test' });
const email = await waitForEmail('from@example.com');
const from = getHeader(email, 'From');
expect(from).toContain('helpmetest.com');
expect(from).toContain('HelpMeTest');
});
it('has reply-to header set', async () => {
await registerUser({ email: 'replyto@example.com', name: 'Reply Test' });
const email = await waitForEmail('replyto@example.com');
const replyTo = getHeader(email, 'Reply-To');
expect(replyTo).toBe('support@helpmetest.com');
});
});Testing That Emails Are NOT Sent
describe('Email suppression', () => {
it('does not send welcome email when user already exists', async () => {
// First registration
await registerUser({ email: 'existing@example.com', name: 'First' });
await waitForEmail('existing@example.com');
await clearMessages();
// Second registration with same email — should fail or silently suppress
await registerUser({ email: 'existing@example.com', name: 'Second' }).catch(() => {});
// Wait 2 seconds, assert no new email
await new Promise(r => setTimeout(r, 2000));
const messages = await getMessages();
expect(messages).toHaveLength(0);
});
it('does not email unsubscribed users', async () => {
await createUser({ email: 'unsub@example.com', unsubscribed: true });
await triggerNotification('unsub@example.com');
await new Promise(r => setTimeout(r, 2000));
const messages = await getMessages();
expect(messages).toHaveLength(0);
});
});Python Integration Tests
# tests/email/test_mailhog.py
import time
import pytest
import requests
MAILHOG_URL = "http://localhost:8025"
def clear_messages():
requests.delete(f"{MAILHOG_URL}/api/v1/messages")
def get_messages() -> list:
r = requests.get(f"{MAILHOG_URL}/api/v2/messages?limit=50")
return r.json().get("items", [])
def wait_for_email(to_address: str, timeout: float = 10.0) -> dict:
deadline = time.time() + timeout
while time.time() < deadline:
for msg in get_messages():
recipients = [f"{r['Mailbox']}@{r['Domain']}" for r in msg["To"]]
if to_address in recipients:
return msg
time.sleep(0.5)
raise TimeoutError(f"No email to {to_address} within {timeout}s")
def get_subject(message: dict) -> str:
return (message["Content"]["Headers"].get("Subject") or [""])[0]
def get_body(message: dict) -> str:
parts = message.get("MIME", {}).get("Parts", [])
for part in parts:
content_type = (part["Headers"].get("Content-Type") or [""])[0]
if "text/html" in content_type:
return part["Body"]
return message["Content"]["Body"]
@pytest.fixture(autouse=True)
def clean_mailhog():
clear_messages()
yield
clear_messages()
class TestPasswordResetEmail:
def test_sends_reset_email(self, client):
client.post("/auth/forgot-password", json={"email": "user@example.com"})
email = wait_for_email("user@example.com")
assert "Password Reset" in get_subject(email)
def test_reset_email_contains_token_link(self, client):
client.post("/auth/forgot-password", json={"email": "user@example.com"})
email = wait_for_email("user@example.com")
body = get_body(email)
assert "/reset-password/" in body
def test_no_email_for_nonexistent_user(self, client):
client.post("/auth/forgot-password", json={"email": "nobody@example.com"})
time.sleep(2)
assert len(get_messages()) == 0Go Integration Tests
// tests/email/mailhog_test.go
package email_test
import (
"encoding/json"
"fmt"
"net/http"
"testing"
"time"
)
const mailhogURL = "http://localhost:8025"
type MailhogMessage struct {
ID string `json:"ID"`
Subject string
To []struct {
Mailbox string `json:"Mailbox"`
Domain string `json:"Domain"`
} `json:"To"`
Content struct {
Headers map[string][]string `json:"Headers"`
Body string `json:"Body"`
} `json:"Content"`
}
func clearMessages(t *testing.T) {
req, _ := http.NewRequest("DELETE", mailhogURL+"/api/v1/messages", nil)
http.DefaultClient.Do(req)
}
func waitForEmail(t *testing.T, toAddress string, timeout time.Duration) *MailhogMessage {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
resp, err := http.Get(mailhogURL + "/api/v2/messages?limit=50")
if err != nil {
t.Fatal(err)
}
var result struct {
Items []MailhogMessage `json:"items"`
}
json.NewDecoder(resp.Body).Decode(&result)
resp.Body.Close()
for _, msg := range result.Items {
for _, to := range msg.To {
addr := fmt.Sprintf("%s@%s", to.Mailbox, to.Domain)
if addr == toAddress {
return &msg
}
}
}
time.Sleep(500 * time.Millisecond)
}
t.Fatalf("No email to %s received within %v", toAddress, timeout)
return nil
}
func TestWelcomeEmail(t *testing.T) {
clearMessages(t)
defer clearMessages(t)
// Trigger email
registerUser(t, "test@example.com", "Test User")
email := waitForEmail(t, "test@example.com", 10*time.Second)
subjects := email.Content.Headers["Subject"]
if len(subjects) == 0 || subjects[0] != "Welcome to MyApp!" {
t.Errorf("Expected welcome subject, got %v", subjects)
}
}MailHog in GitHub Actions
name: Email Integration Tests
on: [push, pull_request]
jobs:
email-tests:
runs-on: ubuntu-latest
services:
mailhog:
image: mailhog/mailhog
ports:
- 1025:1025
- 8025:8025
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: npm ci
- run: npm start & # Start your app
- run: sleep 5 # Wait for app to be ready
- run: npm run test:email
env:
SMTP_HOST: localhost
SMTP_PORT: 1025
MAILHOG_URL: http://localhost:8025MailHog vs Mailtrap — When to Use Which
| Feature | MailHog | Mailtrap |
|---|---|---|
| Cost | Free (open source) | Free tier + paid |
| Setup | Docker, self-hosted | Cloud service |
| Internet required | No | Yes |
| Spam scoring | No | Yes |
| Email rendering previews | Basic | Multi-client |
| Attachment download | Via API | Via API |
| API | Simple HTTP | Full REST + WebSocket |
| Parallel test isolation | Manual (clear + filter) | Per-inbox |
Use MailHog for local dev and CI environments where you want zero external dependencies. Use Mailtrap when you need spam scoring, multi-client rendering previews, or a shared web UI for the team.
For E2E tests that follow an email link and verify what happens in the browser — clicking an activation link and being redirected to an onboarding flow — HelpMeTest covers the browser-side behavior that MailHog assertions can't reach.