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.
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:
- Asserting on raw markup.
expect(html).toContain('<a href="https://acme.test/verify?t=')passes today. Then the template engine reorders attributes, a designer wraps the link in a<span>, or the ESP rewrites URLs for click tracking — and the test fails on a working feature. A raw-string assertion tests the current serialization of your template, not its meaning. - Regexing the HTML for links.
/href="([^"]+)"/finds the unsubscribe link when you wanted the verification link, breaks on single-quoted attributes, and returns entity-encoded URLs —&where a browser would see&— that 404 when the test follows them. - Trusting the HTML part to stand for the email. Multipart emails carry a
text/plainalternative next to the HTML. Template pipelines drop it, or nobody updates it after the redesign, and now the version some clients and screen readers actually read says "Welcome to {{product_name}}". A suite that only reads the HTML part never notices. - Rendering the email to check it. Writing
html_bodyto a file and opening it in a browser — or injecting it into a page withinnerHTML— executes whatever the sender put in it. That's your own staging mail, until the day a test address leaks and it isn't. Email is hostile input; treat it as data to parse, never as a page to load.
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:
{
"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:
- 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. - 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, orother(the SDKs expose it askind). "The email has a verification link" becomes one typed assertion, no parsing. - Parts, via presence. Multipart discipline:
html_bodyandtext_bodyare both non-null, and the content that matters appears in both. - Structure, via a real parser. When you genuinely need "the CTA button exists and its icon has alt text," parse
html_bodywith cheerio or BeautifulSoup in your own test code. A parser gives you semantics; a regex gives you the current byte layout. - 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.
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):
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.rules — the 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:
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:
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:
- Never load it. Not
innerHTMLin a test helper, not "write to a file and open in the browser," not an unsandboxed HTML report artifact. A parsed DOM in cheerio or BeautifulSoup executes nothing; a rendered page executes everything. - Escape it in test reports. If your framework embeds failure context into an HTML report, attach
html_bodyas an escaped string or a.txtartifact, never as markup. A CI report page that renders received email is a stored-XSS delivery vehicle aimed at your own engineers' browsers. - Let the receiving side carry the rendering risk. For the "what does it look like" glance during development, the hosted dashboard renders messages only inside a sandboxed iframe whose CSP allows images strictly as inlined
data:URIs — no remote loads, no scripts — with CID images resolved at render time. Attachment downloads are similarly blunt: forced download,application/octet-stream,nosniff, sanitized filename — an HTML "attachment" must never render on anyone's origin.
Your test code should hold the same posture: parse, assert, discard.
Failure modes
text_body or a parsed DOM, not HTML substringshrefThe extracted links list shows unsubscribe ahead of the link you wantedSelect by classification: kind === "verify"&Use extracted links (decoded), or followLink to click server-sidetext_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<img src="cid:…"> in the HTML; an attachment with inline: trueAssert on inline attachment metadata, not img srcEdge cases worth building in
- Timeouts: make the test-framework timeout comfortably larger than the SDK wait deadline, or the runner kills the test before the wait can report honestly. And the units differ: JS takes milliseconds, Python seconds.
- Parallelism: inboxes are cheap and private — create one inside each test, delete it in teardown. Never share a mailbox across workers.
- Retention: messages expire on a plan-defined window (days, not forever), and deleting the inbox drops its messages immediately. Assert inside the test; anything you need to keep, export before the message expires or teardown deletes it.
- Size: messages are capped at 10 MB as transmitted — a template that CID-embeds a hero image can hit that in test before a customer does. That bounce is a finding, not a nuisance.
What this test does not prove
Worth its own section, because a green content suite invites overconfidence:
- Not rendering. Gmail clips messages over ~102 KB, Outlook renders with Word's HTML engine, dark mode recolors your palette. Content assertions see none of it. If pixel fidelity across clients matters, use a rendering-preview tool for a periodic manual pass — and keep the content suite in CI, because a periodic manual pass doesn't block merges — the content suite does.
- Not inbox placement. A perfectly formed email can still land in spam. Authentication verdicts and spam scores are separate, assertable axes — the SPF/DKIM/DMARC guide and the spam-score guide cover them.
- Not remote images. The extractor collects the URLs; nothing here verifies a remote
<img>actually serves bytes. If a hosted image matters, fetch its URL in your own test code.
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
- What the link classifier and text fallback produce, exactly: the extraction reference.
- When the email carries a file worth opening: attachment testing — metadata first, then the decoded bytes.
- Following the verification link server-side and asserting the flow completes: the magic-link guide.
- Content as one leg of the full bundle — envelope, links, auth, spam score: the transactional email testing guide.