Skip to Content
  • Website
Codsen
  • Home
  • Open Source
  • Articles
  • About

prevOpen Source→email-combnext

email-comb7.4.3

Remove unused CSS from email templates

Downloads per monthChangelogMIT LicensePlayground
  • the top
  • Installation
  • Quick Take
  • Examples
  • API — COMB()
  • CSS ESCAPES…
  • HTML CLASS AN…
  • API — DEFAULTS
  • API — VERSION
  • API — TYPES
  • OPTS — WHITELIST
  • OPTS — UGLIFY
  • OPTS — BACKEND
  • USE WITH GULP
  • Changelog

No 3rd party dependencies. All dependencies and devDependencies, checked recursively, are Codsen packages.

Permalink to InstallationInstallation

Permalink to Quick TakeQuick Take

Permalink to ExamplesExamples

  • Keep classes inside backend template markers
  • Keep HTML comments containing a given string
  • Match decoded HTML class values
  • CSS escapes identify the same class names as their decoded HTML values.
  • Keep CSS comments
  • Keep HTML comments
  • Keep id's referenced by for attributes
  • Minifies and uglifies
  • Preserve CSS comment boundaries
  • Unchanged short names keep the escaping required by their output context.
  • Report progress within a custom range
  • Remove media queries left empty
  • Returns all extracted and deleted classes and id's
  • Works even if <style> is within <body> and there's no <head>
  • Uglify class and ID selectors with the convenience API
  • Whitelist a selector from removal
  • Whitelist using wildcards
Open email-comb playground

API — comb()

comb() removes unused CSS from an email template. Pass the template as a string and, optionally, an options object:

function comb(str: string, opts?: InputOpts | null): Res;

InputOpts has the following shape. Every property is optional:

interface InputOpts {
  whitelist?: string | string[];
  backend?: HeadsAndTailsObj[];
  uglify?: boolean | 0 | 1;
  removeHTMLComments?: boolean;
  removeCSSComments?: boolean;
  doNotRemoveHTMLCommentsWhoseOpeningTagContains?: string | string[];
  htmlCrushOpts?: Partial<HtmlCrushOpts>;
  reportProgressFunc?: null | false | 0 | ((percentage: number) => void);
  reportProgressFuncFrom?: number;
  reportProgressFuncTo?: number;
}
KeyInput typeDefaultDescription
whitelist
Default: []
whiteliststring | string[][]Preserves matching classes, IDs, or complete selector chunks. Supports wildcard patterns such as .module-*.
backend
Default: []
backendHeadsAndTailsObj[][]Protects template-language regions inside attribute values. Each entry defines an opening heads marker and a closing tails marker.
uglify
Default: false
uglifyboolean | 0 | 1falseRenames retained class and ID selectors to shorter names and reports the mapping in log.uglified.
removeHTMLComments
Default: true
removeHTMLCommentsbooleantrueRemoves HTML comments, except comments protected by doNotRemoveHTMLCommentsWhoseOpeningTagContains.
removeCSSComments
Default: true
removeCSSCommentsbooleantrueRemoves CSS comments while retaining a minimal /**/ where deletion would change token boundaries.
doNotRemoveHTMLCommentsWhoseOpeningTagContains
Default: ["[if", "[endif"]
doNotRemoveHTMLCommentsWhoseOpeningTagContainsstring | string[]["[if", "[endif"]Preserves comments whose opening section contains any listed case-insensitive fragment. The defaults preserve common Outlook and IE conditional comments.
htmlCrushOpts
Default: See below
htmlCrushOptsPartial<HtmlCrushOpts>See belowOverrides the final minification pass. See the html-crush options.
reportProgressFunc
Default: null
reportProgressFuncFunction or null, false, or 0nullReceives integer progress values for sufficiently large inputs. Short operations can finish without calling it.
reportProgressFuncFrom
Default: 0
reportProgressFuncFromFinite number0Sets the beginning of the reported progress range.
reportProgressFuncTo
Default: 100
reportProgressFuncToFinite number100Sets the end of the reported progress range. It must be greater than or equal to reportProgressFuncFrom.

Here are all defaults in one place for copying:

The function returns a plain Res object:

