@postalsys/email-text-tools 2.4.21 → 2.4.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "2.4.21"
2
+ ".": "2.4.23"
3
3
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.4.23](https://github.com/postalsys/email-text-tools/compare/v2.4.22...v2.4.23) (2026-10-18)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * contain the message in a wrapper the sender cannot style, drop SVG, MathML and link openers ([7d79698](https://github.com/postalsys/email-text-tools/commit/7d79698f224c2bd4412d4dcd5d261d17c74022c1))
9
+
10
+ ## [2.4.22](https://github.com/postalsys/email-text-tools/compare/v2.4.21...v2.4.22) (2026-10-18)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * bump @postalsys/email-content to 0.1.15 ([447bf4f](https://github.com/postalsys/email-text-tools/commit/447bf4ff7fad5542bd7e14d5b1996ba93ca6fcfe))
16
+
3
17
  ## [2.4.21](https://github.com/postalsys/email-text-tools/compare/v2.4.20...v2.4.21) (2026-10-16)
4
18
 
5
19
 
package/README.md CHANGED
@@ -11,6 +11,12 @@ Email text tools
11
11
  - CSS rules whose selector uses `:is()`, `:where()`, `:has()` or a `:not()` containing another pseudo-class, and any `<style>` text beyond 256 KB per message
12
12
  - CSS inlining altogether when the style text holds more than 5,000 selectors, the document more than 50,000 elements, or the product of the two exceeds 5,000,000 (inlining costs one selector match per element). The sanitized HTML is then returned with its `<style>` rules dropped and `data-css-inlining="skipped"` on the wrapper `<div>`
13
13
  - a `style` attribute that still names a forbidden property after the CSSOM has filtered it, which means the CSSOM could not parse that declaration: the attribute is reduced to the declarations it did parse, or removed
14
+ - SVG and MathML (DOMPurify runs with the `html` profile only): the namespace confusion between the three is where most sanitizer bypasses have come from, and mail has no use for either
15
+ - `width`, `min-width`, `max-width`, `height`, `min-height` and `max-height` from the body's own style, which would otherwise size the container the host gave the message
16
+
17
+ The output is two wrappers: an outer `<div style="overflow: auto;">` that is never given anything else, and inside it a `<div>` carrying the sanitized styles of the message body. Everything the body does (its margins, a `display: contents`, a box-shadow) is clipped by the outer element, and `position` is forbidden on every element because it is the one property that escapes an overflow clip. Every `a` and `area` gets `target="_blank" rel="noopener noreferrer"`, whatever `rel` it came with.
18
+
19
+ Markup without a `<style>` start tag is sanitized once; the first pass exists to find and filter the stylesheets before they are inlined.
14
20
 
15
21
  Input nested more than about 1000 elements deep, or that the parser cannot process, is rendered as escaped plain text instead of throwing. `inlineHtml()` applies the same depth limit to the quoted message and to the new content, and quotes escaped text instead when it is exceeded.
16
22
 
package/lib/mime-html.js CHANGED
@@ -94,6 +94,23 @@ function decodeCssEscapes(value) {
94
94
  // keep the style at first
95
95
  const FORBID_TAGS_ALL = ['style'].concat(FORBID_TAGS);
96
96
 
97
+ // HTML only. The default profiles admit SVG and MathML too, and the namespace confusion between
98
+ // the three is where most sanitizer bypasses have come from: markup one parser closes inside an
99
+ // <svg> or <mglyph> and another reads as HTML. Mail has no use for either, the major webmail
100
+ // clients strip inline SVG and MathML never reached a mail client, so both go.
101
+ const USE_PROFILES = { html: true };
102
+
103
+ // A start tag the HTML parser reads as a style element: the name followed by whitespace, "/" or
104
+ // ">". Any other character inside the name makes it some other element, and a "<style" at the
105
+ // very end of the input is dropped by the parser, so markup this does not match has no stylesheet.
106
+ const STYLE_TAG = /<style[\s/>]/i;
107
+
108
+ // The dimensions a body may carry. They end up on the element the message is wrapped in, where
109
+ // a width or a height would size the box the host gave the message rather than the message.
110
+ const BODY_FORBID_STYLES = ['width', 'min-width', 'max-width', 'height', 'min-height', 'max-height'];
111
+ const BODY_FORBID_STYLES_HINT = /width|height/i;
112
+ const BODY_FORBID_STYLES_DECLARATION = new RegExp('(?:^|[^a-z0-9-])(?:' + BODY_FORBID_STYLES.join('|') + ')\\s*:', 'i');
113
+
97
114
  // One sanitizer for the module. Every sanitize() call passes its full config, so calls do not
98
115
  // leak settings into each other.
99
116
  const DOMPurify = createDOMPurify(new JSDOM('').window);
@@ -329,8 +346,15 @@ function prepareHtml(message) {
329
346
  throw new Error('HTML is nested too deep to process');
330
347
  }
331
348
 
349
+ if (!STYLE_TAG.test(html)) {
350
+ // No style element can come out of this markup, so there is nothing to inline. This pass
351
+ // exists to find and filter the stylesheets before juice sees them, and without one it
352
+ // would only add a parse and a serialization to the pass finishHtml() makes anyway.
353
+ return { html, hasStyle: false, inliningSkipped: false };
354
+ }
355
+
332
356
  // first pass, returns the <html> element of the fixed HTML page
333
- const root = DOMPurify.sanitize(html, { WHOLE_DOCUMENT: true, FORCE_BODY: false, FORBID_TAGS, FORBID_ATTR, RETURN_DOM: true });
357
+ const root = DOMPurify.sanitize(html, { WHOLE_DOCUMENT: true, FORCE_BODY: false, USE_PROFILES, FORBID_TAGS, FORBID_ATTR, RETURN_DOM: true });
334
358
 
335
359
  // Remove any style tags from within body content
336
360
  const body = root.querySelector('body');
@@ -368,71 +392,84 @@ function prepareHtml(message) {
368
392
  return { html: root.outerHTML, hasStyle, inliningSkipped };
369
393
  }
370
394
 
371
- // Second sanitization pass and post-processing of the juice output.
395
+ // Removes the named style properties from an element the way a browser will read them, by the
396
+ // CSSOM first and then by the attribute text the CSSOM did not parse.
397
+ function removeStyleProperties(node, properties, hint, declaration) {
398
+ if (!hint.test(node.getAttribute('style') || '')) {
399
+ return;
400
+ }
401
+ for (const property of properties) {
402
+ node.style.removeProperty(property);
403
+ }
404
+ // removeProperty() rewrites the attribute from the CSSOM, which only holds the declarations
405
+ // cssstyle managed to parse. A forbidden property still named in the attribute after that was
406
+ // never parsed and would go out verbatim for the browser to read its own way: cssstyle 5 parses
407
+ // nothing from an attribute whose "background: url()" shorthand is followed by more
408
+ // declarations, a browser reads every one of them. Keep only what the CSSOM parsed, which may
409
+ // be nothing
410
+ const remaining = node.getAttribute('style') || '';
411
+ if (declaration.test(remaining)) {
412
+ const parsed = node.style.cssText;
413
+ if (parsed) {
414
+ node.setAttribute('style', parsed);
415
+ } else {
416
+ node.removeAttribute('style');
417
+ }
418
+ } else if (!remaining.trim()) {
419
+ node.removeAttribute('style');
420
+ }
421
+ }
422
+
423
+ // The sanitization pass that produces the output (the second one when there was a stylesheet to
424
+ // inline, the only one otherwise) and the post-processing of its result.
372
425
  function finishHtml(html, inliningSkipped) {
373
- // second pass, outputs a BODY DOM element
426
+ // outputs a BODY DOM element
374
427
  const dom = DOMPurify.sanitize(html, {
375
428
  RETURN_DOM: true,
376
429
  WHOLE_DOCUMENT: false,
377
430
  FORCE_BODY: true,
431
+ USE_PROFILES,
378
432
  FORBID_TAGS: FORBID_TAGS_ALL,
379
433
  FORBID_ATTR
380
434
  });
381
435
 
382
- // mark all links to be opened in a new window
383
- for (let elm of dom.querySelectorAll('a')) {
384
- elm.setAttribute('target', '_blank');
436
+ // Every link opens a new browsing context that gets neither an opener nor a referrer. Browsers
437
+ // imply noopener for target="_blank", but rel="opener" opts back in and the sender writes rel,
438
+ // and an opener can navigate the page the message is shown in. An <area> is a link too.
439
+ for (const link of dom.querySelectorAll('a, area')) {
440
+ link.setAttribute('target', '_blank');
441
+ link.setAttribute('rel', 'noopener noreferrer');
385
442
  }
386
443
 
387
- dom.style.overflow = 'auto';
388
-
389
- // return <BODY> HTML
390
- dom.style.removeProperty('width');
391
- dom.style.removeProperty('min-width');
392
- dom.style.removeProperty('max-width');
393
- dom.style.removeProperty('height');
394
- dom.style.removeProperty('min-height');
395
- dom.style.removeProperty('max-height');
396
-
397
444
  // remove specific style properties, touching the CSSOM only where the style text could name one
398
445
  for (const node of [dom, ...dom.querySelectorAll('[style]')]) {
399
446
  if (!node.style) {
400
447
  continue;
401
448
  }
402
- let styleText = node.getAttribute('style') || '';
449
+ const styleText = node.getAttribute('style') || '';
403
450
  if (styleText.indexOf('\\') >= 0) {
404
451
  // A browser resolves CSS escapes in property names (p\osition is position) but jsdom's
405
- // CSSOM does not, so the check below would miss them. Decode first and write the
452
+ // CSSOM does not, so the checks below would miss them. Decode first and write the
406
453
  // decoded text back so the CSSOM sees the same properties the browser will
407
- styleText = decodeCssEscapes(styleText);
408
- node.setAttribute('style', styleText);
409
- }
410
- if (FORBID_STYLES_HINT.test(styleText)) {
411
- for (let disallowedStyle of FORBID_STYLES) {
412
- node.style.removeProperty(disallowedStyle);
413
- }
414
- // removeProperty() rewrites the attribute from the CSSOM, which only holds the
415
- // declarations cssstyle managed to parse. A forbidden property still named in the
416
- // attribute after that was never parsed and would go out verbatim for the browser to
417
- // read its own way: cssstyle 5 parses nothing from an attribute whose "background:
418
- // url()" shorthand is followed by more declarations, a browser reads every one of
419
- // them. Keep only what the CSSOM parsed, which may be nothing
420
- if (FORBID_STYLES_DECLARATION.test(node.getAttribute('style') || '')) {
421
- const parsed = node.style.cssText;
422
- if (parsed) {
423
- node.setAttribute('style', parsed);
424
- } else {
425
- node.removeAttribute('style');
426
- }
427
- }
454
+ node.setAttribute('style', decodeCssEscapes(styleText));
428
455
  }
456
+ removeStyleProperties(node, FORBID_STYLES, FORBID_STYLES_HINT, FORBID_STYLES_DECLARATION);
429
457
  }
430
-
431
- let bodyStyles = (dom.getAttribute('style') || '').toString().trim();
432
-
433
- // embed into a styled container; the attribute tells the caller the CSS inlining caps were hit
458
+ removeStyleProperties(dom, BODY_FORBID_STYLES, BODY_FORBID_STYLES_HINT, BODY_FORBID_STYLES_DECLARATION);
459
+
460
+ const bodyStyles = (dom.getAttribute('style') || '').toString().trim();
461
+
462
+ // Two wrappers. The outer one is ours alone and clips everything inside it; the inner one is
463
+ // the message body with the styles it came with. While the body's styles sat on the clipping
464
+ // element, a sender could undo the clipping (display: contents or inline, an overflow-x:
465
+ // visible written after our overflow) or move the element itself over the page around it (a
466
+ // negative margin, a box-shadow or an outline a few thousand pixels wide). Position is the
467
+ // one property that escapes an overflow clip, and it is forbidden above; whatever else a
468
+ // descendant does stays inside the outer box. The attribute tells the caller the CSS inlining
469
+ // caps were hit
434
470
  const marker = inliningSkipped ? ' data-css-inlining="skipped"' : '';
435
- return `<div style="${he.encode(bodyStyles)}"${marker}>${dom.innerHTML.trim()}</div>`;
471
+ const body = bodyStyles ? `<div style="${he.encode(bodyStyles)}">` : '<div>';
472
+ return `<div style="overflow: auto;"${marker}>${body}${dom.innerHTML.trim()}</div></div>`;
436
473
  }
437
474
 
438
475
  // Runs the pipeline and renders the plain text fallback when it fails, for example when a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@postalsys/email-text-tools",
3
- "version": "2.4.21",
3
+ "version": "2.4.23",
4
4
  "description": "Email text tools",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -29,12 +29,12 @@
29
29
  "homepage": "https://github.com/postalsys/email-text-tools#readme",
30
30
  "devDependencies": {
31
31
  "@eslint/js": "10.0.1",
32
- "eslint": "10.12.0",
32
+ "eslint": "10.13.0",
33
33
  "eslint-config-prettier": "10.1.8",
34
34
  "prettier": "3.9.10"
35
35
  },
36
36
  "dependencies": {
37
- "@postalsys/email-content": "0.1.14",
37
+ "@postalsys/email-content": "0.1.15",
38
38
  "dompurify": "3.4.17",
39
39
  "he": "1.2.0",
40
40
  "html-to-text": "10.0.1",
@@ -0,0 +1,130 @@
1
+ 'use strict';
2
+
3
+ const { describe, it } = require('node:test');
4
+ const assert = require('node:assert');
5
+ const { mimeHtml } = require('..');
6
+
7
+ // The message is shown inside a page that is not the sender's. These tests pin what keeps it there:
8
+ // a clipping wrapper the sender cannot style, links that cannot reach back, no foreign content.
9
+
10
+ describe('container', () => {
11
+ it('wraps a message with no body styles in a bare inner element', () => {
12
+ assert.equal(mimeHtml({ html: '<p>x</p>' }), '<div style="overflow: auto;"><div><p>x</p></div></div>');
13
+ });
14
+
15
+ it('keeps the body styles off the clipping wrapper', () => {
16
+ const html =
17
+ '<html><body style="display: contents; margin: -500px; overflow: visible; overflow-x: visible; box-shadow: 0 0 0 9999px red; outline: 9999px solid red; color: red">' +
18
+ '<div style="transform: translateY(-300px)">x</div></body></html>';
19
+ const out = mimeHtml({ html });
20
+ assert.ok(out.startsWith('<div style="overflow: auto;"><div style="'), out);
21
+ assert.ok(out.endsWith('</div></div>'), out);
22
+ const inner = /^<div style="overflow: auto;"><div style="([^"]*)">/.exec(out)[1];
23
+ assert.match(inner, /display: contents/);
24
+ assert.match(inner, /margin: -500px/);
25
+ assert.match(inner, /color: red/);
26
+ assert.match(out, /transform: translateY\(-300px\)/);
27
+ });
28
+
29
+ it('puts a body rule from the stylesheet on the inner element too', () => {
30
+ const html = '<html><head><style>body { display: contents; margin: -200px; } p { color: red; }</style></head><body><p>x</p></body></html>';
31
+ const out = mimeHtml({ html });
32
+ assert.ok(out.startsWith('<div style="overflow: auto;"><div style="'), out);
33
+ assert.match(out, /display: contents/);
34
+ assert.match(out, /color: red/);
35
+ });
36
+
37
+ it('drops the dimensions of the body and keeps its other styles', () => {
38
+ const out = mimeHtml({
39
+ html: '<html><body style="width: 600px; min-height: 100vh; max-width: 50%; background-color: #fff; color: red">x</body></html>'
40
+ });
41
+ assert.doesNotMatch(out, /width|height/);
42
+ assert.match(out, /background-color: (?:#fff|rgb\(255, 255, 255\))/);
43
+ assert.match(out, /color: red/);
44
+ });
45
+
46
+ it('drops a body width the CSSOM could not parse', () => {
47
+ // cssstyle parses nothing from this attribute, a browser reads the width
48
+ const out = mimeHtml({ html: '<html><body style="background:url(https://example.com/x.gif) no-repeat; width: 5000px">x</body></html>' });
49
+ assert.doesNotMatch(out, /width/);
50
+ assert.equal(out, '<div style="overflow: auto;"><div>x</div></div>');
51
+ });
52
+
53
+ it('keeps body styles the CSSOM could not parse when none of them is forbidden', () => {
54
+ // the old wrapper lost every body style here, because setting its overflow rewrote the
55
+ // attribute from a CSSOM that held nothing
56
+ const out = mimeHtml({ html: '<html><body style="background:url(https://example.com/x.gif) no-repeat; color:red; font:12px serif">x</body></html>' });
57
+ assert.ok(out.includes('<div style="background:url(https://example.com/x.gif) no-repeat; color:red; font:12px serif">x</div>'), out);
58
+ });
59
+
60
+ it('still removes position everywhere, including the body', () => {
61
+ const out = mimeHtml({ html: '<html><body style="position: fixed; inset: 0; color: red"><div style="position: sticky; top: 0">x</div></body></html>' });
62
+ assert.doesNotMatch(out, /position/);
63
+ assert.match(out, /color: red/);
64
+ assert.match(out, /top: 0/);
65
+ });
66
+
67
+ it('marks skipped inlining on the outer wrapper', () => {
68
+ let css = '';
69
+ for (let i = 0; i <= 5000; i++) {
70
+ css += `.c${i} { color: red; }\n`;
71
+ }
72
+ const out = mimeHtml({ html: `<html><head><style>${css}</style></head><body style="color: blue"><p class="c1">x</p></body></html>` });
73
+ assert.ok(out.startsWith('<div style="overflow: auto;" data-css-inlining="skipped"><div style="color: blue">'), out.slice(0, 120));
74
+ });
75
+ });
76
+
77
+ describe('links', () => {
78
+ it('opens every link in a new context that gets no opener and no referrer', () => {
79
+ const out = mimeHtml({ html: '<a href="https://example.com/" rel="opener">x</a><a href="https://example.com/" target="_top" rel="nofollow">y</a>' });
80
+ assert.equal((out.match(/<a /g) || []).length, 2);
81
+ assert.doesNotMatch(out, /rel="opener"|rel="nofollow"|_top/);
82
+ assert.equal((out.match(/target="_blank"/g) || []).length, 2);
83
+ assert.equal((out.match(/rel="noopener noreferrer"/g) || []).length, 2);
84
+ });
85
+
86
+ it('treats an image map area as a link', () => {
87
+ const out = mimeHtml({
88
+ html: '<img src="https://example.com/i.png" usemap="#m"><map name="m"><area shape="rect" coords="0,0,9,9" href="https://example.com/"></map>'
89
+ });
90
+ assert.match(out, /<area [^>]*target="_blank"/);
91
+ assert.match(out, /<area [^>]*rel="noopener noreferrer"/);
92
+ });
93
+ });
94
+
95
+ describe('foreign content', () => {
96
+ it('removes SVG and MathML', () => {
97
+ const out = mimeHtml({
98
+ html:
99
+ '<p>a</p><svg width="20" height="20"><circle cx="10" cy="10" r="8"/></svg><math><mi>x</mi></math>' +
100
+ '<math><mtext><table><mglyph><style><!--</style><img title="--><img src=1 onerror=alert(1)>"><p>b</p>'
101
+ });
102
+ assert.doesNotMatch(out, /<svg|<math|<circle|<mi|onerror/);
103
+ assert.ok(out.includes('<p>a</p>'), out);
104
+ });
105
+ });
106
+
107
+ describe('single sanitization pass for markup without a stylesheet', () => {
108
+ it('sanitizes the same way', () => {
109
+ const out = mimeHtml({
110
+ html: '<p onclick="x()">a</p><script>alert(1)</script><a href="javascript:alert(1)">b</a><iframe src="https://example.com/"></iframe>'
111
+ });
112
+ assert.equal(out, '<div style="overflow: auto;"><div><p>a</p><a target="_blank" rel="noopener noreferrer">b</a></div></div>');
113
+ });
114
+
115
+ for (const [label, tag, close] of [
116
+ ['upper case', '<STYLE>', '</STYLE>'],
117
+ ['whitespace after the name', '<style\n>', '</style>'],
118
+ ['a self-closing slash', '<style/>', '</style>']
119
+ ]) {
120
+ it(`still inlines a stylesheet whose tag uses ${label}`, () => {
121
+ const out = mimeHtml({ html: `<html><head>${tag}p { color: red; }${close}</head><body><p>x</p></body></html>` });
122
+ assert.match(out, /color: red/, out);
123
+ });
124
+ }
125
+
126
+ it('leaves a style tag that is only text alone', () => {
127
+ const out = mimeHtml({ html: '<p>write &lt;style&gt; to add rules</p>' });
128
+ assert.equal(out, '<div style="overflow: auto;"><div><p>write &lt;style&gt; to add rules</p></div></div>');
129
+ });
130
+ });
@@ -298,7 +298,7 @@ describe('hostile input hardening', () => {
298
298
  // rules nested in a media query are not matched against elements and do not count
299
299
  const css = `${rules(2000)}p.x { color: blue; }\n@media screen { ${rules(4000)} }`;
300
300
  const result = mimeHtml({ html: styled(css, '<p class="x">y</p>'.repeat(2000)) });
301
- assert.ok(result.startsWith('<div style="overflow: auto;"><p'), result.slice(0, 80));
301
+ assert.ok(result.startsWith('<div style="overflow: auto;"><div><p'), result.slice(0, 80));
302
302
  assert.match(result, /color: blue/);
303
303
  });
304
304
 
@@ -451,7 +451,7 @@ describe('hostile input hardening', () => {
451
451
  assert.equal(afterText, false);
452
452
  assert.equal(afterMime, true);
453
453
  assert.equal(asyncType, 'function');
454
- assert.equal(out, '<div style="overflow: auto;"><p>x</p></div>');
454
+ assert.equal(out, '<div style="overflow: auto;"><div><p>x</p></div></div>');
455
455
  });
456
456
 
457
457
  it('keeps mimeHtml enumerable and destructurable', () => {