Most "preview your email" screens are a second implementation of the email. There is the renderer that makes the real thing, and there is a React component that approximates it well enough to look right, and the two drift apart in the direction of whichever one gets edited more often. Then you send 400 people something the preview never showed you.
Nakodo sends occasional notices to accounts, and the composer that writes them has one rule, stated in the file's own header comment:
// The notice composer: blocks on the left, the real email on the right.
//
// The preview calls buildNotice, the same pure function the send calls, and
// renders its HTML in an iframe. It is not a likeness of the email, it is the
// email, which is what makes "check it, then send it" a real check.
This post is about what that costs and what has to be true for the claim to hold.
The function
export type BuiltEmail = { subject: string; html: string; text: string };
export function buildNotice({ notice, name, reason, unsubscribeUrl }: {
notice: Notice;
name?: string | null;
reason?: string;
unsubscribeUrl?: string;
}): BuiltEmail
No database, no clock, no randomness, no network. Input is a notice plus the four things that vary per recipient; output is the three strings a provider needs. Which means the preview can be computed in a render:
// The same function the send uses, so what you see here is the email that
// goes out. Rebuilt on every keystroke: it is string building, and the
// iframe only reloads when the markup actually changes.
const preview = buildNotice({
notice,
name: "Sam Lee",
reason: audience === "direct" ? undefined : AUDIENCE_REASONS[audience],
unsubscribeUrl: audience === "marketing" ? "https://nakodo.app/unsubscribe?token=example" : undefined,
});
and rendered with no component of its own:
<iframe
title="Email preview"
srcDoc={preview.html}
sandbox=""
className="h-[480px] w-full rounded-md border bg-white"
/>
sandbox="" with an empty value is every restriction on at once: no scripts, no forms, no same-origin access. The preview is admin-authored content rendered into an admin's own page, which is exactly the shape that gets excused, so it gets the strictest attribute available instead.
The content is a union, not a string
A notice is an ordered list of blocks:
const blockSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("greeting") }),
z.object({ type: z.literal("divider") }),
z.object({ type: z.literal("heading"), text: z.string().max(NOTICE_LIMITS.heading) }),
z.object({ type: z.literal("paragraph"), text: z.string().max(NOTICE_LIMITS.paragraph) }),
z.object({ type: z.literal("bullets"), items: z.array(z.string().max(NOTICE_LIMITS.item)).max(NOTICE_LIMITS.items) }),
z.object({ type: z.literal("button"), label: z.string().max(NOTICE_LIMITS.label), url: z.string().max(NOTICE_LIMITS.url) }),
]);
with every bound written down in one place rather than as a maxLength on an input:
export const NOTICE_LIMITS = {
subject: 200, preheader: 160, heading: 200, paragraph: 5000,
item: 500, items: 40, label: 60, url: 2000, blocks: 50,
} as const;
The union buys two things. Rich text would mean sanitising HTML from a trusted-ish author, which is a job nobody wins. And a closed set of block types means the renderers are exhaustive switches with no default:
function renderText(block: NoticeBlock, name: string | null | undefined): string {
switch (block.type) {
case "greeting":
return `Hi ${greetingName(name)},`;
case "heading":
case "paragraph":
return block.text.trim();
case "bullets":
return block.items.map((i) => i.trim()).filter(Boolean).map((i) => `- ${i}`).join("\n");
case "button":
return `${block.label.trim()}: ${normalizeUrl(block.url)}`;
case "divider":
return "---";
}
}
Add a seventh block type and TypeScript fails the build in three places: the HTML renderer, the text renderer, and hasContent. A default: return "" would have made all three compile and shipped an email with a silently missing section.
The checks that are about people, not types
Zod gets the shapes right and has nothing to say about whether a valid notice should be sent. Two rules sit above it.
First, a notice must actually say something:
// Validates a notice from the composer. A notice of nothing but a greeting and
// a divider is rejected: it would send an empty email to everyone.
const meaningful = parsed.data.blocks.some((b) => b.type !== "greeting" && b.type !== "divider" && hasContent(b));
if (!meaningful) return { ok: false, error: "Add some content: a heading, a paragraph, a list or a button." };
A greeting and a horizontal rule is a schema-valid notice. It is also "Hi Sam," followed by a line, sent to everyone with an account.
Second, empty blocks are dropped rather than rendered as empty markup, and the test pins that by asserting the entire plain-text body:
test("an empty block contributes nothing", () => {
const built = buildNotice({
notice: notice({
blocks: [
{ type: "paragraph", text: "Kept." },
{ type: "paragraph", text: " " },
{ type: "button", label: "Open", url: "" },
{ type: "bullets", items: ["", " "] },
],
}),
});
assert.equal(built.text, "Kept.");
});
Note the third block. A button with a label and no usable URL is not content, so it does not render, so there is no href="" button in anybody's email.
URLs get promoted or dropped, never passed through
// Promotes a bare host to an absolute URL: pasting "nakodo.app/pricing"
// without a scheme is the common case and should simply work. Anything that
// isn't http(s) or mailto comes back empty, which is what keeps a
// `javascript:` or `data:` URL out of a recipient's mail client.
export function normalizeUrl(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) return "";
if (/^mailto:[^\s]+@[^\s]+$/i.test(trimmed)) return trimmed;
try {
const url = new URL(/^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`);
return url.protocol === "http:" || url.protocol === "https:" ? url.toString() : "";
} catch {
return "";
}
}
The allowlist is the shape of the function: anything not http, https or mailto returns the empty string, and the empty string is already defined as "not content". So the dangerous input does not need a separate rejection path, it falls into the one that already exists.
test("a script url is dropped rather than put in front of a recipient", () => {
assert.equal(normalizeUrl("javascript:alert(1)"), "");
assert.equal(normalizeUrl("JavaScript:void(0)"), "");
assert.equal(normalizeUrl("data:text/html,<script>"), "");
assert.equal(normalizeUrl(" "), "");
});
Escaping is asymmetric on purpose, and the test says why in its own assertions:
assert.ok(!built.html.includes("<script>"));
assert.ok(built.html.includes("<script>"));
// The text part is what the sender typed, unescaped.
assert.ok(built.text.includes('<script>alert("x")</script>'));
A plain-text alternative that contains < is a bug, not a defence.
The assert that makes the whole claim
test("building the same notice twice gives the same email", () => {
// The composer's preview and the send call this function; if it weren't
// deterministic the preview would not be the email.
const a = buildNotice({ notice: notice(), name: "Sam Lee" });
const b = buildNotice({ notice: notice(), name: "Sam Lee" });
assert.deepEqual(a, b);
assert.ok(a.html.includes("Hi Sam,"));
});
On its own this looks like a test of nothing: of course a pure function is pure. It is there as a tripwire. The day somebody adds a timestamp to the footer, a cache-busting query on an image, or a random preheader variant, this test fails and the failure message is the architectural claim being broken, not a cosmetic diff.
The shell, and why it hardcodes colours
The body goes into a table-based layout with inline styles, which is the part everyone knows. The part worth quoting is the reasoning about tokens:
// Written as nested tables with inline styles rather than the app's CSS,
// because that is what survives Outlook's Word renderer and Gmail's style
// stripping. The palette restates docs/brand.md's Cyanotype tokens as hex
// literals on purpose: an email is delivered once and then lives in someone's
// archive forever, so it cannot depend on a stylesheet or a CSS variable that
// may be redefined later.
export const PALETTE = {
page: "#f9f5ed", // paper-50
card: "#fefdf9", // paper-0
line: "#e2ded6", // paper-200
ink: "#162433", // ink-900
...
The app's colours are OKLCH in globals.css with not one hex code in 866 lines. The email module is the one place that duplicates them, because a sent email is immutable and a design token is not.
Same reasoning about the domain:
// The live site, not this deployment: an email sent from a laptop must still
// link to the real site, and a broken link in an archived email can't be
// fixed later.
const site = SITE_URL;
And the preheader, which is the hidden line inboxes show after the subject:
const preheaderHtml = preheader
? `<div style="display:none;max-height:0;overflow:hidden;opacity:0;color:transparent;height:0;width:0">${escapeHtml(preheader)}${"‌ ".repeat(60)}</div>`
: "";
Sixty zero-width non-joiners padding it, so the inbox preview stops where the sentence ends instead of running into the first paragraph. Its value comes from a ladder that always produces something:
const preheader = (notice.preheader?.trim() || firstText(notice.blocks) || notice.subject).slice(0, NOTICE_LIMITS.preheader);
Explicit, else the first heading or paragraph, else the subject.
The footer has three shapes and one rule behind them:
// `reason` is the footer line saying why this person is receiving it. Set on
// anything sent to a whole audience: a recipient who can't tell why it arrived
// reports it as spam, whatever it says. `unsubscribeUrl` is set for marketing
// only; service email about an account has nothing to unsubscribe from.
Marketing gets Unsubscribe and Privacy. Account email that can be switched off gets Email settings and Privacy. A direct one-off gets Privacy and Terms. The unsubscribe page is public and acts on arrival, which is a different post.
Where the claim is slightly less true than the comment says
Two honest gaps.
The preview renders preview.html and nothing else. The text part is built by the same call, tested harder than the HTML, and never shown in the composer. So "what you see is the email" is true of the half that a modern client displays and unverified for the half that a plain-text client, a screen reader pipeline, or a spam scorer reads. A second tab in that panel is about twenty lines of work and is the next thing I will do to this screen.
And the preview is personalised with fixed stand-ins: "Sam Lee" for the name, an ?token=example unsubscribe link. That is the right call (an admin should see what personalisation looks like, not an empty gap), but it means the preview equals the email up to the per-recipient substitutions, and the only reason that reasoning is sound is the determinism test above. Take that test away and the claim in the header comment becomes a comment.
Top comments (0)