The result can be serialized as JSON or sent through postMessage(). The result property contains the transformed HTML. The selector arrays describe what the package found and removed, while log contains size, traversal, comment, timing, and optional uglification details. Treat log.timeTakenInMilliseconds as a best-effort measurement rather than a stable value for exact comparisons.

CSS escapes and source boundaries

comb() compares decoded CSS class and ID names with the names in HTML. CSS escapes can contain punctuation that would otherwise separate selectors or rules. For example, .a\,b names one class, a,b, and @m\65 dia is an escaped spelling of @media:

import { comb } from "email-comb";

const result = comb(
  String.raw`<style>@m\65 dia all{.a\,b,.unused{color:red}}</style><body><div class="a,b">x</div></body>`,
);

console.log(result.result);
// => String.raw`<style>@m\65 dia all{.a\,b{color:red}}</style><body><div class="a,b">x</div></body>`

console.log(result.allInHead);
// => [".a,b", ".unused"]

The scanner respects complete CSS escapes, strings, URL tokens, attribute selectors, and nested parentheses when finding rule boundaries. Quoted class and ID values in attribute selectors follow CSS string rules, including backslash-newline continuations. Punctuation and comment markers inside strings or unquoted URLs remain data. Cleanup of empty wrapping at-rules uses those same boundaries.

HTML establishes each CSS region. A literal closing style tag ends style-element text, and the literal HTML attribute quote ends an inline style even when its CSS contains an unfinished string or comment. Unfinished inline comments therefore cannot delete intervening HTML. Inline styles containing character references are preserved conservatively during this scanner pass, including their comments. Setting htmlCrushOpts.removeCSSComments to true enables the final minifier’s decoded inline comment handling.

CSS comment removal preserves token boundaries. Ordinary comments at block or declaration punctuation are removed; unsafe adjacent identifiers, numeric values, function names, and selector fragments retain a minimal /**/. Existing descendant whitespace remains separate from CSS escape terminators, including during the final minification pass:

import { comb } from "email-comb";

const html = String.raw`<style>.a\31/* note */ a{color:red}</style><body><div class="a1"><a>x</a></div></body>`;
console.log(comb(html).result === html.replace("/* note */", "/**/"));
// => true

log.commentsLength counts removed comment source characters, excluding a retained minimal marker. With removeCSSComments: false, comments stay opaque during selector analysis in both passes.

HTML class and ID values

For ordinary HTML attributes, comb() decodes character references once before comparing names with CSS. A class list splits on HTML ASCII whitespace: space, tab, line feed, form feed, and carriage return. Encoded separators such as &#32; therefore separate classes too:

import { comb } from "email-comb";

const html = '<style>.foo{color:red}.bar{color:blue}</style><body><div class="foo&#32;bar">x</div></body>';
const result = comb(html);

console.log(result.allInBody);
// => [".bar", ".foo"]
console.log(result.result === html);
// => true

Other whitespace, including a nonbreaking space, remains part of the class name. Backslashes in HTML values are literal; CSS escape decoding applies to CSS selectors. A value such as foo&amp;#32;bar decodes to the single class foo&#32;bar, without a second decoding pass.

An ID is one complete value, including any whitespace. For example, id="first second" does not match either #first or #second; an exact selector such as [id="first second"] names that whole value. A label’s for attribute references one complete ID, while an output element’s for attribute contains a whitespace-separated ID list. Matching referenced IDs remain protected from removal and uglification.

Kept names retain their original character-reference spelling when they are unchanged. Deletion and replacement use original source spans, so removing one class cannot split a character reference or join its neighboring classes. Configured template regions continue to use the backend handling described below.

API — defaults

You can import defaults:

It's a plain object:

The main function calculates the options to be used by merging the options you passed with these defaults.

API — version

You can import version:

API — types

This package is written in TypeScript and exports the following types:

TypeDescription
HeadsAndTailsObj
Type: HeadsAndTailsObj
HeadsAndTailsObjOne template-marker pair for opts.backend: its opening heads value and closing tails value.
Opts
Type: Opts
OptsThe fully resolved options after the input has been merged with the defaults.
InputOpts
Type: InputOpts
InputOptsThe shape that comb() accepts. Every property is optional, and some properties accept a scalar or an array.
Res
Type: Res
ResThe transformed result, selector counts and reports, and the completion log.
import type { HeadsAndTailsObj, InputOpts, Opts, Res } from "email-comb";

