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 with 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, and from a project with built HTML it is one command:
It pastes the HTML into a test run, waits until every screenshot has rendered, saves
them to screenshots/welcome/<group>/<client>.png and prints a summary: each client,
whether its capture changed since the last run, the report’s verdict and the run’s
dashboard page. The file names carry no timestamps, so running it again overwrites the
same files and git diff (or any image diff) shows what moved. It exits 1 when the
report is an error or a capture failed, so it can gate a release.
--series <name>names the dashboard entry. It defaults to the file name, so every run ofwelcome.htmlstacks as a new attempt on one entry, next to the last one.--clients a,bpicks clients (ids frombunx postboi testing clients),--set <name>uses a saved set (bunx postboi testing sets save core --clients a,b), and--allorders the whole catalog, split into as many runs as it takes.- A client whose capture of the identical email already exists is reused instead of
rendered again, and isn’t billed.
--freshrenders everything anyway. --out <dir>saves somewhere else,--shareprints a read-only link for people without an account,--jsonprints one JSON document, and-reads the HTML from stdin.--no-waitorders and returns;bunx postboi testing download <id>collects the screenshots later, and is also how you resume after a timeout.
Every client is one render from the account’s monthly allowance. Before ordering, the
command checks what is left and says how many clients would be skipped; an order of more
than ten renders asks first on a terminal (--yes skips the question). The summary
says how many renders the run used.
The same runs take a real send too: create a test in the dashboard (or bunx postboi testing add), send your email to the address it gives you from any sender
and 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 the screenshots. A pasted run leaves
those transport panels empty, because nothing travelled.
Local images and fonts
Built HTML often points at files on disk (<img src="/images/hero.png">, a webfont in @font-face, a VML background for Outlook) that no screenshot client can reach. You don’t
have to deploy them or set anything up: testing run (and testing add --html) finds every local
reference in src, srcset, background, url(...) in styles and VML inside <!--[if mso]> comments, uploads the files to Postboi and points the HTML at the copies
before it orders the run. It prints one line when it did:
Uploads are named by a hash of the file, so an unchanged image is never sent twice and an
edited one gets a new URL, which makes it a fresh render rather than a reused capture.
URLs (https://, //, data:, cid:) and anything with Liquid or merge tags in it
({{ hero }}, *|IMAGE|*) are left alone, and so is a file it can’t find, with a warning.
PNG, JPEG, GIF, WebP, AVIF, SVG and ICO images and WOFF, WOFF2, TTF and OTF fonts are
uploaded, up to 10 MB each.
It works from wherever the HTML sits, with no config. Each path is looked up next to the HTML file
first (where build tools copy static files), then in each folder above it up to the project
root (the nearest one with a package.json or .git), then in the current directory, and
the first match wins. So /images/hero.png in public/welcome.html finds images/hero.png at the top of the repo. HTML from stdin (-) starts from the current
directory. --no-assets sends the HTML exactly as it is.
If a root-relative path ever resolves to the wrong file, testing.assets in postboi.config.ts names the one folder /… paths come from, relative to the project
root:
From code
Name a test instead of addressing a person, and mail() sends it to the proving
house instead:
Each preview names its client (client_name, group, platform, os, dark), and on
a repeat attempt says how it compares: reused when an identical capture was copied
rather than rendered, and previous.identical against the last attempt’s image. done.screenshots sums it up (state, ready, failed, notes on any client that
was skipped), and done.share() makes a read-only link. capture() answers a plain Response, so the same code runs in Node, Bun and a Worker; writing files is up to you
(or to postboi testing run).
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, as does set, a saved set
by name. Omit both and a continued entry keeps its previous pick; a fresh one gets the
curated set. fresh: true renders every client again even where an identical capture
could be reused. 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.
To hear when a run is done instead of polling, the Postboi provider’s webhooks carry test_received (the report is ready) and test_completed (every screenshot has
settled), each with the run in event.test; see Webhooks.