CogniPrep has an admin screen for composing announcement emails. The obvious way to build that is a rich text editor and an iframe that shows roughly what the email will look like. We did neither, and the reason is one line in a comment:
Built as a pure function returning
{ subject, html, text }so the same code renders the live dashboard preview (client-side) and the real message the send route hands to Resend, guaranteeing the preview is byte-identical to what recipients receive.
That is the whole design. Everything else follows from refusing to have two renderers.
The content is a list of blocks, not a string of HTML
export type NoticeBlock =
| { type: 'greeting' }
| { type: 'heading'; text: string }
| { type: 'paragraph'; text: string }
| { type: 'bullets'; items: string[] }
| { type: 'button'; label: string; url: string }
| { type: 'divider' };
export interface NoticeContent {
subject: string;
preheader?: string;
blocks: NoticeBlock[];
}
Six block types. No arbitrary HTML, no markdown, no contentEditable. An announcement email is not a document, it is a few paragraphs, maybe a list, maybe one button, and a greeting with the recipient's name in it.
The payoff is that { type: 'greeting' } carries no data. The recipient's name is not in the content, because the content is composed once and the name is different for every recipient. A block that says "a greeting goes here" is the only representation that survives being written once and rendered a thousand times.
If you have ever tried to personalise an email by find-and-replacing {{name}} in a blob of HTML, that is the problem this type is solving. The placeholder is a node in the tree, not a substring.
Two renderers, and the compiler enforces both
There is a renderBlockHtml and a renderBlockText. Both are exhaustive switches over block.type with no default arm:
function renderBlockText(block: NoticeBlock, name?: string | null): 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()}: ${normalizeButtonUrl(block.url)}`;
case 'divider':
return '---';
}
}
The missing default is the feature. With a discriminated union and a declared return type of string, TypeScript narrows the switch to exhaustive and the function type-checks. Add a seventh block type and the narrowing fails: the function can now fall off the end and return undefined, which is not string, so it stops compiling. Both renderers break on the same commit.
That is the cheapest exhaustiveness check available and it needs no assertNever helper, no runtime throw, and no test. It just needs you to not write default.
A plain text renderer is not optional, incidentally. A multipart email without a text part is a deliverability problem, and the text part is the thing a screen reader, a notification preview and a plain text mail client actually read. Writing it by hand alongside the HTML is how the two drift. Deriving both from the same union is how they cannot.
Four consumers, three of which TypeScript protects
The NoticeBlock union has exactly four consumers:
- the composer UI, which lets an admin add and reorder blocks
- a zod schema, which validates what arrives at the server
renderBlockHtmlrenderBlockText
Adding a block type breaks 3 and 4 at compile time, and breaks 2 if the zod schema is typed against the union. It does not break 1, because a UI that cannot create a block type still compiles perfectly. So the known weak link is the composer, and knowing which one of the four is the weak link is worth more than pretending all four are covered.
The schema mirrors the union on purpose
const blockSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('greeting') }),
z.object({ type: z.literal('heading'), text: z.string().trim().max(200) }),
z.object({ type: z.literal('paragraph'), text: z.string().trim().max(5000) }),
z.object({ type: z.literal('bullets'), items: z.array(z.string().trim().max(500)).max(40) }),
z.object({
type: z.literal('button'),
label: z.string().trim().max(60),
url: z.string().trim().max(2000),
}),
z.object({ type: z.literal('divider') }),
]);
z.discriminatedUnion rather than z.union matters here for error messages as much as for speed. A z.union that fails reports every branch's failure, so a typo in a heading produces six errors, five of them about block types you were not using. A discriminated union reads type first, picks the one branch, and reports only that branch's problems.
The caps are not arbitrary and they are not security theatre either, since the only caller is an authenticated admin. They are limits chosen from what the renderer can produce something sensible from. A 60 character button label is about as much text as fits in a padded black pill before it wraps badly. 40 bullets is more than any announcement should have. They are layout constraints written as validation, which is the right place for them, because the renderer cannot refuse input.
Fifty dividers is a valid block list and an invalid notice
This is my favourite part of the file, because it is two functions that both answer "is this empty" and deliberately disagree.
The schema's refinement:
.refine(
(blocks) =>
blocks.some(
(b) =>
(b.type === 'heading' && b.text.trim() !== '') ||
(b.type === 'paragraph' && b.text.trim() !== '') ||
(b.type === 'bullets' && b.items.some((i) => i.trim() !== '')) ||
(b.type === 'button' && b.label.trim() !== '' && b.url.trim() !== '')
),
'Add some content: a heading, paragraph, bullet list or button'
)
And the renderer's filter:
function hasRenderableContent(block: NoticeBlock): boolean {
switch (block.type) {
case 'greeting':
case 'divider':
return true;
// ...text blocks require non-empty text
}
}
A divider is renderable: it draws a horizontal rule. A greeting is renderable: it produces "Hi Sam,". So hasRenderableContent says yes to both. But the refinement does not count either of them, which means a notice consisting of a greeting and three dividers passes the type, renders fine, and is refused at the door.
That is correct, and the two functions are answering different questions:
-
hasRenderableContent: will this block put pixels on the screen? Used to drop empty paragraphs the admin left behind while editing. - the refinement: would a human learn anything from receiving this? Used to stop a send.
Collapsing them into one predicate would mean either dividers block a send (wrong) or an empty notice goes out (worse). Two predicates, two names, two call sites.
A pasted URL without a scheme
export function normalizeButtonUrl(url: string): string {
const trimmed = url.trim();
if (!trimmed) return '';
if (/^https?:\/\//i.test(trimmed)) return trimmed;
if (/^mailto:/i.test(trimmed)) return trimmed;
return `https://${trimmed}`;
}
Someone pasting cogniprep.app/pricing into a URL field has done nothing unreasonable. It is what a browser address bar accepts and what every URL looks like when you copy it out of a conversation. In an email it is fatal in a way it is not on the web: there is no document base, so a relative href in an inbox resolves against nothing and the button is dead.
So the field promotes rather than validates. The mailto: branch is there because the single most likely non-http link in an announcement is a contact address, and prefixing https:// to mailto:support@... would silently break the one case the rule was most likely to hit.
This is a small example of a general preference: when you know what the user meant and you are right nearly always, correct the input instead of rejecting it. Reserve rejection for cases where you would be guessing.
The preheader has three sources and a hard truncation
const preheader = (
content.preheader?.trim() ||
firstText(content.blocks) ||
content.subject
).slice(0, 160);
The preheader is the grey line an inbox shows after the subject. Leave it out and most clients grab whatever text comes first in the HTML, which on a branded email is often the alt text of a logo or a "view in browser" link.
So: use what was typed, fall back to the first heading or paragraph, fall back to the subject. The || chain works because every source is a string and '' is falsy, which is one of the few places where that is clean rather than clever. The .slice(0, 160) runs on all three branches rather than just the typed one, which is the detail that is easy to get wrong when you write it as an if.
Outlook is still why the HTML looks like 2005
The divider block:
<table cellpadding="0" cellspacing="0" role="presentation" style="width:100%;margin:8px 0 24px">
<tr><td style="border-top:1px solid #e5e7eb;font-size:0;line-height:0"> </td></tr>
</table>
A <table> for a horizontal line, because <hr> renders inconsistently across mail clients. font-size:0;line-height:0 so the cell collapses to the border. because an empty <td> may not be rendered at all. And role="presentation" so a screen reader does not announce a one-cell table as a table.
Every block is built like that. This is the one part of the design with no clever insight in it: HTML email is a different rendering target and the correct response is to write for that target rather than to reuse your web components and hope.
Seeing it without being an admin
You cannot open our admin dashboard, but the email shell every one of these notices is wrapped in is shared by seven modules, including the one that answers support messages. So the demonstration is:
- Send a message through the contact form at cogniprep.app/help
- When the reply arrives, view the raw source of the email
What you will see is the same table-based shell, the same preheader placement, and the same plain text alternative part described above, because it is the same wrapEmail call.
What generalises
The specific thing here is an email composer. The general thing is: when two surfaces must agree, do not make them agree, make them the same call.
A preview that renders through a second code path is a promise you have to keep by hand, forever, and every divergence is a bug a user finds before you do. A preview that is the production renderer called from the client cannot diverge, and the price of that is designing your renderer as a pure function with no I/O in it. Which is a good shape anyway.
The practice site all this mail is about is cogniprep.app.
Top comments (0)