An email that renders beautifully in your browser can still collapse in Outlook, get clipped by Gmail, or read as blank to a screen reader. Finding that out from a customer is the expensive way.
postboi/inspect is the cheap way: static analysis for email HTML. One synchronous
call, no network, no dependencies — the same analysis runs in a test, a CI job, a
Worker or the CLI.
In your test suite
The natural pairing is the mock provider: send with your real code, analyze what would have gone out.
Pass what you have. Every input is optional, and a check that needs a missing input stays silent instead of guessing — a bare HTML string is never nagged about headers it couldn’t possibly carry.
What it checks
Client compatibility. The document is matched against a support matrix derived from
the Can I email project’s data (CC BY-SA 4.0) — fifty-odd
CSS and HTML features that actually vary, across Gmail (web, iOS, Android), Outlook
(Windows and Outlook.com), Apple Mail, iOS Mail and Yahoo. Using flexbox, max-width,
background images, @media, :hover, <video>, WebP… each becomes one finding
naming exactly which clients lose it, with a link to the caniemail page as evidence.
Size. Gmail clips messages whose HTML passes ~102KB, hiding everything below the
fold — including, typically, the unsubscribe link. report.size.gmail_clip says whether
you’re over.
Accessibility. Images without alt (an empty alt="" opts a decorative image
out), a missing lang on the root element.
Deliverability signals. A missing plain-text alternative, http: links, an empty
subject. When you pass headers, a missing List-Unsubscribe and — for one-click
unsubscribe, which Gmail requires of bulk senders — a missing List-Unsubscribe-Post (RFC 8058). If you set unsubscribe_url, Postboi already sends both.
Housekeeping. Links that go nowhere (no href, "#", javascript:), images
without declared dimensions, subjects that will truncate in the inbox.
Every finding carries a stable id, a severity (error / warning / info) and a
human sentence; compatibility findings add the feature slug and the per-client impact. report.status is the worst severity present, or "pass". The report also inventories
every link and image it saw, which is what the next section is for.
Checking links for real
analyze() never touches the network. When you want the network — a test suite, a
release check — check_links fetches every http(s) link and reports which ones
answer:
It deduplicates, runs a few requests at a time (concurrency), gives each ten seconds
(timeout_ms), and takes an injectable fetch for stubbing. Run it where outbound
requests are cheap — not on every render.
At the command line
The same analysis, as a CI step:
Findings print one per line; the exit code is the contract — 0 when the email passes,
1 on warnings or errors, so the command drops straight into a pipeline. --links also fetches every link, --subject "…" includes the subject in the analysis, --json emits the full report, and - reads stdin. (One check is deliberately
skipped here: an HTML file has nowhere to carry a plain-text part, so its absence
proves nothing at the command line.)
In the dev inbox
Every message the dev inbox captures gets a Report tab — the same findings, computed on the spot over what your code actually sent. Send from your app, click the capture, read the verdict; no configuration, nothing leaves your machine.
How detection works
No HTML parser and no DOM: email HTML is table soup, and repairing it into a tree means
guessing along with the clients. Instead the document is tokenized in one pass — tags,
attributes, <style> bodies — and features are detected from that flat view. Detection
is deliberately coarse: it answers “does this email use flexbox?”, not “is this
declaration reachable?“. A false positive costs a glance at a finding that links to its
evidence; a false negative costs a broken email.
Outlook conditional comments (<!--[if mso]>…<![endif]-->) are invisible to the
analysis, as they are to every client but Outlook — the downlevel-revealed form
(<!--[if !mso]><!-->…) is read like any other markup.
Testing against real clients
Static analysis tells you what will degrade; it can’t show you the pixels. That’s what Postboi’s hosted testing is for: create a test in the dashboard, send your email to the address it gives you — from any sender, any provider — and read the full report: this same analysis over the exact bytes that arrived, plus SPF, DKIM and DMARC verdicts from a real receiving mail server, your actual DNS records, a real SpamAssassin score, and real-client screenshots (Outlook on Windows, Gmail, Apple Mail and friends) where rendering is enabled.
No sender handy? Paste the HTML straight into the run (or POST it to /v1/testing/{id}/source) and the report and screenshots run on it directly — the
transport-dependent panels stay empty, because nothing travelled.
From code
Name a test instead of addressing a person, and mail() sends it to the proving
house instead:
The name is the entry. Edit the HTML, call again with the same name, and it’s a new attempt on the same dashboard entry — the edit-and-re-run loop, from code:
The types keep the two kinds of send apart: to next to test is a compile error
(a test has readers, not recipients), and clients — screenshot client ids from GET /v1/testing/clients — only exists on a test send. Omit clients and a
continued entry keeps its previous pick; a fresh one gets the curated set. It needs POSTBOI_TOKEN set (create a token under Account → API tokens), whatever
provider your real mail uses.
Testing the transport too. mail({ test }) proves the email itself — rendering,
compatibility, screenshots — without anything travelling. When you want SPF, DKIM,
DMARC and a SpamAssassin score judged over real bytes, hosted_test from postboi/inspect mints the run’s address for your actual sending path:
(hosted_test is also the lower-level client under mail({ test }) — paste with html, tune wait()’s polling, point api somewhere else.)
Every run counts against the account’s daily cap and every screenshot client draws
from the monthly rendering allowance, so a suite that runs on every commit wants
this behind a flag — keep the free, local analyze() in the hot path and save the
hosted run for release checks:
And in the dev inbox, the same thing with no code at all: with POSTBOI_TOKEN set, every capture’s Report tab grows a Photograph in real
clients button that orders screenshots for exactly what your app just sent.