npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@rlnks/mail-audit

v1.0.6

Published

Email HTML quality analysis engine — detect bad practices, score compatibility, get actionable insights before sending.

Downloads

382

Readme

@rlnks/mail-audit

Email HTML quality analysis engine for JavaScript and TypeScript.

Analyze email templates before sending — detect compatibility issues, score your HTML against major email clients, and get actionable insights to fix problems before they reach your users' inboxes.

"Grammarly for HTML emails"

License: MIT npm Node.js

Port of rlnks/php-mail-audit — identical rule set, identical result format, TypeScript-native.


Table of Contents


Requirements

  • Node.js 18 or higher
  • One runtime dependency: parse5 for DOM parsing

Installation

npm install @rlnks/mail-audit

Quick Start

import { MailAudit } from '@rlnks/mail-audit';

const html   = fs.readFileSync('path/to/template.html', 'utf-8');
const result = new MailAudit().analyze(html);

console.log(`Score: ${result.score}/100`);
console.log(`Issues: ${result.summary.total_issues} | Passed: ${result.summary.passed}`);

for (const insight of result.insights) {
  console.log(`[${insight.severity}] ${insight.message}`);
  console.log(`  Fix: ${insight.fix}`);
}

for (const check of result.passed) {
  console.log(`[pass] ${check.message}`);
}

Example output:

Score: 72/100
Issues: 5 | Passed: 14

[error] Flexbox is not supported in Outlook desktop and Outlook on Windows.
  Fix: Replace flexbox with HTML table-based layouts.

[warning] Table with fixed width exceeding 600px detected — emails wider than 600px
cause horizontal scrolling on most desktop clients.
  Fix: Set your content table to a maximum of 600px wide.

[info] No preheader text detected — the inbox preview will show unrelated body text.
  Fix: Add a hidden preheader div immediately after <body>.

[pass] No flexbox layout detected — good compatibility with Outlook desktop.
[pass] All links have accessible text — screen readers can describe every link destination.

Configuration

const audit = new MailAudit(
  config,   // optional: { rules?: Rule[] }
  locale,   // optional: 'en' | 'fr' | 'es' | 'de' | 'pt' | string[] — default 'en'
);

All parameters are optional. The package works out of the box with the 56 bundled rules in English.


Result Format

analyze(html) returns a plain object:

{
  score:    81,          // number, 0–100
  insights: [ ... ],    // triggered rules (issues)
  passed:   [ ... ],    // rules that passed with a positive check message
  summary:  { ... },    // aggregate counts
}

score

An integer between 0 and 100. Higher is better. See Score Calculation.

insights

Each triggered rule produces one insight:

{
  id:               'no-flexbox',
  severity:         'error',          // 'error' | 'warning' | 'info'
  weight:           15,
  message:          'Flexbox is not supported in Outlook desktop...',
  fix:              'Replace flexbox with HTML table-based layout...',
  affected_clients: {
    outlook_desktop: { supported: false, versions: 'all' },
    gmail_web:       { supported: false, versions: '< 2022' },
    apple_mail:      { supported: true },
  },
  tags:      ['css', 'layout'],
  locations: [
    { line: 12, column: 5, offset_start: 450, offset_end: 471 },
  ],
}

Every location entry points to one occurrence of the issue in the source HTML. Use offset_start/offset_end with editor APIs (CodeMirror, Monaco) to highlight the exact position, and line/column for cursor placement.

When multiple locales are requested, message and fix become objects keyed by locale:

// new MailAudit({}, ['en', 'fr'])
insight.message // { en: '...', fr: '...' }

passed

Rules that did not trigger and carry a success_message appear here — useful for showing positive feedback alongside issues:

{
  id:       'no-flexbox',
  severity: 'error',
  message:  'No flexbox detected — good compatibility with Outlook.',
  tags:     ['css', 'layout'],
}

summary

{
  total_rules_checked: 56,
  total_issues:        3,
  errors:              1,
  warnings:            1,
  infos:               1,
  passed:              9,
}

Bundled Rules

56 rules ship with the package. The philosophy: flag bad usage, not feature presence. Media queries, hover states, and class selectors used correctly (with inline fallbacks) score well. The engine penalizes the absence of fallbacks, not the features themselves.

Errors — break rendering in major clients

