@postalsys/email-text-tools 2.4.23 → 2.4.24

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.23"
2
+ ".": "2.4.24"
3
3
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.4.24](https://github.com/postalsys/email-text-tools/compare/v2.4.23...v2.4.24) (2026-10-18)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * contain painting in the outer wrapper too ([d1a8f9b](https://github.com/postalsys/email-text-tools/commit/d1a8f9bf2dd4fcad7adb983c243c524703cad79f))
9
+
3
10
  ## [2.4.23](https://github.com/postalsys/email-text-tools/compare/v2.4.22...v2.4.23) (2026-10-18)
4
11
 
5
12
 
package/README.md CHANGED
@@ -14,7 +14,7 @@ Email text tools
14
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
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
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.
17
+ The output is two wrappers: an outer `<div style="overflow: auto; contain: paint;">` 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. `position` is forbidden on every element because it is the one property that escapes an overflow clip, and the paint containment makes the outer element the containing block of a fixed or absolute descendant in case one slips through. Every `a` and `area` gets `target="_blank" rel="noopener noreferrer"`, whatever `rel` it came with.
18
18
 
19
19
  Markup without a `<style>` start tag is sanitized once; the first pass exists to find and filter the stylesheets before they are inlined.
20
20
 
package/lib/mime-html.js CHANGED
@@ -72,12 +72,14 @@ const FORBID_STYLES = [
72
72
  'zoom'
73
73
  ];
74
74
 
75
- // Matches the style text of any element that could carry one of FORBID_STYLES
76
- const FORBID_STYLES_HINT = /position|float|clip|zoom|anim|offset|scrollbar|paint|clear|\ball\b/i;
75
+ // Properties to remove from style attributes: the list, a cheap test for style text that could
76
+ // name one of them, and a test for one of them in declaration position (at the start, or after any
77
+ // character that cannot be part of a longer property name, so "background-position" is not one)
78
+ function styleRule(properties, hint) {
79
+ return { properties, hint, declaration: new RegExp('(?:^|[^a-z0-9-])(?:' + properties.join('|') + ')\\s*:', 'i') };
80
+ }
77
81
 
78
- // A forbidden property in declaration position: at the start, or after any character that cannot
79
- // be part of a longer property name, so "background-position" is not one
80
- const FORBID_STYLES_DECLARATION = new RegExp('(?:^|[^a-z0-9-])(?:' + FORBID_STYLES.join('|') + ')\\s*:', 'i');
82
+ const FORBID_STYLES_RULE = styleRule(FORBID_STYLES, /position|float|clip|zoom|anim|offset|scrollbar|paint|clear|\ball\b/i);
81
83
 
82
84
  // Resolves CSS escape sequences (\6F, \o) the way a browser does before it matches a property
83
85
  // name, so that a forbidden property cannot hide behind an escaped character
@@ -105,11 +107,10 @@ const USE_PROFILES = { html: true };
105
107
  // very end of the input is dropped by the parser, so markup this does not match has no stylesheet.
106
108
  const STYLE_TAG = /<style[\s/>]/i;
107
109
 
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');
110
+ // The dimensions of the body. In mail they are page-layout leftovers ("width: 100%; height: 100%",
111
+ // "min-height: 100vh") that would only pad or scroll the box the message is shown in, and they
112
+ // were removed when the body was the outermost element, which consumers may rely on.
113
+ const BODY_STYLES_RULE = styleRule(['width', 'min-width', 'max-width', 'height', 'min-height', 'max-height'], /width|height/i);
113
114
 
114
115
  // One sanitizer for the module. Every sanitize() call passes its full config, so calls do not
115
116
  // leak settings into each other.
@@ -392,13 +393,13 @@ function prepareHtml(message) {
392
393
  return { html: root.outerHTML, hasStyle, inliningSkipped };
393
394
  }
394
395
 
395
- // Removes the named style properties from an element the way a browser will read them, by the
396
+ // Removes the properties of a style rule from an element the way a browser will read them, by the
396
397
  // 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') || '')) {
398
+ function removeStyleProperties(node, rule) {
399
+ if (!rule.hint.test(node.getAttribute('style') || '')) {
399
400
  return;
400
401
  }
401
- for (const property of properties) {
402
+ for (const property of rule.properties) {
402
403
  node.style.removeProperty(property);
403
404
  }
404
405
  // removeProperty() rewrites the attribute from the CSSOM, which only holds the declarations
@@ -408,7 +409,7 @@ function removeStyleProperties(node, properties, hint, declaration) {
408
409
  // declarations, a browser reads every one of them. Keep only what the CSSOM parsed, which may
409
410
  // be nothing
410
411
  const remaining = node.getAttribute('style') || '';
411
- if (declaration.test(remaining)) {
412
+ if (rule.declaration.test(remaining)) {
412
413
  const parsed = node.style.cssText;
413
414
  if (parsed) {
414
415
  node.setAttribute('style', parsed);
@@ -453,23 +454,24 @@ function finishHtml(html, inliningSkipped) {
453
454
  // decoded text back so the CSSOM sees the same properties the browser will
454
455
  node.setAttribute('style', decodeCssEscapes(styleText));
455
456
  }
456
- removeStyleProperties(node, FORBID_STYLES, FORBID_STYLES_HINT, FORBID_STYLES_DECLARATION);
457
+ removeStyleProperties(node, FORBID_STYLES_RULE);
457
458
  }
458
- removeStyleProperties(dom, BODY_FORBID_STYLES, BODY_FORBID_STYLES_HINT, BODY_FORBID_STYLES_DECLARATION);
459
+ removeStyleProperties(dom, BODY_STYLES_RULE);
459
460
 
460
- const bodyStyles = (dom.getAttribute('style') || '').toString().trim();
461
+ const bodyStyles = (dom.getAttribute('style') || '').trim();
461
462
 
462
463
  // Two wrappers. The outer one is ours alone and clips everything inside it; the inner one is
463
464
  // the message body with the styles it came with. While the body's styles sat on the clipping
464
465
  // element, a sender could undo the clipping (display: contents or inline, an overflow-x:
465
466
  // visible written after our overflow) or move the element itself over the page around it (a
466
467
  // 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
468
+ // one property that escapes an overflow clip, and it is forbidden above; paint containment
469
+ // makes the outer box the containing block of a fixed or absolute descendant as well, so a
470
+ // position that slipped past the filter would still be clipped by it. The attribute tells
471
+ // the caller the CSS inlining caps were hit
470
472
  const marker = inliningSkipped ? ' data-css-inlining="skipped"' : '';
471
473
  const body = bodyStyles ? `<div style="${he.encode(bodyStyles)}">` : '<div>';
472
- return `<div style="overflow: auto;"${marker}>${body}${dom.innerHTML.trim()}</div></div>`;
474
+ return `<div style="overflow: auto; contain: paint;"${marker}>${body}${dom.innerHTML.trim()}</div></div>`;
473
475
  }
474
476
 
475
477
  // 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.23",
3
+ "version": "2.4.24",
4
4
  "description": "Email text tools",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -9,7 +9,7 @@ const { mimeHtml } = require('..');
9
9
 
10
10
  describe('container', () => {
11
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>');
12
+ assert.equal(mimeHtml({ html: '<p>x</p>' }), '<div style="overflow: auto; contain: paint;"><div><p>x</p></div></div>');
13
13
  });
14
14
 
15
15
  it('keeps the body styles off the clipping wrapper', () => {
@@ -17,9 +17,8 @@ describe('container', () => {
17
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
18
  '<div style="transform: translateY(-300px)">x</div></body></html>';
19
19
  const out = mimeHtml({ html });
20
- assert.ok(out.startsWith('<div style="overflow: auto;"><div style="'), out);
21
20
  assert.ok(out.endsWith('</div></div>'), out);
22
- const inner = /^<div style="overflow: auto;"><div style="([^"]*)">/.exec(out)[1];
21
+ const inner = /^<div style="overflow: auto; contain: paint;"><div style="([^"]*)">/.exec(out)[1];
23
22
  assert.match(inner, /display: contents/);
24
23
  assert.match(inner, /margin: -500px/);
25
24
  assert.match(inner, /color: red/);
@@ -29,7 +28,7 @@ describe('container', () => {
29
28
  it('puts a body rule from the stylesheet on the inner element too', () => {
30
29
  const html = '<html><head><style>body { display: contents; margin: -200px; } p { color: red; }</style></head><body><p>x</p></body></html>';
31
30
  const out = mimeHtml({ html });
32
- assert.ok(out.startsWith('<div style="overflow: auto;"><div style="'), out);
31
+ assert.ok(out.startsWith('<div style="overflow: auto; contain: paint;"><div style="'), out);
33
32
  assert.match(out, /display: contents/);
34
33
  assert.match(out, /color: red/);
35
34
  });
@@ -46,8 +45,7 @@ describe('container', () => {
46
45
  it('drops a body width the CSSOM could not parse', () => {
47
46
  // cssstyle parses nothing from this attribute, a browser reads the width
48
47
  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>');
48
+ assert.equal(out, '<div style="overflow: auto; contain: paint;"><div>x</div></div>');
51
49
  });
52
50
 
53
51
  it('keeps body styles the CSSOM could not parse when none of them is forbidden', () => {
@@ -70,7 +68,7 @@ describe('container', () => {
70
68
  css += `.c${i} { color: red; }\n`;
71
69
  }
72
70
  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));
71
+ assert.ok(out.startsWith('<div style="overflow: auto; contain: paint;" data-css-inlining="skipped"><div style="color: blue">'), out.slice(0, 120));
74
72
  });
75
73
  });
76
74
 
@@ -109,7 +107,7 @@ describe('single sanitization pass for markup without a stylesheet', () => {
109
107
  const out = mimeHtml({
110
108
  html: '<p onclick="x()">a</p><script>alert(1)</script><a href="javascript:alert(1)">b</a><iframe src="https://example.com/"></iframe>'
111
109
  });
112
- assert.equal(out, '<div style="overflow: auto;"><div><p>a</p><a target="_blank" rel="noopener noreferrer">b</a></div></div>');
110
+ assert.equal(out, '<div style="overflow: auto; contain: paint;"><div><p>a</p><a target="_blank" rel="noopener noreferrer">b</a></div></div>');
113
111
  });
114
112
 
115
113
  for (const [label, tag, close] of [
@@ -125,6 +123,6 @@ describe('single sanitization pass for markup without a stylesheet', () => {
125
123
 
126
124
  it('leaves a style tag that is only text alone', () => {
127
125
  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>');
126
+ assert.equal(out, '<div style="overflow: auto; contain: paint;"><div><p>write &lt;style&gt; to add rules</p></div></div>');
129
127
  });
130
128
  });
@@ -257,7 +257,7 @@ describe('hostile input hardening', () => {
257
257
  });
258
258
 
259
259
  describe('CSS inlining caps', () => {
260
- const SKIPPED_PREFIX = '<div style="overflow: auto;" data-css-inlining="skipped">';
260
+ const SKIPPED_PREFIX = '<div style="overflow: auto; contain: paint;" data-css-inlining="skipped">';
261
261
 
262
262
  function styled(css, body) {
263
263
  return `<html><head><style>${css}</style></head><body>${body}</body></html>`;
@@ -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;"><div><p'), result.slice(0, 80));
301
+ assert.ok(result.startsWith('<div style="overflow: auto; contain: paint;"><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;"><div><p>x</p></div></div>');
454
+ assert.equal(out, '<div style="overflow: auto; contain: paint;"><div><p>x</p></div></div>');
455
455
  });
456
456
 
457
457
  it('keeps mimeHtml enumerable and destructurable', () => {