Testland
Browse all skills & agents

email-flow-test-author

Build-an-X for end-to-end email-flow tests - trigger → SMTP capture (via Mailpit / MailHog) → header assertions (DKIM/SPF/DMARC when relayed via real MTA) → body assertions (HTML + plain-text alternative) → link-rewrite + tracking-pixel handling → unsubscribe-link verification → bounce + complaint testing in non-prod (via Mailtrap-style services). Use when authoring tests for any transactional or marketing email flow regardless of the SMTP capture tool.

Install with skills.sh (any agent)

npx skills add testland/qa --skill email-flow-test-author
View source

email-flow-test-author

Overview

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 skill is a build-an-X workflow - a checklist and per-stage test recipes, not a single tool. Pair with mailpit-testing or mailhog-testing for SMTP capture.

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 mailpit-testing:

# 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 mailpit-testing 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 (mailpit-testing or mailhog-testing).
  • 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
  • mailpit-testing, mailhog-testing - 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

Related skills

in-app-notification-test-author

Build-an-X workflow for testing real-time in-app notifications delivered over WebSocket (RFC 6455) or Server-Sent Events (WHATWG SSE spec), Firebase Realtime Database / Firestore listeners, and notification center read/unread state - covers fan-out to multiple sessions, offline-then-reconnect delivery, and ordering guarantees. Distinct from email, SMS, push, and webhook channels. Use when authoring tests for any notification that appears inside a connected web or mobile app UI without leaving the application.

mailhog-testing

Captures and asserts SMTP email in tests with MailHog, the Go-based dev mailbox (SMTP sink on `1025`, web UI + JSON API on `8025`, single Go binary or Docker), reading captured mail via APIv2 (`/api/v2/messages`, `/api/v2/search`) and injecting failures with the Jim chaos monkey. Use when a project already runs MailHog to test password-reset, verification, or notification emails; for new projects prefer Mailpit (richer API, active maintenance) - migration path in references.

mailpit-testing

Configures and runs Mailpit - modern dev-mailbox server for SMTP testing with built-in REST API for assertions; default SMTP `1025` + Web UI `8025`; ships single static binary or multi-architecture Docker images; features Chaos mode (configurable SMTP errors for resilience testing), message tagging (manual + auto via filters and plus-addressing), search filters. Use when the user develops email-sending code locally / in CI and needs SMTP capture with programmatic test assertions, or when migrating from MailHog (which Mailpit succeeds).

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. Use when authoring tests for any push 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

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. Use when authoring tests for webhook senders OR receivers in any system (Stripe / Twilio / SendGrid / GitHub / GitLab outbound webhooks; SaaS app inbound webhooks).