| Rule ID | Description | Weight | |---|---|---| | no-flexbox | CSS display: flex not supported in Outlook | 15 | | no-grid | CSS display: grid not supported anywhere | 15 | | no-form-elements | <form>, <input>, <button> stripped by all clients | 15 | | no-script | <script> stripped by all clients for security reasons | 15 | | no-iframe | <iframe> blocked by all clients | 15 | | no-svg | SVG not rendered in Outlook or Gmail | 12 | | no-video | <video> not supported in Outlook or Gmail | 12 | | no-audio | <audio> not supported in any major client | 10 | | no-css-gap | CSS gap / row-gap / column-gap not supported anywhere | 9 | | no-object-fit | object-fit not supported in any major client | 8 | | no-css-filter | CSS filter not supported in Outlook or Gmail | 8 | | no-clip-path | clip-path not supported in any major client | 8 | | no-css-variables | CSS var() without a fallback value — silently ignored by Outlook and Gmail | 7 |

Warnings — real problems when fallbacks are missing

| Rule ID | Description | Weight | |---|---|---| | style-no-inline-fallback | <style> block present but zero inline styles — layout breaks when Gmail/Outlook strip the style block | 12 | | html-too-large | HTML exceeds 102 KB — Gmail clips the message | 10 | | media-no-inline-base | @media queries present but no inline style baseline | 10 | | img-dimensions | <img> without width/height — layout breaks when images are blocked | 8 | | no-float | float breaks column layouts in Outlook 2007–2019 | 8 | | font-no-fallback | External font loaded but no inline font-family fallback stack | 8 | | no-picture | <picture> / srcset not supported in Outlook or Gmail | 8 | | missing-alt-img | <img> without alt shows broken icons when images are blocked | 7 | | no-css-calc | calc() not supported in Outlook 2007–2019 or Gmail | 7 | | missing-https | HTTP links detected — email clients block mixed content | 6 | | text-image-ratio | Email is too image-heavy — high spam filter risk | 6 | | no-div-layout | <div> with layout CSS — box model ignored by Outlook | 6 | | no-animation | CSS animation / @keyframes ignored by Outlook and Gmail | 6 | | url-unencoded | Unencoded space in a URL — breaks the link in all clients | 5 | | css-at-import | @import in <style> silently ignored by Gmail/Outlook | 5 | | css-at-import-no-link | @import with no <link> fallback — font won't load when <style> is stripped | 5 | | link-no-text | <a> with no accessible text — announced as unlabeled by screen readers | 5 | | email-max-width | Fixed-width <table> over 600 px — overflows Outlook and narrow viewports | 5 | | no-transform | CSS transform not supported in Outlook or Gmail | 5 |

Info — usage noted, minimal score impact

| Rule ID | Description | Weight | |---|---|---| | no-position-absolute | position: absolute/fixed ignored in most clients | 5 | | no-border-radius | border-radius ignored by Outlook desktop | 4 | | no-box-shadow | box-shadow not supported in Outlook | 3 | | no-transition | CSS transition not supported in Outlook or Gmail | 3 | | table-role-presentation | Layout tables without role="presentation" confuse screen readers | 3 | | preheader-missing | No preheader div found — inbox preview shows unrelated body text | 3 | | inline-css | <style> block present — acceptable when paired with inline fallbacks | 2 | | css-class-selectors | Class-based CSS in <style> — Gmail strips class attributes | 2 | | css-media-queries | @media queries detected — great when paired with inline styles | 2 | | no-external-fonts | External font loaded — supported in Apple Mail, not Gmail/Outlook | 2 | | missing-lang | <html> without lang attribute | 2 | | missing-viewport | No <meta name="viewport"> — mobile clients may render at desktop width | 2 | | preheader-too-long | Preheader text exceeds 150 characters — most clients truncate at 85–150 chars | 2 | | heading-order | Heading levels skipped (e.g. <h1> directly followed by <h3>) | 2 | | font-family-unquoted | Multi-word font name used without quotes in font-family | 2 | | missing-charset | No character encoding declaration in <head> | 2 | | missing-doctype | No <!DOCTYPE html> declaration | 2 | | table-cellspacing | <table> without cellpadding="0" cellspacing="0" | 2 | | css-pseudo-selectors | :hover, :focus etc. — ignored in Outlook/Gmail | 1 | | div-content | <div> used as content wrapper — <td> preferred | 1 | | empty-alt-img | <img alt=""> — verify image is truly decorative | 1 | | nbsp-missing | Regular space between a number and a currency/unit symbol | 1 | | missing-body-bgcolor | No background color on <body> | 1 | | tracking-pixel | 1×1 tracking pixel detected — Apple Mail MPP may cause false open events | 0 |


Detection Types

Every rule declares a detection object specifying how the engine finds the issue. All detectors return exact character positions (line, column, offset_start, offset_end) for every match.

css_property

