Sending an HTML email the right way means setting both an HTML body and a plain-text alternative in a single message. Email clients receive a multipart/alternative structure and pick the richest version they can display. Clients that can show HTML render the styled version; others fall back to plain text. Skipping the text fallback is the most common mistake developers make, and it triggers spam filters and breaks accessibility for screen readers.
The code below uses Nodemailer because it is the de-facto Node.js library for SMTP. The same principles apply to any provider API that accepts a raw HTML body and a text body.
The Minimal Working Example
Install Nodemailer if you have not already:
npm install nodemailer
Then send a message with both html and text fields:
const nodemailer = require('nodemailer');
const transporter = nodemailer.createTransport({
host: 'smtp.example.com',
port: 587,
secure: false, // STARTTLS on port 587; use true + port 465 for TLS
auth: {
user: '[email protected]',
pass: process.env.SMTP_PASS,
},
});
await transporter.sendMail({
from: '"Acme App" <[email protected]>',
to: '[email protected]',
subject: 'Your account is ready',
// plain-text first per RFC 2046, clients pick the last version they support
text: 'Hi, your Acme account is ready. Log in at https://app.example.com',
html: `
<html>
<body style="font-family: Arial, sans-serif; color: #333; padding: 24px;">
<h1 style="font-size: 22px;">Your account is ready</h1>
<p style="font-size: 16px; line-height: 1.5;">
Log in to get started:
</p>
<a href="https://app.example.com"
style="display:inline-block; padding:10px 20px; background:#0057ff;
color:#fff; border-radius:4px; text-decoration:none;">
Open Acme
</a>
</body>
</html>
`,
});
Nodemailer wraps text and html in a multipart/alternative container automatically. You do not need to construct the MIME structure by hand.
Why the Plain-Text Part Matters
RFC 2046 specifies that alternatives in a multipart/alternative message should be ordered from least preferred to most preferred. In practice: plain text goes first, HTML goes last. Receiving clients display the last version they can handle, so modern clients show your HTML and legacy or text-only clients show the plain text.
Omitting text means:
- SpamAssassin and similar filters flag the message as suspicious, because legitimate mailers nearly always send both parts.
- Screen readers and terminal email clients get no readable content.
- A small but real share of enterprise mail gateways strips HTML entirely, leaving recipients with a blank email.
Keep the text version readable on its own. Do not just strip the HTML tags and call it done. Write a version that makes sense as plain text with real line breaks and spelled-out URLs.
CSS: Always Inline for HTML Email
CSS rules in a <style> block in your email’s <head> get stripped by Gmail (both desktop and Android), many versions of Outlook, and certain corporate mail proxies. The only CSS form supported by every client without exception is inline styles on each element.
Write your layout and typography as style attributes:
<p style="font-size: 16px; line-height: 1.6; color: #333333; margin: 0 0 16px;">
Your message here.
</p>
For more complex templates, use a CSS inliner library rather than writing inline styles by hand. juice is the most common choice in Node.js:
npm install juice
const juice = require('juice');
const html = juice(`
<style>p { font-size: 16px; color: #333; }</style>
<p>Hello</p>
`);
// Output: <p style="font-size: 16px; color: #333;">Hello</p>
If you are using React to build your templates, React Email handles inlining for you and integrates directly with Nodemailer. See the guide on using React Email with your Node.js setup for the full workflow.
Common Pitfalls
Images break on blocked clients. Many email clients block remote images by default. Always set alt text on every <img> and make sure the email makes sense without any images. Hosting images on a CDN with a reliable domain (not your app server) reduces the chance of the URL being flagged.
Spam triggers. A few patterns reliably damage deliverability: using font tags, all-caps subject lines, excessive punctuation, URL shorteners in the body, or sending HTML-only without a text alternative. Keep your markup clean and your message-to-image ratio above 60% text.
Missing DOCTYPE. Start your HTML with <!DOCTYPE html>. Without it, Outlook’s Word-based renderer applies quirks mode and breaks layout.
Encoding issues. Declare the character set on the Content-Type header. Nodemailer defaults to UTF-8, which is correct for most use cases. Do not override this unless you have a specific reason.
Sending HTML Email with a Provider API
If you are using a provider like SendGrid, Mailgun, or Postmark instead of a raw SMTP connection, the pattern is the same: pass both an html field and a text (or textBody) field. The field names vary by SDK but every major provider supports both. Check the email API integration guide for a side-by-side comparison.
For a complete setup including authentication and environment configuration, see how to send email in Node.js and how to send email in Python.
FAQ
Do I have to send both HTML and plain text?
You are not required to by any sending library, but you should. Spam filters like SpamAssassin penalize HTML-only messages, and roughly 1-2% of recipients use text-only clients or accessibility tools that cannot render HTML. Always include a text field alongside html.
Why does my HTML email look broken in Outlook?
Outlook 2007 through 2019 on Windows uses Microsoft Word’s rendering engine, which ignores many modern CSS properties (flexbox, grid, CSS variables). Stick to table-based layouts for complex multi-column structures and always use inline styles. Testing in a tool like Litmus or Email on Acid before sending catches Outlook issues before they reach users.
What port should I use for SMTP?
Use port 587 with secure: false for STARTTLS (the recommended default for most providers) or port 465 with secure: true for implicit TLS. Port 25 is blocked by most cloud providers and ISPs for outbound email.
Should I inline CSS manually or use a library?
For more than a handful of elements, use a CSS inliner library. Writing inline styles by hand is error-prone and hard to maintain. juice is the standard choice in Node.js ecosystems. React Email users get automatic inlining built in.
What is the correct MIME order for text and HTML parts?
Per RFC 2046, alternatives must be ordered from least preferred to most preferred. Plain text goes first, HTML goes last. Email clients display the last version they can handle, so modern clients show HTML and fallback clients show plain text. Nodemailer follows this ordering automatically when you provide both text and html.
Can I use external CSS stylesheets in HTML email?
No. External stylesheets are fetched only in 21% of email clients and are blocked entirely by Gmail and most mobile clients. All styling must be inline or, for progressive enhancements only, in a <style> block within the email <head>.
I’ve spent my career building software at scale with a soft spot for email: deliverability, lifecycle campaigns, and getting messages to actually land. I started Coldletter to fix what bugged me about transactional and marketing email tools. I’m based in Vancouver.