If your Node backend sends transactional mail through Nodemailer, this takes about a minute to check. Set the display name on a staging account to the string below and trigger a password reset.
<a href="https://not-your-domain.example">Reset your password here</a>If that arrives in the inbox as a clickable link rather than as visible angle brackets, your email template is concatenating unsanitized user input straight into HTML, and this is HTML injection in every message your system sends to that account.
The vulnerable line is almost always the same shape.
const html = `
<p>Hi ${user.displayName},</p>
<p>Click the button below to reset your password.</p>
`;
await transporter.sendMail({ to: user.email, subject: "Password reset", html });A JavaScript template literal concatenates and escapes nothing. Whatever bytes sit in user.displayName land in the body at that position with exactly the same status as the markup you typed yourself. If the value contains an anchor tag, the mail client parses an anchor tag, because nothing in this code path ever told it not to.
Password reset is just the mail you can trigger on demand. Every templated message your system sends has the same shape.
What Nodemailer escapes and what it does not
Nodemailer is not the bug here, and it is worth being precise about where its responsibilities end. The html option takes a string and sends that string. The library has no way to distinguish the markup you wrote from the markup a user supplied, so it treats the whole thing as your intended output.
It does handle the parts it owns. Message headers get proper encoding, so a display name in the From or To field is encoded rather than passed through raw, and newline characters that would let someone append extra headers are stripped. Header injection is a solved problem in the library.
The body is a different matter. Escaping the body is a decision only your application can make, because only your application knows which substring came from a request payload. Every mail library in every language draws the line in the same place.
Where unsanitized user input enters the email template
Display name is the obvious one, but it is rarely the only one. Company name printed on an invoice, project or document title in a notification, filenames from uploads, free text notes quoted back into a summary mail, address lines on a shipping confirmation. Any of these can reach a template, and each interpolation site is independent. Escaping one does nothing for the others.
Template engines split into two behaviors, and the difference is a single character. Handlebars escapes with the double stash and skips escaping with the triple stash.
<p>Hi {{displayName}},</p>
<p>Hi {{{displayName}}},</p>EJS does the same thing with a different symbol, where the equals form escapes and the dash form writes raw output.
<p>Hi <%= displayName %>,</p>
<p>Hi <%- displayName %>,</p>The raw forms exist for a legitimate reason, which is rendering markup you generated yourself. They tend to spread because someone needed to inject a styled button once and copied the pattern outward.
Why HTML injection in an email template behaves differently from XSS
The first difference is that none of your browser defenses are in scope. Content Security Policy is an HTTP response header applied by a browser to a document it fetched. A mail body never travels that path, so the policy you spent a sprint tightening on your web app has no relationship to what a mail client renders. There is no origin boundary to rely on and no header you can set that changes the outcome.
The second difference is that you have no control over the rendering engine and no version to pin. Classic Outlook on Windows renders message HTML through Microsoft Word's engine. Apple Mail renders through WebKit. Gmail's web client runs incoming HTML through its own sanitizer and rewrites large parts of it before display. Every other webmail provider makes its own choices. Testing your payload in one client tells you almost nothing about the others, and a payload that Gmail quietly strips can render intact somewhere else.
The third difference changes the exploit rather than the defense. Nearly every mail client removes script tags and event handler attributes, so the exploit here is content injection inside a message your own domain authenticated rather than JavaScript running in a victim session. Your transactional mail passes SPF and carries a valid DKIM signature aligned to your domain. It also arrives with whatever sending reputation you have built. A phishing link hosted on a throwaway domain gets filtered on the way in. The same link, wrapped in your template and signed by your key, lands in the primary inbox.
What an attacker actually gets out of it
A lookalike link styled to match the rest of your template, because style attributes and table markup interpolate just as easily as the anchor did. A remote image that fires on open and reveals the recipient's mail client and rough network location. Control over the preheader, which is the preview line mail clients pull from the first visible text in the body, meaning the injected string can rewrite what the recipient sees in their message list before they open anything. A fake support number in a fake footer, under your logo.
The case where it stops being only your own inbox
Injecting into mail addressed to yourself reads as low severity, and on its own it is. The escalation is that display names appear in mail sent about you to other people. An invitation notification. A comment notification. A message telling an admin that a new member joined the workspace. A receipt carrying a customer-supplied company name to a billing contact. A support ticket email quoting the requester's own text to an agent.
In all of those, an attacker-controlled string renders in a stranger's inbox, from your domain, in a message that recipient was expecting to receive. That is a better delivery position than most phishing campaigns ever get.
Why mobile developers have never had to think about this
On Android, a TextView or a Compose Text takes a String and draws the characters in it. On iOS, a UILabel or a SwiftUI Text does the same. There is no markup parser anywhere in that path, so a display name containing angle brackets renders as visible angle brackets and the question never arises. This bug class requires a renderer that treats some portion of your data as structure, and the default mobile text stack has no such renderer.
The exceptions are exactly the places where you opt into a parser. Html.fromHtml on Android, NSAttributedString initialized with the HTML document type on iOS, and any package that renders HTML inside a Flutter widget tree all bring parsing behavior with them, and they carry the same escaping obligation. You have to reach for them on purpose, which is why the reflex never develops.
Moving into backend work inverts that default. Server-side rendering and mail templating both produce markup from strings, and escaping becomes your responsibility at every single interpolation site rather than a property of the widget you chose.
How to check your own email templates for unsanitized user input
The fastest check is the payload at the top of this article, run against your own account on staging. If you have a mail catcher such as Mailpit or Mailhog in your local stack, use that instead of a real inbox, because it shows you the raw source of the message and removes the guesswork about which client stripped what.
The static check is grep, aimed at the unescaped output forms.
grep -rn "{{{" ./src/templates
grep -rn "<%-" ./src/views
grep -rn "sendMail" ./srcGrep has a real false negative here. If you assemble the body inside a helper module and pass the finished string to sendMail somewhere else, no single pattern connects the two. Once grep gives you the mail helper, read it by hand and check every value that came from a request body or a database column.
Fixing it before the string reaches Nodemailer
Escape on output, never on input. Storing pre-escaped values corrupts the data for every other consumer, including your JSON API responses and your mobile clients, and it silently fails to cover the next interpolation site somebody adds.
If you are hand-building the string, run every interpolated value through a function that escapes the characters carrying meaning in HTML markup.
const escapeHtml = (s) =>
String(s)
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
const html = `<p>Hi ${escapeHtml(user.displayName)},</p>`;The ampersand replacement has to run first. Reverse that order and you re-encode the entities you just wrote, which turns every escaped character into visible garbage in the delivered mail.
Escaping covers text position. Attribute position additionally needs the attribute quoted, and URL position needs its own handling, because escaping does nothing to stop a scheme you did not intend. Never interpolate a user-supplied value into an href without validating that the scheme is one you allow.
One more layer worth adding is a length cap and a character restriction on display names at the point of write. It will not replace escaping, and it should not be your only control, but it limits how much an attacker can smuggle through fields you have not audited yet, including your plain text alternative body where autolinking will happily turn a pasted URL into a clickable link with no HTML involved at all.
The whole class comes down to one question you can ask about any template in your codebase. Does this value reach the renderer as data or as structure. Everything above is the consequence of getting that answer wrong once.
I write about production mobile engineering with receipts. Follow if that is useful. ❤
Related reading: