Skip to main content

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.

Mailpit capture and preview flow Mailer sends SMTP on port 1025 into Mailpit, which stores MIME and exposes a web UI and REST API on port 8025 for inspection and CI assertions. Mailpit Capture and Preview Flow App Mailer secure: false ignoreTLS: true Mailpit SMTP sink + store MP_MAX_MESSAGES UI + REST API browser + CI asserts /api/v1/messages :1025 :8025 Query captured messages by ID to assert subject, headers, and HTML.
Mailpit ingests SMTP on port 1025, stores raw MIME, and serves a web UI and REST API on port 8025 for inspection and CI assertions.

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:

  1. Force Plaintext SMTP: Mailpit listens on port 1025 without TLS by default — no additional flags are needed. Do not set MP_SMTP_TLS_CERT unless you intentionally want TLS on the preview port.
  2. Client STARTTLS Override: Ensure your mailer library does not auto-negotiate STARTTLS on port 1025. In Nodemailer, set secure: false and ignoreTLS: true. In Python smtplib, use SMTP() instead of SMTP_SSL().
  3. Raw MIME Inspection: If payloads fail to parse, inspect the raw source:
    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}/source"
    Look for malformed multipart boundaries, missing Content-Transfer-Encoding: quoted-printable headers, 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/mailpit image 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: false and ignoreTLS: true (or the stack equivalent) for the plaintext port.
  • MP_SMTP_AUTH_ACCEPT_ANY=1 is set if the app always sends SMTP credentials; otherwise authenticated sessions are rejected.
  • CI omits MP_DATABASE so 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-printable encoding, and matched cid: 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.


← Back to Local Email Preview Servers