From qa-notifications
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.
How this skill is triggered — by the user, by Claude, or both
Slash command
/qa-notifications:email-flow-test-authorThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Email is the most underspecified surface in modern web apps. A
Email is the most underspecified surface in modern web apps. A "the email got sent" assertion misses:
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.
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.
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("[email protected]"),
recipient="[email protected]",
timeout=5,
)
# Now assert against `msg`
(capture_one_email is the helper from
mailpit-testing Step 4.)
def test_email_headers(msg):
assert msg["From"]["Address"] == "[email protected]"
assert msg["To"][0]["Address"] == "[email protected]"
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:
Return-PathFromThese 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.
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.
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.
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="[email protected]")
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.
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": "[email protected]",
"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="[email protected]")
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.
These are MTA-side concerns; tests in CI typically don't validate them. For a pre-production layer:
For each email flow in scope:
List-Unsubscribe (Step 3)| Anti-pattern | Why it fails | Fix |
|---|---|---|
| Test only "the send happened" | Misses every content + link issue | Steps 3 - 6 |
| Skip plain-text alternative | Many corporate gateways strip HTML; bare HTML emails appear blank | Always assert both (Step 4) |
| Hardcode rewritten ESP link | Tests fail when ESP rotates tracker domains | Resolve to final URL (Step 5) |
| Skip unsubscribe test | Compliance failure (CAN-SPAM, CASL, GDPR) + ISP penalties | One-click test (Step 6) |
| Skip bounce/complaint webhooks | Sender reputation degrades; deliverability drops | Per-ESP fixture tests (Step 7) |
mailpit-testing
or mailhog-testing).pdf-print-render-adjacent
domain (visual regression for email).mailpit-testing,
mailhog-testing - SMTP capture
partnerswebhook-delivery-tester -
companion: bounce/complaint webhook handlers receive vendor
webhooks; same patternssms-test-author,
push-notification-test-author - sister channels in this pluginnpx claudepluginhub testland/qa --plugin qa-notificationsProvides CDSS development patterns for drug interaction checking, dose validation, clinical scoring (NEWS2, qSOFA), and alert classification integrated into EMR workflows.