GUIDES / HTML CONTENT

HTML email testing:
assert the content, not the markup.

Your app's emails are rendered templates — variables, conditionals, a designer's HTML, a plain-text sibling somebody wrote once and forgot. All of it regresses silently, because email output never shows up in a code-review diff. This guide covers testing HTML email content in an automated suite: semantic assertions instead of markup string-matching, the multipart text fallback, CID-embedded images, links — and how to handle hostile markup without ever rendering it.

Quick answer. Deliver the email to a real test inbox, fetch it over an API, and assert on content — decoded text, classified links, attachment metadata — never on raw HTML strings. Parse the HTML with a real parser when you need structure. That catches template regressions, broken links, and dropped text parts on every commit. It does not prove the email renders correctly in Gmail or Outlook — nothing short of opening it there does.

THE BOUNDARY MailFixture receives your email and hands you its parsed content — html_body, text_body, classified links, extracted codes, attachment metadata, raw MIME. It does not screenshot your email in Gmail, Outlook, or Apple Mail, and no content assertion proves anything about client rendering, dark-mode inversion, or Outlook's HTML engine. Rendering previews are a different product category, and a content check is not a rendering test — this guide won't pretend otherwise. What content inspection does prove is the part you can run on every commit — that's the rest of this guide.

Why HTML email tests break

The failure modes are specific, and most are self-inflicted:

What arrives: one message, already decoded

A message fetched from the API is parsed data — no MIME boundaries, no quoted-printable, no base64 on your side:

GET /v1/messages/{id}abridged
{
  "subject": "Confirm your Acme account",
  "text_body": "Welcome! Confirm here: https://acme.test/verify?t=…",
  "html_body": "<html>…",
  "extracted": {
    "links": [
      { "url": "https://acme.test/verify?t=…", "text": "Confirm your email", "class": "verify" }
    ],
    "otp": { "best": null, "candidates": [] }
  },
  "attachments": [
    { "filename": "logo.png", "content_type": "image/png", "size": 8122, "inline": true }
  ]
}

Assert on it in this order of preference:

  1. Content, via text. The user-visible words. Assert against text_body (or the text fallback, below) — string containment survives every markup refactor that doesn't change meaning.
  2. Links, via classification. Anchor links from the HTML part (with their anchor text) and bare URLs from the text part, each with a class guess — verify, reset, unsubscribe, or other (the SDKs expose it as kind). "The email has a verification link" becomes one typed assertion, no parsing.
  3. Parts, via presence. Multipart discipline: html_body and text_body are both non-null, and the content that matters appears in both.
  4. Structure, via a real parser. When you genuinely need "the CTA button exists and its icon has alt text," parse html_body with cheerio or BeautifulSoup in your own test code. A parser gives you semantics; a regex gives you the current byte layout.
  5. Images and files, via attachment metadata. Embedded logos and attached PDFs surface as typed metadata — filename, content type, size, inline flag — with the bytes one call away.

The test, in Playwright and pytest

One inbox per test, a long-poll instead of a sleep, delete in teardown. JS timeouts are milliseconds; Python's are seconds.

welcome-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("welcome email content survives the template", async () => {
  const inbox = await mfx.createInbox({ ttlSeconds: 900 });
  await triggerWelcomeEmail(inbox.emailAddress); // your app, your trigger

  const msg = await inbox.waitForMessage({ timeout: 30_000 }); // ms

  // multipart discipline: both parts exist
  expect(msg.htmlBody).not.toBeNull();
  expect(msg.textBody).not.toBeNull();

  // semantic content — in BOTH parts, so the text sibling can't rot
  for (const body of [msg.textBody!, msg.htmlBody!]) {
    expect(body).toContain("Confirm your Acme account");
  }

  // links: classified, no HTML parsing
  const verify = msg.links.find((l) => l.kind === "verify");
  expect(verify).toBeDefined();
  expect(verify!.url).toMatch(/^https:\/\/acme\.test\/verify/);

  await inbox.delete();
});

