We Write Our Transactional Emails as TypeScript Functions
When we rebuilt our lead pipeline, we needed three new transactional emails: a lead magnet delivery, an Operations Engine audit confirmation, and a Proposal Toolkit delivery with a repo link. Three distinct templates, three distinct payloads.
The obvious approach is a template file per email, a template engine, maybe a visual email builder. We did none of that. Every email is a TypeScript function that returns { text, html }.
What the Pattern Looks Like
Each template lives at src/lib/email/, exported as a single named function:
// operations-engine-template.ts
export function buildOperationsEngineEmail({ name, bookingUrl }: BuildArgs) {
const firstName = name.split(/\s+/)[0] || name;
// ...
return { text, html };
}
The API route imports it, calls it, and passes the result straight to Resend:
const { text, html } = buildOperationsEngineEmail({ name });
await resend.emails.send({
from: "Isaac at Twenty1 Media <noreply@twenty1-media.com>",
to: email,
subject: "Got your Operations Engine audit request",
text,
html,
});
No template engine. No runtime file reads. TypeScript checks the arguments at build time.
The HTML Structure
Every email shares the same shell: dark background (#0b0b0c), a small uppercase label in sky blue (#7DD3FC), a serif italic heading, body copy, and a signature. All styles are inline.
<body style="margin:0;padding:32px;background:#0b0b0c;color:#e6e6e6;
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Inter,sans-serif;
line-height:1.55;">
<div style="max-width:560px;margin:0 auto;">
<p style="font-size:11px;letter-spacing:0.18em;text-transform:uppercase;
color:#7DD3FC;margin:0 0 24px 0;">Twenty One Media · Operations Engine</p>
<h1 style="font-family:Georgia,'Playfair Display',serif;font-style:italic;
font-weight:400;font-size:28px;line-height:1.2;color:#fff;margin:0 0 16px 0;">
Hey Isaac — got your audit request.
</h1>
<!-- ... -->
</div>
</body>
The serif italic heading is the same type treatment used on the site. The dark background is unusual for email but reads correctly in modern clients. Someone who clicked through from the site sees a consistent visual language in their inbox.
The Text Fallback Is Not an Afterthought
Every template generates a real plain text version. Not a stripped version of the HTML, but a purpose-written text email that works on its own:
Hey Isaac,
Got your audit request. I read every one personally.
Here's what happens next:
1. Within 24 hours, you'll get a calendar link from me directly.
Pick any 30-minute slot that works.
2. On the call, we talk about where the leak is in your office.
No pitch. No deck. Just the actual operational pain.
3. If we're a fit, you get a 1-page Operations Audit proposal in 48 hours.
Flat fee. Phased payment. No surprises.
The audit itself is $1,500 flat for one week of work...
The text version is what most people actually read in preview panes. It reads like a direct email, not a formatted marketing piece.
Escaping User Content
User-supplied strings go into the HTML through an escapeHtml() helper to prevent injection:
function escapeHtml(str: string): string {
return str
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """);
}
In the template: Hey ${escapeHtml(firstName)}. The first name is the only dynamic string that goes into the HTML directly. Everything else is static.
Three Templates, One Pattern
The lead magnet email delivers a PDF guide and a calendar link. The toolkit email delivers a GitHub repo URL and a six-step quickstart. The Operations Engine email walks through the three next steps after an audit request and states the price clearly.
All three use the same shell, the same type hierarchy, and the same text-plus-HTML return shape. Adding a fourth is about fifteen minutes of work: write the text, write the HTML inside the shared shell, export the function, import it in the route.
Why Not an Email Builder
Drag-and-drop email builders produce bloated HTML, vendor-locked templates, and a separate interface to maintain. For three emails, the function approach is faster to write, faster to change, and trivially version-controlled.
When the Operations Engine audit price changes, we update one string in one file and push. No login, no export, no re-import. The email matches what the site says, because it comes from the same codebase.