Mailpit: Modern Email Testing for Development and CI
Mailpit is a self-hosted email testing tool and SMTP server written in Go. It's the modern replacement for MailHog — actively maintained, faster, and with a cleaner REST API. It captures all outgoing emails from your application and exposes them via a web UI and API.
Why Mailpit
- Actively maintained — regular releases, unlike MailHog (last release 2017)
- Performance — handles thousands of messages without degradation
- Clean API — v1 REST API with consistent JSON responses
- Features — message search, tags, HTML/text preview, attachment handling
- Single binary — no dependencies, ~10MB
Setup
# Docker
docker run -d -p 1025:1025 -p 8025:8025 axllent/mailpit
<span class="hljs-comment"># macOS
brew install mailpit
<span class="hljs-comment"># Go install
go install github.com/axllent/mailpit@latest
<span class="hljs-comment"># Binary
wget https://github.com/axllent/mailpit/releases/latest/download/mailpit-linux-amd64.tar.gzPorts:
1025— SMTP8025— HTTP (web UI + REST API)
Application Configuration
# .env.test
SMTP_HOST=localhost
SMTP_PORT=1025
<span class="hljs-comment"># No authentication needed by defaultREST API
Mailpit's API is at /api/v1/.
# List messages (newest first)
GET http://localhost:8025/api/v1/messages
<span class="hljs-comment"># Get single message
GET http://localhost:8025/api/v1/message/{ID}
<span class="hljs-comment"># Delete all messages
DELETE http://localhost:8025/api/v1/messages
<span class="hljs-comment"># Search messages
GET http://localhost:8025/api/v1/search?query=subject:WelcomeMessage Response Structure
{
"ID": "abc123def456",
"MessageID": "<unique@domain>",
"From": { "Name": "MyApp", "Address": "noreply@myapp.com" },
"To": [{ "Name": "Alice", "Address": "alice@example.com" }],
"Subject": "Welcome to MyApp",
"Date": "2024-01-15T10:30:00Z",
"HTML": "<html>...</html>",
"Text": "Welcome to MyApp...",
"Attachments": 0,
"Size": 2048
}Node.js Test Helper
// helpers/mailpit.js
const BASE = process.env.MAILPIT_URL || 'http://localhost:8025'
export async function getMessages(limit = 50) {
const res = await fetch(`${BASE}/api/v1/messages?limit=${limit}`)
const data = await res.json()
return data.messages || []
}
export async function deleteAllMessages() {
await fetch(`${BASE}/api/v1/messages`, { method: 'DELETE' })
}
export async function waitForEmail({ to, subject, timeout = 5000 } = {}) {
const deadline = Date.now() + timeout
while (Date.now() < deadline) {
const messages = await getMessages()
const match = messages.find(msg => {
const toMatch = !to || msg.To?.some(t => t.Address === to)
const subjectMatch = !subject || msg.Subject?.includes(subject)
return toMatch && subjectMatch
})
if (match) return match
await new Promise(r => setTimeout(r, 200))
}
throw new Error(`No email matching { to: ${to}, subject: ${subject} } within ${timeout}ms`)
}Email Tests with Vitest
import { describe, test, expect, beforeEach } from 'vitest'
import { deleteAllMessages, waitForEmail } from './helpers/mailpit.js'
import { app } from '../src/app.js'
describe('Email flows', () => {
beforeEach(async () => {
await deleteAllMessages()
})
test('sends welcome email after registration', async () => {
await app.request('/api/register', {
method: 'POST',
body: JSON.stringify({ email: 'alice@example.com', name: 'Alice' }),
headers: { 'Content-Type': 'application/json' }
})
const email = await waitForEmail({
to: 'alice@example.com',
subject: 'Welcome'
})
expect(email.From.Address).toBe('noreply@myapp.com')
expect(email.Subject).toBe('Welcome to MyApp, Alice!')
expect(email.HTML).toContain('Confirm your email')
expect(email.HTML).toContain('alice@example.com')
})
test('sends password reset with valid token', async () => {
await app.request('/api/forgot-password', {
method: 'POST',
body: JSON.stringify({ email: 'alice@example.com' }),
headers: { 'Content-Type': 'application/json' }
})
const email = await waitForEmail({ subject: 'Reset your password' })
// Extract and validate reset URL
const tokenMatch = email.HTML.match(/href="[^"]*reset-password\?token=([a-f0-9]+)"/)
expect(tokenMatch).toBeTruthy()
const token = tokenMatch[1]
expect(token.length).toBeGreaterThanOrEqual(32)
// Verify token works
const resetRes = await app.request(`/api/reset-password?token=${token}`, {
method: 'POST',
body: JSON.stringify({ password: 'newpassword123' }),
headers: { 'Content-Type': 'application/json' }
})
expect(resetRes.status).toBe(200)
})
test('does not send email when user not found', async () => {
await app.request('/api/forgot-password', {
method: 'POST',
body: JSON.stringify({ email: 'nonexistent@example.com' }),
headers: { 'Content-Type': 'application/json' }
})
// Wait briefly to ensure no email was sent
await new Promise(r => setTimeout(r, 1000))
const messages = await getMessages()
expect(messages).toHaveLength(0)
})
})Python Example
import requests
import time
import pytest
MAILPIT_URL = "http://localhost:8025"
def get_messages():
r = requests.get(f"{MAILPIT_URL}/api/v1/messages")
return r.json().get('messages', [])
def delete_all_messages():
requests.delete(f"{MAILPIT_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 = [t['Address'] for t in (msg.get('To') or [])]
subj = msg.get('Subject', '')
if (not to or to in to_addrs) and (not subject or subject in subj):
return msg
time.sleep(0.2)
raise TimeoutError(f"No email to={to} subject={subject}")
@pytest.fixture(autouse=True)
def clear_emails():
delete_all_messages()
yield
def test_invoice_email(client):
client.post('/orders', json={'product': 'Pro Plan', 'email': 'cust@example.com'})
email = wait_for_email(to='cust@example.com', subject='Invoice')
assert email['From']['Address'] == 'billing@myapp.com'
assert 'Invoice #' in email['HTML']Docker Compose for Tests
services:
mailpit:
image: axllent/mailpit
ports:
- "1025:1025"
- "8025:8025"
environment:
MP_MAX_MESSAGES: 100
MP_DATABASE: /data/mailpit.db
volumes:
- mailpit_data:/data
volumes:
mailpit_data:GitHub Actions
services:
mailpit:
image: axllent/mailpit
ports:
- 1025:1025
- 8025:8025
steps:
- name: Wait for Mailpit
run: |
until curl -sf http://localhost:8025/api/v1/messages; do sleep 1; done
- name: Run email tests
env:
SMTP_HOST: localhost
SMTP_PORT: 1025
MAILPIT_URL: http://localhost:8025
run: npm testSearch API
Mailpit supports full-text search over captured messages:
// Find all emails with specific content
const res = await fetch('http://localhost:8025/api/v1/search?query=subject:Invoice+to:alice@example.com')
const { messages } = await res.json()Search operators: subject:, to:, from:, cc:, bcc:, is:read, is:unread, has:attachment.
Mailpit vs MailHog
Both tools have near-identical usage patterns. Mailpit is the better choice for new projects: it's actively maintained, has a cleaner API response format (HTML and Text are top-level fields, not nested in MIME parts), and handles large message volumes better. Migration from MailHog is a one-line Docker image change.