Use whenever code sends email and tests must check it: capture messages in Mailpit's local SMTP server (port 1025) and assert on them through its REST API, test SMTP failures with chaos mode, follow multipart, link, unsubscribe and bounce flows, or migrate from MailHog.
97%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
Passed
No findings from the security scan
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). It never delivers mail.
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). 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 count| 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).
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).
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).
# 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). Clean up in teardown with delete_messages_to, or not
at all; never with an unscoped delete on a parallel path.
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). 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.
Start Mailpit with Chaos enabled (--enable-chaos, or
--chaos-triggers Sender:451:100 to set a trigger at startup)
(Chaos). 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). 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.
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-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) |
axllent/mailpit:v1.32, not :latest)./api/v1/info, /api/v1/search (GET and DELETE), /api/v1/message/{ID}, /api/v1/chaos.--smtp-auth-file, --smtp-auth-allow-insecure, --smtp-auth-accept-any.--enable-chaos, --chaos-triggers.