Notifio is a desktop app that watches rental search pages and emails you when a new listing appears. Email is not a side feature of it, email is the product's output. So I went and counted every message the thing can send, where each one is built, and what each one does differently from the others.
Seven HTML bodies. Here is the whole inventory.
| built in | triggered by | |
|---|---|---|
| New listings found | lib/emails/alert.ts | the desktop app posting a find |
| Reply sent / submitted | lib/emails/alert.ts | an auto-reply completing |
| Login required | lib/emails/alert.ts | a saved session going stale |
| Site blocking checks | lib/emails/alert.ts | the app pausing a site |
| Your activation code | lib/emails/activation.ts | a Stripe webhook after a purchase |
| Free licence granted | lib/emails/free-access.ts | a CLI script run by hand |
| New contact message | app/api/contact/route.ts | the form on notifio.app/help |
Four of those live in one module, two have a module each, and the seventh is a template literal inside the API route that sends it. Together they are 16,421 characters of template literal.
The shell, seven times
Every one of the seven is a dark, inline-styled, 560 pixel wide card, because that is the one layout that renders the same way in Gmail, Outlook and Mail. Which means every one of the seven contains these, byte for byte identical:
7 <body style="margin:0;padding:0;background:#0a0a0a;font-family:-apple-system,..."> 7 <table width="100%" cellpadding="0" cellspacing="0" style="background:#0a0a0a;padding:48px 24px;"> 7 <table width="560" cellpadding="0" cellspacing="0" 7 style="background:#171717;border:1px solid #262626;border-radius:8px;overflow:hidden;max-width:560px;width:100%;" The four templates inside alert.ts are so similar that their common prefix is 937 characters long. All four open with the same doctype, the same head, the same two nested tables and the same uppercase "Notifio" eyebrow above the headline, and the first byte where any of them differs from the others is the headline text itself.
There is a real argument for this. Email HTML is not HTML you refactor for elegance, it is HTML you stop touching once it survives contact with Outlook, and a shared layout component is a single place where a change silently alters seven messages you cannot recall. There is also a real cost, which is what measuring these turned up:
- 5 of 7 have the eyebrow at
font-size:11px. The activation and free-licence emails have it at13px, and the headline under it at24pxinstead of22px. Nobody decided that. They were written first. - 6 of 7 carry
<meta name="viewport" content="width=device-width,initial-scale=1">. The one that does not is the contact-form email, whose<head>is only a charset. It is also the only one of the seven that is not sent to a customer, which is why nobody noticed it rendering at desktop width on a phone. - 6 of 7 end with the same
Notifio, notifio.appfooter line. The exception is the contact email again, which ends with "Reply directly to this email to respond to {name}" instead, because its reader is me. - 4 of 7 carry this line above the footer:
Getting this in Promotions? Move it to Primary so you never miss a listing.
Those four are exactly the four the monitor sends. An alert that lands in Promotions is worthless, because the whole product is a race against other applicants, so the nudge belongs on the mail that is time-critical and not on the receipt for a purchase you just made. That one is a deliberate 4, not a drifted 4.
The plain text siblings
All seven have a text part. That matters more than it sounds: a dark-background table-layout HTML email with no text alternative is a spam-filter signal, and these messages have exactly one job, which is to arrive.
Where they live is less tidy than that sounds:
buildAlertText lib/emails/alert.ts buildReplyText lib/emails/alert.ts buildAuthFailureText lib/emails/alert.ts buildBlockedText lib/emails/alert.ts buildFreeAccessText lib/emails/free-access.ts (inline) app/api/contact/route.ts (inline) app/api/webhooks/stripe/route.ts Five are exported functions next to the HTML they shadow. Two are backtick strings at the call site. The activation one is the entire text alternative for the email that carries a licence key:
text: `Your Notifio activation token is: ${token}\n\nOpen Notifio, enter your email and this token to activate.`, Two lines, in a webhook handler, in a different file from the 2,788-character HTML version of the same message. If the HTML one ever changes its instructions, nothing makes that string change with it. The four in alert.ts sit directly under their HTML twin and share its parameters, which is the arrangement that actually keeps a pair in step.
The text versions also do one thing the HTML cannot. They use box-drawing rules as separators, and the listings one sizes its rule to the site heading above it:
return `${alert.siteName}\n${"─".repeat(alert.siteName.length)}\n${lines}`; So a site name of nine characters gets a nine character underline, in a plain text email, which is the kind of detail that exists only because somebody read their own output.
Which ones escape, and why it is not all of them
There are two escHtml functions in the server, one in lib/emails/alert.ts and one in app/api/contact/route.ts, and they are byte-identical:
function escHtml(str: string): string { return String(str) .replace(/&/g, "&") .replace(/</g, "<") .replace(/>/g, ">") .replace(/"/g, """); } Four replacements. No apostrophe, which is correct rather than sloppy: every interpolation in these templates lands either in a text node or inside a double-quoted attribute, so ' cannot terminate anything, and leaving it alone keeps a listing called "Mum's flat" readable in the plain text version built from the same string.
Five of the seven templates escape their inputs. The two that do not are the activation email and the free-licence email, and the difference is the provenance of what they interpolate:
- The five escape listing titles, listing URLs, site names and a stranger's message typed into a form. Every one of those is text that arrived from outside and none of it was ever promised to be HTML-safe. A listing title is written by a landlord, and
<is a character landlords use. - The two that do not interpolate a token this server minted and an email address that arrived either from a completed Stripe session or from a regex check in the CLI script. There is no path by which either contains an angle bracket.
That is a defensible line, but it is a line held by argument rather than by types, and the argument lives nowhere in the code. The cheap fix is not adding escHtml to the other two, it is making the unsafe case loud: a function email(parts: TemplateStringsArray, ...values: Escaped[]) tag would make an unescaped interpolation a type error, and the two emails with nothing to escape would pass trivially. That is the version I would write if there were an eighth.
Four from-addresses, one in use
export const EmailAddresses = { NOREPLY: "Notifio <noreply@notifio.app>", HELLO: "Notifio <hello@notifio.app>", SUPPORT: "Notifio Support <support@notifio.app>", NOTIFICATIONS: "Notifio <notifications@notifio.app>", } as const; Across the whole server there are six sendEmail call sites, and all six pass EmailAddresses.NOREPLY. The other three constants are used zero times. They are not harmless: three unused sending identities on a verified domain look like a plan, so the next person wires the alert mail to NOTIFICATIONS and finds out which of the four addresses actually has a forwarding rule behind it.
And there is a fifth copy of the one address that is used, as a bare string in the CLI script that grants free licences:
const FROM = "Notifio <noreply@notifio.app>"; That script imports plenty from the app, including the email builder itself, straight out of the TypeScript:
import { buildFreeAccessEmail, buildFreeAccessText } from "../src/lib/emails/free-access.ts"; So why not import the constant too? Because the constant lives in lib/resend.ts, and that module does this at the top level:
const resend = new Resend(process.env.RESEND_API_KEY!); Importing a string from that file constructs an API client as a side effect of the import, in a script whose --dry-run mode exists specifically so you can run it without sending anything. Copying one string literal was the smaller evil, and the real fix is the boring one: a constants module with no client in it.
The shape of it
The thing I did not expect from counting was how cleanly the seven split by who reads them. The four from the monitor are built in one module, escape everything, carry the deliverability nudge, and have text twins sitting next to them. The two transactional ones are older, 2px bigger, escape nothing, and have their text parts stranded at the call site. The one that comes to me has no viewport tag and a different footer.
Nobody designed that split. It is just the order the emails were written in, preserved in the markup, and it is only visible if you line up all seven and diff them. The alerts are at notifio.app/alerts, the purchase that triggers the activation email is at notifio.app/pricing, and the form behind the seventh is at notifio.app/help.
Two earlier posts on the plumbing under these: Four kinds of email, one endpoint, and only one caller is allowed to throw on the single route the app posts all four monitor emails to, and Our inbound mail webhook answers 200 to everything it refuses on what happens to mail going the other way.