Matches CSS patterns in inline style="" attributes and <style> blocks.

{ "type": "css_property", "patterns": ["display: flex", "display:flex"] }

Supports "regex": true for precision matching:

{ "type": "css_property", "regex": true, "patterns": ["(?<![a-z-])transform\\s*:"] }

html_content

Matches arbitrary patterns anywhere in the raw HTML string.

{ "type": "html_content", "patterns": ["fonts.googleapis.com"] }

Supports "regex": true. The ~ character is used as delimiter internally — escape it as \\~ if needed.

html_tag

Fires when the specified HTML tag is present. Tag names without angle brackets.

{ "type": "html_tag", "patterns": ["div", "svg", "form"] }

html_attribute_missing

Fires when a tag is missing a required attribute, has the wrong value, or (with only_empty) has an empty value.

{ "type": "html_attribute_missing", "tag": "img", "attributes": ["width", "height"] }
{ "type": "html_attribute_missing", "tag": "table", "attributes": ["role"], "attribute_value": "presentation" }
{ "type": "html_attribute_missing", "tag": "img", "attributes": ["alt"], "only_empty": true }

html_tag_with_style

Fires when a tag is present and its inline style contains one of the CSS patterns.

{ "type": "html_tag_with_style", "tag": "div", "css_patterns": ["width:", "float:"] }

Supports "regex": true.

style_block

Searches exclusively inside <style> block content.

{ "type": "style_block", "patterns": ["@media", "@import"] }

correlation

Fires when a trigger pattern is present but an expected fallback pattern is absent.

{
  "type": "correlation",
  "trigger":  { "type": "html_content", "patterns": ["fonts.googleapis.com"] },
  "fallback": { "type": "css_property", "regex": true, "patterns": ["font-family\\s*:[^;]*,"] }
}

preheader

Detects the standard email preheader pattern — a <div> with display:none and overflow:hidden.

{ "type": "preheader", "mode": "missing" }
{ "type": "preheader", "mode": "too_long", "max_length": 150 }

mode: missing only fires on complete documents (those containing a <body> tag). Fragments are skipped.

html_metric

Fires when a numeric property exceeds a threshold. Currently supported metric: size (byte length).

{ "type": "html_metric", "metric": "size", "threshold": 102400 }

heading_order

Fires when heading levels are skipped in document order (e.g. <h1> directly followed by <h3>). No configuration options.

{ "type": "heading_order" }

link_no_text

Fires when an <a> has no text content and no child <img> with a non-empty alt. No configuration options.

{ "type": "link_no_text" }

tracking_pixel

Fires when an <img> has width="1" and height="1". No configuration options.

{ "type": "tracking_pixel" }

css_font_family

Fires when a multi-word font name is used unquoted in a font-family declaration. No configuration options.

{ "type": "css_font_family" }

table_max_width

Fires when a <table> has a fixed pixel width exceeding max_width (default: 600).

{ "type": "table_max_width", "max_width": 600 }

Localization

Five locales ship out of the box: en, fr, es, de, pt.

Single locale

const audit  = new MailAudit({}, 'fr');
const result = audit.analyze(html);
// result.insights[0].message → string in French

Falls back to en for any rule missing the requested locale.

Multiple locales

const audit  = new MailAudit({}, ['en', 'fr']);
const result = audit.analyze(html);
// result.insights[0].message → { en: '...', fr: '...' }
// result.insights[0].fix     → { en: '...', fr: '...' }

Useful for building multi-language UIs without running analyze() twice.


Custom Rules

1. Write a rule JSON file

{
  "id": "no-marquee",
  "version": "1.0",
  "updated_at": "2026-05-13",
  "tier": "free",
  "severity": "error",
  "weight": 10,
  "tags": ["html"],
  "detection": {
    "type": "html_tag",
    "patterns": ["marquee"]
  },
  "message": {
    "en": "<marquee> is not supported in any modern email client.",
    "fr": "<marquee> n'est pas supporté par les clients email modernes."
  },
  "fix": {
    "en": "Replace with a static text element.",
    "fr": "Remplacez par un élément texte statique."
  }
}

2. Pass it to MailAudit

import { MailAudit } from '@rlnks/mail-audit';
import type { Rule } from '@rlnks/mail-audit';
import noMarquee from './rules/no-marquee.json' assert { type: 'json' };

const result = new MailAudit({ rules: [...defaultRules, noMarquee as Rule] }).analyze(html);

To get the bundled rules array:

import { RuleLoader } from '@rlnks/mail-audit'; // not exported by default — see note below

Or load them yourself:

