GUIDES / ATTACHMENTS

Testing email attachments: assert the invoice, not just the subject line.

Your app emails an invoice PDF. Your test asserts the email arrived. Six weeks later the PDF generator breaks, every invoice goes out as a zero-page file, and the suite stays green — because "the email arrived" was never the thing customers cared about. The attachment was.

Quick answer. To test email attachments in an automated suite, receive the email into a programmatic inbox, assert filename, content type, and size from the message's attachment metadata, then download the decoded bytes and verify the content itself — parse the PDF, check the CSV rows, compare a checksum. Metadata catches the wrong-file bugs; bytes catch the wrong-content bugs. You need both.

Why attachment bugs survive green test suites

Invoice, receipt, and report emails are the classic case: the email is a thin wrapper and the attachment is the product. Three failure patterns show up over and over.

1. The test stops at the envelope. Most email tests assert subject and recipient because that's what's easy to reach. But "Invoice for March" with no PDF attached, or with last month's PDF attached, passes that test. The attachment is a separate artifact produced by a separate code path — a template change can't break it, but a library upgrade, a font path in a container image, or a queue serialization change absolutely can. If no test opens the attachment, those regressions ship.

2. Hand-rolled MIME parsing. Teams that do go deeper often fetch the raw message and dig through MIME parts themselves. Multipart boundaries, base64 vs quoted-printable transfer encodings, RFC 2231 filename continuation, nested multipart/related wrappers — this is a rabbit hole, and the hand-rolled parser becomes the flakiest code in the suite. It works on the fixture email it was written against and breaks the day the sending library restructures the parts.

3. Brittle assertions on the wrong properties. The most common one: asserting an exact byte size. PDF generators pad nondeterministically — timestamps, metadata, object ordering — so size === 48213 passes today and fails Thursday with nothing wrong. The sibling mistake is trusting the declared content type: content_type is what the sender's code claimed, not what the bytes are. A test that checks application/pdf and stops has verified a string constant in the sending code, not a PDF.

What the receiving side gives you

A test inbox API parses the MIME for you on receipt. In MailFixture, every message detail carries an attachments array — metadata only, so reading messages stays fast regardless of what's attached:

message.attachments · wire shape
[
  {
    "filename": "invoice-0042.pdf",
    "content_type": "application/pdf",
    "size": 48213,
    "inline": false
  },
  {
    "filename": "logo.png",
    "content_type": "image/png",
    "size": 8122,
    "inline": true
  }
]

Three things to know about this shape:

Up to 50 attachments are listed per message. The bytes of any one of them are a single request away: GET /v1/messages/{id}/attachments/{index}, where index is the 0-based position in the metadata array. Both SDKs wrap it in a download helper.

Layer one: metadata assertions

Metadata assertions are cheap — no download, no parsing — and they catch the majority of real regressions: attachment missing entirely, wrong filename pattern, wrong declared type, suspiciously tiny file. Here's the shape in a Playwright suite with the JS SDK (mailfixture on npm, zero runtime deps; timeouts are milliseconds):

