Testing email attachments: PDFs, invoices and exports

Updated October 17, 2026 · Zyntra

Assert an attachment by its name and type, then open it: check that a PDF starts with %PDF- and that its text contains the order number the test created. A non-zero size proves only that something was attached, and picking "the first attachment" breaks as soon as the email gets an inline logo.

What to assert

  • Exactly one matching file, chosen by name pattern (invoice-1001.pdf) and MIME type, not by position.
  • It opens. The first bytes match the format: %PDF- for PDF, PK for XLSX and other ZIP-based formats.
  • It is this order's file. The invoice number, the customer's name or the total from the test data appears in the document's text. A template bug that attaches yesterday's invoice passes every other check.
  • Nothing extra. Count the attachments you expect, ignoring inline images.

Inline images look like attachments

A logo embedded in the HTML (<img src="cid:logo">) is a MIME part with a filename, just like the PDF. Most parsers list it with the attachments: in the widely used Node parser mailparser it comes back with contentDisposition: "inline" and related: true. If your test takes attachments[0], it may be holding the logo.

Download and check a PDF

The example reads the email from Zyntra, downloads each attachment by its ID, picks the invoice by filename and reads its text with pdf-parse (version 2).

invoice-email.spec.ts
import { test, expect } from "@playwright/test";
import { PDFParse } from "pdf-parse";

const API = "https://api.zyntra.app";
const headers = { "X-API-Key": process.env.ZYNTRA_API_KEY! };

async function download(uuid: string) {
  const res = await fetch(`${API}/emails/attachment/${uuid}`, { headers });
  if (!res.ok) throw new Error(`Attachment ${uuid}: ${res.status}`);
  const disposition = res.headers.get("content-disposition") ?? "";
  return {
    filename: /filename="([^"]+)"/.exec(disposition)?.[1] ?? "",
    type: res.headers.get("content-type") ?? "",
    bytes: Buffer.from(await res.arrayBuffer()),
  };
}

test("order email carries the right invoice", async ({ page }) => {
  test.setTimeout(90_000);
  const address = `${process.env.ZYNTRA_TEAM_ID}.invoice-${Date.now()}@zyntramail.com`;
  const orderId = await placeOrder(page, address); // your own helper

  const res = await fetch(
    `${API}/emails/last?email=${encodeURIComponent(address)}`,
    { headers, signal: AbortSignal.timeout(45_000) },
  );
  expect(res.ok).toBe(true);
  const email: { attachments_uuid: string[] | null } = await res.json();

  const files = await Promise.all((email.attachments_uuid ?? []).map(download));
  const pdfs = files.filter((f) => /^invoice-.+\.pdf$/.test(f.filename));
  expect(pdfs).toHaveLength(1);
  const pdf = pdfs[0]!;

  expect(pdf.type).toBe("application/pdf");
  expect(pdf.bytes.subarray(0, 5).toString("latin1")).toBe("%PDF-");

  const parser = new PDFParse({ data: new Uint8Array(pdf.bytes) });
  const { text } = await parser.getText();
  await parser.destroy();
  expect(text).toContain(orderId);
});

Each download is one API request, so an email with a PDF and a logo costs three: the email and two files.

CSV and spreadsheet exports

For a CSV, check the header row and one row you know the test created; parse with a CSV library rather than split(","), because quoted fields contain commas. For XLSX, check the PK signature and read a known cell with a spreadsheet library.

The cheaper check runs earlier

A unit test can assert the attachment before any email is sent: Laravel's mailables have assertHasAttachedData, Rails mailer tests can inspect mail.attachments, Django's mail.outbox keeps attachments on each message. Keep those for the content, and one E2E test for the path through your real email provider, which has its own message size limit.

Questions

Why compare the first bytes instead of trusting Content-Type?
The type is whatever the sender declared. A bug that attaches an HTML error page named invoice.pdf still says application/pdf; the bytes don't lie.
Can an AI agent check attachments?
Zyntra's MCP server has a list_attachments tool that returns each file's name and type. The download itself goes through the REST API.