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

<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&amp;utm_source=receipt">View the receipt</a></p>
// 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(); });
// 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

  1. 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 27472

    Everything after this reads the file that server writes, so the subject of the test is the delivered message.

  2. Step 2.

    Send the template through the mail library.

    node build-html.js
    
    queued: 250 OK: message queued  messageId=<38cab0af-d34a-721a-acfe-446bc985206d@build.test>

    The server wrote the message into capture/. Copy that file to receipt.eml for the steps below. Reviewing the template instead skips the library, which is the part that decides structure and encoding.

  3. Step 3.

    Read the second part of the raw message.

    sed -n '16,26p' receipt.eml
    
    Content-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.

  4. Step 4.

    Find the long link in the encoded body.

    grep -n -A3 '<p><a href' receipt.eml
    
    44:        <p><a href=3D"https://app.build.test/orders/4821/receipt?=
    45-token=3D9f2c1ab47de54b0e8a6d3c5f21b7e480&amp;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.

  5. Step 5.

    Run the audit against the captured message and the template.

    node audit.js receipt.eml receipt.html
    
    subject            : 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&amp;utm_source=receipt

    decoded == source : true says the encoding round trip is lossless, which retires the whole question raised by steps 3 and 4. The line under .muted is 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 76 is the encoder obeying its limit, not a coincidence.

  6. 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 &amp; | The attribute is correctly escaped in HTML | The browser decodes it to &. A literal &amp; inside the query string after decoding is the bug. |

Thresholds

76 characters, the maximum encoded line in quoted-printable Source: RFC 2045 section 6.7 rule 5: encoded lines must not be longer than 76 characters, and a line break may be inserted by adding an equals sign as the last character

Common mistakes

Sign: The template renders correctly in the browser and wrongly in a mail client.Cause: A browser keeps the <style> block. Several webmail clients remove <head> before rendering, which takes the block with it. Step 5 names the rules that have no inline copy: .muted here, and nothing warns about it anywhere else in the pipeline.
Sign: A test reports a broken link in the message body.Cause: Quoted-printable wraps at 76 characters and marks the wrap with a trailing equals sign, which step 4 shows landing inside a query string. The link is intact after decoding. Parse the message and read mail.html, rather than grepping the raw file.
Sign: The template file passes review and the delivered message still differs.Cause: The mail library builds the MIME structure, picks the transfer encoding and can rewrite the HTML. decoded == source in step 5 is the assertion that it did not. Run it on the captured message, because nothing before that point is the message.
Sign: Rendering is signed off from a preview in the sending tool.Cause: A preview is the tool's own renderer, with no client quirks in it. This procedure checks the source, and it cannot tell you how Outlook lays out a table. For appearance, use a screenshot service and treat its images as evidence for the clients it covers, and for no others.

What to check next

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.

intermediate12 minpublished updated Maks Verny