invoice.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("invoice email carries exactly one well-formed PDF", async () => {
  const inbox = await mfx.createInbox({ ttlSeconds: 900 });

  // Drive your app: issue an invoice to this address.
  await issueInvoice({ email: inbox.emailAddress, amountCents: 123_400 });

  // Long-poll: resolves the moment the email lands, up to the ceiling.
  const msg = await inbox.waitForMessage({
    match: "subject:invoice",
    timeout: 30_000,
  });

  // Filter out cid-inline images before counting.
  const files = msg.attachments.filter((a) => !a.inline);
  expect(files).toHaveLength(1);

  const [invoice] = files;
  expect(invoice.filename).toMatch(/^invoice-\d+\.pdf$/);
  expect(invoice.contentType).toBe("application/pdf");
  expect(invoice.size).toBeGreaterThan(5_000); // a floor, never an exact size

  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, which is what makes the toHaveLength(1) assertion meaningful: in a shared mailbox, parallel workers cross-contaminate and "exactly one attachment" is a race.

Note the size assertion is a floor. A zero-page PDF from a broken generator is a few hundred bytes; a real invoice is tens of thousands. size > 5000 catches the catastrophic case without betting on the generator's exact padding.

Layer two: download the bytes and open the file

Metadata can't tell you the invoice total is right. For that, download the decoded bytes and parse them in your test process — with the Python SDK (mailfixture on PyPI, stdlib-only; timeouts are seconds) and any PDF library:

test_invoice.py · pytestpip install mailfixture pypdf
from io import BytesIO

from mailfixture import MailFixture
from pypdf import PdfReader

mfx = MailFixture()  # reads MAILFIXTURE_API_KEY

def test_invoice_pdf_states_the_right_total(inbox, app):
    app.issue_invoice(email=inbox.email_address, amount_cents=123400)

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

    pdfs = [
        a for a in msg.attachments
        if not a.inline and a.content_type == "application/pdf"
    ]
    assert len(pdfs) == 1

    data = mfx.download_attachment(msg.id, pdfs[0].index)  # bytes

    # The bytes are the truth; the declared type was only a claim.
    assert data.startswith(b"%PDF-")

    reader = PdfReader(BytesIO(data))
    text = "".join(page.extract_text() for page in reader.pages)
    assert "Total due: $1,234.00" in text
    assert "March 2026" in text

Each SDK Attachment carries its own index (its position in the metadata array), so you can filter first and download the element you matched — pdfs[0].index stays correct even when the PDF isn't the first part in the message. In JS the same call is await mfx.downloadAttachment(msg.id, invoice.index) and returns a Uint8Array.

The same pattern covers the other invoice-adjacent formats. A CSV export: decode, split, assert row count and a known cell. A calendar invite: parse the text/calendar part with an iCal library and assert DTSTART. A ZIP of monthly reports: open it in-memory and assert the entry names. The API's job ends at handing you faithful bytes; the assertion logic belongs in your test code, in your language, with whatever parser you already trust.

For generated files where "correct" means "byte-identical to a known-good artifact" — a static terms-of-service PDF, a firmware file — skip parsing and compare a checksum against a committed fixture. Do that only for genuinely static files; anything with a generation timestamp inside will never hash stable.

One security note worth understanding

Attachment downloads from the API are deliberately blunt: always application/octet-stream, always a forced-download disposition with a sanitized filename, always nosniff. That's not an oversight to work around — email is hostile input, and an HTML or SVG "attachment" must never render on the API's origin no matter what type the sender claimed. Your test code is the safe place to parse contents, and the API's job is to get the bytes there unexecuted. If you're asserting on an HTML attachment, assert on it as a string or a parsed DOM in your test — don't try to load it in a live browser context pointed at the download URL.

Failure modes

SYMPTOM & CAUSEEVIDENCEFIX
Expected 1 attachment, found 2 — logo or signature embedded via Content-ID counts as a MIME attachmentThe extra entry has inline: true and an image content typeFilter !a.inline before counting
Exact-size assertion fails intermittently — PDF/Office generators pad nondeterministically (timestamps, metadata)Size differs by a few hundred bytes run to runAssert a floor or range; verify content by parsing, not size
Content-type assertion passes but the file won't opencontent_type is sender-declared; the generator produced garbage under the right labelBytes don't start with the format's magic number (%PDF-, PK)Download the bytes; assert the magic number and parse
Attachment download returns 404 — index past the end of the attachments arrayMetadata array is shorter than the index usedUse the index field from the metadata entry you matched, not a hardcoded 0
Wait times out but the app "sent" the email — send path failed silently, or the match filter excludes the messageInbox message list is empty, or the message is present with an unexpected subjectList messages without match to see what actually arrived; fix the filter or the send path
Email rejected at SMTP time — message exceeded the 10 MB cap (body plus all attachments, as transmitted)Sending side gets a size rejectionKeep test artifacts small; a large attachment plus base64 overhead breaches 10 MB faster than its file size suggests

Edge cases and limits

Testing attachments with AI agents over MCP

If an agent is driving your app instead of a scripted test, the same attachment surface is exposed as MCP tools, with one rule worth knowing: attachments up to 1 MiB are returned inline as base64 in the tool result, and anything larger returns a pointer to the REST download instead. That's deliberate — an agent's context window is the wrong place for a 9 MB PDF. Agents verifying "the receipt email has the PDF" work directly from metadata; agents that need file contents fetch small files inline and delegate big ones to code. The agent guide covers the setup.

What this doesn't prove

Honesty about the boundary, so your test names don't overpromise:

Next steps

Attachment assertions slot into the same inbox-per-test pattern as the rest of your email suite:

100 messages/mo free · attachments arrive decoded, not hand-parsed
Start free Read the quickstart