Overview
Mailpit is an SMTP server for tests: it captures every message the app sends, shows it in a web UI and exposes it through a REST API, and its Chaos feature returns configurable SMTP errors for resilience tests (docs (opens in new window)). It never delivers mail.
When to use
Step 1 - Run it
docker run -d --name mailpit -p 8025:8025 -p 1025:1025 axllent/mailpit:v1.32
# or: brew install mailpit && mailpitSMTP listens on 1025 and the web UI / API on 8025 (repository (opens in new window)). Pin an image tag; the API changes between releases. Prefer the image or a release binary over the repository's install script, which pipes remote code into a root shell.
Check it is up before any test:
curl -fsS http://localhost:8025/api/v1/info # 200 with version and message countStep 2 - Point the app at it
| Stack | Setting |
|---|---|
| Django | EMAIL_HOST=localhost, EMAIL_PORT=1025 |
| Spring | spring.mail.host=localhost, spring.mail.port=1025 |
| Rails | smtp_settings: { address: "localhost", port: 1025 } |
| Nodemailer | createTransport({ host: "localhost", port: 1025 }) |
Mailpit accepts unauthenticated SMTP by default (SMTP options (opens in new window)). If production uses SMTP auth, test it for real: start Mailpit with --smtp-auth-file <file> --smtp-auth-allow-insecure and add a test that a wrong password is rejected. Never use --smtp-auth-accept-any for that: it accepts any username and password, or none, so a broken credential still passes (SMTP options (opens in new window)).
Step 3 - Give each test its own address
Tests that share one mailbox and clear it with DELETE /api/v1/messages delete each other's mail when they run in parallel. Instead, send each test to a unique plus-address. Plus-addressing tags are on by default: alice+t-3f9a@example.com gets the tag t-3f9a (tagging (opens in new window)).
# tests/email/mailpit.py
import time, uuid, requests
BASE = "http://localhost:8025"
def unique_address(local="alice", domain="example.com"):
return f"{local}+t-{uuid.uuid4().hex[:8]}@{domain}"
def wait_for_message(to, timeout=5.0):
"""Poll search scoped to one recipient; return the full message."""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
found = requests.get(f"{BASE}/api/v1/search",
params={"query": f'to:"{to}"'}, timeout=2).json()
if found["messages"]:
msg_id = found["messages"][0]["ID"]
return requests.get(f"{BASE}/api/v1/message/{msg_id}", timeout=2).json()
time.sleep(0.1)
raise AssertionError(f"no message to {to} within {timeout}s")
def delete_messages_to(to):
requests.delete(f"{BASE}/api/v1/search", params={"query": f'to:"{to}"'}, timeout=2)GET /api/v1/search and DELETE /api/v1/search take the same search query, so a test only ever reads or deletes its own messages (API (opens in new window)). Clean up in teardown with delete_messages_to, or not at all; never with an unscoped delete on a parallel path.
Step 4 - Assert on the full message
import re
def test_password_reset_email():
to = unique_address()
trigger_password_reset(to) # the app call under test
msg = wait_for_message(to)
assert msg["Subject"] == "Reset your password"
assert msg["To"][0]["Address"] == to
assert msg["From"]["Address"] == "no-reply@example.com"
m = re.search(r"/reset\?token=([A-Za-z0-9_-]{20,})", msg["Text"])
assert m, "reset link with a real token missing from the text part"
assert m.group(1) != "undefined"GET /api/v1/message/{ID} (singular) returns Subject, From, To, Text and HTML (API (opens in new window)). Assert on extracted values directly: an assertion inside a loop over links passes when the list is empty. Message IDs are generated per send, so never hard-code one.
Step 5 - Chaos: real SMTP errors
Start Mailpit with Chaos enabled (--enable-chaos, or --chaos-triggers Sender:451:100 to set a trigger at startup) (Chaos (opens in new window)). Then set triggers per test through the API:
def set_chaos(**triggers):
# e.g. set_chaos(Recipient={"ErrorCode": 451, "Probability": 100})
r = requests.put(f"{BASE}/api/v1/chaos", json=triggers, timeout=2)
r.raise_for_status()
def test_transient_error_is_retried():
to = unique_address()
set_chaos(Recipient={"ErrorCode": 451, "Probability": 100})
try:
send_notification(to) # app should queue a retry
assert_retry_scheduled(to)
finally:
set_chaos() # {} resets every trigger to 0%
run_retry_worker()
assert wait_for_message(to)["Subject"]PUT /api/v1/chaos takes Sender, Recipient and Authentication triggers, each with ErrorCode (400-599) and Probability (0-100); omitted triggers reset to 0%, and the route errors if Chaos is not enabled (API (opens in new window)). Cover 4xx (retry with backoff) and 5xx (stop after one attempt) separately. Chaos is server-wide, so run Chaos tests in their own job or serially. A provider bounce webhook is a different event; see references/email-flows.md.
Step 6 - CI
services:
mailpit:
image: axllent/mailpit:v1.32
ports: ["1025:1025", "8025:8025"]
env:
MP_ENABLE_CHAOS: "true"
steps:
- run: curl --retry 10 --retry-connrefused -fsS http://localhost:8025/api/v1/info
- run: pytest tests/email -n 4
env: { SMTP_HOST: localhost, SMTP_PORT: "1025" }Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
Unscoped DELETE /api/v1/messages in beforeEach | Parallel workers delete each other's mail | Unique address per test, scoped search delete (Step 3) |
| Fixed sleep, then read the newest message | Reads another test's mail or nothing | Poll search scoped to the recipient (Step 3) |
--smtp-auth-accept-any | Any credential passes; auth bugs ship | Real auth file + wrong-password test (Step 2) |
| Hard-coded message IDs | IDs are generated per send | Search by recipient (Step 3) |
| Assertions only inside a loop over extracted links | Empty capture passes | Assert on the extracted value (Step 4) |
| Only the happy path | Retry / dead-letter code untested | Chaos (Step 5) |
Limitations
References
Email is the most underspecified surface in modern web apps. A "the email got sent" assertion misses:
This is a build-an-X workflow - a checklist and per-stage test recipes, not a single tool. The SMTP capture layer is the host SKILL.md (Mailpit) or mailhog-legacy.md (opens in new window).
When to use
Step 1 - Stage your capture environment
Per the host SKILL.md:
# CI config
services:
mailpit:
image: axllent/mailpit:v1.20.0
ports: [1025:1025, 8025:8025]Configure the app to relay via this SMTP for the test environment.
Step 2 - Trigger + capture
from email_test_helpers import trigger_password_reset, capture_one_email
def test_password_reset_email_complete():
msg = capture_one_email(
action=lambda: trigger_password_reset("alice@example.com"),
recipient="alice@example.com",
timeout=5,
)
# Now assert against `msg`(capture_one_email is the helper from the host SKILL.md Step 4.)
Step 3 - Header assertions
def test_email_headers(msg):
assert msg["From"]["Address"] == "noreply@example.com"
assert msg["To"][0]["Address"] == "alice@example.com"
assert msg["Subject"] == "Reset your password"
assert "List-Unsubscribe" in msg["Headers"] # required by Gmail/Yahoo bulk-sender rules
assert "List-Unsubscribe-Post" in msg["Headers"] # one-click unsubscribe per RFC 8058For relayed-via-MTA tests (where DKIM signing happens), additional checks:
These checks require a relay capable of signing (production MTA or a test-relay like Postmark sandbox). Mailpit doesn't sign; verify DKIM in a separate staging-with-real-MTA test layer.
Step 4 - Body content assertions
Email is multipart: HTML and plain-text alternatives. Both need verification:
def test_email_body_alternatives(msg):
# Plain-text body present
assert msg["Text"]
assert "alice" in msg["Text"]
assert "/reset?token=" in msg["Text"]
# HTML body present + matches plain-text intent
html = msg["HTML"]
assert "<a href=" in html
assert "/reset?token=" in html
# The "view in browser" link
assert ("/view-in-browser/" in html) or ("This email best viewed" in html)Per RFC 2046 §5.1.4, mailers should always include a plain-text alternative; tests catch when developers ship HTML-only emails by accident.
Step 5 - Link rewriting + tracking pixels
Many email service providers (Mailgun, SendGrid, Postmark, Customer.io) rewrite links for click tracking. After rewriting, the link in the captured email points to the ESP's tracker, not the target URL.
Test pattern: assert against the final URL after redirect, not the rewritten one:
import requests
def resolve_redirects(url, max_hops=5):
for _ in range(max_hops):
response = requests.get(url, allow_redirects=False, timeout=5)
if response.status_code not in (301, 302, 303, 307, 308):
return response.url
url = response.headers["Location"]
raise ValueError("Too many redirects")
def test_password_reset_link_resolves_to_app(msg):
link = extract_first_link(msg["HTML"])
final_url = resolve_redirects(link)
assert "example.com/reset" in final_url
assert "token=" in final_urlFor tests of unsigned ESP links, accept the rewrite as expected and test that resolution lands on the app's domain.
Step 6 - Unsubscribe-link verification
def test_unsubscribe_link_works(msg):
unsubscribe_url = msg["Headers"]["List-Unsubscribe"][0].strip("<>")
response = requests.post(unsubscribe_url)
assert response.status_code == 200
# Verify the user is now unsubscribed:
user = User.objects.get(email="alice@example.com")
assert user.subscribed is FalsePer RFC 8058, one-click unsubscribe is a POST (not GET) to the List-Unsubscribe URL with body List-Unsubscribe=One-Click.
Step 7 - Bounce + complaint handling
Bounces (delivery failures) and complaints (recipient marks as spam) come from the ESP via webhook. Test the app's handler with a representative payload:
def test_bounce_webhook_marks_user_undeliverable(client):
bounce_payload = {
"event": "bounce",
"recipient": "bounce@nonexistent.example.com",
"reason": "550 5.1.1 user unknown",
}
response = client.post("/webhooks/email-events", json=bounce_payload)
assert response.status_code == 200
user = User.objects.get(email="bounce@nonexistent.example.com")
assert user.email_status == "undeliverable"For each ESP, find a sample bounce/complaint payload in the ESP's docs and use it as the test fixture.
Step 8 - DKIM/SPF/DMARC for production-bound emails
These are MTA-side concerns; tests in CI typically don't validate them. For a pre-production layer:
Step 9 - End-to-end test recipe
For each email flow in scope:
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
| Test only "the send happened" | Misses every content + link issue | Steps 3 - 6 |
| Skip plain-text alternative | Many corporate gateways strip HTML; bare HTML emails appear blank | Always assert both (Step 4) |
| Hardcode rewritten ESP link | Tests fail when ESP rotates tracker domains | Resolve to final URL (Step 5) |
| Skip unsubscribe test | Compliance failure (CAN-SPAM, CASL, GDPR) + ISP penalties | One-click test (Step 6) |
| Skip bounce/complaint webhooks | Sender reputation degrades; deliverability drops | Per-ESP fixture tests (Step 7) |
Limitations
References
MailHog (legacy) capture + migration to Mailpit
View source (opens in a new tab) (opens in new window)Per github.com/mailhog/MailHog (opens in new window):
"MailHog is an email testing tool for developers" that allows you to "Configure your application to use MailHog for SMTP delivery" and "View messages in the web UI, or retrieve them with the JSON API."
MailHog is legacy as of the mid-2020s - Mailpit (the host SKILL.md) succeeded it with richer API + active maintenance. This reference covers MailHog for existing deployments + provides migration guidance to Mailpit.
When to use
For new projects, use the host SKILL.md (Mailpit).
How to use
Install
Per mh-gh (opens in new window):
# Go install
go install github.com/mailhog/MailHog@latestDocker:
docker run -d \
--name mailhog \
-p 1025:1025 \
-p 8025:8025 \
mailhog/mailhogDefault ports
Per mh-gh (opens in new window):
| Port | Service |
|---|---|
| 1025 | SMTP server |
| 8025 | HTTP server (UI + APIv1 + APIv2) |
Configure your app's SMTP
Same pattern as Mailpit (since both expose unauthenticated SMTP on 1025 by default):
smtp:
host: localhost
port: 1025
auth: noneAssert via APIv2
Per mh-gh (opens in new window) MailHog has both APIv1 + APIv2; APIv2 is the modern one. Endpoints:
| Endpoint | Use |
|---|---|
GET /api/v2/messages | List captured messages (paginated) |
GET /api/v2/messages?limit=N&start=M | Pagination |
GET /api/v2/search?kind=to&query=alice@x.com | Search by recipient / subject / containing |
DELETE /api/v1/messages | Clear all messages (uses APIv1; APIv2 has no delete) |
The MailHog message structure is more nested than Mailpit's: Content.Headers.Subject is an array, not the flat Subject field Mailpit exposes. Walk the nested path or the assertion silently fails.
Worked example
Capture and assert a single password-reset email end to end. Clear the mailbox first so a stale message can't satisfy the assertion, trigger the app action, poll APIv2 until the message lands, then assert on its subject and body:
import requests
BASE = "http://localhost:8025"
def test_password_reset_via_mailhog():
requests.delete(f"{BASE}/api/v1/messages") # clear (APIv1 - no APIv2 delete)
trigger_password_reset("alice@example.com") # app under test sends the email
msg = poll_for_message(BASE, to="alice@example.com") # GET /api/v2/search?kind=to&query=...
assert msg["Content"]["Headers"]["Subject"][0] == "Reset your password"
assert "/reset?token=" in msg["Content"]["Body"]poll_for_message retries GET /api/v2/search?kind=to&query=alice@example.com until a message appears, then returns it. Sub-second SMTP delivery isn't guaranteed, so never assert immediately after triggering.
Jim chaos monkey
Per mh-gh (opens in new window): "Chaos Monkey for failure testing" via the Jim component. Jim is configurable via CLI flags or environment:
mailhog -invite-jim # enables the chaos monkeyOnce Jim is invited, MailHog injects failures (random connection drops, slow responses) per the configured probabilities. Configuration flags are documented in mailhog -h; the README references "Introduction to Jim" for details.
For new test work needing chaos, prefer Mailpit's Chaos mode (host SKILL.md Step 5) - it has richer per-recipient configuration.
CI integration
services:
mailhog:
image: mailhog/mailhog
ports: [1025:1025, 8025:8025]
steps:
- run: pytest tests/integration/email/ -vMigrating to Mailpit
For new projects, and for teams ready to leave MailHog, migrate to Mailpit (the host SKILL.md) - the actively maintained successor with a flatter API and richer chaos configuration. The feature-mapping table, the schema-rewrite notes, and the step-by-step cutover live in migrating-to-mailpit.md (opens in new window).
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
| Start new project on MailHog | Legacy; loses richer Mailpit features | Use Mailpit (Migrating to Mailpit + cross-skill) |
| Use APIv1 for new test code | Deprecated by APIv2 (within MailHog) | APIv2 endpoints (Assert via APIv2) |
| Assume MailHog flat schema | MailHog's Content.Headers.Subject is an array | Walk the nested structure (Worked example) |
| Skip per-test message clear | Same problem as Mailpit Step 4; stale messages | DELETE /api/v1/messages in setup |
| Skip Jim coverage | Same as Mailpit Step 5; misses resilience | Enable Jim or migrate to Mailpit Chaos |
Limitations
References
Deep reference for mailhog-legacy.md (opens in new window). Consult when moving an existing MailHog deployment to Mailpit (the host SKILL.md), the actively maintained successor with a flatter API.
Why migrate
MailHog has had no significant release since ~2020 and is effectively unmaintained. Mailpit is the actively maintained successor with a flatter JSON schema, a single REST API for both reads and deletes, and richer per-recipient chaos configuration.
Feature mapping
| MailHog | Mailpit equivalent |
|---|---|
SMTP on 1025 | SMTP on 1025 (same) |
HTTP UI on 8025 | Web UI on 8025 (same) |
GET /api/v2/messages | GET /api/v1/messages (path differs; flat schema) |
GET /api/v2/search?kind=to&query=... | GET /api/v1/search?query=to:... (Lucene-ish syntax) |
DELETE /api/v1/messages | DELETE /api/v1/messages (same path) |
mailhog -invite-jim | Chaos mode (richer per-recipient config) |
Schema rewrite
The largest test-code change is the message schema. MailHog nests the subject as Content.Headers.Subject[0] (an array) and the body as Content.Body; Mailpit exposes a flat Subject string and moves the body to Text. Every assertion that walks MailHog's nested structure must be rewritten against Mailpit's flatter shape.
Cutover steps
Chaos parity
MailHog's Jim chaos monkey (mailhog -invite-jim) injects random connection drops and slow responses at globally configured probabilities. Mailpit's Chaos mode replaces it with per-recipient configuration, so migrate resilience tests to Mailpit Chaos rather than porting Jim's flags.
- Five email controls, a Friday deadline, and a compliance lead who has already promised five green ticks
- Email tests went from never-red to red a third of the time the day we parallelised CI
- Eleven days of dead password-reset links and the email suite stayed green the whole time
- We lost 2,840 password-reset emails in a relay wobble and the dashboards said 100% sent
- Staging rejected our mailer credentials and CI has been green on the same config for six weeks