Learn

Email testing checklist

A skimmable, pre-ship checklist for the mail your own product sends — signup confirmations, one-time codes, password resets, receipts, and the rest. Run it against a real private inbox you own, so you are checking what actually arrives, not what your code intended to send.

Last updated July 2026

The gap between “the send call returned 200” and “the message a user receives is correct” is where most email bugs live. A broken template variable, a reset link that points at the wrong host, a DKIM signature that doesn’t align — none of these fail your unit tests, and all of them reach production if the only thing you check is that your code called the sender.

The reliable way to close that gap is to point each event at a real address on a domain that accepts inbound mail, then inspect the delivered message directly. On Perenmail that address is a private, persistent inbox tied to your account: it doesn’t expire between test runs, new mail surfaces at the top the moment it lands, and you can read the rendered body, the plain-text fallback, and the raw source of the same message. Work through the groups below for each email your product sends.

Delivery & triggers

Before anything about content matters, confirm the message is actually produced and delivered for every path that should produce it.

  • Every trigger fires: each user action or system event that is supposed to send mail (signup, invite, reset request, receipt, alert) produces exactly one message in the inbox.
  • No duplicate or missing sends: a retried request or a double-clicked button doesn’t fan out into two emails, and a successful action never sends zero.
  • Asynchronous senders complete: webhook handlers, queue workers, and cron/scheduled jobs that generate mail actually run to completion and their output lands — not just the synchronous request path.
  • Retries behave: when a downstream failure triggers a retry, the recipient gets one correct message, not a burst of partials.
  • Timing is right: delayed and scheduled mail (a “your trial ends tomorrow” nudge, a digest) arrives at the expected time, and time-sensitive codes arrive fast enough to be usable.

Content & rendering

Now that the message arrives, confirm it looks right in every form a mail client might show it. Toggle the Rendered, Text, and Source views on the same message as you go.

  • Subject and preheader read correctly — no truncated sentences, no leftover template syntax, and the preheader isn’t accidentally the first line of hidden markup.
  • No unrendered template variables: search the delivered body for stray {{ }}, %name%, undefined, or null that leaked from an unfilled field.
  • The HTML body renders cleanly in the Rendered view, and the plain-text fallback in the Text view is a real, readable message — not an empty part or a raw HTML dump.
  • Images and links resolve: logos and inline images load from absolute URLs, and every link points at the right host (not localhost, a staging domain, or a placeholder) — the one-click link capture surfaces every URL in the message so you can check them at a glance.
  • The raw source in the Source view is well-formed: Content-Type, charset, and the multipart/alternative structure are what you expect, with both an HTML and a text part present.

Actionable elements

Transactional mail usually exists to make the user do one thing. Confirm that thing works end-to-end, from the delivered message back into your app.

  • One-time codes are correct and usable: the OTP in the message matches what your backend expects, and it’s easy to copy cleanly — the one-tap code capture pulls the verification code out for you so you’re not retyping it. See OTP & 2FA testing.
  • Verification and reset links work: clicking the link in the delivered message lands on the right route, in the right environment, and completes the flow for a real signed-in test account.
  • Tokens expire and are single-use: a link or code works once within its window, and is rejected after it expires or after it’s already been used — test both the happy path and the stale-token path.
  • CTAs and buttons carry the correct target: the visible button and its underlying href agree, and neither leaks a query param or token you didn’t mean to include.

Authentication & deliverability

A perfect message that fails authentication still ends up in spam. Read the auth results on the delivered message rather than trusting your DNS config in the abstract.

  • SPF passes for the sending source: the connecting server’s IP is authorized for the Return-Path domain.
  • DKIM passes: the message carries a valid signature and the d= domain and selector are the ones you expect.
  • DMARC passes and aligns: a passing SPF or DKIM domain lines up with the visible From: domain — the usual reason a valid signature still fails is an alignment mismatch. Read the SPF/DKIM/DMARC guide.
  • You verify this on the message itself: Perenmail’s raw source view surfaces synthesized SPF, DKIM and DMARC results alongside the full RFC822 headers, so you read what a receiver actually saw. Raw source, headers and attachments are part of Personal+.

Edge cases

Finally, exercise the paths that only show up when a user does something slightly off-script. These are where the last round of embarrassing bugs hides.

  • Resend works cleanly: requesting the same email again produces a fresh, valid message — a new working code or link — without silently invalidating the previous one in a way that confuses the user.
  • Expired-token handling is graceful: following an old reset or verification link shows a clear “this expired, request a new one” path, not a stack trace or a dead end.
  • Unsubscribe and preference links honor the request: for any mail that carries them, the link works, records the choice, and (where applicable) the List-Unsubscribe header is present and correct.
  • Internationalized and unusual addresses are handled: names with non-ASCII characters, plus-addressing (user+tag@domain), and long local parts render correctly in the To: and body, and don’t break your parsing.
  • Empty and boundary states look intentional: a receipt with one line item, a digest with zero items, or a very long subject still produces a sensible message.

Run it against a real inbox, not a mock

Every item above is about the delivered message. A mock or an assertion on your outbound payload can’t tell you that a mail client renders the HTML correctly, that a link resolves, or that the receiver’s DMARC check aligned — only a message that actually travelled through DNS, routing, and inbound delivery can. Because a Perenmail inbox is persistent, you can keep the same address wired into your staging environment across many test runs and simply watch new mail arrive as you trigger each flow. See how transactional email testing works.

Keep reading

Run the checklist against a real inbox

Point your app’s mail at a private, persistent Perenmail address and inspect exactly what arrives. Free to start, no credit card.