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.

Where Does Local Email Go Today? Decision diagram classifying the current local email configuration into three outcomes. Where Does Local Email Go Today? What is SMTP_HOST set to? localhost, nothing listens ECONNREFUSED real provider real delivery risk mailpit captured, inspectable
Only the capture option is both safe and testable.

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

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

  1. 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
  1. 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"
  1. 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"
Local Email Path With Mailpit Flow of an outbound email from the application to Mailpit's SMTP listener, UI and test API. Local Email Path With Mailpit api sends SMTP to mail:1025 Mailpit accepts any auth stored up to 2000 msgs UI and API mail.localhost
Nothing leaves the laptop; the same message is visible to people and to tests.

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

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

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

  3. Keep a template test. A CI job that triggers each transactional email and checks Mailpit for the subject catches broken templates before customers do.

Local Email Options Compared Table comparing commenting out sends, real provider credentials and Mailpit on safety, test coverage and visibility. Local Email Options Compared Option Safe Code exercised Visible comment out sends yes no no real provider no yes inbox only Mailpit yes yes UI and API
Mailpit is the only option that is safe and exercises the real sending code.

Platform caveats

Apple Silicon (ARM64): axllent/mailpit is multi-arch and runs natively.

macOS (Docker Desktop): to reach Mailpit's API from host test scripts, publish 127.0.0.1:8025:8025 or 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 localhost when 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.