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_amd64MailHog 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 requiredREST API
List Messages
GET http://localhost:8025/api/v2/messagesResponse:
{
"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/messagesNode.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 NoneDocker 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:
- mailhogGitHub 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 testLimitations
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.