Capturing Outbound Email Locally With Mailpit
A developer testing the password-reset flow either sees Error: connect ECONNREFUSED 127.0.0.1:25 because nothing listens for SMTP locally, or — worse — the local stack is configured with real SMTP or SES credentials and a test run sends forty emails to real customer addresses copied from a database snapshot. Both happen because outbound email has no local destination. This page adds Mailpit, an SMTP server that accepts every message and shows it in a web UI instead of delivering it, as part of emulating cloud services locally.
Mailpit replaces the older MailHog, which is no longer maintained. It is a single small binary with an SMTP listener on port 1025, a UI and REST API on port 8025, and search, HTML rendering checks and link inspection built in.
Diagnostic
Find out where the application currently sends mail and whether anything can receive it:
#!/usr/bin/env bash
set -euo pipefail
docker compose exec -T api env | grep -E '^(SMTP_|MAIL_|EMAIL_|SES_)' | sed -E 's/(PASS(WORD)?=).*/\1***/'
docker compose exec -T api sh -c 'nc -z -w2 "${SMTP_HOST:-localhost}" "${SMTP_PORT:-25}" && echo "smtp reachable" || echo "smtp unreachable"'
docker compose logs api --since 10m | grep -iE 'smtp|mail|ses' | tail -5 || true
Expected bad output in the dangerous variant:
SMTP_HOST=email-smtp.eu-west-1.amazonaws.com
SMTP_PORT=587
SMTP_USER=AKIA...
SMTP_PASS=***
smtp reachable
api-1 | mail sent to [email protected] (reset-password)
Real SES credentials are configured, SMTP is reachable, and a reset email went to an address from a copied dataset.
Root cause
Email is usually configured by a handful of environment variables — host, port, username, password, TLS mode — and the local defaults are often copied from a staging .env file, because that is the configuration someone had to hand when the feature was built. Nothing in the application distinguishes a local send from a real one, and SMTP providers accept any syntactically valid recipient. Combined with anonymised-but-not-quite datasets, where some real addresses survive, a local stack becomes a spam cannon. The opposite case, ECONNREFUSED, pushes developers to comment out the send call, which means the email templates and the code that renders them are never exercised locally. Both problems disappear when there is always a local SMTP server that accepts and captures everything.
A related source of trouble is the provider SDK path. Applications that send through the SES or SendGrid HTTP API rather than SMTP cannot simply be pointed at an SMTP catcher. For those, route SES API calls to LocalStack, which records them, or add a transport abstraction so the local configuration selects SMTP while production selects the provider API — the one place a small, explicit configuration switch is justified.
Resolution
- Add Mailpit to the stack and route its UI through the proxy:
services:
mail:
image: axllent/mailpit:v1.20
environment:
MP_MAX_MESSAGES: 2000
MP_SMTP_AUTH_ACCEPT_ANY: "1"
MP_SMTP_AUTH_ALLOW_INSECURE: "1"
healthcheck:
test: ["CMD", "/mailpit", "readyz"]
interval: 5s
retries: 10
labels:
- traefik.enable=true
- traefik.http.routers.mail.rule=Host(`mail.localhost`)
- traefik.http.routers.mail.entrypoints=websecure
- traefik.http.routers.mail.tls=true
- traefik.http.services.mail.loadbalancer.server.port=8025
MP_SMTP_AUTH_ACCEPT_ANY lets the application keep sending a username and password, so the same code path that authenticates against the real provider runs locally.
- Point the application at it with the same variables production uses:
services:
api:
environment:
SMTP_HOST: mail
SMTP_PORT: "1025"
SMTP_USER: local
SMTP_PASS: local
SMTP_SECURE: "false"
depends_on:
mail:
condition: service_healthy
- Remove real mail credentials from every local env file. Search the repository and developers' generated files; real provider credentials have no legitimate use in a local stack:
#!/usr/bin/env bash
set -euo pipefail
grep -rnE 'SMTP_HOST=.*(amazonaws|sendgrid|mailgun|postmark)|SES_ACCESS' --include='.env*' . && { echo "real mail provider configured locally"; exit 1; } || echo "no real mail providers in local env files"
- Assert on email in integration tests through Mailpit's API instead of mocking the mailer:
#!/usr/bin/env bash
set -euo pipefail
curl -fsS -X DELETE http://localhost:8025/api/v1/messages
curl -fsS -X POST https://api.localhost/auth/reset -H 'content-type: application/json' -d '{"email":"[email protected]"}'
sleep 1
curl -fsS 'http://localhost:8025/api/v1/search?query=to:[email protected]' \
| jq -e '.messages[0].Subject | test("Reset your password")' >/dev/null && echo "reset email captured"
Expected output
$ curl -fsS 'http://localhost:8025/api/v1/messages?limit=1' | jq -r '.messages[0] | "\(.To[0].Address) \(.Subject)"'
[email protected] Reset your password
The message appears in the Mailpit UI at https://mail.localhost, with HTML and plain-text views, headers, and a link checker that flags broken URLs in the template. Nothing was delivered to a real inbox.
Capture also makes several email bugs visible that are invisible without it. Links in templates that point at http://localhost:3000 instead of the configured public URL show up immediately in the link checker. Missing plain-text alternatives, oversized inline images and headers such as Reply-To that fall back to a default are all readable in the message detail view. And because every message is kept, a developer can see that a single action triggered three emails instead of one — a duplicate-send bug that a mocked mailer in unit tests would never reveal, since the mock only records that send was called, not how often the surrounding workflow called it.
Prevention
Fail fast on real providers. Make the application refuse to start with a real mail host when
APP_ENV=local; a two-line check at boot prevents the whole class of accident.Scrub addresses in snapshots. Rewrite every email in copied data to a reserved domain such as
example.test, as covered in anonymizing production snapshots. Even with Mailpit in place, real addresses in local data leak through other paths.Keep a template test. A CI job that triggers each transactional email and checks Mailpit for the subject catches broken templates before customers do.
Platform caveats
Apple Silicon (ARM64):
axllent/mailpitis multi-arch and runs natively.
macOS (Docker Desktop): to reach Mailpit's API from host test scripts, publish
127.0.0.1:8025:8025or use the proxy hostname; keep it bound to loopback so captured mail is not visible on the office network.
WSL2: host scripts in WSL reach published ports on
localhostwhen Docker Desktop's WSL integration is enabled; with Docker Engine inside WSL, use the Compose network from a tools container instead.
Rollback
#!/usr/bin/env bash
set -euo pipefail
docker compose rm -sf mail
git restore compose.yaml
Captured messages live in memory by default and disappear with the container, which is the intended behaviour for a development mail catcher.
Frequently Asked Questions
Is Mailpit a replacement for MailHog?
Yes. MailHog has not been maintained for several years; Mailpit offers the same SMTP-capture model with a faster UI, search, a documented REST API, HTML compatibility checks and active maintenance. Switching is a change of image and ports.
Our app sends through the SES API, not SMTP. Can Mailpit help?
Not directly. Either route the SES API to LocalStack, which records sent messages and exposes them at /_aws/ses, or add a small transport switch so local configuration uses SMTP to Mailpit while production uses the SES API.
Can Mailpit relay some messages to a real inbox?
Yes, it supports a relay configuration with an allowlist of recipients. Use it sparingly and only for addresses you control, for example to check rendering in a real mail client.
Does Mailpit keep messages after a restart?
Not by default. Set MP_DATABASE to a file on a volume if you need persistence, but ephemeral capture is usually better for development and tests.