Turning HTML-Only Email Receipts into PDFs with Headless Chromium

Many receipts are HTML mails without attachment. Render them to PDF with Playwright, and dodge the Gmail label gotcha that reprocesses a thread forever.

Share
Turning HTML-Only Email Receipts into PDFs with Headless Chromium

My receipt sorter was built around one assumption: a receipt is a PDF attachment. That assumption didn't survive contact with the real inbox. A surprising share of what lands in the accounting inbox has no attachment at all. Subscriptions, payment providers, SaaS tools: they send a nicely styled HTML mail that is the receipt. Amount, date, invoice number, all in the body. No file.

If your pipeline only looks at attachments, those receipts simply don't exist, and at month end they show up as "receipt missing" against the credit card statement.

TL;DR: Render the HTML body to a PDF with Playwright's headless Chromium (page.set_content() → page.pdf()), then feed that PDF into the same pipeline as every other receipt. Install the browser into a fixed directory so cron can find it. And if you mark Gmail mails as processed with a label, label the thread, not the first message, or Gmail search will hand you the same thread forever.

The render

from pathlib import Path
from playwright.sync_api import sync_playwright

def html_to_pdf(html: str, out: Path) -> None:
    with sync_playwright() as p:
        browser = p.chromium.launch()
        ctx = browser.new_context(java_script_enabled=False)
        page = ctx.new_page()
        page.set_content(html, wait_until="load", timeout=30_000)
        page.pdf(path=str(out), format="A4", print_background=True,
                 margin={"top": "10mm", "bottom": "10mm", "left": "10mm", "right": "10mm"})
        browser.close()

That's the whole trick. set_content loads the HTML string as a page, pdf() prints it. print_background=True matters more than you'd think: lots of receipt templates put the table borders and header bars in background colors, and without it you get a white page with floating numbers.

Two notes:

  • page.pdf() only works in Chromium, and only headless. Not Firefox, not WebKit. That's a Playwright limitation, not a config issue.
  • The resulting PDF has a real text layer, because Chromium renders text as text. That's a big deal for my pipeline: the step that checks "does the amount the model extracted appear verbatim in the document?" works on these PDFs just like on vendor-generated ones. An HTML receipt rendered this way is easier to verify than a scanned paper one.

Installing the browser where cron can find it

By default Playwright downloads its browsers into a cache directory in the home of whoever ran playwright install. Then cron runs as a different user, or with a different HOME, and you get "Executable doesn't exist". I put the browsers in their own directory next to the app and point Playwright at it explicitly:

python3 -m venv /opt/receipt-sorter/venv
/opt/receipt-sorter/venv/bin/pip install playwright pdfplumber

export PLAYWRIGHT_BROWSERS_PATH=/opt/receipt-sorter/browsers
/opt/receipt-sorter/venv/bin/playwright install --with-deps chromium

And in the script itself, before importing Playwright:

import os
os.environ.setdefault("PLAYWRIGHT_BROWSERS_PATH", "/opt/receipt-sorter/browsers")

Setting it in the code instead of the crontab means it works no matter how the script gets started. --with-deps pulls in the system libraries Chromium needs (fonts, NSS and friends). On a minimal server image those are what's usually missing.

I run this in a plain venv on the host. If you'd rather containerize it, Microsoft publishes Playwright Docker images with the browsers and system deps baked in, which skips the whole install dance; Docker on Ubuntu is all you need underneath. Either way, pin the Playwright version: the Python package and the browser build belong together, and upgrading one without the other breaks the launch.

Sanitizing: what should the browser be allowed to do?

You're rendering HTML from email, i.e. from strangers. Some thoughts:

  • JavaScript off. java_script_enabled=False on the context. Mail clients don't run JS in emails either, so receipts don't depend on it. There's no reason to execute it.
  • Remote resources: your call. Receipt mails load logos and sometimes tracking pixels from remote servers. If you let Chromium fetch them, you get prettier PDFs and you tell the sender "this mail was opened, from this server, at this time". If you block them, the layout usually survives fine, logos are just missing. Blocking looks like this:
