Every content page compiles to an email bundle alongside the HTML — same source, different channel. The bundle is three sibling artifacts you can hand straight to your send infrastructure:
| File | What |
|---|---|
{slug}.email/index.html |
Inline-styled, table-based, cross-client email HTML |
{slug}.email/index.txt |
Plaintext alternative for the multipart/alternative MIME part |
{slug}.email/manifest.json |
{ subject, preheader, from, replyTo, charset, messageRef } |
We don't send the emails — that's your SES / SendGrid / Mailgun / Postmark. We produce the bytes; you POST them.
Why we don't ship SMTP ourselves
Deliverability, SPF/DKIM/DMARC, bounce + complaint handling, sender reputation, and CAN-SPAM/GDPR consent — that's multi-month infrastructure for one feature. Your existing email provider has solved it. We hand you ready-to-send bytes; you tell your provider to send them.
(Same rationale we use for form-submission notifications. Compiler outputs, not SMTP.)
What the HTML side does for you
- Inline styles only (no
<style>block, no external CSS) — renders consistently in Outlook 2016+, Gmail (web + iOS + Android), Apple Mail, Yahoo. - 600px-centered table layout — the industry standard, survives the resize math every client does differently.
- Relative-URL rewriting — every
hrefin the body resolves against the canonical web URL, so<a href="./details/">more</a>becomeshttps://yoursite.com/blog/post/details/. No broken links in the inbox. - UTM auto-tagging (opt-in) — supply
utmCampaign: "q3-launch"and every outbound link gets?utm_source=email&utm_medium=email&utm_campaign=q3-launch. We never overwrite an existingutm_source— your customer's intentional tagging always wins. - Brand accent — pass
accent: "#5A7D4D"and the CTA button + accents pick it up. Defaults to the StaticOwl sage. - Preheader — the hidden inbox-preview snippet (
<div style="display:none">) so clients show yourdecknext to the subject line, not the boilerplate "Read in browser…" - CTA — a "Read in browser" button to the canonical URL, so the email never traps the reader in a styling-mismatch loop.
What the plaintext side does for you
- HTML stripped + whitespace collapsed.
- Links preserved as
text (https://full/url)so the URL survives in clients that fall back to text/plain. - List bullets preserved (
- One,- Two). - Headings + paragraphs separated by blank lines.
- Footer with site name + canonical link.
Multipart/alternative + a real text/plain part isn't optional for deliverability — every major provider scores you down for HTML-only mail because that's what spammers send. Shipping the .txt makes you a good citizen.
What the manifest does for you
{
"subject": "Q3 launch announcement",
"preheader": "Read about the new feature shipping today",
"from": "StaticOwl <no-reply@staticowl.com>",
"replyTo": "StaticOwl <no-reply@staticowl.com>",
"charset": "utf-8",
"messageRef": "1k3h8a2x"
}
messageRef is a stable id derived from (campaign, canonicalUrl, title) — re-sending the same campaign for the same page produces the same ref, which keeps inboxes that key on it from fragmenting the thread.
Recipes
AWS SES
import { SESv2Client, SendEmailCommand } from '@aws-sdk/client-sesv2';
import fs from 'node:fs/promises';
const html = await fs.readFile('dist/blog/post.email/index.html', 'utf8');
const text = await fs.readFile('dist/blog/post.email/index.txt', 'utf8');
const m = JSON.parse(await fs.readFile('dist/blog/post.email/manifest.json', 'utf8'));
await new SESv2Client({ region: 'us-east-1' }).send(new SendEmailCommand({
FromEmailAddress: m.from,
ReplyToAddresses: [m.replyTo],
Destination: { ToAddresses: ['someone@example.com'] },
Content: {
Simple: {
Subject: { Data: m.subject, Charset: m.charset },
Body: {
Html: { Data: html, Charset: m.charset },
Text: { Data: text, Charset: m.charset },
},
},
},
}));
SendGrid
import sgMail from '@sendgrid/mail';
sgMail.setApiKey(process.env.SENDGRID_API_KEY);
await sgMail.send({
to: 'someone@example.com',
from: m.from, replyTo: m.replyTo,
subject: m.subject,
text, html,
customArgs: { messageRef: m.messageRef },
});
Mailgun
import formData from 'form-data';
import Mailgun from 'mailgun.js';
const mg = new Mailgun(formData).client({ username: 'api', key: process.env.MAILGUN_API_KEY });
await mg.messages.create('mg.yoursite.com', {
from: m.from, 'h:Reply-To': m.replyTo,
to: 'someone@example.com',
subject: m.subject,
text, html,
'v:messageRef': m.messageRef,
});
Postmark
import postmark from 'postmark';
const client = new postmark.ServerClient(process.env.POSTMARK_TOKEN);
await client.sendEmail({
From: m.from, ReplyTo: m.replyTo,
To: 'someone@example.com',
Subject: m.subject,
HtmlBody: html, TextBody: text,
Headers: [{ Name: 'X-Entity-Ref-ID', Value: m.messageRef }],
});
Generic — anything that takes raw SMTP
Content-Type: multipart/alternative; boundary="boundary"
Subject: <m.subject>
From: <m.from>
Reply-To: <m.replyTo>
--boundary
Content-Type: text/plain; charset=utf-8
<text>
--boundary
Content-Type: text/html; charset=utf-8
<html>
--boundary--
Enabling email output per site
Email is one of the channels in site.config.outputChannels. Add it to enable per-page email artifact emission:
// site config
outputChannels: ['web', 'email', 'social_post', 'qr_code']
web is always required; the others are opt-in. Per-content-type opt-out is on the roadmap (some types like "internal-only documentation" probably don't need an email version).
What's NOT in scope
- Sending the email — that's your provider. We produce the bytes.
- Audience list / segmentation / subscription management — your provider's territory; or use the newsletter feature for managed lists.
- Open / click tracking pixels — opt-in for some providers, dangerous for others. Add them in your send-time pipeline if your provider doesn't already.
- Per-recipient personalization — the bundle is one fixed message. Templates with
{{ first_name }}happen at send time via your provider's substitution. - Dark-mode-aware colors — we use solid hex colors that work in both modes; explicit
prefers-color-schemestyling is on the roadmap.
API reference
| Function | What |
|---|---|
compileEmail(input) → EmailBundle |
The pure compile. Inputs in the type definition. |
htmlToPlainText(input) → string |
The HTML → text derivation. Pure. |
rewriteLinks(html, base, utm) → string |
URL rewrite + UTM tagging. Pure. |
messageRef(campaign, url, title) → string |
Stable thread-ref id. Pure. |
renderEmail(input) → string |
The lower-level template wrapper. compileEmail calls it. |