Notifio sends six kinds of email. Every one of them is a template literal returning a string of HTML with inline styles and nested tables, which is a thing I would be embarrassed about if the target were a browser rather than an inbox.
I have written about the routing side of this before, in Four kinds of email, one endpoint, and only one caller is allowed to throw and in Two of our four emails exist only to say we stopped working, and three rules keep them from becoming noise. This post is about the markup itself, and about an audit I ran over the templates that turned up something I did not expect.
The six
Three files, six HTML builders:
src/lib/emails/alert.ts
buildAlertEmail new listings found
buildReplyEmail an auto-reply went out
buildAuthFailureEmail a site needs you to log in again
buildBlockedEmail a site has paused us
src/lib/emails/activation.ts
buildActivationEmail your activation token, after purchase
src/lib/emails/free-access.ts
buildFreeAccessEmail a licence somebody was given rather than bought
Five of the six have a plain-text twin: buildAlertText, buildReplyText, buildAuthFailureText, buildBlockedText, buildFreeAccessText. The send helper puts both parts on the message when both exist:
if (html) emailOptions.html = html;
if (text) emailOptions.text = text;
I went looking for which one was missing on the theory that it would be a minor one. It is buildActivationEmail. The only email in the product that a customer absolutely must be able to read, because it contains the token that turns the downloaded app into a working one, is the only email we send as HTML and nothing else.
That is not a crash, and nobody has complained. Mail clients that cannot render HTML are rare now. But "rare" is doing a lot of work in that sentence when the alternative is eighteen lines of string concatenation, and the failure mode is a paying customer with an app they cannot activate and a blank email. The free-access email, which does the same job for a licence that was given away rather than sold, has a text twin, with a comment on it that reads "Plain-text fallback, kept in step with the HTML above". The paid path just never got one.
Why nested tables, still
The body of every one of the six is the same shape:
<body style="margin:0;padding:0;background:#0a0a0a;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#0a0a0a;padding:48px 24px;">
<tr>
<td align="center">
<table width="560" cellpadding="0" cellspacing="0"
style="background:#171717;border:1px solid #262626;border-radius:8px;overflow:hidden;max-width:560px;width:100%;">
An outer table that paints the page background and provides the outer padding, a centering cell, and an inner table pinned to 560 pixels that is the card. Then a header row, a body row, and a footer row separated by border-top and border-bottom instead of anything resembling a gap.
Every style is an attribute. There is no <style> block and no class in any of the six, because a hosted mail client may strip a <style> block, and Gmail historically has. There is no flexbox and no grid. width="560" appears as an HTML attribute and again as max-width:560px;width:100% in the inline style, because Outlook's rendering engine takes the attribute and everything else takes the CSS.
None of that is clever and all of it is 2010 technique. The reason to write it out by hand rather than reach for MJML or React Email is that six templates of about forty lines each is less code than the toolchain that would generate them, and the templates do not change. The product sends the same six messages it sent in July.
The chrome is pasted six times
That exact card opening appears six times across the three files. I checked:
$ grep -c 'background:#171717;border:1px solid #262626;border-radius:8px' src/lib/emails/*.ts
src/lib/emails/activation.ts:1
src/lib/emails/free-access.ts:1
src/lib/emails/alert.ts:4
The obvious fix is a shell(title, body) function and six calls to it. I have not done it, and unlike the duplicated helpers in our legal pages, which I am confident about, this one I think is genuinely borderline.
The argument for leaving it: an email template is a build output that happens to live in source. Reading one top to bottom and seeing the whole document, doctype to closing tag, is worth something when you are debugging why one client has added 8 pixels somewhere. The argument against: six copies means a change to the card chrome is six edits, and the sixth one is the one you forget. The thing stopping me is that no such change has been needed yet. If one is, that is the moment the function earns its place, and not before.
Four characters, escaped twice over
Only one of the three files escapes anything. alert.ts has this at the top:
function escHtml(str: string): string {
return String(str)
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """);
}
Four characters, ampersand first so the later replacements do not get double-escaped. Every listing title and every URL goes through it, because those strings come from somebody else's web page, and a listing title with an angle bracket in it is not hypothetical. The same function exists, character for character, in the contact form's route handler, where the thing being escaped is what a stranger typed into a textarea. Two copies of a four-line function in two places that will never be refactored together is a trade I will take.
The interesting absence is activation.ts, which interpolates two values and escapes neither:
<li>Enter your email: <strong style="color:#fafafa;">${email}</strong></li>
The token is generated from an alphabet I chose, so there is nothing in it to escape. The email address is whatever the customer typed into Stripe. The blast radius is one inbox, that of the person who owns the address, so this is not a vulnerability so much as a way to produce a mangled line in your own email. It still should go through escHtml, and it is on the list now.
The footer sentence, and the character in it
All four alert templates and both text twins end with the same line:
Getting this in Promotions? Move it to Primary so you never miss a listing.
Which is the least technical and most useful sentence in the whole set. A tool whose entire value is that you hear about a room before the other forty people do has a hard dependency on Gmail's tab classification, and there is nothing in code I can do about it. So the email asks.
The footer under it is Notifio — notifio.app, and writing this post is how I noticed that the templates contain ten — entities and one literal em dash, in a product whose copy rule is that user-facing text does not use them. The website follows that rule. The emails, written earlier, quietly do not. Six small edits, and a reminder that a rule only holds where somebody has looked.
If you want to see what these actually arrive as, the two that matter most are the listing alert and the activation code. The help page covers what to do when one does not show up, the sites we watch are what the alert email is a digest of, and the thing the activation email activates is on the download page.
Top comments (0)