@postalsys/email-text-tools 2.4.16 → 2.4.18

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.16"
2
+ ".": "2.4.18"
3
3
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.4.18](https://github.com/postalsys/email-text-tools/compare/v2.4.17...v2.4.18) (2026-09-28)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * keep the text of an over-deep HTML body in the fallback rendering ([a2e06b7](https://github.com/postalsys/email-text-tools/commit/a2e06b75878d3763bb35d225a93225aa6a3f18ab))
9
+
10
+ ## [2.4.17](https://github.com/postalsys/email-text-tools/compare/v2.4.16...v2.4.17) (2026-09-28)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * linear CSS pre-filter, bounded nesting, Intl dates, lazy jsdom, forms removed by the sanitiser ([9ed33c9](https://github.com/postalsys/email-text-tools/commit/9ed33c90b7b8c286b11bfc522baef75899a07325))
16
+
3
17
  ## [2.4.16](https://github.com/postalsys/email-text-tools/compare/v2.4.15...v2.4.16) (2026-09-07)
4
18
 
5
19
 
package/README.md CHANGED
@@ -1,3 +1,15 @@
1
1
  # email-text-tools
2
2
 
3
3
  Email text tools
4
+
5
+ ## mimeHtml sanitizer policy
6
+
7
+ `mimeHtml()` sanitizes with DOMPurify and additionally removes:
8
+
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
+ - attributes: `action`, `formaction`, `popover`, `popovertarget`
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
+
13
+ Input nested more than about 1000 elements deep, or that the parser cannot process, is rendered as escaped plain text instead of throwing.
14
+
15
+ 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.
package/index.js CHANGED
@@ -4,12 +4,19 @@ const textToHtml = require('./lib/text-to-html');
4
4
  const htmlToText = require('./lib/html-to-text');
5
5
  const inlineHtml = require('./lib/inline-html');
6
6
  const inlineText = require('./lib/inline-text');
7
- const mimeHtml = require('./lib/mime-html');
8
7
 
9
8
  module.exports = {
10
9
  textToHtml,
11
10
  htmlToText,
12
11
  inlineHtml,
13
- inlineText,
14
- mimeHtml
12
+ inlineText
15
13
  };
14
+
15
+ // mimeHtml loads jsdom, which costs tens of megabytes of heap, so it is only loaded on first
16
+ // access and callers that only need the other helpers never pay for it.
17
+ Object.defineProperty(module.exports, 'mimeHtml', {
18
+ enumerable: true,
19
+ get() {
20
+ return require('./lib/mime-html');
21
+ }
22
+ });
@@ -0,0 +1,63 @@
1
+ 'use strict';
2
+
3
+ const DATE_FORMAT = {
4
+ weekday: 'short',
5
+ year: 'numeric',
6
+ month: 'short',
7
+ day: 'numeric',
8
+ hour: 'numeric',
9
+ minute: '2-digit'
10
+ };
11
+
12
+ // An unsupported locale would silently fall back to the host's default locale, and a Node build
13
+ // with only English ICU data (pkg binaries use small-icu) renders other locales as broken
14
+ // placeholders such as "M09 28, Mon", so anything not actually supported becomes 'en'.
15
+ function resolveLocale(locale) {
16
+ if (locale) {
17
+ try {
18
+ const supported = Intl.DateTimeFormat.supportedLocalesOf([locale]);
19
+ if (supported.length) {
20
+ return supported[0];
21
+ }
22
+ } catch (_err) {
23
+ // structurally invalid language tag
24
+ }
25
+ }
26
+ return 'en';
27
+ }
28
+
29
+ /**
30
+ * Formats the date of a quoted message for the reply/forward header, for example
31
+ * "Sat, Jun 15, 2024, 2:30 PM" in English.
32
+ *
33
+ * @param {Date|String|Number} value Date to format
34
+ * @param {Object} [options]
35
+ * @param {String} [options.locale] BCP 47 language tag, defaults to 'en'
36
+ * @param {String} [options.tz] IANA time zone, defaults to the local time zone
37
+ * @returns {String} Formatted date, or the input as text when it is not a valid date
38
+ */
39
+ function formatDate(value, options) {
40
+ options = options || {};
41
+ const date = value instanceof Date ? value : new Date(value);
42
+ if (isNaN(date.getTime())) {
43
+ return String(value);
44
+ }
45
+
46
+ const locale = resolveLocale(options.locale);
47
+ let formatter;
48
+ try {
49
+ formatter = new Intl.DateTimeFormat(locale, Object.assign({ timeZone: options.tz || undefined }, DATE_FORMAT));
50
+ } catch (err) {
51
+ if (!(err instanceof RangeError)) {
52
+ throw err;
53
+ }
54
+ // an unknown time zone falls back to UTC rather than to whatever zone the host runs in
55
+ formatter = new Intl.DateTimeFormat(locale, Object.assign({ timeZone: 'UTC' }, DATE_FORMAT));
56
+ }
57
+ const formatted = formatter.format(date);
58
+
59
+ // ICU puts a narrow no-break space before "PM", which older mail clients show as a box
60
+ return formatted.replace(/[\u00a0\u202f]/g, ' ');
61
+ }
62
+
63
+ module.exports = formatDate;
@@ -42,13 +42,15 @@ const htmlToTextCompiled = compile({
42
42
  ],
43
43
  hideLinkHrefIfSameAsText: true,
44
44
  // Preserve whitespace better for content
45
- preserveNewlines: true
45
+ preserveNewlines: true,
46
+ // The DOM walk is recursive, so hostile nesting would overflow the stack. Content below this
47
+ // depth is dropped; real email nests a few dozen levels.
48
+ limits: { maxDepth: 500, ellipsis: '' }
46
49
  });
47
50
 
48
51
  // Wrapper function that applies post-processing
49
52
  function htmlToText(html) {
50
- const text = htmlToTextCompiled(html);
51
- return cleanupNewlines(text);
53
+ return cleanupNewlines(htmlToTextCompiled(html));
52
54
  }
53
55
 
54
56
  module.exports = htmlToText;
@@ -2,7 +2,7 @@
2
2
 
3
3
  const HTMLParser = require('node-html-parser');
4
4
  const he = require('he');
5
- const moment = require('moment-timezone');
5
+ const formatDate = require('./format-date');
6
6
  const textToHtml = require('./text-to-html');
7
7
 
