How to test transactional email rendering
Send the template through your mail library into a local catch-all server, then read the captured message rather than the template. node audit.js receipt.eml receipt.html reports the MIME structure, the encoded line length, and every CSS rule that lives only in the <style> block.
Why check this
Run this when the template changes, when the mail library is upgraded, and once before any release that introduces a new transactional message. This procedure checks the source of the delivered message. It does not check appearance in any particular client: there is no Outlook and no Gmail on the build machine, and a screenshot service is the only honest way to get that. What it does catch is everything that is wrong before a client is involved.
The failure it prevents: a receipt is built with a <style> block, it looks correct in every browser preview, and a webmail client that strips <head> drops the rule that made the total bold. The template is fine. The message is not.
Prerequisites
- RFC 2045 section 6.7 for quoted-printable and its line limit.
guard.jsand the certificate from How to test email sending in a test environment, listening on 2531.receipt.html, the template. It carries three class rules, two of which are also written inline, one image, and one long tracked link:
<style>
.card { background: #f5f5f4; border: 1px solid #d6d3d1; padding: 24px; }
.total { font-weight: 700; font-size: 20px; }
.muted { color: #57534e; }
</style>
…
<img src="https://cdn.build.test/logo.png" width="120" height="32" alt="Build Test">
<p class="total" style="font-weight:700;font-size:20px;">EUR 49.00</p>
<p class="muted">Keep this message for your records.</p>
<p><a href="https://app.build.test/orders/4821/receipt?token=9f2c1ab47de54b0e8a6d3c5f21b7e480&utm_source=receipt">View the receipt</a></p>
build-html.js, which sends the template the way the application does:
// Sends the template through the local catch-all so the message under test is
// the one the mail library actually produced, not the template file.
const fs = require('fs');
const nodemailer = require('nodemailer');
const html = fs.readFileSync('receipt.html', 'utf8');
const transport = nodemailer.createTransport({ host: '127.0.0.1', port: 2531, tls: { rejectUnauthorized: false } });
transport.sendMail({
from: 'Build Test <no-reply@build.test>',
to: 'qa@build.test',
subject: 'Order 4821 is confirmed',
text: 'Order 4821 is confirmed. Total EUR 49.00.',
html,
}).then((i) => { console.log('queued: ' + i.response + ' messageId=' + i.messageId); transport.close(); });
audit.js, which reads the captured message and reports what survives a client that drops<style>:
// Reads a captured message and reports what a client that drops <style> keeps.
// node audit.js receipt.eml receipt.html
const fs = require('fs');
const { simpleParser } = require('mailparser');
(async () => {
const raw = fs.readFileSync(process.argv[2]);
const text = raw.toString('binary');
const mail = await simpleParser(raw);
const boundary = (text.match(/boundary="([^"]+)"/) || [])[1];
console.log('subject : ' + mail.subject);
console.log('structure : ' + (text.match(/Content-Type: (multipart\/\w+)/) || [])[1]);
for (const part of text.split('--' + boundary).slice(1, -1)) {
const head = part.slice(0, part.indexOf('\r\n\r\n')).trim().split(/\r\n/);
const body = part.slice(part.indexOf('\r\n\r\n') + 4);
const longest = Math.max(...body.split(/\r\n/).map((l) => l.length));
console.log(' part : ' + head.join('; ') +
'; longest line ' + longest + '; soft breaks ' + (body.match(/=\r\n/g) || []).length);
}
console.log('text alternative : ' + (mail.text ? 'present, ' + mail.text.trim().length + ' chars' : 'MISSING'));
const html = mail.html;
const source = fs.readFileSync(process.argv[3], 'utf8');
console.log('html size : ' + Buffer.byteLength(html) + ' bytes');
console.log('decoded == source : ' + (html === source));
const styleBlock = (html.match(/<style[^>]*>([\s\S]*?)<\/style>/i) || [])[1] || '';
const selectors = [...styleBlock.matchAll(/\.([\w-]+)\s*\{([^}]*)\}/g)]
.map((m) => ({ cls: m[1], decls: m[2].trim().replace(/\s+/g, ' ') }));
const stripped = html.replace(/<style[\s\S]*?<\/style>/gi, '');
console.log('<style> blocks : ' + (html.match(/<style/gi) || []).length +
', ' + selectors.length + ' class rules');
for (const s of selectors) {
const el = new RegExp('<[^>]*class="' + s.cls + '"[^>]*>', 'i').exec(stripped);
const inline = el && /style="/i.test(el[0]);
console.log(' .' + s.cls.padEnd(16) + (el ? 'used' : 'unused') +
', inline copy: ' + (inline ? 'yes' : 'NO, lost with the block [' + s.decls + ']'));
}
const imgs = [...html.matchAll(/<img[^>]*>/gi)];
console.log('images : ' + imgs.length +
', with alt: ' + imgs.filter((i) => /alt="[^"]+"/i.test(i[0])).length +
', absolute src: ' + imgs.filter((i) => /src="https?:/i.test(i[0])).length);
const links = [...html.matchAll(/<a[^>]*href="([^"]*)"/gi)].map((m) => m[1]);
console.log('links : ' + links.length +
', absolute: ' + links.filter((l) => /^https?:/i.test(l)).length);
for (const l of links) console.log(' href : ' + l);
})();
Steps
- Step 1.
Start the catch-all server and keep its process id.
node guard.js &guarded catch-all on 127.0.0.1:2531, pid 27472Everything after this reads the file that server writes, so the subject of the test is the delivered message.
- Step 2.
Send the template through the mail library.
node build-html.jsqueued: 250 OK: message queued messageId=<38cab0af-d34a-721a-acfe-446bc985206d@build.test>The server wrote the message into
capture/. Copy that file toreceipt.emlfor the steps below. Reviewing the template instead skips the library, which is the part that decides structure and encoding. - Step 3.
Read the second part of the raw message.
sed -n '16,26p' receipt.emlContent-Type: text/html; charset=utf-8 Content-Transfer-Encoding: quoted-printable <!doctype html> <html lang=3D"en"> <head> <meta charset=3D"utf-8"> <style> .card { background: #f5f5f4; border: 1px solid #d6d3d1; padding: 24px; } .total { font-weight: 700; font-size: 20px; } .muted { color: #57534e; =This is not the HTML that was written. Every
=is=3D, and the last line ends with a bare=, which is a soft line break: the encoder reached its line limit inside a CSS rule and continued on the next line. - Step 4.
Find the long link in the encoded body.
grep -n -A3 '<p><a href' receipt.eml44: <p><a href=3D"https://app.build.test/orders/4821/receipt?= 45-token=3D9f2c1ab47de54b0e8a6d3c5f21b7e480&utm_source=3Dreceipt">View the= 46- receipt</a></p> 47- </td></tr>The URL is split across two lines in the middle of a query string, and the anchor text is split between "the" and "receipt". Both are correct quoted-printable and both reassemble on decode. A tool that reads the message with a regular expression instead of a MIME parser sees a broken link here and reports a bug that does not exist.
- Step 5.
Run the audit against the captured message and the template.
node audit.js receipt.eml receipt.htmlsubject : Order 4821 is confirmed structure : multipart/alternative part : Content-Type: text/plain; charset=utf-8; Content-Transfer-Encoding: 7bit; longest line 41; soft breaks 0 part : Content-Type: text/html; charset=utf-8; Content-Transfer-Encoding: quoted-printable; longest line 76; soft breaks 8 text alternative : present, 41 chars html size : 1069 bytes decoded == source : true <style> blocks : 1, 3 class rules .card used, inline copy: yes .total used, inline copy: yes .muted used, inline copy: NO, lost with the block [color: #57534e;] images : 1, with alt: 1, absolute src: 1 links : 1, absolute: 1 href : https://app.build.test/orders/4821/receipt?token=9f2c1ab47de54b0e8a6d3c5f21b7e480&utm_source=receiptdecoded == source : truesays the encoding round trip is lossless, which retires the whole question raised by steps 3 and 4. The line under.mutedis the finding: one rule has no inline copy, so the grey text turns to body colour in any client that drops the block.longest line 76is the encoder obeying its limit, not a coincidence. - Step 6.
Stop the server by its process id.
powershell -Command "Stop-Process -Id 27472 -Force"$ netstat -ano | grep LISTENING | grep ":2531 " $Nothing is listening. The captured messages stay on disk for the next run to compare against.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| decoded == source : false | The library changed the HTML on the way out | Compare the two strings. Minifiers and inliners run at send time. |
| text alternative : MISSING | The message is HTML only | Add a plain text part. Clients that show text first display the raw markup. |
| inline copy: NO on a rule | That declaration exists only in the <style> block | Write it inline on the element, or accept that some clients will drop it. |
| longest line 76 | Quoted-printable is wrapping normally | Nothing to do. Soft breaks reassemble on decode. |
| A URL split across two lines with a trailing = | A soft line break, not a broken link | Read the message with a MIME parser, not with a regular expression. |
| with alt: 0 | Images have no alternative text | Most clients block remote images by default, so the alt text is the message. |
| absolute src: 0 | An image is referenced by a relative path | It cannot resolve in a mail client. Use an absolute URL. |
| href printed with & | The attribute is correctly escaped in HTML | The browser decodes it to &. A literal & inside the query string after decoding is the bug. |
Thresholds
Common mistakes
What to check next
- How to test email sending in a test environment: the catch-all server that captured this message.
- How to check List-Unsubscribe header: another header to assert on in the same captured file.
- How to check if email bounced: what happens to this message when the recipient does not exist.
- How to check alt text on images: the rule behind the
with altline, and why it matters more in mail. - Email testing checklist: where this sits in a release that touches mail.
FAQ
How do I test HTML email rendering without sending mail?
Capture the message from a local SMTP server and read it, as in this procedure. That verifies structure, encoding and CSS placement. Appearance in a named client needs that client, or a screenshot service that runs it.
Why do email templates use tables and inline styles?
Because a client can drop the <style> block and often supports little CSS beyond what is on the element. A table with inline styles survives that treatment. Step 5 shows a single rule that did not.
Does the plain text part matter if everyone reads HTML?
It matters when the client is set to prefer text, when a screen reader or a watch reads the message, and when a filter compares the two parts. A missing text part is one line in the audit output.
What is =3D in my message?
Quoted-printable encoding of the equals sign. The encoder replaces = with =3D and wraps long lines with a trailing =. Decoding restores the original bytes, which decoded == source : true confirms.
Verified
Verified by Maks Vernynode 22.23.2nodemailer 10.0.8mailparser 3.9.25
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
intermediate12 minpublished updated Maks Verny