GUIDES / TRANSACTIONAL

Your transactional email is a feature.
Test it like one.

Password resets, receipts, welcome emails, invoices — transactional email is a screen every user eventually sees, built from a code path almost no suite exercises. The template lives in an ESP dashboard, the copy gets edited by marketing, the DNS belongs to infra — and the first integration test most of it ever gets is a customer replying "this link doesn't work."

Quick answer. To test transactional email, receive it into a programmatic inbox from your app's real sending path, then assert on the parsed message: sender and subject, the content of both body parts, the links it carries, the SPF/DKIM/DMARC verdicts, and the spam score. One received message carries all of it — the whole bundle is a handful of assertions in the E2E suite you already run.

The most-used screen nobody tests

A transactional email fails differently from a web page, because more teams can break it and fewer tests watch it:

None of these break the send. All of them break the feature. That's why "the email arrived" — the only thing most suites assert, when they assert anything — is the weakest claim you can make about a message you're already holding.

The checks that don't catch it

One message, five assertions

The fix is structural: point the flow under test at a real inbox you can query, and assert on the message as parsed data. Everything below rides on one received message — no second fetch, no scraping:

ASSERTION LAYERWHAT REGRESSES THEREDEEP DIVE
Envelope — sender, subject, recipientWrong from-address after an ESP move; subject templates losing their variables
Content — both MIME partsTemplate debris (undefined, unrendered placeholders); the text part silently droppedthis guide
Links — classified, then followedEnvironment-config URLs pointing at the wrong host; the verify/reset link missing entirelyverification, magic links
Attachments — metadata, then bytesInvoice PDFs generated broken under the right filenameattachments
Authentication — SPF, DKIM, DMARCDropped SPF includes, rotated DKIM selectors, alignment lost in an ESP switchSPF/DKIM/DMARC
Spam score — SpamAssassin verdictHTML-only bodies, image-heavy templates, shouty subject rewritesspam score

Here's the bundle in a Playwright suite with the JS SDK (mailfixture on npm, zero runtime deps; timeouts are milliseconds):

password-reset-email.spec.ts · Playwrightnpm i -D mailfixture
import { test, expect } from "@playwright/test";
import { MailFixture } from "mailfixture";

const mfx = new MailFixture(); // reads MAILFIXTURE_API_KEY from the environment

test("reset email is correct, whole, and deliverable", async () => {
  const inbox = await mfx.createInbox({ ttlSeconds: 900 });
  await requestPasswordReset({ email: inbox.emailAddress });

  const msg = await inbox.waitForMessage({
    match: "subject:reset",
    timeout: 30_000,
  });

  // 1 — envelope: the identity users (and filters) see
  expect(msg.fromAddr).toBe("no-reply@yourapp.example");
  expect(msg.subject).toBe("Reset your password");

  // 2 — content: both parts exist, neither carries template debris
  expect(msg.textBody).toBeTruthy(); // HTML-only mail scores spam points
  for (const body of [msg.textBody!, msg.htmlBody!]) {
    expect(body).toMatch(/Reset your password/);
    expect(body).not.toMatch(/undefined|\{\{|\bNone\b/);
  }

  // 3 — the link: classified as a reset link, and it actually answers
  expect(msg.links.some((l) => l.kind === "reset")).toBe(true);
  const followed = await mfx.followLink(msg.id, { kind: "reset" });
  expect(followed.ok).toBe(true); // server-side GET, redirects followed

  // 4 + 5 — deliverability: authenticates, and doesn't look like spam
  expect(msg.auth?.dmarc.result).toBe("pass");
  expect(msg.spam?.verdict).toBe("ham");

  await inbox.delete(); // teardown: messages go with the inbox
});

The wait is a ceiling, not a sleep: the server holds the request open and answers the moment the message arrives, so the suite pays real latency, not worst-case latency. The inbox is created inside the test and deleted in teardown, which is what makes every assertion above race-free at any worker count — the parallel guide covers why.

The same bundle in pytest with the Python SDK (mailfixture on PyPI, stdlib-only; timeouts are seconds), using the conftest fixture from the pytest guide:

test_receipt_email.py · pytestpip install mailfixture
def test_receipt_email_bundle(inbox, app):
    app.place_order(email=inbox.email_address, amount_cents=4900)

    msg = inbox.wait_for_message(match="subject:receipt", timeout=30)

    assert msg.from_addr == "billing@yourapp.example"
    assert msg.text_body and msg.html_body          # both parts, always
    assert "Order #" in msg.text_body
    assert "$49.00" in msg.text_body               # the number, not just the shape

    kinds = {link.kind for link in msg.links}
    assert "unsubscribe" not in kinds             # a receipt is not a campaign

    assert msg.auth and msg.auth.dmarc.result == "pass"
    assert msg.spam and msg.spam.verdict == "ham"

Assert real values, not shapes: the receipt test checks $49.00, not "contains a dollar sign." Personalization bugs almost always render something — the fallback greeting, last month's total, the right template with the wrong data. Only an assertion pinned to the data this test created can tell the difference.

The deliverability legs need the real sending path

HONESTY Assertions 4 and 5 are only meaningful when the message travels your real sending path — your staging app rendering the real template through your actual ESP from your actual domain. Mail a fixture script pushes to the inbox directly reads none across the auth verdicts (there's no DNS trail to verify — that's the honest answer, not a bug), and its spam score reflects your fixture, not your product. auth and spam are null when verification or scoring didn't run at all — treat that as "unknown", never as a pass.

That constraint decides where the bundle runs. The envelope, content, and link assertions work anywhere your app can send from — local, staging, CI. The authentication and spam legs earn their keep in the E2E suite that exercises staging end-to-end, because that's the configuration that breaks silently from a DNS edit and the one your users receive from. Splitting it that way is normal; both halves are the same handful of lines.

Failure modes

SYMPTOM & CAUSEEVIDENCEFIX
Wait times out but the app "sent" it — the send path failed silently, or the match filter excludes the messageListing the inbox without match shows nothing, or shows an unexpected subjectList unfiltered to see what arrived; fix the filter or the send path
Text-part assertion fails after a redesign — the ESP's visual editor rebuilt the template HTML-onlytext_body is null; MIME_HTML_ONLY appears in spam.rulesRestore the plain-text leg; keep the assertion so it can't regress again
followed.ok is false in staging — link URLs built from environment config point at an internal hostrequested_url shows a host the follower can't reach (it requires public HTTPS targets)Build email URLs from the environment's public origin, not service-internal config
Greeting shows the fallback — personalization data not threaded to the mailer, template falls back to "Hi there"Body renders, but with defaults instead of this test's dataAssert the actual name/total the test created, not just that a greeting exists
Auth verdicts read none everywhere — the message didn't travel the real sending pathspf.result == "none", empty dkim, no DMARC record foundRun the deliverability legs against staging's real ESP path (see above)
Suite green, users still report a broken email — the regression is in a template variant no test triggersThe failing variant (locale, plan, first-vs-repeat) never appears in test dataParameterize the test over the variants the template actually branches on

Edge cases worth building in

What this doesn't prove

Next steps

The bundle is the hub; each leg has a deep-dive guide when a layer needs more than one assertion:

one received message · the whole bundle · 100 messages/mo free
Start free Read the quickstart