Notifio is a desktop app that watches rental listing sites and tells you when something new appears. The desktop app does the watching, but it never talks to an email provider: it posts to one endpoint on the server with its licence, and the server decides what to send. That keeps provider credentials off every user's laptop, and it means all the email templates live in one file on the server.
There are four of them, each built twice, and the whole file is string concatenation. Here is why.
The four emails and their twins
export function buildAlertEmail(alerts: AlertEntry[], totalCount: number): string
export function buildAlertText(alerts: AlertEntry[]): string
export function buildReplyEmail(reply: ReplyNotice): string
export function buildReplyText(reply: ReplyNotice): string
export function buildAuthFailureEmail(siteName: string): string
export function buildAuthFailureText(siteName: string): string
export function buildBlockedEmail(siteName: string): string
export function buildBlockedText(siteName: string): string
Four messages: new listings found, a reply went out, a site needs you to sign in again, a site is refusing automated checks. Every one of them has an HTML version and a plain-text version, and both are sent on every message as html and text.
The text version is not a formality. Some clients and most notification previews read it, and an email with no text part is a deliverability signal you do not want on a message whose entire value is arriving quickly. For the listings email the text twin is doing real work:
export function buildAlertText(alerts: AlertEntry[]): string {
return alerts
.map((alert) => {
const lines = alert.newListings
.map((l) => ` ${l.title}\n ${l.url}`)
.join("\n\n");
return `${alert.siteName}\n${"─".repeat(alert.siteName.length)}\n${lines}`;
})
.join("\n\n\n");
}
An underline of box-drawing characters exactly as long as the site name above it. That is the plain-text equivalent of the uppercase letter-spaced eyebrow in the HTML version, and it is the kind of detail that only exists if somebody actually opened the text part and looked at it.
Why this file is not JSX
The server is a Next.js app. Everywhere else, React escapes interpolated values for us and we never think about it. The email file is the one place in the codebase that builds HTML by concatenating strings, and that is deliberate on two counts.
First, email HTML is not web HTML. It is nested <table> elements with every style inline, because a meaningful share of clients still strip <style> blocks and have patchy flexbox support. The markup looks like this all the way down:
<table width="560" cellpadding="0" cellspacing="0"
style="background:#171717;border:1px solid #262626;border-radius:8px;overflow:hidden;max-width:560px;width:100%;">
A component layer over that abstracts nothing, because there is no reuse to find: the constraint is the client, not the code.
Second, a template literal is auditable. You can read the function top to bottom and see every point where a value enters the document. That property is what the next section depends on.
The escaper, and why it lives in this file
function escHtml(str: string): string {
return String(str)
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """);
}
Five lines, not exported, sitting at the top of the template file. Reaching for a library here would be reasonable, and I did not, for one reason: this is the only module in the server that needs it, and a shared escapeHtml in a utils folder is an invitation for somebody to use it somewhere React was already handling the problem, which teaches the wrong lesson about where escaping belongs.
The ordering is the only subtle thing in it. & is replaced first. Do it last and you double-escape your own output, turning < into &lt; and printing the entity text to the reader.
The reason it is needed at all is the data. A listing title in that email was typed by a stranger on somebody else's website and travelled through the desktop app to this function. Nothing between those two points is in our control, so a title containing < has to be treated as a title containing < and not as the start of a tag.
What the escaper does not do
It escapes the double quote, which closes the obvious hole:
<a href="${escHtml(l.url)}" style="color:#fafafa;...">${escHtml(l.title)}</a>
With " escaped, a crafted URL cannot break out of the attribute and add its own. What it does not do is validate the URL scheme. A string starting javascript: survives escHtml intact, because nothing in those four replacements is about schemes.
In this app that is a tidiness problem rather than a vulnerability, and it is worth being precise about why. The endpoint requires a valid licence and a matching email address, and the only address it will ever send to is the one on that licence. Anybody able to put a hostile URL into the payload is sending it to their own inbox. Mail clients also overwhelmingly refuse to execute javascript: hrefs. The blast radius is one person, and that person is the attacker.
Which is the right way to decide these things: not "is this input trusted" but "who receives the output". An allowlist of http: and https: still belongs in the shape validation, and it costs two lines, so it is going in. But it is going in as hygiene, not as an incident.
The current shape check guards the structural case, the one that would otherwise throw a 500 mid-request:
if (!alerts.every((a) => a && typeof a.siteName === "string" && Array.isArray(a.newListings))) {
return NextResponse.json({ error: "Each alert requires siteName and newListings[]." }, { status: 400 });
}
It checks the container and not yet each listing inside it. String(str) inside the escaper is what stops a non-string slipping through and throwing on .replace, which is a seatbelt rather than a validation.
The one email allowed to say it is not sure
The reply email is the interesting one editorially. The app can submit a contact form and then not be able to tell whether the site accepted it, because the site showed no confirmation we recognise. There is a strong pull toward rounding that up to success.
The template refuses to:
const headline = reply.confirmed ? "Reply Sent" : "Reply Submitted";
const body = reply.confirmed
? `Notifio completed the contact form and sent your message for this listing on <strong>${escHtml(reply.siteName)}</strong>.`
: `Notifio completed the contact form and submitted your message for this listing on <strong>${escHtml(reply.siteName)}</strong>. The site did not show a confirmation we recognise, so it is worth a quick check.`;
"Sent" and "Submitted" are different words in the subject line and in the headline, and the uncertain version says what it does not know and what to do about it. The alternative is an email that says "Sent" and is sometimes wrong, which costs the reader a room and costs us the next five emails' worth of credibility.
The flag feeding that is a one-line translation on the app side, and the comment is the whole argument:
// 'probably_sent' means submitted without a confirmation we recognise.
// The email says so rather than overclaiming.
confirmed: record.status === 'sent',
The line at the bottom of all four
Every one of the eight functions ends with the same sentence:
Getting this in Promotions? Move it to Primary so you never miss a listing.
On a rental site the gap between an email arriving and somebody else replying first is the entire product, which the guide on how fast listings go goes into properly. A correct, fast, well-escaped email filed under Promotions and read four hours later has failed. One sentence asking the reader to fix that is worth more than any amount of template polish, and it is in all four because the first email somebody receives might be any of them.
Takeaways
- Build the text part. Write it, then actually read it in a client.
- Escape at the point of interpolation, in the file that interpolates, and replace
&first. - HTML escaping and URL scheme validation are different jobs. Doing one does not give you the other.
- When your system cannot tell whether something worked, say so in the email. Two different words cost nothing.
The app itself is on the download page, and the per-site alert pages show which sites these emails get sent about.
Top comments (0)