import { MailAudit } from '@rlnks/mail-audit';

// The simplest approach: pass your extra rule alongside the bundled ones
// by subclassing (or just use the rules config option with a merged array).

Custom Detectors

1. Implement DetectorInterface

import type { DetectorInterface } from '@rlnks/mail-audit';
import type { Location, DetectionConfig } from '@rlnks/mail-audit';

class MjmlTagDetector implements DetectorInterface {
  findMatches(html: string, detection: DetectionConfig): Location[] {
    const locations: Location[] = [];
    for (const tag of detection.tags ?? []) {
      let idx = html.indexOf(`<mj-${tag}`);
      while (idx !== -1) {
        const end = html.indexOf('>', idx) + 1;
        locations.push({ line: 1, column: 1, offset_start: idx, offset_end: end });
        idx = html.indexOf(`<mj-${tag}`, idx + 1);
      }
    }
    return locations;
  }

  matches(html: string, detection: DetectionConfig): boolean {
    return this.findMatches(html, detection).length > 0;
  }
}

2. Register it

import { DetectorFactory } from '@rlnks/mail-audit';

DetectorFactory.register('mjml_tag', MjmlTagDetector);

3. Use it in a rule JSON

{ "detection": { "type": "mjml_tag", "tags": ["section", "column"] } }

Registration is global — register once at application bootstrap before calling analyze().


Browser Usage

The package is Node-first (rules are loaded from disk at runtime). For browser usage, pass the rules explicitly:

// In your bundler entry point, import the rules statically
import noFlexbox from '@rlnks/mail-audit/rules/no-flexbox.json' assert { type: 'json' };
// ... import all rules you need

import { MailAudit } from '@rlnks/mail-audit';

const rules  = [noFlexbox, /* ... */];
const result = new MailAudit({ rules }).analyze(html);

parse5 (the DOM parser) works in browsers via any bundler — no Node-specific APIs used.


Score Calculation

The score starts at 100. Each triggered rule deducts a weighted amount:

deduction = weight × severity_multiplier

severity multipliers:
  error   → 1.0
  warning → 0.6
  info    → 0.3

score = max(0, round(100 − Σ deductions))

Example:

| Rule | Severity | Weight | Multiplier | Deduction | |---|---|---|---|---| | no-svg | error | 12 | × 1.0 | 12.0 | | style-no-inline-fallback | warning | 12 | × 0.6 | 7.2 | | no-css-calc | warning | 7 | × 0.6 | 4.2 | | css-media-queries | info | 2 | × 0.3 | 0.6 | | Total | | | | 24.0 | | Score | | | | 76 / 100 |


Integration Examples

Node.js / CI pipeline

import { MailAudit } from '@rlnks/mail-audit';
import { readFileSync } from 'node:fs';

const html   = readFileSync('templates/welcome.html', 'utf-8');
const result = new MailAudit().analyze(html);

if (result.score < 70) {
  console.error(`Email quality score too low: ${result.score}/100`);
  process.exit(1);
}
console.log(`Score: ${result.score}/100 — OK`);

GitHub Actions

- name: Audit email templates
  run: |
    node --input-type=module << 'EOF'
    import { MailAudit } from '@rlnks/mail-audit';
    import { readFileSync } from 'node:fs';
    const result = new MailAudit().analyze(readFileSync('templates/welcome.html', 'utf-8'));
    if (result.score < 70) { console.error('Score too low: ' + result.score); process.exit(1); }
    console.log('Score: ' + result.score + '/100 — OK');
    EOF

Express API endpoint

import express from 'express';
import { MailAudit } from '@rlnks/mail-audit';

const app   = express();
const audit = new MailAudit({}, 'en');

app.post('/audit', express.text({ type: 'text/html', limit: '1mb' }), (req, res) => {
  res.json(audit.analyze(req.body));
});

React / Next.js (browser bundle)

import { MailAudit } from '@rlnks/mail-audit';
// Rules must be passed explicitly in browser contexts
import rules from './email-rules.json'; // pre-assembled array of rule objects

export function auditEmail(html: string) {
  return new MailAudit({ rules }).analyze(html);
}

Development

npm install          # install dependencies
npm test             # run vitest test suite (46 tests)
npm run typecheck    # tsc --noEmit (strict mode)
npm run build        # tsup → dist/ (ESM + CJS + .d.ts)
npm run test:watch   # vitest in watch mode

The rules/ directory contains the 56 JSON rule files shared with the PHP package. Any rule file added there is automatically picked up by RuleLoader — no registration needed.


License

MIT — © 2026 rlnks