Mailpit: Modern Email Testing for Development and CI

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

Ports:

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

Application Configuration

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

REST 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:Welcome

Message 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 test

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

Read more