The same shape in pytest, plus a structural assertion through a real parser (pip install beautifulsoup4 — your test dependency, not the SDK's):

test_welcome_email.py · pytestpip install mailfixture
from bs4 import BeautifulSoup
from mailfixture import MailFixture

mfx = MailFixture()  # reads MAILFIXTURE_API_KEY

def test_welcome_email_content():
    inbox = mfx.create_inbox(ttl_seconds=900)
    try:
        trigger_welcome_email(inbox.email_address)  # your app, your trigger

        msg = inbox.wait_for_message(timeout=30)  # seconds

        # multipart discipline + content in both parts
        assert msg.html_body is not None
        assert msg.text_body is not None
        assert "Confirm your Acme account" in msg.text_body

        # links: classified
        assert any(l.kind == "verify" for l in msg.links)

        # structure: parse, don't regex
        soup = BeautifulSoup(msg.html_body, "html.parser")
        cta = soup.select_one("a.button")
        assert cta is not None and cta["href"].startswith("https://")
        assert all(img.get("alt") for img in soup.find_all("img"))
    finally:
        inbox.delete()

The waitForMessage / wait_for_message call long-polls: the server holds the request open and answers the moment the email lands, so the timeout is a ceiling for the red case, not a duration the green case pays. Keep the API key in MAILFIXTURE_API_KEY (a CI secret), never in the repo.

The multipart text fallback: HTML-only emails

Two distinct situations, two distinct assertions.

Your email should have a text part. Then assert it: msg.text_body is not None. This is the cheapest high-value email test there is — it catches the template migration that silently dropped text/plain, which no visual check ever notices, because visual checks look at the HTML. (A missing text part also costs you spam points: MIME_HTML_ONLY shows up in spam.rulesthe SpamAssassin guide covers it.)

Your email is deliberately HTML-only. You still want to assert on its words without parsing markup. When a message arrives with no text/plain part, extraction stores a plain-text rendering of the HTML as extracted.text — tags stripped, entities decoded. When the email has a real text part, that key is absent (use text_body). A part-agnostic content assertion is one line:

part-agnostic content assertionpython
body_text = msg.text_body or msg.extracted.get("text") or ""
assert "Confirm your Acme account" in body_text

The presence of extracted.text is itself a signal: it means the sender shipped no text part. A suite that requires multipart can assert the key is absent; a suite testing an HTML-only stream leans on it as the assertion surface. The extraction reference states the exact behavior.

CID inline images: the logo is an attachment

HTML emails embed images two ways: remote URLs — an <img src="https://…"> your parser can simply inspect — and CID references, where the image bytes travel inside the MIME message as a part with a Content-ID and the HTML says <img src="cid:logo@acme">. CID images are invisible to naive HTML inspection: the src means nothing outside the message.

On the API they surface as attachments with inline: true, next to "real" attachments like a PDF invoice (inline: false). That flag lets a test say precisely what it means:

inline vs. attached, then the bytespython
import hashlib

inline_images = [a for a in msg.attachments if a.inline]
real_files    = [a for a in msg.attachments if not a.inline]

# the embedded logo made it into the message
assert any(a.content_type == "image/png" for a in inline_images)

# and the receipt has exactly one PDF, not counting embedded images
assert [a.content_type for a in real_files] == ["application/pdf"]

# bytes when you need them: checksum the logo
logo = mfx.download_attachment(msg.id, inline_images[0].index)
assert hashlib.sha256(logo).hexdigest() == EXPECTED_LOGO_SHA256

GET /v1/messages/{id}/attachments/{index} returns the decoded bytes (in JS: await mfx.downloadAttachment(msg.id, logo.index), a Uint8Array). The honesty fine print: content_type is what the sender declared, never verified — when the byte content matters, checksum or parse the bytes. The attachment testing guide covers both layers in depth.

Unsafe markup: email is hostile input

An email's HTML is attacker-controlled by definition — anyone who learns an address can send to it. That shapes how a test suite handles html_body:

Your test code should hold the same posture: parse, assert, discard.

Failure modes

SYMPTOM & CAUSEEVIDENCEFIX
Test fails after a visual-only template change — raw-markup string assertionThe diff shows an attribute reorder or wrapper element; content unchangedAssert on text_body or a parsed DOM, not HTML substrings
Link assertion returns the wrong URL — a regex over the HTML grabbed the first hrefThe extracted links list shows unsubscribe ahead of the link you wantedSelect by classification: kind === "verify"
Followed link 404s — entity-encoded URL scraped from raw HTMLThe URL contains a literal &amp;Use extracted links (decoded), or followLink to click server-side
text_body assertion suddenly fails — the pipeline dropped the text/plain partextracted.text now present; MIME_HTML_ONLY in spam.rulesFix the template build; keep the assertion as the tripwire
Logo assertion can't find the image — it's CID-embedded, not a remote URL<img src="cid:…"> in the HTML; an attachment with inline: trueAssert on inline attachment metadata, not img src
Flaky under parallel workers — tests share one inboxThe assertion matched a sibling test's emailInbox-per-test — the parallel guide covers why

Edge cases worth building in

What this test does not prove

Worth its own section, because a green content suite invites overconfidence:

Content inspection is the layer that runs on every commit, catches the regressions that actually happen — a broken link, a stale text part, a template variable leaking as {{name}} — and never asks a human to compare screenshots. Know what it proves; assert it hard.

Next steps

parse, assert, discard · both parts covered · 100 messages/mo free
Start free Read the quickstart