Testland
Browse all skills & agents

mailpit-testing

The email-testing home: configures and runs Mailpit - modern dev-mailbox server for SMTP testing with built-in REST API for assertions; default SMTP `1025` + Web UI `8025`; Chaos mode (configurable SMTP errors for resilience testing), message tagging, search filters. Carries the end-to-end email-flow workflow (multipart body, link-rewrite resolution, unsubscribe per RFC 8058, bounce + complaint webhooks) in references/email-flows.md and the legacy MailHog capture patterns + migration path in references/mailhog-legacy.md. Use when developing or testing email-sending code locally / in CI - SMTP capture, full-flow assertions, or migrating an existing MailHog deployment.

Install with skills.sh (any agent)

npx skills add testland/qa --skill mailpit-testing
View source

mailpit-testing

Overview

Per mailpit.axllent.org/docs/ (opens in new window):

"Mailpit is packed full of features for developers wanting to test SMTP and emails. It acts as an SMTP server, provides a modern web interface to view & test intercepted emails."

Per mp-docs (opens in new window) the differentiated features:

  • "Chaos feature to enable configurable SMTP errors for testing application resilience."
  • "Message tagging, including manual tagging or automated tagging using filtering and 'plus addressing'."
  • "A REST API for integration testing"

Mailpit succeeded MailHog as the de facto OSS dev mailbox in the mid-2020s; new projects start with Mailpit by default.

When to use

  • The repo has email-sending code (transactional emails, password reset, notifications) that needs local + CI testing.
  • Tests assert on captured email content (subject, body, headers, attachments).
  • The team needs Chaos mode to test app resilience to SMTP errors (bounce, timeout, rate-limit).
  • The full email flow needs coverage beyond capture - headers, multipart body, links, unsubscribe, bounces - per references/email-flows.md.
  • Migrating from MailHog (Mailpit is API-compatible at the SMTP layer + has a richer REST API) - per references/mailhog-legacy.md.

Step 1 - Install

Per github.com/axllent/mailpit (opens in new window):

# macOS
brew install mailpit