opts.whitelist

Email CSS often contains client-specific fixes that have no matching class or ID in the message body. For example:

#outlook a {
  padding: 0;
}
.ReadMsgBody {
  width: 100%;
}

Without a whitelist, these selectors look unused because they appear only in the <head>. Preserve them by passing their selectors to whitelist:

var html = "<!DOCTYPE html>...";
comb(html, {
  whitelist: ["#outlook", ".ExternalClass", ".ReadMsgBody"],
});

You can also use wildcard patterns. For example, preserve every module class whose name starts with module-:

var html = "<!DOCTYPE html>...";
comb(html, {
  whitelist: [".module-*"],
});

opts.uglify

Set uglify: true to shorten retained class and ID names. The generated replacements are valid in CSS identifiers, quoted CSS attribute-selector values, and quoted or unquoted HTML attribute values.

When the name stays the same, comb() keeps its existing source spelling. This preserves CSS escapes and HTML character references that are required by their context. For example, the short class name 1 must remain escaped in an ordinary CSS class selector:

import { comb } from "email-comb";

const html = String.raw`<style>.\31{color:red}</style><body><div class="1">x</div></body>`;
const result = comb(html, { uglify: true });

console.log(result.result === html);
// => true

console.log(result.log.uglified);
// => [[".1", ".1"]]

The mapping can include unchanged names. A mapping entry records the canonical name before and after uglification; it does not mean the source text was rewritten.

opts.backend

Email templates often contain template-language expressions, such as this Jinja or Nunjucks value:

<td class="mt10 {{ module.on }} module-box blackbg">

Define each template region with an opening heads marker and a closing tails marker. For example, the Mailchimp expression *|tralala|* uses *| as its head and |* as its tail.

The following example protects Jinja and Nunjucks markers:

import { comb } from "email-comb";

const res = comb(
  `<!doctype html>
<html>
<head>
<style>
.aaa {
color:  black;
}
</style></head>
<body class="{% var1 %}">
<div class="{{ var2 }}">
</div>
</body>
</html>
`,
  {
    backend: [
      {
        heads: "{{",
        tails: "}}",
      },
      {
        heads: "{%",
        tails: "%}",
      },
    ],
  },
).result;

console.log("res =\n" + res);

Template expressions can also contain branches. For example:

<td class="db{% if module_on || oodles %}on{% else %}off{% endif %} pt10"></td>

db and pt10 are CSS class names. Everything between {% and %} is Nunjucks code.

For reliable selector analysis, move the branch outside the HTML attribute:

{% set switch = 'off' %}
{% if module_on || oodles %}
  {% set switch = 'on' %}
{% endif %}
<td class="db {{ switch }} pt10"></td>

The attribute then contains one protected template expression, which opts.backend can recognize safely.

Use with Gulp

Gulp files commonly expose their contents as buffers. With gulp-tapopens in a new tab, convert each buffer to a string, run comb(), and replace the buffer with the transformed result:

import gulp from "gulp";
import tap from "gulp-tap";
import { comb } from "email-comb";

const whitelist = [
  ".External*",
  ".ReadMsgBody",
  ".yshortcuts",
  ".Mso*",
  "#outlook",
  ".module*",
];

gulp.task("build", () => {
  return gulp.src("emails/*.html").pipe(
    tap((file) => {
      if (!file.isBuffer()) return;

      const res = comb(file.contents.toString(), {
        whitelist,
      });

      console.log(
        `Removed ${res.deletedFromHead.length} selectors from the head:`,
        res.deletedFromHead.join(" "),
      );
      console.log(
        `Removed ${res.deletedFromBody.length} selectors from the body:`,
        res.deletedFromBody.join(" "),
      );
      file.contents = Buffer.from(res.result);
    }),
  );
});

Permalink to changelogChangelog

Open Changelog
↑ back to top
prev next

Copyright

All rights reserved © Roy Revelt 2026
All our open source packages are under MIT licenceopens in a new tab

Activities

🐛 See a bug? Raise an issueopens in a new tab
💘 Check out the Indiewebopens in a new tab and Libera manifestoopens in a new tab