A “View in browser” link needs a page to point at. Postboi hosts it: publish the email’s HTML once and you get a page for it, personalised for each reader if you want, linked from emails sent by OneSignal, Braze, Iterable, Customer.io, Klaviyo, Mailchimp, SendGrid or Postboi itself.
That one command:
- reads the email’s Liquid and sorts every variable it uses (see the four kinds),
- uploads the images and fonts it points at on disk, and the HTML as a
new version of the view
welcome(the file name, slugified, or--slug), - prints the link in your sender’s own merge syntax, URL-encoded, and the data feed setup when it needs one,
- with
--write, puts that link in the file and marks unsubscribe linksdata-web-hide.
The page lives at https://view.postboi.app/<account>/welcome, or on your own domain. Publishing again keeps every earlier version and
serves the newest. Publishing needs a verified sending domain or a paid plan.
What each variable becomes
Context. Static data the template reads, like content keyed by week. Pass it with --context data.json, or a module whose default export is the object
(--context data/locals.build.js). Anything whose first segment is a context key is filled
from it, for every reader.
Public params. Values safe to put on a URL: ?week=20. Each one is typed, integer, date (YYYY-MM-DD) or enum (a fixed list), and anything else on the URL is ignored. When
the template picks from context by a variable, like dynamic_content.pregnancy_body[week], the CLI makes that variable an enum of the
context’s keys without being asked. Name others with --public week:integer,start:date.
Free text is refused: anyone can edit a URL, and the page would print whatever they typed.
Private data. Everything else, like a first name. It reaches the page through your sender’s data feed or a sealed link, never in the clear (see three ways). Without either, the page shows the generic version: the template with those fields empty.
System variables. Your sender’s own per-reader values: unsubscribe links, OneSignal’s subscription.* and message.id, Braze’s ${…unsubscribe…}, Customer.io’s unsubscribe_url, Klaviyo’s unsubscribe_link, Mailchimp’s *|UNSUB|*. Postboi never
fills them, so a forwarded page can’t unsubscribe the person it was forwarded to. The CLI
names the element that holds each one; --write adds data-web-hide to it (asking first
on a terminal, --yes to skip asking), which leaves it out of the web page.
Where the link goes
With --write the link goes into the element marked data-postboi-view-link (or the
first <a> inside it), else into the <a> whose text is “View in browser”, whatever its
case. Without either, the CLI says so and prints the link to paste. Nothing else in the
file changes.
Images and fonts
Images and fonts the email points at on disk (<img src="/images/hero.png">, a webfont in @font-face, a VML background for Outlook) are uploaded with the HTML, and the page points
at the copies. It’s the same lookup as testing run: src, srcset, background, url(...) and VML, each path looked up next to the HTML, then in each folder above it up to
the project root, then in the current directory, with testing.assets in postboi.config.ts to pin the folder for /… paths. It prints one line when it did:
The copies are named by a hash of the file, so an unchanged image is never sent twice, and
they don’t expire. Test uploads do, after 30 days, so when the HTML already loads an image
or font from one of your test uploads, publishing copies it across and points the page at
the copy (one that has already expired is left as it is). Other URLs, Liquid and merge tags
are left alone, a file it can’t find is warned about and left as it is, and the file on
disk keeps its own paths, --write or not. --no-assets uploads nothing and leaves the
local paths as they are.
Per sender
Say who sends with --provider. Left out, the CLI guesses from the merge syntax in the
file, then the provider in postboi.config.ts.
The link for a public week param, in each sender’s syntax:
Private fields need a sender that can call Postboi as it sends: OneSignal, Braze and Iterable can. With the others, the page shows the generic version for those fields, or your code can send sealed links.
OneSignal Data Feeds
When the email has private fields, the CLI prints the Data Feed to create in OneSignal
(Messages, Data Feeds): alias postboi_view, method GET, a URL that carries each field in
OneSignal’s Liquid, and an Authorization: Bearer pbf_… header. The first publish that
needs one mints a feed key for it, shown once. A feed key can only mint view links for
your team; it can’t send mail or read anything.
At send time OneSignal calls the feed for each recipient, Postboi keeps their fields (encrypted, for 90 days by default) under a short random id, and answers with the reader’s URL. The link in the email reads it:
Two things OneSignal decides that are worth knowing. A template takes one data feed, so an
email that already reads another (data_feed.ad, say) can’t add this one: make its fields
public, or have that feed’s backend call Postboi and pass the url along. And OneSignal
skips a recipient whose feed call fails, so the feed is on the send path. If that’s not a
trade you want, public params need no call at all.
Braze Connected Content
The CLI prints one tag to put at the top of the email body. Braze calls Postboi as it renders each message and saves the answer:
Iterable Data Feeds
The same shape as OneSignal: a data feed in Iterable (Content, Data Feeds) with the URL
and header the CLI prints, turned on in the template, and [[url]] as the link.
Postboi
Sending through Postboi there’s nothing to publish. Put {{ postboi.web_url }} where the
link goes and ask for a web version on the send:
Postboi keeps the message as sent, as long as it keeps the message, and fills the link
before it goes out. %postboi_web_url% works too, and so does the text part. With neither
in the message, nothing is hosted and the send answers a web_version_unused warning,
which the SDK prints to the console. Only Postboi can host a web version: any other
provider refuses web_version with a web_version_unsupported error rather than send the
placeholder as text. Link a published view there instead, below.
Any provider, from your code
With a published view, mail({ view }) mints the reader’s link and fills {{ postboi.web_url }} (or %postboi_web_url%) in the html and text before sending,
whichever provider sends it:
Private data, three ways
Use public params for things that are fine to see and edit: a week, a date, a plan. Use a data feed when your sender can fetch and you don’t want to write code. Use sealed links when your own code sends the email.
A sealed link is AES-GCM under your team’s view key and bound to the view’s slug, so it
can’t be read, edited or moved to another view. With POSTBOI_VIEW_KEY in the
environment (bunx postboi sync writes it once your team has published a view) it’s
minted locally with no request, which is
what a send loop wants, in Node, Bun, Deno or a Worker. Without it, Postboi mints it. views.seal(slug, data) gives you the token alone. The data is a JSON object of up to
4096 bytes: what every reader shares belongs in the view’s context. A link that’s expired, tampered with
or unknown shows the generic page rather than an error.
Seeing who viewed
Postboi counts every real view of a page: not a prefetch, not a HEAD, and not the link
scanners mail filters send ahead of the reader. Counts are per UTC day, with visitors
told apart by a daily hash of their address and browser (no address is stored). Counts
are kept for 180 days:
The params table splits the same views by the public params they were opened with, so a
week-keyed email shows which weeks get read. From code, views.stats("welcome", { days: 90 }) answers the same { days, params, identified }.
Who it was: the view.viewed webhook
When Postboi can tell who a reader is, it also sends a view.viewed webhook, which receive() and webhook() hand you as a view_viewed event with the page and the reader
in event.view:
A reader is known three ways: a data feed’s record (data is the fields your sender
passed; id is the record’s own id), a sealed link (data is what your code sealed), or the
reader param below (id).
Views nobody can be told apart for are counted and nothing more.
Check that the endpoint’s events on the dashboard include view.viewed, then send the
view back to where your audience lives. To Braze, as
a custom event:
Or to OneSignal, as a tag on the user:
A feed record holds the fields the email reads, so it carries the reader’s id only when the template reads one. When it doesn’t, the reader param is the short way to get it.
The reader param
--reader names the template path of your sender’s id for the reader, and the link
carries it as u, in the sender’s syntax:
The page never shows u or fills the template from it; it only goes on view.viewed as reader.id. It’s off unless you ask for it, because of what it costs:
- Anyone can change it. Edit the URL and the webhook names someone else, so treat it as a claim, never as proof of who read what. A sealed link is the one way to be sure.
- It’s in the URL. A forwarded email carries it, and so do browser history, proxies and server logs along the way. Use an opaque id, never an email address.
Publishing again with the CLI keeps it on (it reads the last version and asks for it
again), but only the flag or views.<slug>.reader in postboi.config.ts knows the path,
so put it there to keep it on the link. --reader off turns it off. With it on, no public
param can be called u. In code it’s views.publish({ ..., reader: true }) on every
publish: a version published without it doesn’t read u.
Postboi’s own sends
A send with web_version: true records each view of its page on the message
too: “Viewed in browser” on its timeline in the dashboard, and an email.viewed webhook,
which arrives as a viewed event with the message_id, the recipient in email, and the
browser in client. It is not an open: a page in a browser says nothing about the inbox,
so it never sets the message as opened or sends email.opened. An endpoint made before email.viewed existed only gets it once you subscribe it on the dashboard.
Safety
- Sandboxed. Every page is served with
Content-Security-Policy: sandbox: no scripts, no forms, no frames. Images, styles and fonts load over https. - Not indexed, not referred.
X-Robots-Tag: noindexandReferrer-Policy: no-referrer. A page with reader data isn’t cached. - Typed params. Only declared params are read, each checked against its type. Free text never is.
data-web-hide. Anything marked with it is left out of the web page. Use it for unsubscribe links, preference centres and anything else that acts for the reader.- System variables stay empty. A sender’s own per-reader variables are never filled from params or feeds.
On your own domain
Turn on view.<your domain> for a verified domain on the dashboard’s Domains page, the
same way as hosted forms, and pages answer at https://view.example.com/welcome. The CLI
prints links on it from then on, and views.url() uses it once POSTBOI_VIEW_URL is https://view.example.com (postboi sync writes it).
Saving the choices
Re-publishing reuses the last version’s params and feed fields, so the flags are only
needed once. To keep them in the repo, add a views section to postboi.config.ts; it
wins over the last version, and flags win over it:
The rest of the CLI
Every one takes --json.