Local Email Testing with MailHog in Docker: Capturing, Inspecting, and Asserting Emails

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 run -d \
  --name mailhog \
  -p 1025:1025 \   # SMTP port
  -p 8025:8025 \   <span class="hljs-comment"># Web UI port
  mailhog/mailhog

Access 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 API

MailHog 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 message

The 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()) == 0

Go 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:8025

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

Read more