@postalsys/email-text-tools 2.4.16 → 2.4.17

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.17"
3
3
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.4.17](https://github.com/postalsys/email-text-tools/compare/v2.4.16...v2.4.17) (2026-09-28)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * 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))
9
+
3
10
  ## [2.4.16](https://github.com/postalsys/email-text-tools/compare/v2.4.15...v2.4.16) (2026-09-07)
4
11
 
5
12
 
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
@@ -11,6 +11,7 @@ const path = require('path');
11
11
  const WorkerPool = require('./worker-pool');
12
12
 
13
13
  const textToHtml = require('./text-to-html');
14
+ const htmlToText = require('./html-to-text');
14
15
 
15
16
  const FORBID_TAGS = [
16
17
  'title',
@@ -31,9 +32,18 @@ const FORBID_TAGS = [
31
32
  'canvas',
32
33
  'audio',
33
34
  'video',
34
- 'applet'
35
+ 'applet',
36
+ // forms and their controls have no use in a displayed message and can be styled to phish
37
+ 'form',
38
+ 'input',
39
+ 'button',
40
+ 'select',
41
+ 'textarea'
35
42
  ];
36
43
 
44
+ // form submission targets and popovers, which let sender markup act on or overlay the viewer
45
+ const FORBID_ATTR = ['action', 'formaction', 'popover', 'popovertarget'];
46
+
37
47
  const FORBID_STYLES = [
38
48
  'all',
39
49
  'position',
@@ -62,9 +72,28 @@ const FORBID_STYLES = [
62
72
  'zoom'
63
73
  ];
64
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;
77
+
78
+ // Resolves CSS escape sequences (\6F, \o) the way a browser does before it matches a property
79
+ // name, so that a forbidden property cannot hide behind an escaped character
80
+ function decodeCssEscapes(value) {
81
+ return value.replace(/\\([0-9a-f]{1,6})[ \t\n\r\f]?|\\(.)/gi, (match, hex, chr) => {
82
+ if (hex) {
83
+ let code = parseInt(hex, 16);
84
+ return code && code <= 0x10ffff ? String.fromCodePoint(code) : '�';
85
+ }
86
+ return chr;
87
+ });
88
+ }
89
+
65
90
  // keep the style at first
66
91
  const FORBID_TAGS_ALL = ['style'].concat(FORBID_TAGS);
67
92
 
93
+ // One sanitizer for the module. Every sanitize() call passes its full config, so calls do not
94
+ // leak settings into each other.
95
+ const DOMPurify = createDOMPurify(new JSDOM('').window);
96
+
68
97
  // Global worker pool instance (lazy initialization)
69
98
  let globalWorkerPool = null;
70
99
 
@@ -166,105 +195,155 @@ blockquote blockquote blockquote blockquote{
166
195
 
167
196
  </style>`;
168
197
 
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;
198
+ // Only this much <style> text is kept per message, the rest is dropped. juice and the selector
199
+ // filter below scale with it, and no legitimate email needs more.
200
+ const MAX_STYLE_TEXT = 256 * 1024;
201
+
202
+ // jsdom builds a tree in time quadratic to its depth (10,000 unclosed <b> tags take about 9 s) and
203
+ // its serializer overflows the stack a few thousand levels down, so deeper input is not parsed at all.
204
+ // Real email nests a few dozen levels.
205
+ const MAX_NESTING_DEPTH = 1000;
206
+
207
+ // Elements that never stay open in HTML content, either void or closed implicitly by a sibling,
208
+ // so they do not add to the depth an HTML parser ends up with. Inside SVG and MathML they can nest,
209
+ // so there every start tag counts.
210
+ const NON_NESTING_TAGS = new Set(
211
+ '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(
212
+ ','
213
+ )
214
+ );
215
+
216
+ // A single linear pass estimating how deep the parsed tree would get. It only ever overestimates
217
+ // (tags inside comments, attribute values and raw text count too), which at worst means an
218
+ // extreme message is shown as plain text.
219
+ function exceedsNestingDepth(html, maxDepth) {
220
+ const tagName = /[a-z][a-z0-9:-]*/iy;
221
+ let depth = 0;
222
+ let foreign = 0;
223
+ let pos = 0;
224
+ while (pos < html.length) {
225
+ const tagStart = html.indexOf('<', pos);
226
+ if (tagStart < 0) {
227
+ break;
175
228
  }
176
- }
177
-
178
- html = (html || '').toString().replace(/^"data:[^"]+"/g, '');
179
-
180
- const window = new JSDOM('').window;
229
+ const tagEnd = html.indexOf('>', tagStart + 1);
230
+ if (tagEnd < 0) {
231
+ break;
232
+ }
233
+ // resume right after the first ">", so no tag the parser would see is ever skipped
234
+ pos = tagEnd + 1;
235
+
236
+ const closing = html.charAt(tagStart + 1) === '/';
237
+ tagName.lastIndex = tagStart + (closing ? 2 : 1);
238
+ const nameMatch = tagName.exec(html);
239
+ if (!nameMatch) {
240
+ continue;
241
+ }
242
+ const name = nameMatch[0].toLowerCase();
243
+ const isForeignRoot = name === 'svg' || name === 'math';
181
244
 
182
- const DOMPurify = createDOMPurify(window);
245
+ if (closing) {
246
+ if (isForeignRoot && foreign > 0) {
247
+ foreign--;
248
+ }
249
+ if (depth > 0 && (foreign || !NON_NESTING_TAGS.has(name))) {
250
+ depth--;
251
+ }
252
+ continue;
253
+ }
183
254
 
184
- // first pass, outputs a fixed HTML page
185
- html = DOMPurify.sanitize(html, { WHOLE_DOCUMENT: true, FORCE_BODY: false, FORBID_TAGS });
255
+ if (!foreign && NON_NESTING_TAGS.has(name)) {
256
+ continue;
257
+ }
186
258
 
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
- });
259
+ // a self-closing flag is ignored on HTML elements, only SVG and MathML honor it
260
+ if ((foreign || isForeignRoot) && html.charAt(tagEnd - 1) === '/') {
261
+ continue;
262
+ }
210
263
 
211
- html = tempDoc.documentElement.outerHTML;
264
+ if (isForeignRoot) {
265
+ foreign++;
266
+ }
212
267
 
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
268
+ depth++;
269
+ if (depth > maxDepth) {
270
+ return true;
271
+ }
217
272
  }
273
+ return false;
274
+ }
218
275
 
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');
276
+ // Rendered in place of the sanitized HTML when the pipeline cannot process the input at all, for
277
+ // example when a hostile nesting depth overflows the stack of the parser or the serializer.
278
+ function fallbackHtml(message) {
279
+ let text = message.text;
280
+ if (!text && message.html) {
281
+ text = htmlToText(message.html.toString());
230
282
  }
283
+ return `<div style="overflow: auto;"><div style="white-space: pre-wrap;">${he.encode((text || '').toString(), { useNamedReferences: true })}</div></div>`;
284
+ }
231
285
 
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');
242
-
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);
286
+ // True when a :not( argument contains another colon. Such selectors, like :is(), :where() and
287
+ // :has(), can make juice hang. A single pass with a paren counter keeps this linear, the regex
288
+ // it replaces backtracked catastrophically on a few hundred bytes of repeated ":not(a:b)".
289
+ function hasNotWithPseudo(selector) {
290
+ // paren depth just inside the open :not(, 0 when there is none. Any colon inside it is a
291
+ // match, so a nested :not( never needs tracking.
292
+ let notDepth = 0;
293
+ let depth = 0;
294
+ for (let i = 0; i < selector.length; i++) {
295
+ const c = selector.charAt(i);
296
+ if (c === ':') {
297
+ if (notDepth) {
298
+ return true;
248
299
  }
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);
300
+ if (selector.substr(i + 1, 4).toLowerCase() === 'not(') {
301
+ depth++;
302
+ notDepth = depth;
303
+ i += 4;
304
+ }
305
+ } else if (c === '(') {
306
+ depth++;
307
+ } else if (c === ')') {
308
+ if (depth === notDepth) {
309
+ notDepth = 0;
310
+ }
311
+ if (depth > 0) {
312
+ depth--;
256
313
  }
257
314
  }
258
- };
259
- walkDom(dom);
315
+ }
316
+ return false;
317
+ }
260
318
 
261
- let bodyStyles = (dom.getAttribute('style') || '').toString().trim();
319
+ function isProblematicSelector(selector) {
320
+ const lower = selector.toLowerCase();
321
+ return lower.includes(':is(') || lower.includes(':where(') || lower.includes(':has(') || hasNotWithPseudo(selector);
322
+ }
262
323
 
263
- // embed into a styled container
264
- return `<div style="${he.encode(bodyStyles)}">${dom.innerHTML.trim()}</div>`;
324
+ // Drops every rule whose selector could make juice hang. The text is split into rules on "}" and
325
+ // the selector of each rule is the text between the last two "{" in it, so a rule nested in an
326
+ // at-rule is dropped without its enclosing block.
327
+ function filterCss(css) {
328
+ if (!/:(is|where|has|not)\(/i.test(css)) {
329
+ return css;
330
+ }
331
+ const pieces = css.split('}');
332
+ const output = [];
333
+ for (let i = 0; i < pieces.length; i++) {
334
+ const piece = pieces[i];
335
+ const isLast = i === pieces.length - 1;
336
+ const ruleStart = isLast ? -1 : piece.lastIndexOf('{');
337
+ const selectorStart = ruleStart < 0 ? 0 : piece.lastIndexOf('{', ruleStart - 1) + 1;
338
+ const drop = ruleStart >= 0 && isProblematicSelector(piece.substring(selectorStart, ruleStart));
339
+ output.push(drop ? piece.substring(0, selectorStart) : isLast ? piece : piece + '}');
340
+ }
341
+ return output.join('');
265
342
  }
266
343
 
267
- async function mimeHtmlAsync(message, options = {}) {
344
+ // Sanitizes the whole document and strips the CSS juice is known to choke on. Returns the HTML
345
+ // to run juice on and whether any style text is left for juice to inline.
346
+ function prepareHtml(message) {
268
347
  let html = message.html;
269
348
  if (!html && message.text) {
270
349
  html = textToHtml(message.text) || '';
@@ -275,57 +354,47 @@ async function mimeHtmlAsync(message, options = {}) {
275
354
 
276
355
  html = (html || '').toString().replace(/^"data:[^"]+"/g, '');
277
356
 
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
357
+ if (exceedsNestingDepth(html, MAX_NESTING_DEPTH)) {
358
+ throw new Error('HTML is nested too deep to process');
359
+ }
317
360
 
318
- if (options.fallbackOnError === false) {
319
- throw err;
361
+ // first pass, returns the <html> element of the fixed HTML page
362
+ const root = DOMPurify.sanitize(html, { WHOLE_DOCUMENT: true, FORCE_BODY: false, FORBID_TAGS, FORBID_ATTR, RETURN_DOM: true });
363
+
364
+ // Remove any style tags from within body content
365
+ const body = root.querySelector('body');
366
+ const bodyStyleTags = body ? body.querySelectorAll('style') : [];
367
+ bodyStyleTags.forEach(tag => tag.remove());
368
+
369
+ // Process remaining style tags to remove known problematic selectors
370
+ let styleBudget = MAX_STYLE_TEXT;
371
+ let hasStyle = false;
372
+ root.querySelectorAll('style').forEach(styleTag => {
373
+ let cssContent = styleTag.textContent || '';
374
+ if (cssContent.length > styleBudget) {
375
+ // cut after the last complete rule, a partial rule would skip the selector filter
376
+ cssContent = styleBudget > 0 ? cssContent.substring(0, cssContent.lastIndexOf('}', styleBudget - 1) + 1) : '';
320
377
  }
321
- }
378
+ styleBudget -= cssContent.length;
379
+ cssContent = filterCss(cssContent);
380
+ styleTag.textContent = cssContent;
381
+ if (cssContent.trim()) {
382
+ hasStyle = true;
383
+ }
384
+ });
385
+
386
+ return { html: root.outerHTML, hasStyle };
387
+ }
322
388
 
389
+ // Second sanitization pass and post-processing of the juice output.
390
+ function finishHtml(html) {
323
391
  // second pass, outputs a BODY DOM element
324
392
  const dom = DOMPurify.sanitize(html, {
325
393
  RETURN_DOM: true,
326
394
  WHOLE_DOCUMENT: false,
327
395
  FORCE_BODY: true,
328
- FORBID_TAGS: FORBID_TAGS_ALL
396
+ FORBID_TAGS: FORBID_TAGS_ALL,
397
+ FORBID_ATTR
329
398
  });
330
399
 
331
400
  // mark all links to be opened in a new window
@@ -343,23 +412,25 @@ async function mimeHtmlAsync(message, options = {}) {
343
412
  dom.style.removeProperty('min-height');
344
413
  dom.style.removeProperty('max-height');
345
414
 
346
- // remove specific style properties for all DOM nodes
347
- let walkDom = node => {
348
- if (node.style) {
415
+ // remove specific style properties, touching the CSSOM only where the style text could name one
416
+ for (const node of [dom, ...dom.querySelectorAll('[style]')]) {
417
+ if (!node.style) {
418
+ continue;
419
+ }
420
+ let styleText = node.getAttribute('style') || '';
421
+ if (styleText.indexOf('\\') >= 0) {
422
+ // A browser resolves CSS escapes in property names (p\osition is position) but jsdom's
423
+ // CSSOM does not, so the check below would miss them. Decode first and write the
424
+ // decoded text back so the CSSOM sees the same properties the browser will
425
+ styleText = decodeCssEscapes(styleText);
426
+ node.setAttribute('style', styleText);
427
+ }
428
+ if (FORBID_STYLES_HINT.test(styleText)) {
349
429
  for (let disallowedStyle of FORBID_STYLES) {
350
430
  node.style.removeProperty(disallowedStyle);
351
431
  }
352
432
  }
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);
433
+ }
363
434
 
364
435
  let bodyStyles = (dom.getAttribute('style') || '').toString().trim();
365
436
 
@@ -367,6 +438,65 @@ async function mimeHtmlAsync(message, options = {}) {
367
438
  return `<div style="${he.encode(bodyStyles)}">${dom.innerHTML.trim()}</div>`;
368
439
  }
369
440
 
441
+ // Runs the pipeline and renders the plain text fallback when it fails, for example when a
442
+ // hostile nesting depth overflows the stack of the parser or the serializer.
443
+ function guarded(fn, message, rethrow) {
444
+ const onError = err => {
445
+ if (rethrow) {
446
+ throw err;
447
+ }
448
+ return fallbackHtml(message);
449
+ };
450
+ try {
451
+ const result = fn();
452
+ return result && typeof result.then === 'function' ? result.catch(onError) : result;
453
+ } catch (err) {
454
+ return onError(err);
455
+ }
456
+ }
457
+
458
+ function mimeHtmlSync(message) {
459
+ return guarded(
460
+ () => {
461
+ let { html, hasStyle } = prepareHtml(message);
462
+ if (hasStyle) {
463
+ try {
464
+ // Run juice synchronously - risky but backwards compatible
465
+ html = juice(html);
466
+ } catch (_err) {
467
+ // not so important, so we'll ignore if style inlining fails
468
+ }
469
+ }
470
+ return finishHtml(html);
471
+ },
472
+ message,
473
+ false
474
+ );
475
+ }
476
+
477
+ async function mimeHtmlAsync(message, options = {}) {
478
+ return guarded(
479
+ async () => {
480
+ let { html, hasStyle } = prepareHtml(message);
481
+ if (hasStyle) {
482
+ try {
483
+ // Use worker thread with timeout for juice - provides protection against ANY
484
+ // unexpected CSS that might cause hangs (beyond our known problematic selectors)
485
+ html = await juiceWithTimeout(html, options);
486
+ } catch (err) {
487
+ // If juice fails or times out, continue without inlining styles
488
+ if (options.fallbackOnError === false) {
489
+ throw err;
490
+ }
491
+ }
492
+ }
493
+ return finishHtml(html);
494
+ },
495
+ message,
496
+ options.fallbackOnError === false
497
+ );
498
+ }
499
+
370
500
  // Main export - maintains backward compatibility with sync API
371
501
  // Architecture:
372
502
  // - Both sync and async versions pre-filter known problematic CSS selectors
@@ -382,14 +512,8 @@ function mimeHtml(message) {
382
512
  mimeHtml.async = mimeHtmlAsync;
383
513
  mimeHtml.sync = mimeHtmlSync;
384
514
 
385
- // Close worker pool when process exits
386
- if (globalWorkerPool) {
387
- process.on('exit', () => {
388
- if (globalWorkerPool) {
389
- globalWorkerPool.close().catch(() => {});
390
- }
391
- });
392
- }
515
+ // Exposed for tests only, not part of the public API
516
+ mimeHtml._internal = { filterCss, hasNotWithPseudo };
393
517
 
394
518
  // Export function to manually close worker pool
395
519
  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.17",
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,344 @@
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
+ // text below the html-to-text depth limit is dropped
150
+ assert.doesNotMatch(result, /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(ms < 15000, `took ${ms} ms`);
159
+ });
160
+
161
+ for (const [label, html] of [
162
+ ['self-closing HTML tags', '<div/>'.repeat(20000) + 'deep'],
163
+ ['cells nested in SVG', '<svg>' + '<td>'.repeat(20000) + 'deep'],
164
+ ['an abruptly closed comment', '<!-->' + '<b>'.repeat(20000) + 'deep-->'],
165
+ ['style inside SVG', '<svg><style>' + '<b>'.repeat(20000) + 'deep']
166
+ ]) {
167
+ it(`mimeHtml depth check is not bypassed by ${label}`, () => {
168
+ const { result, ms } = timed(() => mimeHtml({ html }));
169
+ assert.ok(result.startsWith(FALLBACK_PREFIX));
170
+ assert.ok(ms < 15000, `took ${ms} ms`);
171
+ });
172
+ }
173
+
174
+ it('mimeHtml keeps long flat content with implicitly closed tags', () => {
175
+ const html = '<p>para'.repeat(3000) + '<ul>' + '<li>item'.repeat(3000) + '</ul><table>' + '<tr><td>cell'.repeat(3000) + '</table>';
176
+ const result = mimeHtml({ html });
177
+ assert.doesNotMatch(result, /white-space: pre-wrap/);
178
+ assert.ok(result.includes('<li>item</li>'));
179
+ });
180
+
181
+ it('mimeHtml prefers the text part for the fallback', () => {
182
+ const result = mimeHtml({ html: '<div>'.repeat(4000) + 'html part', text: 'text <part>' });
183
+ assert.ok(result.includes('text &lt;part&gt;'));
184
+ });
185
+
186
+ it('mimeHtml still renders moderately nested HTML normally', () => {
187
+ const result = mimeHtml({ html: '<div>'.repeat(200) + 'normal' + '</div>'.repeat(200) });
188
+ assert.ok(result.includes('<div><div>'));
189
+ assert.ok(result.includes('normal'));
190
+ });
191
+
192
+ it('mimeHtml handles text with 5000 quote levels', () => {
193
+ const result = mimeHtml({ text: '>'.repeat(5000) + ' deep' });
194
+ assert.ok(result.includes('deep'));
195
+ });
196
+
197
+ it('mimeHtml.async falls back instead of rejecting', async () => {
198
+ const result = await mimeHtml.async({ html: 'async shallow' + '<div>'.repeat(4000) + 'async deep' });
199
+ assert.ok(result.startsWith(FALLBACK_PREFIX));
200
+ assert.ok(result.includes('async shallow'));
201
+ });
202
+
203
+ it('mimeHtml.async rejects on hostile nesting with fallbackOnError: false', async () => {
204
+ await assert.rejects(mimeHtml.async({ html: '<div>'.repeat(4000) + 'x' }, { fallbackOnError: false }), /nested too deep/);
205
+ });
206
+
207
+ it('mimeHtml.async skips the juice worker when there is no style sheet', async () => {
208
+ await mimeHtml.closeWorkerPool();
209
+ const result = await mimeHtml.async({ html: '<p style="color: red">no sheet</p>' });
210
+ assert.ok(result.includes('no sheet'));
211
+ assert.equal(mimeHtml.getWorkerPoolStats(), null);
212
+ });
213
+
214
+ it('htmlToText does not overflow the stack on deep nesting', () => {
215
+ for (const tag of ['div', 'b', 'blockquote']) {
216
+ const html = 'hello &amp; <b>world</b>' + `<${tag}>`.repeat(20000) + 'deep' + `</${tag}>`.repeat(20000) + 'after';
217
+ const text = htmlToText(html);
218
+ assert.ok(text.startsWith('hello & world'), text);
219
+ assert.ok(text.includes('after'), text);
220
+ // content below the depth limit is dropped
221
+ assert.ok(!text.includes('deep'), text);
222
+ }
223
+ });
224
+ });
225
+
226
+ describe('sanitizer policy', () => {
227
+ it('removes forms and form controls but keeps their text', () => {
228
+ const html =
229
+ '<form action="https://evil.example/collect" method="post"><p>Please log in</p>' +
230
+ '<input type="password" name="p"><select><option>a</option></select>' +
231
+ '<textarea>typed</textarea><button formaction="https://evil.example/x">Send</button></form>';
232
+ const result = mimeHtml({ html });
233
+ assert.doesNotMatch(result, /<form|<input|<select|<textarea|<button/i);
234
+ assert.doesNotMatch(result, /evil\.example/);
235
+ assert.ok(result.includes('Please log in'));
236
+ assert.ok(result.includes('Send'));
237
+ });
238
+
239
+ it('removes forbidden style properties, including from styles juice inlined', () => {
240
+ const html =
241
+ '<html><head><style>p.x { float: left; color: red; }</style></head><body style="position: fixed">' +
242
+ '<div style="Position: absolute; top: 0">a</div><span style="small: 1; color: blue">b</span><p class="x">c</p></body></html>';
243
+ const result = mimeHtml({ html });
244
+ assert.doesNotMatch(result, /position|float/i);
245
+ assert.ok(result.includes('top: 0'));
246
+ assert.ok(result.includes('color: red'));
247
+ assert.ok(result.includes('color: blue'));
248
+ });
249
+
250
+ it('removes popover and form submission attributes', () => {
251
+ const html = '<div popover id="p">overlay</div><a href="https://example.com/" popovertarget="p" formaction="https://evil.example/">link</a>';
252
+ const result = mimeHtml({ html });
253
+ assert.doesNotMatch(result, /popover|formaction/i);
254
+ assert.ok(result.includes('overlay'));
255
+ assert.ok(result.includes('href="https://example.com/"'));
256
+ });
257
+ });
258
+
259
+ describe('reply/forward date header', () => {
260
+ const date = new Date('2024-06-15T14:30:00Z');
261
+ const hasLocale = locale => Intl.DateTimeFormat.supportedLocalesOf([locale]).length > 0;
262
+
263
+ it('formats in English by default', () => {
264
+ assert.equal(formatDate(date, { tz: 'UTC' }), 'Sat, Jun 15, 2024, 2:30 PM');
265
+ });
266
+
267
+ it('honors the time zone', () => {
268
+ assert.equal(formatDate(date, { tz: 'America/New_York' }), 'Sat, Jun 15, 2024, 10:30 AM');
269
+ assert.equal(formatDate(date, { tz: 'Asia/Tokyo' }), 'Sat, Jun 15, 2024, 11:30 PM');
270
+ });
271
+
272
+ it('honors the locale', t => {
273
+ if (!hasLocale('de') || !hasLocale('et') || !hasLocale('ja')) {
274
+ t.skip('Node built without full ICU data');
275
+ return;
276
+ }
277
+ assert.equal(formatDate(date, { locale: 'de', tz: 'Europe/Berlin' }), 'Sa., 15. Juni 2024, 16:30');
278
+ assert.equal(formatDate(date, { locale: 'et', tz: 'Europe/Tallinn' }), 'L, 15. juuni 2024, 17:30');
279
+ assert.ok(formatDate(date, { locale: 'ja', tz: 'Asia/Tokyo' }).includes('2024'));
280
+ assert.ok(formatDate(date, { locale: 'ja', tz: 'Asia/Tokyo' }).includes('23:30'));
281
+ });
282
+
283
+ it('falls back to English on an invalid or unknown locale', () => {
284
+ assert.equal(formatDate(date, { locale: 'not a locale!!', tz: 'UTC' }), 'Sat, Jun 15, 2024, 2:30 PM');
285
+ assert.equal(formatDate(date, { locale: 'xx', tz: 'UTC' }), 'Sat, Jun 15, 2024, 2:30 PM');
286
+ });
287
+
288
+ it('falls back to UTC on an unknown time zone', () => {
289
+ assert.equal(formatDate(date, { tz: 'Not/AZone' }), 'Sat, Jun 15, 2024, 2:30 PM');
290
+ });
291
+
292
+ it('accepts date strings and passes invalid dates through', () => {
293
+ assert.equal(formatDate('2024-06-15T14:30:00Z', { tz: 'UTC' }), 'Sat, Jun 15, 2024, 2:30 PM');
294
+ assert.equal(formatDate('Sat, 15 Jun 2024 14:30:00 +0000', { tz: 'UTC' }), 'Sat, Jun 15, 2024, 2:30 PM');
295
+ assert.equal(formatDate('garbage', {}), 'garbage');
296
+ });
297
+
298
+ it('is used by inlineHtml and inlineText', () => {
299
+ const data = { date, from: { address: 'a@example.com' }, subject: 'S', html: '<p>Body</p>', text: 'Body' };
300
+ assert.ok(inlineHtml('forward', '', data, { tz: 'UTC' }).includes('Sat, Jun 15, 2024, 2:30 PM'));
301
+ assert.ok(inlineText('forward', '', data, { tz: 'UTC' }).includes('Date: Sat, Jun 15, 2024, 2:30 PM'));
302
+ });
303
+ });
304
+
305
+ describe('lazy loading', () => {
306
+ it('requiring the package does not load jsdom until mimeHtml is used', () => {
307
+ // A fresh process, since this test process has already loaded jsdom
308
+ const script = [
309
+ "const tools = require('.');",
310
+ "const loaded = () => Object.keys(require.cache).some(k => k.includes('/node_modules/jsdom/'));",
311
+ 'const before = loaded();',
312
+ "tools.htmlToText('<p>x</p>');",
313
+ 'const afterText = loaded();',
314
+ "const out = tools.mimeHtml({ html: '<p>x</p>' });",
315
+ 'console.log(JSON.stringify([before, afterText, loaded(), typeof tools.mimeHtml.async, out]));'
316
+ ].join('\n');
317
+ const output = execFileSync(process.execPath, ['-e', script], { cwd: path.join(__dirname, '..') }).toString();
318
+ const [before, afterText, afterMime, asyncType, out] = JSON.parse(output);
319
+ assert.equal(before, false);
320
+ assert.equal(afterText, false);
321
+ assert.equal(afterMime, true);
322
+ assert.equal(asyncType, 'function');
323
+ assert.equal(out, '<div style="overflow: auto;"><p>x</p></div>');
324
+ });
325
+
326
+ it('keeps mimeHtml enumerable and destructurable', () => {
327
+ const tools = require('..');
328
+ assert.ok(Object.keys(tools).includes('mimeHtml'));
329
+ const { mimeHtml: fn } = tools;
330
+ assert.equal(fn, mimeHtml);
331
+ });
332
+ });
333
+ });
334
+
335
+ describe('forbidden style properties behind CSS escapes', () => {
336
+ it('strips a position property whose name uses CSS escapes', () => {
337
+ // a browser reads p\osition and \70 osition as position, jsdom's CSSOM does not
338
+ const html = '<div style="p\\osition: fixed; color: red; \\70 osition: absolute; float: left">x</div>';
339
+ const out = mimeHtml({ html });
340
+ assert.ok(!/position/i.test(out), out);
341
+ assert.ok(!/float/i.test(out), out);
342
+ assert.ok(/color:\s*red/i.test(out), out);
343
+ });
344
+ });
@@ -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>'