8
8
  function getHtmlBody(html) {
@@ -100,17 +100,7 @@ function inlineHtml(action, messageContent, messageData, options) {
100
100
  let dateStr = '';
101
101
 
102
102
  if (messageData.date) {
103
- let dateElm = moment(messageData.date);
104
-
105
- if (options.locale) {
106
- dateElm = dateElm.locale(options.locale);
107
- }
108
-
109
- if (options.tz) {
110
- dateElm = dateElm.tz(options.tz);
111
- }
112
-
113
- dateStr = dateElm.format('llll');
103
+ dateStr = formatDate(messageData.date, options);
114
104
 
115
105
  let entry = {
116
106
  title: 'Date',
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- const moment = require('moment-timezone');
3
+ const formatDate = require('./format-date');
4
4
  const htmlToText = require('./html-to-text');
5
5
 
6
6
  function formatAddressText(addr) {
@@ -79,17 +79,7 @@ function inlineText(type, messageContent, messageData, options) {
79
79
  let dateStr = '';
80
80
 
81
81
  if (messageData.date) {
82
- let dateElm = moment(messageData.date);
83
-
84
- if (options.locale) {
85
- dateElm = dateElm.locale(options.locale);
86
- }
87
-
88
- if (options.tz) {
89
- dateElm = dateElm.tz(options.tz);
90
- }
91
-
92
- dateStr = dateElm.format('llll');
82
+ dateStr = formatDate(messageData.date, options);
93
83
 
94
84
  let entry = {
95
85
  title: 'Date',
package/lib/mime-html.js CHANGED
@@ -31,9 +31,18 @@ const FORBID_TAGS = [
31
31
  'canvas',
32
32
  'audio',
33
33
  'video',
34
- 'applet'
34
+ 'applet',
35
+ // forms and their controls have no use in a displayed message and can be styled to phish
36
+ 'form',
37
+ 'input',
38
+ 'button',
39
+ 'select',
40
+ 'textarea'
35
41
  ];
36
42
 
43
+ // form submission targets and popovers, which let sender markup act on or overlay the viewer
44
+ const FORBID_ATTR = ['action', 'formaction', 'popover', 'popovertarget'];
45
+
37
46
  const FORBID_STYLES = [
38
47
  'all',
39
48
  'position',
@@ -62,9 +71,28 @@ const FORBID_STYLES = [
62
71
  'zoom'
63
72
  ];
64
73
 
74
+ // Matches the style text of any element that could carry one of FORBID_STYLES
75
+ const FORBID_STYLES_HINT = /position|float|clip|zoom|anim|offset|scrollbar|paint|clear|\ball\b/i;
76
+
77
+ // Resolves CSS escape sequences (\6F, \o) the way a browser does before it matches a property
78
+ // name, so that a forbidden property cannot hide behind an escaped character
79
+ function decodeCssEscapes(value) {
80
+ return value.replace(/\\([0-9a-f]{1,6})[ \t\n\r\f]?|\\(.)/gi, (match, hex, chr) => {
81
+ if (hex) {
82
+ let code = parseInt(hex, 16);
83
+ return code && code <= 0x10ffff ? String.fromCodePoint(code) : '�';
84
+ }
85
+ return chr;
86
+ });
87
+ }
88
+
65
89
  // keep the style at first
66
90
  const FORBID_TAGS_ALL = ['style'].concat(FORBID_TAGS);
67
91
 
92
+ // One sanitizer for the module. Every sanitize() call passes its full config, so calls do not
93
+ // leak settings into each other.
94
+ const DOMPurify = createDOMPurify(new JSDOM('').window);
95
+
68
96
  // Global worker pool instance (lazy initialization)
69
97
  let globalWorkerPool = null;
70
98
 
@@ -166,105 +194,192 @@ blockquote blockquote blockquote blockquote{
166
194
 
167
195
  </style>`;
168
196
 
169
- function mimeHtmlSync(message) {
170
- let html = message.html;
171
- if (!html && message.text) {
172
- html = textToHtml(message.text) || '';
173
- if (html) {
174
- html = INLINE_STYLE_BLOCK + html;
197
+ // Only this much <style> text is kept per message, the rest is dropped. juice and the selector
198
+ // filter below scale with it, and no legitimate email needs more.
199
+ const MAX_STYLE_TEXT = 256 * 1024;
200
+
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;
220
+ 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;
175
227
  }
176
- }
177
-
178
- html = (html || '').toString().replace(/^"data:[^"]+"/g, '');
179
-
180
- const window = new JSDOM('').window;
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';
181
243
 
182
- const DOMPurify = createDOMPurify(window);
244
+ if (closing) {
245
+ if (isForeignRoot && foreign > 0) {
246
+ foreign--;
247
+ }
248
+ if (depth > 0 && (foreign || !NON_NESTING_TAGS.has(name))) {
249
+ depth--;
250
+ }
251
+ continue;
252
+ }
183
253
 
184
- // first pass, outputs a fixed HTML page
185
- html = DOMPurify.sanitize(html, { WHOLE_DOCUMENT: true, FORCE_BODY: false, FORBID_TAGS });
254
+ if (!foreign && NON_NESTING_TAGS.has(name)) {
255
+ continue;
256
+ }
186
257
 
187
- try {
188
- // Pre-process HTML to remove known problematic CSS that causes juice to hang
189
- // This is a fallback for the sync version - the async version with timeout is preferred
190
- const tempDoc = new JSDOM(html).window.document;
191
-
192
- // Remove any style tags from within body content
193
- const bodyStyleTags = tempDoc.body ? tempDoc.body.querySelectorAll('style') : [];
194
- bodyStyleTags.forEach(tag => tag.remove());
195
-
196
- // Process remaining style tags to remove known problematic selectors
197
- const styleTags = tempDoc.querySelectorAll('style');
198
- styleTags.forEach(styleTag => {
199
- let cssContent = styleTag.textContent;
200
-
201
- // Remove rules with known problematic pseudo-classes
202
- const problematicSelectors = [':is\\(', ':where\\(', ':has\\(', ':not\\(.*:.*\\)'];
203
- problematicSelectors.forEach(selector => {
204
- const regex = new RegExp(`[^{}]*${selector}[^{}]*\\{[^}]*\\}`, 'gi');
205
- cssContent = cssContent.replace(regex, '');
206
- });
207
-
208
- styleTag.textContent = cssContent;
209
- });
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
+ }
210
262
 
211
- html = tempDoc.documentElement.outerHTML;
263
+ if (isForeignRoot) {
264
+ foreign++;
265
+ }
212
266
 
213
- // Run juice synchronously - risky but backwards compatible
214
- html = juice(html);
215
- } catch (_err) {
216
- // not so important, so we'll ignore if style inlining fails
267
+ depth++;
268
+ if (depth > maxDepth) {
269
+ return true;
270
+ }
217
271
  }
272
+ return false;
273
+ }
218
274
 
219
- // second pass, outputs a BODY DOM element
220
- const dom = DOMPurify.sanitize(html, {
221
- RETURN_DOM: true,
222
- WHOLE_DOCUMENT: false,
223
- FORCE_BODY: true,
224
- FORBID_TAGS: FORBID_TAGS_ALL
225
- });
226
-
227
- // mark all links to be opened in a new window
228
- for (let elm of dom.querySelectorAll('a')) {
229
- elm.setAttribute('target', '_blank');
275
+ // Rendered in place of the sanitized HTML when the pipeline cannot process the input at all, for
276
+ // 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(' ');
230
310
  }
311
+ return he.decode(parts.join(''));
312
+ }
231
313
 
232
- dom.style.overflow = 'auto';
233
-
234
- // return <BODY> HTML
235
-
236
- dom.style.removeProperty('width');
237
- dom.style.removeProperty('min-width');
238
- dom.style.removeProperty('max-width');
239
- dom.style.removeProperty('height');
240
- dom.style.removeProperty('min-height');
241
- dom.style.removeProperty('max-height');
314
+ function fallbackHtml(message) {
315
+ let text = message.text;
316
+ if (!text && message.html) {
317
+ text = fallbackText(message.html.toString());
318
+ }
319
+ return `<div style="overflow: auto;"><div style="white-space: pre-wrap;">${he.encode((text || '').toString(), { useNamedReferences: true })}</div></div>`;
320
+ }
242
321
 
243
- // remove specific style properties for all DOM nodes
244
- let walkDom = node => {
245
- if (node.style) {
246
- for (let disallowedStyle of FORBID_STYLES) {
247
- node.style.removeProperty(disallowedStyle);
322
+ // True when a :not( argument contains another colon. Such selectors, like :is(), :where() and
323
+ // :has(), can make juice hang. A single pass with a paren counter keeps this linear, the regex
324
+ // it replaces backtracked catastrophically on a few hundred bytes of repeated ":not(a:b)".
325
+ function hasNotWithPseudo(selector) {
326
+ // paren depth just inside the open :not(, 0 when there is none. Any colon inside it is a
327
+ // match, so a nested :not( never needs tracking.
328
+ let notDepth = 0;
329
+ let depth = 0;
330
+ for (let i = 0; i < selector.length; i++) {
331
+ const c = selector.charAt(i);
332
+ if (c === ':') {
333
+ if (notDepth) {
334
+ return true;
248
335
  }
249
- }
250
-
251
- if (node.childNodes) {
252
- // Convert NodeList to array to avoid infinite loops if DOM changes during iteration
253
- const children = Array.from(node.childNodes);
254
- for (let childNode of children) {
255
- walkDom(childNode);
336
+ if (selector.substr(i + 1, 4).toLowerCase() === 'not(') {
337
+ depth++;
338
+ notDepth = depth;
339
+ i += 4;
340
+ }
341
+ } else if (c === '(') {
342
+ depth++;
343
+ } else if (c === ')') {
344
+ if (depth === notDepth) {
345
+ notDepth = 0;
346
+ }
347
+ if (depth > 0) {
348
+ depth--;
256
349
  }
257
350
  }
258
- };
259
- walkDom(dom);
351
+ }
352
+ return false;
353
+ }
260
354
 
261
- let bodyStyles = (dom.getAttribute('style') || '').toString().trim();
355
+ function isProblematicSelector(selector) {
356
+ const lower = selector.toLowerCase();
357
+ return lower.includes(':is(') || lower.includes(':where(') || lower.includes(':has(') || hasNotWithPseudo(selector);
358
+ }
262
359
 
263
- // embed into a styled container
264
- return `<div style="${he.encode(bodyStyles)}">${dom.innerHTML.trim()}</div>`;
360
+ // Drops every rule whose selector could make juice hang. The text is split into rules on "}" and
361
+ // the selector of each rule is the text between the last two "{" in it, so a rule nested in an
362
+ // at-rule is dropped without its enclosing block.
363
+ function filterCss(css) {
364
+ if (!/:(is|where|has|not)\(/i.test(css)) {
365
+ return css;
366
+ }
367
+ const pieces = css.split('}');
368
+ const output = [];
369
+ for (let i = 0; i < pieces.length; i++) {
370
+ const piece = pieces[i];
371
+ const isLast = i === pieces.length - 1;
372
+ const ruleStart = isLast ? -1 : piece.lastIndexOf('{');
373
+ const selectorStart = ruleStart < 0 ? 0 : piece.lastIndexOf('{', ruleStart - 1) + 1;
374
+ const drop = ruleStart >= 0 && isProblematicSelector(piece.substring(selectorStart, ruleStart));
375
+ output.push(drop ? piece.substring(0, selectorStart) : isLast ? piece : piece + '}');
376
+ }
377
+ return output.join('');
265
378
  }
266
379
 
267
- async function mimeHtmlAsync(message, options = {}) {
380
+ // Sanitizes the whole document and strips the CSS juice is known to choke on. Returns the HTML
381
+ // to run juice on and whether any style text is left for juice to inline.
382
+ function prepareHtml(message) {
268
383
  let html = message.html;
269
384
  if (!html && message.text) {
270
385
  html = textToHtml(message.text) || '';
@@ -275,57 +390,47 @@ async function mimeHtmlAsync(message, options = {}) {
275
390
 
276
391
  html = (html || '').toString().replace(/^"data:[^"]+"/g, '');
277
392
 
278
- const window = new JSDOM('').window;
279
- const DOMPurify = createDOMPurify(window);
280
-
281
- // first pass, outputs a fixed HTML page
282
- html = DOMPurify.sanitize(html, { WHOLE_DOCUMENT: true, FORCE_BODY: false, FORBID_TAGS });
283
-
284
- try {
285
- // Pre-process HTML to remove known problematic CSS that causes juice to hang
286
- // This is the same filtering as the sync version to avoid unnecessary timeouts
287
- const tempDoc = new JSDOM(html).window.document;
288
-
289
- // Remove any style tags from within body content
290
- const bodyStyleTags = tempDoc.body ? tempDoc.body.querySelectorAll('style') : [];
291
- bodyStyleTags.forEach(tag => tag.remove());
292
-
293
- // Process remaining style tags to remove known problematic selectors
294
- const styleTags = tempDoc.querySelectorAll('style');
295
- styleTags.forEach(styleTag => {
296
- let cssContent = styleTag.textContent;
297
-
298
- // Remove rules with known problematic pseudo-classes
299
- const problematicSelectors = [':is\\(', ':where\\(', ':has\\(', ':not\\(.*:.*\\)'];
300
- problematicSelectors.forEach(selector => {
301
- const regex = new RegExp(`[^{}]*${selector}[^{}]*\\{[^}]*\\}`, 'gi');
302
- cssContent = cssContent.replace(regex, '');
303
- });
304
-
305
- styleTag.textContent = cssContent;
306
- });
307
-
308
- html = tempDoc.documentElement.outerHTML;
309
-
310
- // Use worker thread with timeout for juice - provides protection against ANY
311
- // unexpected CSS that might cause hangs (beyond our known problematic selectors)
312
- html = await juiceWithTimeout(html, options);
313
- } catch (err) {
314
- // If juice fails or times out, continue without inlining styles
315
- // The HTML is still usable, just without inlined CSS
316
- // Note: logErrors option can be used to control error visibility
393
+ if (exceedsNestingDepth(html, MAX_NESTING_DEPTH)) {
394
+ throw new Error('HTML is nested too deep to process');
395
+ }
317
396
 
318
- if (options.fallbackOnError === false) {
319
- throw err;
397
+ // first pass, returns the <html> element of the fixed HTML page
398
+ const root = DOMPurify.sanitize(html, { WHOLE_DOCUMENT: true, FORCE_BODY: false, FORBID_TAGS, FORBID_ATTR, RETURN_DOM: true });
399
+
400
+ // Remove any style tags from within body content
401
+ const body = root.querySelector('body');
402
+ const bodyStyleTags = body ? body.querySelectorAll('style') : [];
403
+ bodyStyleTags.forEach(tag => tag.remove());
404
+
405
+ // Process remaining style tags to remove known problematic selectors
406
+ let styleBudget = MAX_STYLE_TEXT;
407
+ let hasStyle = false;
408
+ root.querySelectorAll('style').forEach(styleTag => {
409
+ let cssContent = styleTag.textContent || '';
410
+ if (cssContent.length > styleBudget) {
411
+ // cut after the last complete rule, a partial rule would skip the selector filter
412
+ cssContent = styleBudget > 0 ? cssContent.substring(0, cssContent.lastIndexOf('}', styleBudget - 1) + 1) : '';
320
413
  }
321
- }
414
+ styleBudget -= cssContent.length;
415
+ cssContent = filterCss(cssContent);
416
+ styleTag.textContent = cssContent;
417
+ if (cssContent.trim()) {
418
+ hasStyle = true;
419
+ }
420
+ });
322
421
 
422
+ return { html: root.outerHTML, hasStyle };
423
+ }
424
+
425
+ // Second sanitization pass and post-processing of the juice output.
426
+ function finishHtml(html) {
323
427
  // second pass, outputs a BODY DOM element
324
428
  const dom = DOMPurify.sanitize(html, {
325
429
  RETURN_DOM: true,
326
430
  WHOLE_DOCUMENT: false,
327
431
  FORCE_BODY: true,
328
- FORBID_TAGS: FORBID_TAGS_ALL
432
+ FORBID_TAGS: FORBID_TAGS_ALL,
433
+ FORBID_ATTR
329
434
  });
330
435
 
331
436
  // mark all links to be opened in a new window
@@ -343,23 +448,25 @@ async function mimeHtmlAsync(message, options = {}) {
343
448
  dom.style.removeProperty('min-height');
344
449
  dom.style.removeProperty('max-height');
345
450
 
346
- // remove specific style properties for all DOM nodes
347
- let walkDom = node => {
348
- if (node.style) {
451
+ // remove specific style properties, touching the CSSOM only where the style text could name one
452
+ for (const node of [dom, ...dom.querySelectorAll('[style]')]) {
453
+ if (!node.style) {
454
+ continue;
455
+ }
456
+ let styleText = node.getAttribute('style') || '';
457
+ if (styleText.indexOf('\\') >= 0) {
458
+ // A browser resolves CSS escapes in property names (p\osition is position) but jsdom's
459
+ // CSSOM does not, so the check below would miss them. Decode first and write the
460
+ // decoded text back so the CSSOM sees the same properties the browser will
461
+ styleText = decodeCssEscapes(styleText);
462
+ node.setAttribute('style', styleText);
463
+ }
464
+ if (FORBID_STYLES_HINT.test(styleText)) {
349
465
  for (let disallowedStyle of FORBID_STYLES) {
350
466
  node.style.removeProperty(disallowedStyle);
351
467
  }
352
468
  }
353
-
354
- if (node.childNodes) {
355
- // Convert NodeList to array to avoid infinite loops if DOM changes during iteration
356
- const children = Array.from(node.childNodes);
357
- for (let childNode of children) {
358
- walkDom(childNode);
359
- }
360
- }
361
- };
362
- walkDom(dom);
469
+ }
363
470
 
364
471
  let bodyStyles = (dom.getAttribute('style') || '').toString().trim();
365
472
 
@@ -367,6 +474,65 @@ async function mimeHtmlAsync(message, options = {}) {
367
474
  return `<div style="${he.encode(bodyStyles)}">${dom.innerHTML.trim()}</div>`;
368
475
  }
369
476
 
477
+ // Runs the pipeline and renders the plain text fallback when it fails, for example when a
478
+ // hostile nesting depth overflows the stack of the parser or the serializer.
479
+ function guarded(fn, message, rethrow) {
480
+ const onError = err => {
481
+ if (rethrow) {
482
+ throw err;
483
+ }
484
+ return fallbackHtml(message);
485
+ };
486
+ try {
487
+ const result = fn();
488
+ return result && typeof result.then === 'function' ? result.catch(onError) : result;
489
+ } catch (err) {
490
+ return onError(err);
491
+ }
492
+ }
493
+
494
+ function mimeHtmlSync(message) {
495
+ return guarded(
496
+ () => {
497
+ let { html, hasStyle } = prepareHtml(message);
498
+ if (hasStyle) {
499
+ try {
500
+ // Run juice synchronously - risky but backwards compatible
501
+ html = juice(html);
502
+ } catch (_err) {
503
+ // not so important, so we'll ignore if style inlining fails
504
+ }
505
+ }
506
+ return finishHtml(html);
507
+ },
508
+ message,
509
+ false
510
+ );
511
+ }
512
+
513
+ async function mimeHtmlAsync(message, options = {}) {
514
+ return guarded(
515
+ async () => {
516
+ let { html, hasStyle } = prepareHtml(message);
517
+ if (hasStyle) {
518
+ try {
519
+ // Use worker thread with timeout for juice - provides protection against ANY
520
+ // unexpected CSS that might cause hangs (beyond our known problematic selectors)
521
+ html = await juiceWithTimeout(html, options);
522
+ } catch (err) {
523
+ // If juice fails or times out, continue without inlining styles
524
+ if (options.fallbackOnError === false) {
525
+ throw err;
526
+ }
527
+ }
528
+ }
529
+ return finishHtml(html);
530
+ },
531
+ message,
532
+ options.fallbackOnError === false
533
+ );
534
+ }
535
+
370
536
  // Main export - maintains backward compatibility with sync API
371
537
  // Architecture:
372
538
  // - Both sync and async versions pre-filter known problematic CSS selectors
@@ -382,14 +548,8 @@ function mimeHtml(message) {
382
548
  mimeHtml.async = mimeHtmlAsync;
383
549
  mimeHtml.sync = mimeHtmlSync;
384
550
 
385
- // Close worker pool when process exits
386
- if (globalWorkerPool) {
387
- process.on('exit', () => {
388
- if (globalWorkerPool) {
389
- globalWorkerPool.close().catch(() => {});
390
- }
391
- });
392
- }
551
+ // Exposed for tests only, not part of the public API
552
+ mimeHtml._internal = { filterCss, hasNotWithPseudo };
393
553
 
394
554
  // Export function to manually close worker pool
395
555
  mimeHtml.closeWorkerPool = async () => {
@@ -10,6 +10,11 @@ const linkify = linkifyIt()
10
10
  .add('git:', 'http:') // Add `git:` protocol as "alias"
11
11
  .set({ fuzzyIP: true });
12
12
 
13
+ // Quote levels deeper than this are kept as literal "> " text. Every level costs a few stack
14
+ // frames here and a nested <blockquote> later, so hostile input with thousands of leading ">"
15
+ // used to overflow the stack.
16
+ const MAX_QUOTE_DEPTH = 100;
17
+
13
18
  function generateQuotedTree(text) {
14
19
  let lines = text.split(/\r?\n/);
15
20
 
@@ -29,9 +34,9 @@ function generateQuotedTree(text) {
29
34
  return node;
30
35
  };
31
36
 
32
- let walkNode = (curNode, lines) => {
37
+ let walkNode = (curNode, lines, depth) => {
33
38
  for (let line of lines) {
34
- if (/^>/.test(line)) {
39
+ if (depth < MAX_QUOTE_DEPTH && /^>/.test(line)) {
35
40
  if (curNode.type !== 'quote') {
36
41
  curNode = createNode(curNode.parent, 'quote');
37
42
  }
@@ -42,7 +47,7 @@ function generateQuotedTree(text) {
42
47
  let quoteNode = curNode;
43
48
 
44
49
  let childNode = createNode(curNode, 'text');
45
- walkNode(childNode, quoteNode.lines);
50
+ walkNode(childNode, quoteNode.lines, depth + 1);
46
51
 
47
52
  curNode = createNode(curNode.parent, 'text');
48
53
  curNode.lines.push(line);
@@ -57,12 +62,12 @@ function generateQuotedTree(text) {
57
62
  let quoteNode = curNode;
58
63
 
59
64
  let childNode = createNode(curNode, 'text');
60
- walkNode(childNode, quoteNode.lines);
65
+ walkNode(childNode, quoteNode.lines, depth + 1);
61
66
  }
62
67
  };
63
68
 
64
69
  let rootNode = createNode(tree, 'text');
65
- walkNode(rootNode, lines);
70
+ walkNode(rootNode, lines, 0);
66
71
 
67
72
  return tree;
68
73
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@postalsys/email-text-tools",
3
- "version": "2.4.16",
3
+ "version": "2.4.18",
4
4
  "description": "Email text tools",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -29,18 +29,17 @@
29
29
  "homepage": "https://github.com/postalsys/email-text-tools#readme",
30
30
  "devDependencies": {
31
31
  "@eslint/js": "10.0.1",
32
- "eslint": "10.10.0",
32
+ "eslint": "10.11.0",
33
33
  "eslint-config-prettier": "10.1.8",
34
- "prettier": "3.9.6"
34
+ "prettier": "3.9.9"
35
35
  },
36
36
  "dependencies": {
37
- "dompurify": "3.4.15",
37
+ "dompurify": "3.4.16",
38
38
  "he": "1.2.0",
39
39
  "html-to-text": "10.0.1",
40
40
  "jsdom": "27.0.0",
41
41
  "juice": "11.1.1",
42
42
  "linkify-it": "5.0.2",
43
- "moment-timezone": "0.6.3",
44
43
  "node-html-parser": "9.0.4",
45
44
  "tlds": "1.261.0"
46
45
  }
@@ -277,7 +277,8 @@ describe('Comprehensive Test Suite', () => {
277
277
  describe('worker pool configuration', () => {
278
278
  it('should respect min/max worker settings', async () => {
279
279
  const input = {
280
- html: '<p>Pool config test</p>'
280
+ // juice, and with it the pool, only runs when there is a style sheet to inline
281
+ html: '<style>p { color: red; }</style><p>Pool config test</p>'
281
282
  };
282
283
 
283
284
  // First close any existing pool
@@ -328,7 +329,8 @@ describe('Comprehensive Test Suite', () => {
328
329
  describe('memory leak prevention', () => {
329
330
  it('should clean up workers after timeout', async () => {
330
331
  const input = {
331
- html: '<p>Timeout test</p>'
332
+ // a style sheet, so the juice worker actually runs and times out
333
+ html: '<style>p { color: red; }</style><p>Timeout test</p>'
332
334
  };
333
335
 
334
336
  try {
@@ -0,0 +1,364 @@
1
+ 'use strict';
2
+
3
+ const { describe, it, after } = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+ const { execFileSync } = require('node:child_process');
6
+ const path = require('node:path');
7
+
8
+ const mimeHtml = require('../lib/mime-html');
9
+ const textToHtml = require('../lib/text-to-html');
10
+ const htmlToText = require('../lib/html-to-text');
11
+ const inlineHtml = require('../lib/inline-html');
12
+ const inlineText = require('../lib/inline-text');
13
+ const formatDate = require('../lib/format-date');
14
+
15
+ function timed(fn) {
16
+ const start = process.hrtime.bigint();
17
+ const result = fn();
18
+ return { result, ms: Number(process.hrtime.bigint() - start) / 1e6 };
19
+ }
20
+
21
+ function headStyle(css) {
22
+ return `<html><head><style>${css}</style></head><body><p class="x">content</p></body></html>`;
23
+ }
24
+
25
+ describe('hostile input hardening', () => {
26
+ after(async () => {
27
+ await mimeHtml.closeWorkerPool();
28
+ });
29
+
30
+ describe('CSS selector pre-filter', () => {
31
+ const { filterCss, hasNotWithPseudo } = mimeHtml._internal;
32
+
33
+ // The regex pre-filter this replaces took 26 s on 900 bytes of ":not(a:b)" and never
34
+ // finished on 1.8 KB. Timed directly on large inputs, so the bound is about the filter and
35
+ // not about jsdom or juice.
36
+ function assertLinear(fn, unit) {
37
+ const small = unit.repeat(Math.ceil((256 * 1024) / unit.length));
38
+ const large = small + small;
39
+ fn(small); // warm up
40
+ const a = timed(() => fn(small));
41
+ const b = timed(() => fn(large));
42
+ // doubling the input must not blow up the time (loose bound, the old regex was cubic)
43
+ assert.ok(b.ms < Math.max(a.ms * 6, 200), `${fn.name}: 256 KB ${a.ms} ms vs 512 KB ${b.ms} ms`);
44
+ }
45
+
46
+ // Units that never match, so the whole input is scanned
47
+ for (const unit of [':not(.a)', ':not(((.a)))', ':nox(a:b)', '(((:', ')))']) {
48
+ it(`hasNotWithPseudo stays linear on repeated ${JSON.stringify(unit)}`, () => {
49
+ assert.equal(hasNotWithPseudo(unit), false);
50
+ assertLinear(hasNotWithPseudo, unit);
51
+ });
52
+ }
53
+
54
+ for (const unit of ['p:not(.a) { color: red }', 'p:not(a:b){color:red}', 'p:is(.a){}', '{{:not(.a)}', ':not(.a){', '@media x { :where(p) { a: b } }']) {
55
+ it(`filterCss stays linear on repeated ${JSON.stringify(unit)}`, () => {
56
+ assertLinear(filterCss, unit);
57
+ });
58
+ }
59
+
60
+ it('hasNotWithPseudo matches a pseudo-class only inside :not()', () => {
61
+ assert.equal(hasNotWithPseudo('p:not(a:hover)'), true);
62
+ assert.equal(hasNotWithPseudo('P:NOT(A:HOVER)'), true);
63
+ assert.equal(hasNotWithPseudo('p:not(:not(.a))'), true);
64
+ assert.equal(hasNotWithPseudo('p:not(.y):hover'), false);
65
+ assert.equal(hasNotWithPseudo('p:not(.y(z)):hover'), false);
66
+ assert.equal(hasNotWithPseudo('a:hover'), false);
67
+ });
68
+
69
+ it('filterCss returns style text without risky pseudo-classes unchanged', () => {
70
+ const css = '@media screen { p { color: red } } a:hover { color: blue } p:not(.y) {}';
71
+ assert.equal(filterCss(css), css);
72
+ });
73
+
74
+ it('mimeHtml stays fast on a hostile selector list', () => {
75
+ const { result, ms } = timed(() => mimeHtml({ html: headStyle(':not(a:b)'.repeat(512)) }));
76
+ assert.ok(result.includes('content'));
77
+ assert.ok(ms < 2500, `took ${ms} ms`);
78
+ });
79
+
80
+ it('drops only the rules with problematic selectors', () => {
81
+ const css = [
82
+ 'p { color: red; }',
83
+ 'p:is(.x) { font-weight: bold; }',
84
+ 'p:where(.x) { margin: 1px; }',
85
+ 'p:has(b) { padding: 2px; }',
86
+ 'P:NOT(A:HOVER) { text-align: center; }',
87
+ 'p:not(.y):hover { text-decoration: underline; }',
88
+ 'p:not(.y) { font-style: italic; }',
89
+ '@media screen { p:is(.x) { border: 1px solid; } }'
90
+ ].join('\n');
91
+ const result = mimeHtml({ html: headStyle(css) });
92
+ assert.match(result, /color: red/);
93
+ assert.match(result, /font-style: italic/);
94
+ assert.doesNotMatch(result, /font-weight/);
95
+ assert.doesNotMatch(result, /margin/);
96
+ assert.doesNotMatch(result, /padding/);
97
+ assert.doesNotMatch(result, /text-align/);
98
+ assert.doesNotMatch(result, /border/);
99
+ });
100
+
101
+ it('keeps rules nested in at-rules that use safe selectors', () => {
102
+ const html = headStyle('@media screen { p:is(.a) { color: red; } } p { color: blue; }');
103
+ const result = mimeHtml({ html });
104
+ assert.match(result, /color: blue/);
105
+ });
106
+
107
+ it('caps the amount of style text processed', () => {
108
+ const filler = '.unused { color: red; }\n'.repeat(Math.ceil((300 * 1024) / 24));
109
+ const html = headStyle(filler + 'p.x { color: blue; }');
110
+ const result = mimeHtml({ html });
111
+ assert.ok(result.includes('content'));
112
+ assert.doesNotMatch(result, /color: blue/);
113
+ });
114
+
115
+ it('cuts the style text at a rule boundary, so a truncated rule is not inlined unfiltered', () => {
116
+ // the cap falls inside the last rule, after its declarations but before its closing brace
117
+ const rule = 'p:is(.x) { color: green; }';
118
+ const prefixLength = 256 * 1024 - rule.length + 1;
119
+ const filler = '.unused { color: red; }\n'.repeat(Math.floor(prefixLength / 24));
120
+ const html = headStyle(filler + ' '.repeat(prefixLength - filler.length) + rule);
121
+ const result = mimeHtml({ html });
122
+ assert.ok(result.includes('content'));
123
+ assert.doesNotMatch(result, /color: green/);
124
+ });
125
+ });
126
+
127
+ describe('deep nesting', () => {
128
+ it('textToHtml does not overflow the stack on 5000 quote levels', () => {
129
+ const result = textToHtml('>'.repeat(5000) + ' deep\nafter');
130
+ assert.equal((result.match(/<blockquote /g) || []).length, 100);
131
+ assert.ok(result.includes('deep'));
132
+ assert.ok(result.includes('after'));
133
+ });
134
+
135
+ it('textToHtml keeps normal nested quotes', () => {
136
+ const result = textToHtml('>> two\n> one\nnone');
137
+ assert.ok(result.includes('ee-block-1'));
138
+ assert.ok(result.includes('ee-block-2'));
139
+ });
140
+
141
+ const FALLBACK_PREFIX = '<div style="overflow: auto;"><div style="white-space: pre-wrap;">';
142
+
143
+ it('mimeHtml falls back to escaped text on 4000 nested divs', () => {
144
+ const html = 'hello &lt;img src=x onerror=alert(1)&gt;' + '<div>'.repeat(4000) + 'deep' + '</div>'.repeat(4000);
145
+ const { result, ms } = timed(() => mimeHtml({ html }));
146
+ assert.ok(result.startsWith(FALLBACK_PREFIX));
147
+ assert.ok(result.includes('hello &lt;img'));
148
+ assert.doesNotMatch(result, /<img/);
149
+ // the fallback strips tags itself, so text below any parser depth limit survives
150
+ assert.ok(result.includes('deep'));
151
+ assert.ok(ms < 10000, `took ${ms} ms`);
152
+ });
153
+
154
+ it('mimeHtml does not spend seconds on 50000 unclosed <b> tags', () => {
155
+ const { result, ms } = timed(() => mimeHtml({ html: 'shallow' + '<b>'.repeat(50000) + 'bold' }));
156
+ assert.ok(result.startsWith(FALLBACK_PREFIX));
157
+ assert.ok(result.includes('shallow'));
158
+ assert.ok(result.includes('bold'));
159
+ assert.ok(ms < 15000, `took ${ms} ms`);
160
+ });
161
+
162
+ for (const [label, html] of [
163
+ ['self-closing HTML tags', '<div/>'.repeat(20000) + 'deep'],
164
+ ['cells nested in SVG', '<svg>' + '<td>'.repeat(20000) + 'deep'],
165
+ ['an abruptly closed comment', '<!-->' + '<b>'.repeat(20000) + 'deep-->'],
166
+ ['style inside SVG', '<svg><style>' + '<b>'.repeat(20000) + 'deep']
167
+ ]) {
168
+ it(`mimeHtml depth check is not bypassed by ${label}`, () => {
169
+ const { result, ms } = timed(() => mimeHtml({ html }));
170
+ assert.ok(result.startsWith(FALLBACK_PREFIX));
171
+ assert.ok(ms < 15000, `took ${ms} ms`);
172
+ });
173
+ }
174
+
175
+ it('mimeHtml keeps long flat content with implicitly closed tags', () => {
176
+ const html = '<p>para'.repeat(3000) + '<ul>' + '<li>item'.repeat(3000) + '</ul><table>' + '<tr><td>cell'.repeat(3000) + '</table>';
177
+ const result = mimeHtml({ html });
178
+ assert.doesNotMatch(result, /white-space: pre-wrap/);
179
+ assert.ok(result.includes('<li>item</li>'));
180
+ });
181
+
182
+ it('mimeHtml prefers the text part for the fallback', () => {
183
+ const result = mimeHtml({ html: '<div>'.repeat(4000) + 'html part', text: 'text <part>' });
184
+ assert.ok(result.includes('text &lt;part&gt;'));
185
+ });
186
+
187
+ it('mimeHtml still renders moderately nested HTML normally', () => {
188
+ const result = mimeHtml({ html: '<div>'.repeat(200) + 'normal' + '</div>'.repeat(200) });
189
+ assert.ok(result.includes('<div><div>'));
190
+ assert.ok(result.includes('normal'));
191
+ });
192
+
193
+ it('mimeHtml handles text with 5000 quote levels', () => {
194
+ const result = mimeHtml({ text: '>'.repeat(5000) + ' deep' });
195
+ assert.ok(result.includes('deep'));
196
+ });
197
+
198
+ it('mimeHtml.async falls back instead of rejecting', async () => {
199
+ const result = await mimeHtml.async({ html: 'async shallow' + '<div>'.repeat(4000) + 'async deep' });
200
+ assert.ok(result.startsWith(FALLBACK_PREFIX));
201
+ assert.ok(result.includes('async shallow'));
202
+ assert.ok(result.includes('async deep'));
203
+ });
204
+
205
+ it('the fallback keeps the text of an over-deep body that has no plain part', async () => {
206
+ const html = '<style>.x{color:red}</style><script>alert(1)</script>' + '<div>'.repeat(4000) + 'only &lt;html&gt; deep' + '</div>'.repeat(4000);
207
+ const result = await mimeHtml.async({ html });
208
+ assert.ok(result.startsWith(FALLBACK_PREFIX));
209
+ assert.ok(result.includes('only &lt;html&gt; deep'));
210
+ assert.ok(!result.includes('<div>'));
211
+ assert.ok(!result.includes('color:red'));
212
+ assert.ok(!result.includes('alert(1)'));
213
+ });
214
+
215
+ it('the fallback drops an unterminated style block and tag instead of rendering them', async () => {
216
+ const result = await mimeHtml.async({ html: 'kept <style>.x{}' + '<div>'.repeat(4000) });
217
+ assert.ok(result.includes('kept'));
218
+ assert.ok(!result.includes('.x{}'));
219
+ const unterminated = await mimeHtml.async({ html: 'kept <div' + '<div>'.repeat(4000) });
220
+ assert.ok(unterminated.includes('kept'));
221
+ });
222
+
223
+ it('mimeHtml.async rejects on hostile nesting with fallbackOnError: false', async () => {
224
+ await assert.rejects(mimeHtml.async({ html: '<div>'.repeat(4000) + 'x' }, { fallbackOnError: false }), /nested too deep/);
225
+ });
226
+
227
+ it('mimeHtml.async skips the juice worker when there is no style sheet', async () => {
228
+ await mimeHtml.closeWorkerPool();
229
+ const result = await mimeHtml.async({ html: '<p style="color: red">no sheet</p>' });
230
+ assert.ok(result.includes('no sheet'));
231
+ assert.equal(mimeHtml.getWorkerPoolStats(), null);
232
+ });
233
+
234
+ it('htmlToText does not overflow the stack on deep nesting', () => {
235
+ for (const tag of ['div', 'b', 'blockquote']) {
236
+ const html = 'hello &amp; <b>world</b>' + `<${tag}>`.repeat(20000) + 'deep' + `</${tag}>`.repeat(20000) + 'after';
237
+ const text = htmlToText(html);
238
+ assert.ok(text.startsWith('hello & world'), text);
239
+ assert.ok(text.includes('after'), text);
240
+ // content below the depth limit is dropped
241
+ assert.ok(!text.includes('deep'), text);
242
+ }
243
+ });
244
+ });
245
+
246
+ describe('sanitizer policy', () => {
247
+ it('removes forms and form controls but keeps their text', () => {
248
+ const html =
249
+ '<form action="https://evil.example/collect" method="post"><p>Please log in</p>' +
250
+ '<input type="password" name="p"><select><option>a</option></select>' +
251
+ '<textarea>typed</textarea><button formaction="https://evil.example/x">Send</button></form>';
252
+ const result = mimeHtml({ html });
253
+ assert.doesNotMatch(result, /<form|<input|<select|<textarea|<button/i);
254
+ assert.doesNotMatch(result, /evil\.example/);
255
+ assert.ok(result.includes('Please log in'));
256
+ assert.ok(result.includes('Send'));
257
+ });
258
+
259
+ it('removes forbidden style properties, including from styles juice inlined', () => {
260
+ const html =
261
+ '<html><head><style>p.x { float: left; color: red; }</style></head><body style="position: fixed">' +
262
+ '<div style="Position: absolute; top: 0">a</div><span style="small: 1; color: blue">b</span><p class="x">c</p></body></html>';
263
+ const result = mimeHtml({ html });
264
+ assert.doesNotMatch(result, /position|float/i);
265
+ assert.ok(result.includes('top: 0'));
266
+ assert.ok(result.includes('color: red'));
267
+ assert.ok(result.includes('color: blue'));
268
+ });
269
+
270
+ it('removes popover and form submission attributes', () => {
271
+ const html = '<div popover id="p">overlay</div><a href="https://example.com/" popovertarget="p" formaction="https://evil.example/">link</a>';
272
+ const result = mimeHtml({ html });
273
+ assert.doesNotMatch(result, /popover|formaction/i);
274
+ assert.ok(result.includes('overlay'));
275
+ assert.ok(result.includes('href="https://example.com/"'));
276
+ });
277
+ });
278
+
279
+ describe('reply/forward date header', () => {
280
+ const date = new Date('2024-06-15T14:30:00Z');
281
+ const hasLocale = locale => Intl.DateTimeFormat.supportedLocalesOf([locale]).length > 0;
282
+
283
+ it('formats in English by default', () => {
284
+ assert.equal(formatDate(date, { tz: 'UTC' }), 'Sat, Jun 15, 2024, 2:30 PM');
285
+ });
286
+
287
+ it('honors the time zone', () => {
288
+ assert.equal(formatDate(date, { tz: 'America/New_York' }), 'Sat, Jun 15, 2024, 10:30 AM');
289
+ assert.equal(formatDate(date, { tz: 'Asia/Tokyo' }), 'Sat, Jun 15, 2024, 11:30 PM');
290
+ });
291
+
292
+ it('honors the locale', t => {
293
+ if (!hasLocale('de') || !hasLocale('et') || !hasLocale('ja')) {
294
+ t.skip('Node built without full ICU data');
295
+ return;
296
+ }
297
+ assert.equal(formatDate(date, { locale: 'de', tz: 'Europe/Berlin' }), 'Sa., 15. Juni 2024, 16:30');
298
+ assert.equal(formatDate(date, { locale: 'et', tz: 'Europe/Tallinn' }), 'L, 15. juuni 2024, 17:30');
299
+ assert.ok(formatDate(date, { locale: 'ja', tz: 'Asia/Tokyo' }).includes('2024'));
300
+ assert.ok(formatDate(date, { locale: 'ja', tz: 'Asia/Tokyo' }).includes('23:30'));
301
+ });
302
+
303
+ it('falls back to English on an invalid or unknown locale', () => {
304
+ assert.equal(formatDate(date, { locale: 'not a locale!!', tz: 'UTC' }), 'Sat, Jun 15, 2024, 2:30 PM');
305
+ assert.equal(formatDate(date, { locale: 'xx', tz: 'UTC' }), 'Sat, Jun 15, 2024, 2:30 PM');
306
+ });
307
+
308
+ it('falls back to UTC on an unknown time zone', () => {
309
+ assert.equal(formatDate(date, { tz: 'Not/AZone' }), 'Sat, Jun 15, 2024, 2:30 PM');
310
+ });
311
+
312
+ it('accepts date strings and passes invalid dates through', () => {
313
+ assert.equal(formatDate('2024-06-15T14:30:00Z', { tz: 'UTC' }), 'Sat, Jun 15, 2024, 2:30 PM');
314
+ assert.equal(formatDate('Sat, 15 Jun 2024 14:30:00 +0000', { tz: 'UTC' }), 'Sat, Jun 15, 2024, 2:30 PM');
315
+ assert.equal(formatDate('garbage', {}), 'garbage');
316
+ });
317
+
318
+ it('is used by inlineHtml and inlineText', () => {
319
+ const data = { date, from: { address: 'a@example.com' }, subject: 'S', html: '<p>Body</p>', text: 'Body' };
320
+ assert.ok(inlineHtml('forward', '', data, { tz: 'UTC' }).includes('Sat, Jun 15, 2024, 2:30 PM'));
321
+ assert.ok(inlineText('forward', '', data, { tz: 'UTC' }).includes('Date: Sat, Jun 15, 2024, 2:30 PM'));
322
+ });
323
+ });
324
+
325
+ describe('lazy loading', () => {
326
+ it('requiring the package does not load jsdom until mimeHtml is used', () => {
327
+ // A fresh process, since this test process has already loaded jsdom
328
+ const script = [
329
+ "const tools = require('.');",
330
+ "const loaded = () => Object.keys(require.cache).some(k => k.includes('/node_modules/jsdom/'));",
331
+ 'const before = loaded();',
332
+ "tools.htmlToText('<p>x</p>');",
333
+ 'const afterText = loaded();',
334
+ "const out = tools.mimeHtml({ html: '<p>x</p>' });",
335
+ 'console.log(JSON.stringify([before, afterText, loaded(), typeof tools.mimeHtml.async, out]));'
336
+ ].join('\n');
337
+ const output = execFileSync(process.execPath, ['-e', script], { cwd: path.join(__dirname, '..') }).toString();
338
+ const [before, afterText, afterMime, asyncType, out] = JSON.parse(output);
339
+ assert.equal(before, false);
340
+ assert.equal(afterText, false);
341
+ assert.equal(afterMime, true);
342
+ assert.equal(asyncType, 'function');
343
+ assert.equal(out, '<div style="overflow: auto;"><p>x</p></div>');
344
+ });
345
+
346
+ it('keeps mimeHtml enumerable and destructurable', () => {
347
+ const tools = require('..');
348
+ assert.ok(Object.keys(tools).includes('mimeHtml'));
349
+ const { mimeHtml: fn } = tools;
350
+ assert.equal(fn, mimeHtml);
351
+ });
352
+ });
353
+ });
354
+
355
+ describe('forbidden style properties behind CSS escapes', () => {
356
+ it('strips a position property whose name uses CSS escapes', () => {
357
+ // a browser reads p\osition and \70 osition as position, jsdom's CSSOM does not
358
+ const html = '<div style="p\\osition: fixed; color: red; \\70 osition: absolute; float: left">x</div>';
359
+ const out = mimeHtml({ html });
360
+ assert.ok(!/position/i.test(out), out);
361
+ assert.ok(!/float/i.test(out), out);
362
+ assert.ok(/color:\s*red/i.test(out), out);
363
+ });
364
+ });
@@ -24,7 +24,8 @@ describe('mime-html additional coverage', () => {
24
24
  // Use timeout: 1 to force pool timeout, triggering fallback to single worker
25
25
  // Both may time out, but fallbackOnError defaults to true so processing continues
26
26
  const result = await mimeHtml.async(
27
- { html: '<p>Fallback test</p>' },
27
+ // a style sheet, so the juice worker actually runs
28
+ { html: '<style>p { color: red; }</style><p>Fallback test</p>' },
28
29
  {
29
30
  timeout: 1,
30
31
  useWorkerPool: true
@@ -174,7 +174,8 @@ describe('mime-html', () => {
174
174
 
175
175
  it('should use worker pool by default', async () => {
176
176
  const input = {
177
- html: '<p>Pool test</p>'
177
+ // juice, and with it the pool, only runs when there is a style sheet to inline
178
+ html: '<style>p { color: red; }</style><p>Pool test</p>'
178
179
  };
179
180
 
180
181
  const promises = [];
@@ -202,32 +203,6 @@ describe('mime-html', () => {
202
203
  assert.ok(result.includes('No pool test'));
203
204
  });
204
205
 
205
- it('should handle problematic CSS with pre-filtering', async () => {
206
- const input = {
207
- html: `
208
- <html>
209
- <head>
210
- <style>
211
- .test:is(.a, .b) { color: red; }
212
- .item:where(.x) { color: blue; }
213
- .parent:has(.child) { border: 1px solid; }
214
- </style>
215
- </head>
216
- <body>
217
- <p class="test a">Async test</p>
218
- </body>
219
- </html>
220
- `
221
- };
222
-
223
- const start = Date.now();
224
- const result = await mimeHtml.async(input, { timeout: 2000 });
225
- const elapsed = Date.now() - start;
226
-
227
- assert.ok(result.includes('Async test'));
228
- assert.ok(elapsed < 1000, 'Should complete quickly with pre-filtering');
229
- });
230
-
231
206
  it('should handle errors with fallback', async () => {
232
207
  const input = {
233
208
  html: '<p>Error test</p>'