@postalsys/email-text-tools 2.4.19 → 2.4.20

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.19"
2
+ ".": "2.4.20"
3
3
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.4.20](https://github.com/postalsys/email-text-tools/compare/v2.4.19...v2.4.20) (2026-10-16)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * bound CSS inlining, guard inlineHtml like mimeHtml and drop style attributes the CSSOM could not parse ([13b2725](https://github.com/postalsys/email-text-tools/commit/13b27254ae31a2aeab56a3edd7dd84737fa016cd))
9
+ * bump @postalsys/email-content to 0.1.13 ([1a8738d](https://github.com/postalsys/email-text-tools/commit/1a8738da39f1adc404f871ab63c8dda5d3733dd2))
10
+
3
11
  ## [2.4.19](https://github.com/postalsys/email-text-tools/compare/v2.4.18...v2.4.19) (2026-10-14)
4
12
 
5
13
 
package/README.md CHANGED
@@ -9,7 +9,9 @@ Email text tools
9
9
  - elements: `title`, `link`, `meta`, `base`, `basefont`, `frame`, `iframe`, `frameset`, `template`, `script`, `noframes`, `noscript`, `object`, `embed`, `dialog`, `canvas`, `audio`, `video`, `applet`, and forms with their controls (`form`, `input`, `button`, `select`, `textarea`; their text content is kept)
10
10
  - attributes: `action`, `formaction`, `popover`, `popovertarget`
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
+ - 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
+ - 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
12
14
 
13
- Input nested more than about 1000 elements deep, or that the parser cannot process, is rendered as escaped plain text instead of throwing.
15
+ 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.
14
16
 
15
17
  Reply/forward date headers from `inlineHtml()` and `inlineText()` are formatted with `Intl.DateTimeFormat` (for example `Sat, Jun 15, 2024, 2:30 PM`). An unsupported locale falls back to English and an unknown time zone to UTC. A Node build without full ICU data only has English.
@@ -0,0 +1,127 @@
1
+ 'use strict';
2
+
3
+ const he = require('he');
4
+
5
+ // jsdom builds a tree in time quadratic to its depth (10,000 unclosed <b> tags take about 9 s) and
6
+ // its serializer overflows the stack a few thousand levels down; node-html-parser spends cubic time
7
+ // on the elements left open at the end of a document. Deeper input is not parsed at all. Real
8
+ // email nests a few dozen levels.
9
+ const MAX_NESTING_DEPTH = 1000;
10
+
11
+ // Elements that never stay open in HTML content, either void or closed implicitly by a sibling,
12
+ // so they do not add to the depth an HTML parser ends up with. Inside SVG and MathML they can nest,
13
+ // so there every start tag counts.
14
+ const NON_NESTING_TAGS = new Set(
15
+ 'area,base,br,col,embed,hr,img,input,keygen,link,meta,param,source,track,wbr,p,li,dt,dd,td,th,tr,tbody,thead,tfoot,colgroup,caption,option,optgroup,rt,rp'.split(
16
+ ','
17
+ )
18
+ );
19
+
20
+ // A single linear pass estimating how deep the parsed tree would get. An end tag closes the
21
+ // nearest open element of its name together with everything opened inside it, and one that names
22
+ // no open element is ignored, as an HTML parser ignores it: counting every end tag down instead
23
+ // let "<b></i>" repeated nest as deep as the input allowed, and 10,000 copies took jsdom ten
24
+ // seconds. Otherwise the estimate only ever overestimates (tags inside comments, attribute values
25
+ // and raw text count too), which at worst means an extreme message is shown as plain text.
26
+ function exceedsNestingDepth(html, maxDepth) {
27
+ const tagName = /[a-z][a-z0-9:-]*/iy;
28
+ const stack = [];
29
+ const open = new Map();
30
+ let foreign = 0;
31
+ let pos = 0;
32
+ while (pos < html.length) {
33
+ const tagStart = html.indexOf('<', pos);
34
+ if (tagStart < 0) {
35
+ break;
36
+ }
37
+ const tagEnd = html.indexOf('>', tagStart + 1);
38
+ if (tagEnd < 0) {
39
+ break;
40
+ }
41
+ // resume right after the first ">", so no tag the parser would see is ever skipped
42
+ pos = tagEnd + 1;
43
+
44
+ const closing = html.charAt(tagStart + 1) === '/';
45
+ tagName.lastIndex = tagStart + (closing ? 2 : 1);
46
+ const nameMatch = tagName.exec(html);
47
+ if (!nameMatch) {
48
+ continue;
49
+ }
50
+ const name = nameMatch[0].toLowerCase();
51
+ const isForeignRoot = name === 'svg' || name === 'math';
52
+
53
+ if (closing) {
54
+ if (!open.get(name)) {
55
+ continue;
56
+ }
57
+ let popped;
58
+ do {
59
+ popped = stack.pop();
60
+ open.set(popped, open.get(popped) - 1);
61
+ if (popped === 'svg' || popped === 'math') {
62
+ foreign--;
63
+ }
64
+ } while (popped !== name);
65
+ continue;
66
+ }
67
+
68
+ if (!foreign && NON_NESTING_TAGS.has(name)) {
69
+ continue;
70
+ }
71
+
72
+ // a self-closing flag is ignored on HTML elements, only SVG and MathML honor it
73
+ if ((foreign || isForeignRoot) && html.charAt(tagEnd - 1) === '/') {
74
+ continue;
75
+ }
76
+
77
+ if (isForeignRoot) {
78
+ foreign++;
79
+ }
80
+
81
+ stack.push(name);
82
+ open.set(name, (open.get(name) || 0) + 1);
83
+ if (stack.length > maxDepth) {
84
+ return true;
85
+ }
86
+ }
87
+ return false;
88
+ }
89
+
90
+ // The text of an HTML body for a fallback rendering. The fallback runs when the HTML could not be
91
+ // processed (hostile nesting, a parser or serializer overflow), so it cannot lean on a real HTML
92
+ // parser either: html-to-text gives up on a body nested past its own limit and returned nothing
93
+ // for exactly the input that lands here. One pass over the string: tags become a space, style and
94
+ // script blocks are dropped whole, entities are decoded once
95
+ function fallbackText(html) {
96
+ const lower = html.toLowerCase();
97
+ const parts = [];
98
+ let pos = 0;
99
+ while (pos < html.length) {
100
+ const lt = html.indexOf('<', pos);
101
+ if (lt < 0) {
102
+ parts.push(html.slice(pos));
103
+ break;
104
+ }
105
+ parts.push(html.slice(pos, lt));
106
+ const gt = html.indexOf('>', lt + 1);
107
+ if (gt < 0) {
108
+ // an unterminated tag runs to the end of the input
109
+ break;
110
+ }
111
+ const name = /^(style|script)(?:[\s/]|$)/.exec(lower.slice(lt + 1, gt));
112
+ if (name) {
113
+ const close = lower.indexOf('</' + name[1], gt + 1);
114
+ const closeEnd = close < 0 ? -1 : html.indexOf('>', close);
115
+ if (closeEnd < 0) {
116
+ break;
117
+ }
118
+ pos = closeEnd + 1;
119
+ } else {
120
+ pos = gt + 1;
121
+ }
122
+ parts.push(' ');
123
+ }
124
+ return he.decode(parts.join(''));
125
+ }
126
+
127
+ module.exports = { MAX_NESTING_DEPTH, exceedsNestingDepth, fallbackText };
@@ -4,6 +4,12 @@ const HTMLParser = require('node-html-parser');
4
4
  const he = require('he');
5
5
  const formatDate = require('./format-date');
6
6
  const textToHtml = require('./text-to-html');
7
+ const { MAX_NESTING_DEPTH, exceedsNestingDepth, fallbackText } = require('./html-guard');
8
+
9
+ // Escaped text in place of a body that is not worth parsing
10
+ function fallbackBody(html) {
11
+ return `<div style="white-space: pre-wrap;">${he.encode(fallbackText(html), { useNamedReferences: true })}</div>`;
12
+ }
7
13
 
8
14
  function getHtmlBody(html) {
9
15
  html = ((html && html.toString()) || '').trim();
@@ -11,12 +17,22 @@ function getHtmlBody(html) {
11
17
  return '';
12
18
  }
13
19
 
14
- const root = HTMLParser.parse(html);
15
- let body = root.querySelector('body');
16
- if (!body) {
17
- body = root.querySelector('html') || root;
20
+ // node-html-parser spends cubic time removing the elements left open at the end of a document
21
+ // (16 KB of unclosed <b> tags took ten seconds), so the depth check that guards mimeHtml()
22
+ // refuses the obvious shapes before parsing. The parse option leaves whatever is still open in
23
+ // place instead of reparenting it, so a shape the check underestimates (it follows the HTML
24
+ // parsing rules, node-html-parser ignores more end tags) costs linear time and at worst a stack
25
+ // overflow in the serializer, caught below
26
+ if (exceedsNestingDepth(html, MAX_NESTING_DEPTH)) {
27
+ return fallbackBody(html);
28
+ }
29
+ try {
30
+ const root = HTMLParser.parse(html, { parseNoneClosedTags: true });
31
+ const body = root.querySelector('body') || root.querySelector('html') || root;
32
+ return (body.innerHTML || '').trim();
33
+ } catch (_err) {
34
+ return fallbackBody(html);
18
35
  }
19
- return ((body && body.innerHTML) || '').trim();
20
36
  }
21
37
 
22
38
  function formatAddressHtml(addr) {
package/lib/mime-html.js CHANGED
@@ -9,6 +9,7 @@ const he = require('he');
9
9
  const { Worker } = require('worker_threads');
10
10
  const path = require('path');
11
11
  const WorkerPool = require('./worker-pool');
12
+ const { MAX_NESTING_DEPTH, exceedsNestingDepth, fallbackText } = require('./html-guard');
12
13
 
13
14
  const textToHtml = require('./text-to-html');
14
15
 
@@ -74,6 +75,10 @@ const FORBID_STYLES = [
74
75
  // Matches the style text of any element that could carry one of FORBID_STYLES
75
76
  const FORBID_STYLES_HINT = /position|float|clip|zoom|anim|offset|scrollbar|paint|clear|\ball\b/i;
76
77
 
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');
81
+
77
82
  // Resolves CSS escape sequences (\6F, \o) the way a browser does before it matches a property
78
83
  // name, so that a forbidden property cannot hide behind an escaped character
79
84
  function decodeCssEscapes(value) {
@@ -198,119 +203,49 @@ blockquote blockquote blockquote blockquote{
198
203
  // filter below scale with it, and no legitimate email needs more.
199
204
  const MAX_STYLE_TEXT = 256 * 1024;
200
205
 
201
- // jsdom builds a tree in time quadratic to its depth (10,000 unclosed <b> tags take about 9 s) and
202
- // its serializer overflows the stack a few thousand levels down, so deeper input is not parsed at all.
203
- // Real email nests a few dozen levels.
204
- const MAX_NESTING_DEPTH = 1000;
205
-
206
- // Elements that never stay open in HTML content, either void or closed implicitly by a sibling,
207
- // so they do not add to the depth an HTML parser ends up with. Inside SVG and MathML they can nest,
208
- // so there every start tag counts.
209
- const NON_NESTING_TAGS = new Set(
210
- 'area,base,br,col,embed,hr,img,input,keygen,link,meta,param,source,track,wbr,p,li,dt,dd,td,th,tr,tbody,thead,tfoot,colgroup,caption,option,optgroup,rt,rp'.split(
211
- ','
212
- )
213
- );
214
-
215
- // A single linear pass estimating how deep the parsed tree would get. It only ever overestimates
216
- // (tags inside comments, attribute values and raw text count too), which at worst means an
217
- // extreme message is shown as plain text.
218
- function exceedsNestingDepth(html, maxDepth) {
219
- const tagName = /[a-z][a-z0-9:-]*/iy;
206
+ // juice matches every selector against every element, so its cost is the product of the two
207
+ // counts: 1,000 rules on 10,000 paragraphs took 2.7 s and 10,000 on 10,000 took 25 s, on the
208
+ // calling thread, and the style text cap alone leaves the body unbounded. Each bound sits well
209
+ // above real mail (an Outlook thread of fifty replies is a few hundred selectors on some fifteen
210
+ // thousand elements, a heavy newsletter a thousand on a few thousand) and the product bound keeps
211
+ // the worst case around a second: 0.25 us per pair measured when every selector matches every
212
+ // element, a quarter of that when none does. Past any of them the sanitized HTML is returned
213
+ // without inlined CSS, and the wrapper says so.
214
+ const MAX_INLINE_SELECTORS = 5000;
215
+ const MAX_INLINE_ELEMENTS = 50000;
216
+ const MAX_INLINE_WORK = 5000000;
217
+
218
+ // Selectors juice would match against the document: every selector of every rule at the top level
219
+ // of the style text. A rule nested in an at-rule is not inlined (juice keeps media queries as rules),
220
+ // so it only costs its parse and is not counted. One linear pass
221
+ function countSelectors(css) {
222
+ let count = 0;
220
223
  let depth = 0;
221
- let foreign = 0;
222
- let pos = 0;
223
- while (pos < html.length) {
224
- const tagStart = html.indexOf('<', pos);
225
- if (tagStart < 0) {
226
- break;
227
- }
228
- const tagEnd = html.indexOf('>', tagStart + 1);
229
- if (tagEnd < 0) {
230
- break;
231
- }
232
- // resume right after the first ">", so no tag the parser would see is ever skipped
233
- pos = tagEnd + 1;
234
-
235
- const closing = html.charAt(tagStart + 1) === '/';
236
- tagName.lastIndex = tagStart + (closing ? 2 : 1);
237
- const nameMatch = tagName.exec(html);
238
- if (!nameMatch) {
239
- continue;
240
- }
241
- const name = nameMatch[0].toLowerCase();
242
- const isForeignRoot = name === 'svg' || name === 'math';
243
-
244
- if (closing) {
245
- if (isForeignRoot && foreign > 0) {
246
- foreign--;
224
+ let commas = 0;
225
+ for (let i = 0; i < css.length; i++) {
226
+ const c = css.charAt(i);
227
+ if (c === '{') {
228
+ if (depth === 0) {
229
+ count += commas + 1;
230
+ commas = 0;
247
231
  }
248
- if (depth > 0 && (foreign || !NON_NESTING_TAGS.has(name))) {
232
+ depth++;
233
+ } else if (c === '}') {
234
+ if (depth > 0) {
249
235
  depth--;
250
236
  }
251
- continue;
252
- }
253
-
254
- if (!foreign && NON_NESTING_TAGS.has(name)) {
255
- continue;
256
- }
257
-
258
- // a self-closing flag is ignored on HTML elements, only SVG and MathML honor it
259
- if ((foreign || isForeignRoot) && html.charAt(tagEnd - 1) === '/') {
260
- continue;
261
- }
262
-
263
- if (isForeignRoot) {
264
- foreign++;
265
- }
266
-
267
- depth++;
268
- if (depth > maxDepth) {
269
- return true;
237
+ commas = 0;
238
+ } else if (c === ';' && depth === 0) {
239
+ commas = 0;
240
+ } else if (c === ',' && depth === 0) {
241
+ commas++;
270
242
  }
271
243
  }
272
- return false;
244
+ return count;
273
245
  }
274
246
 
275
247
  // Rendered in place of the sanitized HTML when the pipeline cannot process the input at all, for
276
248
  // example when a hostile nesting depth overflows the stack of the parser or the serializer.
277
- // The text of an HTML body for the fallback rendering. The fallback runs when the HTML could
278
- // not be processed (hostile nesting, a parser or serializer overflow), so it cannot lean on a
279
- // real HTML parser either: html-to-text gives up on a body nested past its own limit and
280
- // returned nothing for exactly the input that lands here. One pass over the string: tags become
281
- // a space, style and script blocks are dropped whole, entities are decoded once
282
- function fallbackText(html) {
283
- const lower = html.toLowerCase();
284
- const parts = [];
285
- let pos = 0;
286
- while (pos < html.length) {
287
- const lt = html.indexOf('<', pos);
288
- if (lt < 0) {
289
- parts.push(html.slice(pos));
290
- break;
291
- }
292
- parts.push(html.slice(pos, lt));
293
- const gt = html.indexOf('>', lt + 1);
294
- if (gt < 0) {
295
- // an unterminated tag runs to the end of the input
296
- break;
297
- }
298
- const name = /^(style|script)(?:[\s/]|$)/.exec(lower.slice(lt + 1, gt));
299
- if (name) {
300
- const close = lower.indexOf('</' + name[1], gt + 1);
301
- const closeEnd = close < 0 ? -1 : html.indexOf('>', close);
302
- if (closeEnd < 0) {
303
- break;
304
- }
305
- pos = closeEnd + 1;
306
- } else {
307
- pos = gt + 1;
308
- }
309
- parts.push(' ');
310
- }
311
- return he.decode(parts.join(''));
312
- }
313
-
314
249
  function fallbackHtml(message) {
315
250
  let text = message.text;
316
251
  if (!text && message.html) {
@@ -405,6 +340,7 @@ function prepareHtml(message) {
405
340
  // Process remaining style tags to remove known problematic selectors
406
341
  let styleBudget = MAX_STYLE_TEXT;
407
342
  let hasStyle = false;
343
+ let selectors = 0;
408
344
  root.querySelectorAll('style').forEach(styleTag => {
409
345
  let cssContent = styleTag.textContent || '';
410
346
  if (cssContent.length > styleBudget) {
@@ -416,14 +352,24 @@ function prepareHtml(message) {
416
352
  styleTag.textContent = cssContent;
417
353
  if (cssContent.trim()) {
418
354
  hasStyle = true;
355
+ selectors += countSelectors(cssContent);
419
356
  }
420
357
  });
421
358
 
422
- return { html: root.outerHTML, hasStyle };
359
+ let inliningSkipped = false;
360
+ if (hasStyle) {
361
+ const elements = root.getElementsByTagName('*').length;
362
+ if (selectors > MAX_INLINE_SELECTORS || elements > MAX_INLINE_ELEMENTS || selectors * elements > MAX_INLINE_WORK) {
363
+ hasStyle = false;
364
+ inliningSkipped = true;
365
+ }
366
+ }
367
+
368
+ return { html: root.outerHTML, hasStyle, inliningSkipped };
423
369
  }
424
370
 
425
371
  // Second sanitization pass and post-processing of the juice output.
426
- function finishHtml(html) {
372
+ function finishHtml(html, inliningSkipped) {
427
373
  // second pass, outputs a BODY DOM element
428
374
  const dom = DOMPurify.sanitize(html, {
429
375
  RETURN_DOM: true,
@@ -465,13 +411,28 @@ function finishHtml(html) {
465
411
  for (let disallowedStyle of FORBID_STYLES) {
466
412
  node.style.removeProperty(disallowedStyle);
467
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
+ }
468
428
  }
469
429
  }
470
430
 
471
431
  let bodyStyles = (dom.getAttribute('style') || '').toString().trim();
472
432
 
473
- // embed into a styled container
474
- return `<div style="${he.encode(bodyStyles)}">${dom.innerHTML.trim()}</div>`;
433
+ // embed into a styled container; the attribute tells the caller the CSS inlining caps were hit
434
+ const marker = inliningSkipped ? ' data-css-inlining="skipped"' : '';
435
+ return `<div style="${he.encode(bodyStyles)}"${marker}>${dom.innerHTML.trim()}</div>`;
475
436
  }
476
437
 
477
438
  // Runs the pipeline and renders the plain text fallback when it fails, for example when a
@@ -494,7 +455,7 @@ function guarded(fn, message, rethrow) {
494
455
  function mimeHtmlSync(message) {
495
456
  return guarded(
496
457
  () => {
497
- let { html, hasStyle } = prepareHtml(message);
458
+ let { html, hasStyle, inliningSkipped } = prepareHtml(message);
498
459
  if (hasStyle) {
499
460
  try {
500
461
  // Run juice synchronously - risky but backwards compatible
@@ -503,7 +464,7 @@ function mimeHtmlSync(message) {
503
464
  // not so important, so we'll ignore if style inlining fails
504
465
  }
505
466
  }
506
- return finishHtml(html);
467
+ return finishHtml(html, inliningSkipped);
507
468
  },
508
469
  message,
509
470
  false
@@ -513,7 +474,7 @@ function mimeHtmlSync(message) {
513
474
  async function mimeHtmlAsync(message, options = {}) {
514
475
  return guarded(
515
476
  async () => {
516
- let { html, hasStyle } = prepareHtml(message);
477
+ let { html, hasStyle, inliningSkipped } = prepareHtml(message);
517
478
  if (hasStyle) {
518
479
  try {
519
480
  // Use worker thread with timeout for juice - provides protection against ANY
@@ -526,7 +487,7 @@ async function mimeHtmlAsync(message, options = {}) {
526
487
  }
527
488
  }
528
489
  }
529
- return finishHtml(html);
490
+ return finishHtml(html, inliningSkipped);
530
491
  },
531
492
  message,
532
493
  options.fallbackOnError === false
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@postalsys/email-text-tools",
3
- "version": "2.4.19",
3
+ "version": "2.4.20",
4
4
  "description": "Email text tools",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -34,6 +34,7 @@
34
34
  "prettier": "3.9.10"
35
35
  },
36
36
  "dependencies": {
37
+ "@postalsys/email-content": "0.1.13",
37
38
  "dompurify": "3.4.17",
38
39
  "he": "1.2.0",
39
40
  "html-to-text": "10.0.1",
@@ -231,6 +231,19 @@ describe('hostile input hardening', () => {
231
231
  assert.equal(mimeHtml.getWorkerPoolStats(), null);
232
232
  });
233
233
 
234
+ it('mimeHtml depth check is not bypassed by end tags that close nothing', () => {
235
+ // the check counted every end tag down, so "<b></i>" repeated passed it while the
236
+ // parser, which ignores the </i>, nested the <b> tags: 10,000 copies took 9.7 s
237
+ const { result, ms } = timed(() => mimeHtml({ html: '<b></i>'.repeat(10000) + 'deep' }));
238
+ assert.ok(result.startsWith(FALLBACK_PREFIX));
239
+ assert.ok(result.includes('deep'));
240
+ assert.ok(ms < 1500, `took ${ms} ms`);
241
+ // an end tag still closes what it names, including what was opened inside it
242
+ const nested = mimeHtml({ html: '<div><span><b>x</div>'.repeat(2000) + 'shallow' });
243
+ assert.ok(!nested.startsWith(FALLBACK_PREFIX));
244
+ assert.ok(nested.includes('shallow'));
245
+ });
246
+
234
247
  it('htmlToText does not overflow the stack on deep nesting', () => {
235
248
  for (const tag of ['div', 'b', 'blockquote']) {
236
249
  const html = 'hello &amp; <b>world</b>' + `<${tag}>`.repeat(20000) + 'deep' + `</${tag}>`.repeat(20000) + 'after';
@@ -243,6 +256,104 @@ describe('hostile input hardening', () => {
243
256
  });
244
257
  });
245
258
 
259
+ describe('CSS inlining caps', () => {
260
+ const SKIPPED_PREFIX = '<div style="overflow: auto;" data-css-inlining="skipped">';
261
+
262
+ function styled(css, body) {
263
+ return `<html><head><style>${css}</style></head><body>${body}</body></html>`;
264
+ }
265
+
266
+ function rules(count, selector) {
267
+ let css = '';
268
+ for (let i = 0; i < count; i++) {
269
+ css += `${selector || '.c' + i} { color: #${(i % 4096).toString(16).padStart(3, '0')}; }\n`;
270
+ }
271
+ return css;
272
+ }
273
+
274
+ it('skips inlining when the selectors times the elements is too much work', () => {
275
+ // 10,000 rules on 10,000 paragraphs took 25 s in juice, on the calling thread
276
+ const html = styled(rules(10000, 'p'), '<p>x</p>'.repeat(10000));
277
+ const { result, ms } = timed(() => mimeHtml({ html }));
278
+ assert.ok(ms < 3000, `took ${ms} ms`);
279
+ assert.ok(result.startsWith(SKIPPED_PREFIX), result.slice(0, 80));
280
+ assert.ok(result.includes('<p>x</p>'));
281
+ assert.doesNotMatch(result, /color:/);
282
+ });
283
+
284
+ it('counts every selector of a rule, not every rule', () => {
285
+ const html = styled(`${'p, '.repeat(9999)}p { color: red; }`, '<p>x</p>'.repeat(10000));
286
+ const result = mimeHtml({ html });
287
+ assert.ok(result.startsWith(SKIPPED_PREFIX), result.slice(0, 80));
288
+ });
289
+
290
+ it('skips inlining past the selector cap or the element cap on their own', () => {
291
+ const manyRules = mimeHtml({ html: styled(rules(5001), '<p class="c1">x</p>') });
292
+ assert.ok(manyRules.startsWith(SKIPPED_PREFIX), manyRules.slice(0, 80));
293
+ const manyElements = mimeHtml({ html: styled('p { color: red; }', '<i></i>'.repeat(50001)) });
294
+ assert.ok(manyElements.startsWith(SKIPPED_PREFIX), manyElements.slice(0, 80));
295
+ });
296
+
297
+ it('inlines a large but ordinary message', () => {
298
+ // rules nested in a media query are not matched against elements and do not count
299
+ const css = `${rules(2000)}p.x { color: blue; }\n@media screen { ${rules(4000)} }`;
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));
302
+ assert.match(result, /color: blue/);
303
+ });
304
+
305
+ it('mimeHtml.async skips the same way', async () => {
306
+ const html = styled(rules(10000, 'p'), '<p>x</p>'.repeat(10000));
307
+ const result = await mimeHtml.async({ html });
308
+ assert.ok(result.startsWith(SKIPPED_PREFIX), result.slice(0, 80));
309
+ assert.ok(result.includes('<p>x</p>'));
310
+ });
311
+ });
312
+
313
+ describe('inlineHtml parse guard', () => {
314
+ const FALLBACK_BODY = '<div style="white-space: pre-wrap;">';
315
+
316
+ it('does not spend seconds on tags left open in the quoted message', () => {
317
+ // 16 KB of "<b>x" took node-html-parser 10.6 s to clean up
318
+ const { result, ms } = timed(() => inlineHtml('reply', '<p>my reply</p>', { html: '<b>x'.repeat(4000) }));
319
+ assert.ok(ms < 1000, `took ${ms} ms`);
320
+ assert.ok(result.includes('my reply'));
321
+ assert.ok(result.includes(FALLBACK_BODY));
322
+ assert.ok(!result.includes('<b>'));
323
+ assert.ok(/x x x/.test(result));
324
+ });
325
+
326
+ for (const [label, html] of [
327
+ ['end tags that close nothing', '<b></i>'.repeat(5000) + 'deep'],
328
+ ['end tags the parser ignores', '<td><font>x</td>'.repeat(3000)],
329
+ ['padded end tags', '<a><b><c>y</a></b>'.repeat(3000)],
330
+ ['rows left open', '<table>' + '<tr><td>cell'.repeat(5000)]
331
+ ]) {
332
+ it(`stays fast on ${label}`, () => {
333
+ const { result, ms } = timed(() => inlineHtml('forward', '<p>fyi</p>', { html }));
334
+ assert.ok(ms < 1000, `took ${ms} ms`);
335
+ assert.ok(result.includes('fyi'));
336
+ assert.ok(result.includes('<blockquote'));
337
+ });
338
+ }
339
+
340
+ it('guards the new content the same way', () => {
341
+ const { result, ms } = timed(() => inlineHtml('reply', '<b>x'.repeat(4000), { html: '<p>Original</p>' }));
342
+ assert.ok(ms < 1000, `took ${ms} ms`);
343
+ assert.ok(result.includes('<p>Original</p>'));
344
+ assert.ok(result.includes(FALLBACK_BODY));
345
+ });
346
+
347
+ it('keeps the quoted body of ordinary and sloppy markup', () => {
348
+ const result = inlineHtml('reply', '<p>r</p>', { html: '<html><body><div><b>bold text<p>para</body></html>' });
349
+ assert.ok(result.includes('bold text'));
350
+ assert.ok(result.includes('<b>'));
351
+ // only the reply template's own body element is left
352
+ assert.equal((result.match(/<body/g) || []).length, 1);
353
+ assert.ok(!result.includes(FALLBACK_BODY));
354
+ });
355
+ });
356
+
246
357
  describe('sanitizer policy', () => {
247
358
  it('removes forms and form controls but keeps their text', () => {
248
359
  const html =
@@ -362,3 +473,32 @@ describe('forbidden style properties behind CSS escapes', () => {
362
473
  assert.ok(/color:\s*red/i.test(out), out);
363
474
  });
364
475
  });
476
+
477
+ describe('forbidden style properties the CSSOM could not parse', () => {
478
+ it('drops a style attribute cssstyle rejected that names a forbidden property', () => {
479
+ // cssstyle 5 parses nothing from an attribute whose background shorthand with a url() is
480
+ // followed by more declarations, so the raw text went out verbatim and a browser, which
481
+ // parses it fine, honored the position
482
+ const out = mimeHtml({ html: '<div style="background:url(https://example.com/x.gif) no-repeat; position:fixed; top:0">x</div>' });
483
+ assert.ok(!/position|fixed|top:/i.test(out), out);
484
+ assert.ok(out.includes('<div>x</div>'), out);
485
+ });
486
+
487
+ it('keeps the declarations the CSSOM parsed around one it could not', () => {
488
+ const out = mimeHtml({ html: '<div style="color:red;}position:fixed;top:0">x</div>' });
489
+ assert.ok(!/position|fixed/i.test(out), out);
490
+ assert.match(out, /color: red/);
491
+ assert.match(out, /top: 0/);
492
+ });
493
+
494
+ it('leaves an attribute that names no forbidden property as it was, parsed or not', () => {
495
+ const out = mimeHtml({ html: '<div style="background:url(clear.gif); color:red">x</div>' });
496
+ assert.ok(out.includes('style="background:url(clear.gif); color:red"'), out);
497
+ });
498
+
499
+ it('does not take a longer property name for a forbidden one', () => {
500
+ const out = mimeHtml({ html: '<div style="background-position: center top; text-underline-offset: 2px; color:red">x</div>' });
501
+ assert.ok(out.includes('background-position: center top'), out);
502
+ assert.ok(out.includes('text-underline-offset: 2px'), out);
503
+ });
504
+ });