Running Local Email Previews with Mailpit
Install and configure Mailpit as a local SMTP server to preview and debug transactional HTML emails directly in your browser.
Core Architecture & SMTP Interception
Modern transactional systems frequently exhibit rendering discrepancies between staging and production. Deploying Local Email Preview Servers intercepts outbound SMTP traffic at the network layer, providing deterministic validation without external dependencies. Mailpit operates as a lightweight SMTP sink paired with a real-time MIME renderer, capturing raw payloads, tracking header injection, and verifying attachment encoding prior to deployment.
Initialize a production-stable container, pinning to a specific image tag for reproducibility:
docker run -d \
--name mailpit \
-p 1025:1025 \
-p 8025:8025 \
-e MP_MAX_MESSAGES=500 \
axllent/mailpit:v1.21
Port 1025 accepts SMTP. The web UI and REST API are served at port 8025.
REST API Payload Routing & Configuration
Route your application's mailer to localhost:1025 for SMTP, and query the REST API at http://localhost:8025/api/v1/messages. Mailpit stores raw MIME and exposes structured JSON containing headers, HTML/text bodies, and inline asset metadata.
Environment Configuration (Universal):
SMTP_HOST=127.0.0.1
SMTP_PORT=1025
SMTP_SECURE=false
SMTP_IGNORE_TLS=true
List messages and extract fields (Bash/cURL):
# Fetch the message list and extract subject + from for the first result
curl -s http://localhost:8025/api/v1/messages | \
jq '{
total: .total,
first_subject: .messages[0].Subject,
first_from: .messages[0].From.Address
}'
Fetch full message body by ID:
MESSAGE_ID=$(curl -s http://localhost:8025/api/v1/messages | jq -r '.messages[0].ID')
curl -s "http://localhost:8025/api/v1/message/${MESSAGE_ID}" | \
jq '{html_body: .HTML, text_body: .Text, has_attachments: (.Attachments | length > 0)}'
This schema enables programmatic validation without manual UI inspection. Integrate this parsing logic into your test harness to assert against expected header values and DOM structure.
Headless CI/CD Pipeline Integration
Embedding deterministic validation into Email Testing & QA Workflows requires headless execution and strict API assertions. Deploy Mailpit as a CI service dependency, trigger your test suite, and query the API to verify routing across microservices.
Docker Compose (CI Optimized):
services:
app:
build: .
depends_on:
- mailpit
environment:
SMTP_HOST: mailpit
SMTP_PORT: 1025
SMTP_SECURE: "false"
mailpit:
image: axllent/mailpit:v1.21
ports:
- "1025:1025"
- "8025:8025"
environment:
MP_MAX_MESSAGES: 100
MP_DATABASE: "/tmp/mailpit.db"
Headless Assertion Script (Python):
import requests
import sys
MAILPIT_API = "http://localhost:8025/api/v1"
def verify_delivery(expected_subject, expected_from):
resp = requests.get(f"{MAILPIT_API}/messages", params={"limit": 1})
resp.raise_for_status()
data = resp.json()
if not data.get("messages"):
print("FAIL: No messages captured")
sys.exit(1)
msg = data["messages"][0]
assert msg["Subject"] == expected_subject, f"Subject mismatch: {msg['Subject']}"
assert msg["From"]["Address"] == expected_from, f"From mismatch: {msg['From']['Address']}"
print("PASS: Delivery and routing verified")
verify_delivery("Order Confirmation", "noreply@yourdomain.com")
Search for specific messages:
# Search by subject keyword
curl -s "http://localhost:8025/api/v1/search?query=subject:Order+Confirmation&limit=5" | jq '.messages[] | {ID, Subject}'
Troubleshooting SMTP Handshake & TLS Failures
Integration failures typically originate from TLS negotiation mismatches or incorrect EHLO declarations. Local environments must explicitly disable transport layer security to prevent connection drops.
Critical Configuration Fixes:
- Force Plaintext SMTP: Mailpit listens on port 1025 without TLS by default — no additional flags are needed. Do not set
MP_SMTP_TLS_CERTunless you intentionally want TLS on the preview port. - Client STARTTLS Override: Ensure your mailer library does not auto-negotiate
STARTTLSon port1025. In Nodemailer, setsecure: falseandignoreTLS: true. In Pythonsmtplib, useSMTP()instead ofSMTP_SSL(). - Raw MIME Inspection: If payloads fail to parse, inspect the raw source:
Look for malformed multipart boundaries, missingMESSAGE_ID=$(curl -s http://localhost:8025/api/v1/messages | jq -r '.messages[0].ID') curl -s "http://localhost:8025/api/v1/message/${MESSAGE_ID}/source"Content-Transfer-Encoding: quoted-printableheaders, or truncated base64 streams.
Delete all messages between test runs:
curl -X DELETE http://localhost:8025/api/v1/messages
Error Handling Matrix:
| Symptom | Root Cause | Resolution |
|---|---|---|
Connection refused |
Port 1025 blocked or container not running | Verify docker ps and host firewall rules |
502 Bad Gateway (API) |
Mailpit process crashed or DB locked | Restart container; use an in-memory DB by omitting MP_DATABASE |
TLS handshake failure |
Client forcing encryption on plaintext port | Disable STARTTLS in client config (ignoreTLS: true in Nodemailer) |
| Empty message list | Messages purged or MP_MAX_MESSAGES=0 |
Increase retention limit; check MP_MAX_MESSAGES env var |
Mailpit Configuration Reference
Mailpit is configured entirely through MP_-prefixed environment variables, which makes it trivial to declare in Compose or pass to the standalone binary. The variables you will reach for most:
docker run -d --name mailpit \
-p 1025:1025 -p 8025:8025 \
-e MP_MAX_MESSAGES=500 \ # ring-buffer cap; oldest messages drop past this count
-e MP_DATABASE=/data/mailpit.db \ # persist across restarts; omit entirely for an in-memory CI store
-e MP_SMTP_AUTH_ACCEPT_ANY=1 \ # accept any SMTP username/password (apps that always send creds)
-e MP_SMTP_AUTH_ALLOW_INSECURE=1 \# allow AUTH over the plaintext capture port without TLS
-v "$PWD/mailpit-data:/data" \
axllent/mailpit:v1.21
MP_SMTP_AUTH_ACCEPT_ANY matters when your application unconditionally sends SMTP credentials — a Nodemailer transport with an auth block, or a Django config with EMAIL_HOST_USER set — because by default Mailpit rejects authenticated sessions on a port with no configured users. Pairing it with MP_SMTP_AUTH_ALLOW_INSECURE lets that authenticated handshake complete over the plaintext port without TLS. For CI, omit MP_DATABASE so the store lives in memory and disappears with the container, leaving no mailpit.db artifact to clean up.
Message-Source Inspection
The rendered preview is convenient but lossy; the raw source is authoritative. Mailpit exposes it both in the UI Source tab and at a dedicated endpoint:
# Pull the raw RFC 5322 source of the newest captured message.
MESSAGE_ID=$(curl -s http://localhost:8025/api/v1/messages | jq -r '.messages[0].ID')
curl -s "http://localhost:8025/api/v1/message/${MESSAGE_ID}/raw"
When the rendered HTML looks wrong, the source tells you whether the fault is yours or the renderer's. Confirm the Content-Type: multipart/alternative; boundary="..." header declares a boundary that actually delimits both the text/plain and text/html parts; a mismatch leaves the body empty. Verify HTML parts carry Content-Transfer-Encoding: quoted-printable and that no =3D escape sequences leaked into visible text (the signature of double-encoding). For inline images, check that every src="cid:..." token has a matching MIME part with Content-ID and Content-Disposition: inline — a missing match is what later renders as a broken-image icon in Apple Mail.
Mailpit additionally runs an automated HTML check and link check per message, surfaced in the UI, which flag client-unsupported CSS and dead URLs before the template ever reaches Litmus or Email on Acid for the paid cross-client pass.
API & Automation Beyond Basic Assertions
The REST API supports more than fetch-and-compare. Three endpoints unlock richer test harnesses:
# 1. Structured search: limit assertions to messages a single test produced.
curl -s "http://localhost:8025/api/v1/search?query=to:user%40example.com+subject:Welcome&limit=5" \
| jq '.messages[] | {ID, Subject, Created}'
# 2. Fetch decoded part bodies and attachment metadata in one structured response.
MESSAGE_ID=$(curl -s http://localhost:8025/api/v1/messages | jq -r '.messages[0].ID')
curl -s "http://localhost:8025/api/v1/message/${MESSAGE_ID}" \
| jq '{subject: .Subject, html_len: (.HTML | length), attachments: [.Attachments[].FileName]}'
# 3. Release a captured message to a real relay (manual deliverability spot-check).
# Requires MP_SMTP_RELAY_CONFIG to be configured pointing at, e.g., Amazon SES.
curl -s -X POST "http://localhost:8025/api/v1/message/${MESSAGE_ID}/release" \
-H "Content-Type: application/json" \
-d '{"To":["qa@yourdomain.com"]}'
The search endpoint is the key to test isolation: rather than purging the whole store, a test can scope its assertion to messages addressed to a recipient it controls, so parallel test cases do not read each other's mail. The release endpoint is the controlled escape hatch — when you genuinely need to see a message in a real Gmail or Outlook inbox, release a single captured message through a configured relay (MP_SMTP_RELAY_CONFIG pointing at Amazon SES or SendGrid) instead of re-sending from the application and risking quota or reputation effects.
Variant Cases
Standalone binary, no Docker. Mailpit is a single static Go binary; the identical environment variables apply as flags or MP_* exports:
MP_MAX_MESSAGES=500 ./mailpit --smtp 0.0.0.0:1025 --listen 0.0.0.0:8025
Multiple isolated sinks on one host. Running parallel test suites? Give each its own ports and in-memory store so their captures never collide:
docker run -d -p 2025:1025 -p 9025:8025 axllent/mailpit:v1.21 # suite A: SMTP 2025, UI 9025
docker run -d -p 3025:1025 -p 9035:8025 axllent/mailpit:v1.21 # suite B: SMTP 3025, UI 9035
Authenticated-relay simulation. To exercise an application code path that requires SMTP auth (mirroring SendGrid's apikey user or a Postmark token), enable MP_SMTP_AUTH_ACCEPT_ANY=1 and MP_SMTP_AUTH_ALLOW_INSECURE=1 so the authenticated handshake succeeds against the plaintext port without you provisioning real credentials.
Asserting attachment integrity. Transactional flows that attach a PDF invoice or an .ics calendar file need the attachment verified, not just the body. The message endpoint exposes attachment metadata, and a dedicated endpoint returns the decoded bytes so a test can hash them:
MESSAGE_ID=$(curl -s http://localhost:8025/api/v1/messages | jq -r '.messages[0].ID')
# Confirm the invoice is present, is a PDF, and matches a known checksum.
curl -s "http://localhost:8025/api/v1/message/${MESSAGE_ID}/part/1" -o /tmp/invoice.pdf
file /tmp/invoice.pdf # expect: PDF document
sha256sum /tmp/invoice.pdf # compare against the fixture checksum in your test
This closes a gap that body-only assertions miss: a template that renders perfectly but silently drops or corrupts its attachment would pass a subject/HTML check yet fail a real recipient. Hashing the decoded part bytes makes attachment regressions a hard build failure.
Validation Checklist
- The
axllent/mailpitimage is pinned to an explicit tag (e.g.v1.21), never:latest. - SMTP intake (
1025) and the UI/API port (8025) are both mapped and reachable. - The application transport sets
secure: falseandignoreTLS: true(or the stack equivalent) for the plaintext port. MP_SMTP_AUTH_ACCEPT_ANY=1is set if the app always sends SMTP credentials; otherwise authenticated sessions are rejected.- CI omits
MP_DATABASEso the store is in-memory and leaves no artifact behind. - Tests scope assertions via the search endpoint or purge the store (
DELETE /api/v1/messages) before each run. - The raw source (
/raw) confirms valid multipart boundaries,quoted-printableencoding, and matchedcid:parts. - Mailpit's HTML-check and link-check warnings are reviewed before the template advances to the paid cross-client pass.
Conclusion
Running local email previews with Mailpit eliminates dependency on external rendering farms while providing granular control over SMTP traffic. By leveraging its REST API and querying captured messages by ID, engineering teams can embed deterministic email validation directly into deployment pipelines. Pin the axllent/mailpit image tag in your Docker configurations and regularly audit API response schemas to ensure long-term pipeline stability. If your stack favors a Node-native sink or you need MailDev's auto-relay behavior, compare this setup against previewing emails with MailDev in Docker before standardizing on one capture server.
Related
- Previewing emails with MailDev in Docker — the Node-based alternative sink and its config differences
- Litmus & Email on Acid workflows — cross-client cloud rendering once local previews look correct
- Automated snapshot testing — turn captured HTML into baseline diffs in CI
← Back to Local Email Preview Servers