# Linux + macOS via install script
sudo sh < <(curl -sL https://raw.githubusercontent.com/axllent/mailpit/develop/install.sh)

# Custom install path
sudo INSTALL_PATH=/usr/bin sh < <(curl -sL https://raw.githubusercontent.com/axllent/mailpit/develop/install.sh)

Docker is the recommended path for CI; consult mp-gh (opens in new window) for current Docker pull commands.

Step 2 - Start

Per mp-gh (opens in new window):

"The Mailpit web UI listens by default on http://0.0.0.0:8025 and the SMTP port on 0.0.0.0:1025."

Foreground:

mailpit
# Web UI: http://localhost:8025
# SMTP:   localhost:1025

As a background service on macOS:

brew services start mailpit

Discover all options:

mailpit -h

Step 3 - Configure your app's SMTP

Point the application's SMTP config at Mailpit:

# example: Rails action_mailer config
smtp_settings:
  address: localhost
  port: 1025
  domain: localhost
  authentication: nil   # Mailpit accepts unauthenticated SMTP by default

Equivalent envs work for Django (EMAIL_HOST=localhost, EMAIL_PORT=1025), Spring (spring.mail.host=localhost, spring.mail.port=1025), Node nodemailer, etc.

Step 4 - Assert via REST API

Per mp-docs (opens in new window) Mailpit ships a "REST API for integration testing." The canonical endpoints (consult mp-docs (opens in new window) for current paths per release) follow this shape:

import requests

BASE = "http://localhost:8025"

def test_password_reset_sends_email():
    # 1. Clear inbox before the test
    requests.delete(f"{BASE}/api/v1/messages")

    # 2. Trigger the email send
    trigger_password_reset("alice@example.com")

    # 3. Poll until the email lands (typical: <1s)
    msg = poll_for_message(BASE, to="alice@example.com", timeout=5)

    # 4. Assert
    assert msg["Subject"] == "Reset your password"
    assert "/reset?token=" in msg["Text"]
    assert msg["To"][0]["Address"] == "alice@example.com"
def poll_for_message(base, to, timeout):
    import time
    deadline = time.time() + timeout
    while time.time() < deadline:
        response = requests.get(f"{base}/api/v1/search", params={"query": f"to:{to}"})
        messages = response.json().get("messages", [])
        if messages:
            return requests.get(f"{base}/api/v1/message/{messages[0]['ID']}").json()
        time.sleep(0.1)
    raise AssertionError(f"No email to {to} within {timeout}s")

The exact endpoint paths may evolve - always check the live API schema at http://localhost:8025/api/v1/ (Mailpit ships an OpenAPI schema for self-introspection).

Step 5 - Chaos mode

Per mp-docs (opens in new window): "Chaos feature to enable configurable SMTP errors for testing application resilience."

Use cases for app-level resilience testing:

  • Verify retry-with-backoff works when SMTP returns 421 / 451
  • Verify dead-letter handling for permanent failures (5xx)
  • Verify rate-limit handling (server returns 421 4.7.0)

Per mp-docs (opens in new window), Chaos is configurable per-recipient or globally; consult the live docs for current Chaos API shape.

Step 6 - Tagging + plus-addressing

Per mp-docs (opens in new window): "automated tagging using filtering and 'plus addressing'."

Pattern for test-isolation: each test sends to alice+test-${test_id}@example.com; Mailpit auto-tags by the +test-... suffix; tests filter by tag to isolate their email batch from concurrent test runs:

import uuid
test_id = uuid.uuid4().hex[:8]
to_addr = f"alice+test-{test_id}@example.com"
trigger_email(to_addr)
msg = requests.get(
    f"{BASE}/api/v1/search",
    params={"query": f'tag:"test-{test_id}"'},
).json()["messages"][0]

Step 7 - CI integration

services:
  mailpit:
    image: axllent/mailpit:latest
    ports: [1025:1025, 8025:8025]

steps:
  - run: pytest tests/integration/email/ -v
    env:
      SMTP_HOST: localhost
      SMTP_PORT: 1025

Anti-patterns

Anti-patternWhy it failsFix
Skip per-test inbox clearStale messages cause false-positivesDELETE /api/v1/messages in setup (Step 4)
Assume Mailpit handles authenticated SMTPDefault config is unauthenticated; auth tests need explicit configConfigure --smtp-auth-allow-insecure or proper auth config
Hardcode message IDs in testsIDs are random per send; tests failSearch by recipient/subject (Step 4)
Skip polling; assert immediatelySub-second SMTP delivery isn't guaranteedPoll with timeout (Step 4)
Test only happy pathMisses retry/dead-letter scenariosUse Chaos mode (Step 5)

Limitations

  • Mailpit does NOT actually deliver email - it only captures. For end-to-end deliverability tests, use a real-mail-with-test-domain service (Mailtrap, Mailosaur).
  • Some advanced SMTP features (DKIM signing assertion, SPF lookup) are not Mailpit's focus; verify via separate tooling.
  • The REST API surface evolves between Mailpit releases; pin a specific version in CI (axllent/mailpit:v1.20.0 not :latest).

References

End-to-end email-flow test workflow

View source (opens in new window)

End-to-end email-flow test workflow

Email is the most underspecified surface in modern web apps. A "the email got sent" assertion misses:

  • Did the user receive both HTML + plain-text alternatives? (RFC 2046 §5.1.4 multipart/alternative)
  • Was the link in the email actually correct (after tracking rewrites)?
  • Did the unsubscribe link work?
  • For relayed messages: do DKIM / SPF / DMARC pass?
  • Does the app handle bounces + complaints?

This is a build-an-X workflow - a checklist and per-stage test recipes, not a single tool. The SMTP capture layer is the host SKILL.md (Mailpit) or mailhog-legacy.md (opens in new window).

When to use

  • Any feature triggers a transactional email (signup, password reset, order confirmation, account notification).
  • Marketing-email integration with the app needs regression coverage.
  • Compliance review requires evidence of unsubscribe + bounce handling.

Step 1 - Stage your capture environment

Per the host SKILL.md:

# CI config
services:
  mailpit:
    image: axllent/mailpit:v1.20.0
    ports: [1025:1025, 8025:8025]

Configure the app to relay via this SMTP for the test environment.

Step 2 - Trigger + capture

from email_test_helpers import trigger_password_reset, capture_one_email

def test_password_reset_email_complete():
    msg = capture_one_email(
        action=lambda: trigger_password_reset("alice@example.com"),
        recipient="alice@example.com",
        timeout=5,
    )
    # Now assert against `msg`

(capture_one_email is the helper from the host SKILL.md Step 4.)

Step 3 - Header assertions

def test_email_headers(msg):
    assert msg["From"]["Address"] == "noreply@example.com"
    assert msg["To"][0]["Address"] == "alice@example.com"
    assert msg["Subject"] == "Reset your password"
    assert "List-Unsubscribe" in msg["Headers"]    # required by Gmail/Yahoo bulk-sender rules
    assert "List-Unsubscribe-Post" in msg["Headers"]   # one-click unsubscribe per RFC 8058

For relayed-via-MTA tests (where DKIM signing happens), additional checks:

  • DKIM signature present + valid
  • SPF-aligned Return-Path
  • DMARC-aligned From

These checks require a relay capable of signing (production MTA or a test-relay like Postmark sandbox). Mailpit doesn't sign; verify DKIM in a separate staging-with-real-MTA test layer.

Step 4 - Body content assertions

Email is multipart: HTML and plain-text alternatives. Both need verification:

def test_email_body_alternatives(msg):
    # Plain-text body present
    assert msg["Text"]
    assert "alice" in msg["Text"]
    assert "/reset?token=" in msg["Text"]

    # HTML body present + matches plain-text intent
    html = msg["HTML"]
    assert "<a href=" in html
    assert "/reset?token=" in html

    # The "view in browser" link
    assert ("/view-in-browser/" in html) or ("This email best viewed" in html)

Per RFC 2046 §5.1.4, mailers should always include a plain-text alternative; tests catch when developers ship HTML-only emails by accident.

Step 5 - Link rewriting + tracking pixels

Many email service providers (Mailgun, SendGrid, Postmark, Customer.io) rewrite links for click tracking. After rewriting, the link in the captured email points to the ESP's tracker, not the target URL.

Test pattern: assert against the final URL after redirect, not the rewritten one:

import requests

def resolve_redirects(url, max_hops=5):
    for _ in range(max_hops):
        response = requests.get(url, allow_redirects=False, timeout=5)
        if response.status_code not in (301, 302, 303, 307, 308):
            return response.url
        url = response.headers["Location"]
    raise ValueError("Too many redirects")

def test_password_reset_link_resolves_to_app(msg):
    link = extract_first_link(msg["HTML"])
    final_url = resolve_redirects(link)
    assert "example.com/reset" in final_url
    assert "token=" in final_url

For tests of unsigned ESP links, accept the rewrite as expected and test that resolution lands on the app's domain.

Step 6 - Unsubscribe-link verification

def test_unsubscribe_link_works(msg):
    unsubscribe_url = msg["Headers"]["List-Unsubscribe"][0].strip("<>")
    response = requests.post(unsubscribe_url)
    assert response.status_code == 200

    # Verify the user is now unsubscribed:
    user = User.objects.get(email="alice@example.com")
    assert user.subscribed is False

Per RFC 8058, one-click unsubscribe is a POST (not GET) to the List-Unsubscribe URL with body List-Unsubscribe=One-Click.

Step 7 - Bounce + complaint handling

Bounces (delivery failures) and complaints (recipient marks as spam) come from the ESP via webhook. Test the app's handler with a representative payload:

def test_bounce_webhook_marks_user_undeliverable(client):
    bounce_payload = {
        "event": "bounce",
        "recipient": "bounce@nonexistent.example.com",
        "reason": "550 5.1.1 user unknown",
    }
    response = client.post("/webhooks/email-events", json=bounce_payload)
    assert response.status_code == 200

    user = User.objects.get(email="bounce@nonexistent.example.com")
    assert user.email_status == "undeliverable"

For each ESP, find a sample bounce/complaint payload in the ESP's docs and use it as the test fixture.

Step 8 - DKIM/SPF/DMARC for production-bound emails

These are MTA-side concerns; tests in CI typically don't validate them. For a pre-production layer:

  • mail-tester.com: send a test email; receive a 0 - 10 deliverability score covering DKIM/SPF/DMARC + content quality
  • dmarcian.com: per-domain DMARC monitoring for production
  • Postmark / SendGrid / Mailgun inbound parse sandbox to test what arrives

Step 9 - End-to-end test recipe

For each email flow in scope:

  1. ✅ Trigger + capture via Mailpit (Step 2)
  2. ✅ Header assertions including List-Unsubscribe (Step 3)
  3. ✅ Multipart body - both text + HTML present (Step 4)
  4. ✅ Link rewrites resolve to app domain (Step 5)
  5. ✅ Unsubscribe one-click POST works (Step 6)
  6. ✅ Bounce webhook handler updates user state (Step 7)
  7. ✅ Complaint webhook handler updates user state (Step 7)
  8. ✅ Pre-prod DKIM/SPF/DMARC verification via mail-tester (out-of-CI; periodic) (Step 8)

Anti-patterns

Anti-patternWhy it failsFix
Test only "the send happened"Misses every content + link issueSteps 3 - 6
Skip plain-text alternativeMany corporate gateways strip HTML; bare HTML emails appear blankAlways assert both (Step 4)
Hardcode rewritten ESP linkTests fail when ESP rotates tracker domainsResolve to final URL (Step 5)
Skip unsubscribe testCompliance failure (CAN-SPAM, CASL, GDPR) + ISP penaltiesOne-click test (Step 6)
Skip bounce/complaint webhooksSender reputation degrades; deliverability dropsPer-ESP fixture tests (Step 7)

Limitations

  • This is a build-an-X workflow. Tests use the application's HTTP client + an SMTP capture tool (the host SKILL.md or mailhog-legacy.md (opens in new window)).
  • DKIM / SPF / DMARC validation requires a real MTA; CI tests cover content + handler logic, not authentication-on-the-wire.
  • Per-ESP webhook payloads vary; test fixtures must come from each ESP's official docs.
  • Email rendering across clients (Outlook, Gmail, Apple Mail) isn't covered here - that's a pdf-print-render-adjacent domain (visual regression for email).

References

  • IETF RFC 2046 §5.1.4 - multipart/alternative
  • IETF RFC 8058 - Signaling One-Click Functionality for List-Unsubscribe Email Headers
  • IETF RFC 5321 - Simple Mail Transfer Protocol (SMTP)
  • IETF RFC 5322 - Internet Message Format
  • mail-tester.com - pre-prod deliverability scoring
  • dmarcian.com - DMARC monitoring
  • The host SKILL.md (Mailpit) + mailhog-legacy.md (opens in new window) - SMTP capture partners
  • webhook-delivery-tester - companion: bounce/complaint webhook handlers receive vendor webhooks; same patterns
  • sms-test-author, push-notification-test-author - sister channels

MailHog (legacy) capture + migration to Mailpit

View source (opens in new window)

MailHog (legacy) capture + migration to Mailpit

Per github.com/mailhog/MailHog (opens in new window):

"MailHog is an email testing tool for developers" that allows you to "Configure your application to use MailHog for SMTP delivery" and "View messages in the web UI, or retrieve them with the JSON API."

MailHog is legacy as of the mid-2020s - Mailpit (the host SKILL.md) succeeded it with richer API + active maintenance. This reference covers MailHog for existing deployments + provides migration guidance to Mailpit.

When to use

  • Existing MailHog deployment that the team isn't ready to migrate.
  • A specific MailHog feature behaves differently than Mailpit's equivalent (rare; investigate before assuming MailHog-specific behavior is required).
  • Tests already wired against MailHog's APIv2 + the team needs to document what the tests do before migration.

For new projects, use the host SKILL.md (Mailpit).

How to use

  1. Install MailHog via Go or Docker and start it - SMTP on 1025, web UI + JSON API on 8025.
  2. Point the app under test at localhost:1025 with SMTP auth disabled.
  3. In each test, clear the mailbox (DELETE /api/v1/messages), trigger the action that sends mail, then poll APIv2 (/api/v2/messages or /api/v2/search) until the message lands.
  4. Assert on the captured message, walking MailHog's nested Content.Headers.Subject array - see the Worked example.
  5. Add failure-injection coverage with the Jim chaos monkey (mailhog -invite-jim); when leaving MailHog, follow Migrating to Mailpit.

Install

Per mh-gh (opens in new window):

# Go install
go install github.com/mailhog/MailHog@latest

Docker:

docker run -d \
  --name mailhog \
  -p 1025:1025 \
  -p 8025:8025 \
  mailhog/mailhog

Default ports

Per mh-gh (opens in new window):

PortService
1025SMTP server
8025HTTP server (UI + APIv1 + APIv2)

Configure your app's SMTP

Same pattern as Mailpit (since both expose unauthenticated SMTP on 1025 by default):

smtp:
  host: localhost
  port: 1025
  auth: none

Assert via APIv2

Per mh-gh (opens in new window) MailHog has both APIv1 + APIv2; APIv2 is the modern one. Endpoints:

EndpointUse
GET /api/v2/messagesList captured messages (paginated)
GET /api/v2/messages?limit=N&start=MPagination
GET /api/v2/search?kind=to&query=alice@x.comSearch by recipient / subject / containing
DELETE /api/v1/messagesClear all messages (uses APIv1; APIv2 has no delete)

The MailHog message structure is more nested than Mailpit's: Content.Headers.Subject is an array, not the flat Subject field Mailpit exposes. Walk the nested path or the assertion silently fails.

Worked example

Capture and assert a single password-reset email end to end. Clear the mailbox first so a stale message can't satisfy the assertion, trigger the app action, poll APIv2 until the message lands, then assert on its subject and body:

import requests

BASE = "http://localhost:8025"

def test_password_reset_via_mailhog():
    requests.delete(f"{BASE}/api/v1/messages")            # clear (APIv1 - no APIv2 delete)
    trigger_password_reset("alice@example.com")           # app under test sends the email
    msg = poll_for_message(BASE, to="alice@example.com")  # GET /api/v2/search?kind=to&query=...

    assert msg["Content"]["Headers"]["Subject"][0] == "Reset your password"
    assert "/reset?token=" in msg["Content"]["Body"]

poll_for_message retries GET /api/v2/search?kind=to&query=alice@example.com until a message appears, then returns it. Sub-second SMTP delivery isn't guaranteed, so never assert immediately after triggering.

Jim chaos monkey

Per mh-gh (opens in new window): "Chaos Monkey for failure testing" via the Jim component. Jim is configurable via CLI flags or environment:

mailhog -invite-jim   # enables the chaos monkey

Once Jim is invited, MailHog injects failures (random connection drops, slow responses) per the configured probabilities. Configuration flags are documented in mailhog -h; the README references "Introduction to Jim" for details.

For new test work needing chaos, prefer Mailpit's Chaos mode (host SKILL.md Step 5) - it has richer per-recipient configuration.

CI integration

services:
  mailhog:
    image: mailhog/mailhog
    ports: [1025:1025, 8025:8025]

steps:
  - run: pytest tests/integration/email/ -v

Migrating to Mailpit

For new projects, and for teams ready to leave MailHog, migrate to Mailpit (the host SKILL.md) - the actively maintained successor with a flatter API and richer chaos configuration. The feature-mapping table, the schema-rewrite notes, and the step-by-step cutover live in migrating-to-mailpit.md (opens in new window).

Anti-patterns

Anti-patternWhy it failsFix
Start new project on MailHogLegacy; loses richer Mailpit featuresUse Mailpit (Migrating to Mailpit + cross-skill)
Use APIv1 for new test codeDeprecated by APIv2 (within MailHog)APIv2 endpoints (Assert via APIv2)
Assume MailHog flat schemaMailHog's Content.Headers.Subject is an arrayWalk the nested structure (Worked example)
Skip per-test message clearSame problem as Mailpit Step 4; stale messagesDELETE /api/v1/messages in setup
Skip Jim coverageSame as Mailpit Step 5; misses resilienceEnable Jim or migrate to Mailpit Chaos

Limitations

  • MailHog has not had significant new releases since ~2020; unmaintained for new features.
  • APIv2 is the modern API but APIv1 is required for the message-clear operation (no APIv2 equivalent); awkward inconsistency.
  • Jim chaos-monkey configuration is less rich than Mailpit's Chaos mode.
  • Some Docker images on Docker Hub may be unmaintained; verify the pulled image was built recently.

References

Migrating from MailHog to Mailpit

View source (opens in new window)

Migrating from MailHog to Mailpit

Deep reference for mailhog-legacy.md (opens in new window). Consult when moving an existing MailHog deployment to Mailpit (the host SKILL.md), the actively maintained successor with a flatter API.

Why migrate

MailHog has had no significant release since ~2020 and is effectively unmaintained. Mailpit is the actively maintained successor with a flatter JSON schema, a single REST API for both reads and deletes, and richer per-recipient chaos configuration.

Feature mapping

MailHogMailpit equivalent
SMTP on 1025SMTP on 1025 (same)
HTTP UI on 8025Web UI on 8025 (same)
GET /api/v2/messagesGET /api/v1/messages (path differs; flat schema)
GET /api/v2/search?kind=to&query=...GET /api/v1/search?query=to:... (Lucene-ish syntax)
DELETE /api/v1/messagesDELETE /api/v1/messages (same path)
mailhog -invite-jimChaos mode (richer per-recipient config)

Schema rewrite

The largest test-code change is the message schema. MailHog nests the subject as Content.Headers.Subject[0] (an array) and the body as Content.Body; Mailpit exposes a flat Subject string and moves the body to Text. Every assertion that walks MailHog's nested structure must be rewritten against Mailpit's flatter shape.

Cutover steps

  1. Stand up Mailpit alongside MailHog on different ports temporarily.
  2. Update test code to hit Mailpit's endpoints and flatter schema.
  3. Run both in CI in parallel for one PR cycle to confirm parity.
  4. Cut over, then retire the MailHog service.

Chaos parity

MailHog's Jim chaos monkey (mailhog -invite-jim) injects random connection drops and slow responses at globally configured probabilities. Mailpit's Chaos mode replaces it with per-recipient configuration, so migrate resilience tests to Mailpit Chaos rather than porting Jim's flags.

Related skills

push-notification-test-author

Build-an-X for push notification tests (push notifications, web push, FCM / APNs push messages) across Web Push (RFC 8030 / VAPID), Apple Push Notification Service (APNs), and Firebase Cloud Messaging (FCM) - covers subscription handshake, payload encryption, badge / sound / click-action assertions, expired-subscription cleanup, silent-vs-alert, and topic-vs-targeted routing; also carries the in-app notification test workflow (WebSocket / SSE / Firebase-listener delivery, read-unread state, multi-session fan-out, offline-then-reconnect) in references/in-app.md. Use when authoring tests for any push or in-app notification flow.

sms-test-author

Build-an-X for SMS-flow tests - uses Twilio Magic Numbers (`+15005550006` valid recipient, `+15005550001` invalid number, `+15005550002` cannot route, `+15005550003` international restriction, etc.) and Test Credentials for safe assertion-only Twilio interactions; covers segment-counting (GSM-7 vs UCS-2 encoding); rate-limit + opt-out keyword (STOP / HELP / UNSUBSCRIBE) handling; alphanumeric sender vs short-code vs 10DLC differences. Use when authoring tests for any Twilio-backed SMS flow.

webhook-delivery-tester

The single webhook-testing home, sender AND receiver: build-an-X for webhook delivery + receiver tests per Standard Webhooks (standardwebhooks.com) - HMAC-SHA256 signature verification, retry semantics with exponential backoff + jitter, replay-window check via timestamp tolerance, ordering guarantees, dead-letter handling for permanent failures, content-type + body-encoding fidelity - plus inbound capture-and-replay hardening (runtime-signed fixtures, tampered-payload and future-timestamp rejection, key-rotation acceptance, sanitized production captures) in references/inbound-replay.md. Use when authoring tests for webhook senders OR receivers in any system (Stripe / Twilio / SendGrid / GitHub / GitLab outbound webhooks; SaaS app inbound webhooks), including payment and realtime integrations.