def block_remote(route):
    if route.request.url.startswith(("data:", "about:")):
        route.continue_()
    else:
        route.abort()

page.route("**/*", block_remote)

For an accounting archive the numbers matter, not the logo. Decide once, consistently.

  • Timeouts. Without remote fetching, rendering is fast. With it, one slow image server can hang wait_until="load". Keep the explicit timeout and treat a timeout as "send to review", not as a crash.
  • Charset. Pass the decoded HTML string from your mail library, not raw bytes. If the mail declared its charset and your parser honored it, umlauts come out right; if not, you'll see mojibake in the PDF, and the verbatim check will reject amounts that contain a mangled character.

Gotcha: the Gmail label that only stuck to one message

This one cost me more than the rendering. The inbox is a Gmail account, and "processed" is a Gmail label. The loop was:

  1. search for mails without the processed label
  2. process them
  3. set the label

What I did in step 3 was the message-level modify, on the message I'd fetched first. Gmail labels belong to messages. A thread with five messages where one is labeled is, for search purposes, a thread with four unlabeled messages. So the search -label:receipt-sorted kept returning that thread. Every run. The processed-marker was set, it just didn't cover what the search was looking at.

In Gmail API terms, the difference is:

POST /gmail/v1/users/me/messages/{messageId}/modify   # labels ONE message
POST /gmail/v1/users/me/threads/{threadId}/modify     # labels EVERY message in the thread

Whatever client or CLI you use, it'll have both variants. Use the thread one.

The fix wasn't just "label the thread". It was also "process the thread". Receipts don't come in neat one-message threads. A vendor sends the invoice, then a payment confirmation, then a reminder, all in one conversation. Or someone forwards three receipts into an existing thread. So the unit of work became the thread: fetch all messages, process every attachment and every HTML body, then label the whole thread in one call.

for thread in gmail.search_threads("-label:receipt-sorted"):
    for msg in gmail.get_thread(thread.id).messages:
        for att in msg.pdf_attachments():
            handle_pdf(att)
        if not msg.pdf_attachments() and msg.html_body:
            handle_pdf(html_to_pdf_bytes(msg.html_body))
    gmail.modify_thread(thread.id, add_labels=["receipt-sorted"])

(Pseudo-API, but that's the shape.)

Idempotency: the label is not your only safety net

A label set via API is a side effect on a remote system. Network blips happen between "I filed the receipt" and "I set the label". Then the next run sees the thread again. That must be harmless, so there are two more layers:

  • SQLite state. Every processed message ID is recorded locally. The label is for humans looking at the inbox; the DB is what the code trusts.
  • Content-level duplicate detection. Same invoice number + same amount = same receipt, filed once. This catches the classic double: invoice as PDF attachment and the same invoice rendered in the HTML body of the same mail. Without it you'd file both. With it the second one is logged and skipped.

Plus flock around the cron job so two runs never process the same thread in parallel. I've written about the cost of getting this order wrong before; a cron job that touches a mailbox is exactly where it bites.

When not to render

Not every HTML mail is a receipt. Newsletters, shipping notifications, "your invoice is ready, log in to download it". The last one is the annoying one: it looks like a receipt, has no amount, and rendering it produces a PDF that will never verify. In a pipeline like mine that's fine: the verification step can't find a verifiable amount in the text, so it goes to the review folder, and a human downloads the real invoice. I'd rather have a few of those in review than try to teach the system to log into vendor portals.

FAQ

Why not wkhtmltopdf? It's effectively unmaintained and its old WebKit engine renders modern mail CSS badly. Chromium renders what Gmail shows you, more or less.

Isn't a full browser overkill? It's a few hundred MB on disk and a short render per mail. For a cron job every 10 minutes handling a handful of mails, nobody cares.

What about the mail's inline images (cid:)? Those are attachments referenced from the HTML. If you want them in the PDF, replace cid: references with data: URIs before set_content. For receipts I've not needed it.

If you're feeding these PDFs to a model afterwards, make sure the model's environment can actually read PDFs; I wrote about AI assistants and scanned PDFs when that bit me. And if you schedule this on a systemd box, timers beat cron for seeing what happened on each run.


Related posts