MailHog: Self-Hosted Email Testing for Development and CI

MailHog: Self-Hosted Email Testing for Development and CI

MailHog is a self-hosted fake SMTP server written in Go. It captures all outgoing emails and exposes them via a web UI and REST API. It runs as a single binary or Docker container with no configuration required.

Installation

# Docker (recommended)
docker run -d -p 1025:1025 -p 8025:8025 mailhog/mailhog

<span class="hljs-comment"># macOS
brew install mailhog

<span class="hljs-comment"># Go
go install github.com/mailhog/MailHog@latest

<span class="hljs-comment"># Binary download
wget https://github.com/mailhog/MailHog/releases/latest/download/MailHog_linux_amd64
<span class="hljs-built_in">chmod +x MailHog_linux_amd64
./MailHog_linux_amd64

MailHog listens on:

  • Port 1025 — SMTP
  • Port 8025 — HTTP (web UI + REST API)

Application Configuration

# .env.test
SMTP_HOST=localhost
SMTP_PORT=1025
<span class="hljs-comment"># No authentication required

REST API

List Messages

GET http://localhost:8025/api/v2/messages

Response:

{
  "total": 1,
  "count": 1,
  "start": 0,
  "items": [
    {
      "ID": "abc123",
      "From": { "Relays": null, "Mailbox": "noreply", "Domain": "myapp.com", "Params": "" },
      "To": [{ "Relays": null, "Mailbox": "user", "Domain": "example.com", "Params": "" }],
      "Content": {
        "Headers": { "Subject": ["Welcome to MyApp"] },
        "Body": "...",
        "Size": 1234
      },
      "MIME": { "Parts": [...] }
    }
  ]
}

Delete All Messages

DELETE http://localhost:8025/api/v1/messages

Node.js Test Helper

// helpers/mailhog.js
const MAILHOG_URL = process.env.MAILHOG_URL || 'http://localhost:8025'

export async function getMessages() {
  const res = await fetch(`${MAILHOG_URL}/api/v2/messages`)
  const data = await res.json()
  return data.items || []
}

export async function clearMessages() {
  await fetch(`${MAILHOG_URL}/api/v1/messages`, { method: 'DELETE' })
}

export async function waitForEmail({ to, subject } = {}, timeout = 5000) {
  const start = Date.now()
  while (Date.now() - start < timeout) {
    const messages = await getMessages()
    const match = messages.find(msg => {
      const toMatch = !to || msg.To.some(t => `${t.Mailbox}@${t.Domain}` === to)
      const subjectHeader = msg.Content?.Headers?.Subject?.[0] || ''
      const subjectMatch = !subject || subjectHeader.includes(subject)
      return toMatch && subjectMatch
    })
    if (match) return match
    await new Promise(r => setTimeout(r, 200))
  }
  throw new Error(`Email not received within ${timeout}ms`)
}

export function getEmailBody(message) {
  // MIME multipart — find HTML part
  const parts = message.MIME?.Parts || []
  const htmlPart = parts.find(p => p.Headers?.['Content-Type']?.[0]?.includes('text/html'))
  if (htmlPart) return htmlPart.Body
  return message.Content?.Body || ''
}

Writing Email Tests

import { clearMessages, waitForEmail, getEmailBody } from './helpers/mailhog.js'

beforeEach(async () => {
  await clearMessages()
})

test('sends confirmation email on registration', async () => {
  await fetch('/api/auth/register', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ email: 'alice@example.com', password: 'secret123' })
  })

  const email = await waitForEmail({ to: 'alice@example.com', subject: 'Confirm' })
  const body = getEmailBody(email)

  expect(body).toContain('/confirm/')
  expect(body).not.toContain('undefined')
  expect(body).not.toContain('null')
})

test('password reset email contains valid token', async () => {
  await fetch('/api/auth/forgot-password', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ email: 'alice@example.com' })
  })

  const email = await waitForEmail({ subject: 'Reset' })
  const body = getEmailBody(email)

  const tokenMatch = body.match(/\/reset-password\?token=([a-f0-9]+)/)
  expect(tokenMatch).toBeTruthy()
  expect(tokenMatch[1].length).toBeGreaterThanOrEqual(32)
})

Python Example

import requests
import time

MAILHOG_URL = "http://localhost:8025"

def get_messages():
    r = requests.get(f"{MAILHOG_URL}/api/v2/messages")
    return r.json().get('items', [])

def clear_messages():
    requests.delete(f"{MAILHOG_URL}/api/v1/messages")

def wait_for_email(to=None, subject=None, timeout=5):
    deadline = time.time() + timeout
    while time.time() < deadline:
        for msg in get_messages():
            to_addrs = [f"{t['Mailbox']}@{t['Domain']}" for t in msg.get('To', [])]
            subj = msg['Content']['Headers'].get('Subject', [''])[0]
            if (not to or to in to_addrs) and (not subject or subject in subj):
                return msg
        time.sleep(0.2)
    raise TimeoutError("Email not received")

def test_order_confirmation(client):
    clear_messages()
    client.post('/orders', json={'product_id': 1, 'email': 'bob@example.com'})
    
    email = wait_for_email(to='bob@example.com', subject='Order Confirmation')
    assert email is not None

Docker Compose Setup

# docker-compose.test.yml
services:
  mailhog:
    image: mailhog/mailhog
    ports:
      - "1025:1025"
      - "8025:8025"

  app:
    build: .
    environment:
      SMTP_HOST: mailhog
      SMTP_PORT: 1025
    depends_on:
      - mailhog

GitHub Actions

services:
  mailhog:
    image: mailhog/mailhog
    ports:
      - 1025:1025
      - 8025:8025

steps:
  - name: Wait for MailHog
    run: |
      until curl -sf http://localhost:8025/api/v2/messages; do sleep 1; done

  - name: Run tests
    env:
      SMTP_HOST: localhost
      SMTP_PORT: 1025
      MAILHOG_URL: http://localhost:8025
    run: npm test

Limitations

MailHog has not had a major release since 2017. It works reliably as a fake SMTP server, but for new projects consider Mailpit — a modern replacement written in Go with a cleaner API, better performance, and active maintenance. The usage pattern is nearly identical.

Read more