@postalsys/email-text-tools 2.4.25 → 2.5.0

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.
Files changed (84) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +10 -0
  3. package/dist/cjs/index.d.ts +18 -0
  4. package/dist/cjs/index.js +28 -0
  5. package/dist/cjs/lib/commonjs.cjs +17 -0
  6. package/dist/cjs/lib/commonjs.d.cts +4 -0
  7. package/dist/cjs/lib/format-date.d.ts +15 -0
  8. package/dist/cjs/lib/format-date.js +60 -0
  9. package/dist/cjs/lib/html-guard.d.ts +3 -0
  10. package/dist/cjs/lib/html-guard.js +119 -0
  11. package/dist/cjs/lib/html-to-text.d.ts +1 -0
  12. package/dist/cjs/lib/html-to-text.js +49 -0
  13. package/dist/cjs/lib/inline-html.d.ts +19 -0
  14. package/dist/cjs/lib/inline-html.js +150 -0
  15. package/dist/cjs/lib/inline-text.d.ts +3 -0
  16. package/dist/cjs/lib/inline-text.js +111 -0
  17. package/dist/cjs/lib/juice-worker.d.ts +1 -0
  18. package/dist/cjs/lib/juice-worker.js +19 -0
  19. package/dist/cjs/lib/mime-html.d.ts +34 -0
  20. package/dist/cjs/lib/mime-html.js +529 -0
  21. package/dist/cjs/lib/text-to-html.d.ts +1 -0
  22. package/dist/cjs/lib/text-to-html.js +182 -0
  23. package/dist/cjs/lib/worker-pool.d.ts +61 -0
  24. package/dist/cjs/lib/worker-pool.js +229 -0
  25. package/dist/cjs/package.json +3 -0
  26. package/dist/esm/index.d.ts +18 -0
  27. package/dist/esm/index.js +12 -0
  28. package/dist/esm/lib/commonjs.cjs +17 -0
  29. package/dist/esm/lib/commonjs.d.cts +4 -0
  30. package/dist/esm/lib/format-date.d.ts +15 -0
  31. package/{lib → dist/esm/lib}/format-date.js +8 -16
  32. package/dist/esm/lib/html-guard.d.ts +3 -0
  33. package/{lib → dist/esm/lib}/html-guard.js +8 -24
  34. package/dist/esm/lib/html-to-text.d.ts +1 -0
  35. package/{lib → dist/esm/lib}/html-to-text.js +2 -14
  36. package/dist/esm/lib/inline-html.d.ts +19 -0
  37. package/{lib → dist/esm/lib}/inline-html.js +17 -55
  38. package/dist/esm/lib/inline-text.d.ts +3 -0
  39. package/{lib → dist/esm/lib}/inline-text.js +8 -31
  40. package/dist/esm/lib/juice-worker.d.ts +1 -0
  41. package/{lib → dist/esm/lib}/juice-worker.js +7 -8
  42. package/dist/esm/lib/mime-html.d.ts +34 -0
  43. package/{lib → dist/esm/lib}/mime-html.js +79 -127
  44. package/dist/esm/lib/text-to-html.d.ts +1 -0
  45. package/{lib → dist/esm/lib}/text-to-html.js +35 -63
  46. package/dist/esm/lib/worker-pool.d.ts +61 -0
  47. package/{lib → dist/esm/lib}/worker-pool.js +18 -64
  48. package/dist/esm/package.json +3 -0
  49. package/package.json +72 -10
  50. package/.eslintrc +0 -15
  51. package/.github/VULNERABILITY_REPORT.yml +0 -75
  52. package/.github/workflows/release.yaml +0 -37
  53. package/.github/workflows/test.yaml +0 -31
  54. package/.ncurc.js +0 -22
  55. package/.prettierignore +0 -14
  56. package/.prettierrc.js +0 -8
  57. package/.release-please-manifest.json +0 -3
  58. package/benchmark/benchmark.js +0 -358
  59. package/benchmark/concurrency-benchmark.js +0 -176
  60. package/benchmark/quick-benchmark.js +0 -200
  61. package/benchmark/quick-concurrency.js +0 -64
  62. package/benchmark/real-world-benchmark.js +0 -267
  63. package/docs/PERFORMANCE.md +0 -262
  64. package/eslint.config.js +0 -52
  65. package/examples/failing.html +0 -617
  66. package/examples/fixtures/message-1.json +0 -74
  67. package/examples/inline-html.js +0 -24
  68. package/examples/inline-text.js +0 -24
  69. package/examples/mime-html.js +0 -284
  70. package/index.js +0 -22
  71. package/release-please-config.json +0 -10
  72. package/test/comprehensive.test.js +0 -358
  73. package/test/containment.test.js +0 -128
  74. package/test/edge-cases.test.js +0 -62
  75. package/test/hardening.test.js +0 -504
  76. package/test/html-to-text.test.js +0 -253
  77. package/test/inline-html.test.js +0 -230
  78. package/test/inline-text.test.js +0 -204
  79. package/test/mime-html-extra.test.js +0 -39
  80. package/test/mime-html.test.js +0 -302
  81. package/test/text-to-html-extra.test.js +0 -42
  82. package/test/worker-pool-extra.test.js +0 -132
  83. package/test/worker-pool-regression.test.js +0 -128
  84. package/test/worker-pool.test.js +0 -282
@@ -1,18 +1,13 @@
1
- 'use strict';
2
-
3
- const createDOMPurify = require('dompurify');
4
- const { JSDOM } = require('jsdom');
1
+ import createDOMPurify from 'dompurify';
2
+ import { JSDOM } from 'jsdom';
5
3
  // Pinned to juice 11.x: juice 12+ is pure ESM (requires Node >=22.12) and breaks pkg builds.
6
- // The `.default` fallback keeps this working if juice ever ships a CJS-compatible build again.
7
- const juice = require('juice').default || require('juice');
8
- const he = require('he');
9
- const { Worker } = require('worker_threads');
10
- const path = require('path');
11
- const WorkerPool = require('./worker-pool');
12
- const { MAX_NESTING_DEPTH, exceedsNestingDepth, fallbackText } = require('./html-guard');
13
-
14
- const textToHtml = require('./text-to-html');
15
-
4
+ import juice from 'juice';
5
+ import he from 'he';
6
+ import { Worker } from 'node:worker_threads';
7
+ import WorkerPool from './worker-pool.js';
8
+ import { MAX_NESTING_DEPTH, exceedsNestingDepth, fallbackText } from './html-guard.js';
9
+ import { JUICE_WORKER_PATH } from './commonjs.cjs';
10
+ import textToHtml from './text-to-html.js';
16
11
  const FORBID_TAGS = [
17
12
  'title',
18
13
  'link',
@@ -40,10 +35,8 @@ const FORBID_TAGS = [
40
35
  'select',
41
36
  'textarea'
42
37
  ];
43
-
44
38
  // form submission targets and popovers, which let sender markup act on or overlay the viewer
45
39
  const FORBID_ATTR = ['action', 'formaction', 'popover', 'popovertarget'];
46
-
47
40
  const FORBID_STYLES = [
48
41
  'all',
49
42
  'position',
@@ -71,16 +64,10 @@ const FORBID_STYLES = [
71
64
  'scrollbar-width',
72
65
  'zoom'
73
66
  ];
74
-
75
- // Properties to remove from style attributes: the list, a cheap test for style text that could
76
- // name one of them, and a test for one of them in declaration position (at the start, or after any
77
- // character that cannot be part of a longer property name, so "background-position" is not one)
78
67
  function styleRule(properties, hint) {
79
68
  return { properties, hint, declaration: new RegExp('(?:^|[^a-z0-9-])(?:' + properties.join('|') + ')\\s*:', 'i') };
80
69
  }
81
-
82
70
  const FORBID_STYLES_RULE = styleRule(FORBID_STYLES, /position|float|clip|zoom|anim|offset|scrollbar|paint|clear|\ball\b/i);
83
-
84
71
  // Resolves CSS escape sequences (\6F, \o) the way a browser does before it matches a property
85
72
  // name, so that a forbidden property cannot hide behind an escaped character
86
73
  function decodeCssEscapes(value) {
@@ -92,33 +79,26 @@ function decodeCssEscapes(value) {
92
79
  return chr;
93
80
  });
94
81
  }
95
-
96
82
  // keep the style at first
97
83
  const FORBID_TAGS_ALL = ['style'].concat(FORBID_TAGS);
98
-
99
84
  // HTML only. The default profiles admit SVG and MathML too, and the namespace confusion between
100
85
  // the three is where most sanitizer bypasses have come from: markup one parser closes inside an
101
86
  // <svg> or <mglyph> and another reads as HTML. Mail has no use for either, the major webmail
102
87
  // clients strip inline SVG and MathML never reached a mail client, so both go.
103
88
  const USE_PROFILES = { html: true };
104
-
105
89
  // A start tag the HTML parser reads as a style element: the name followed by whitespace, "/" or
106
90
  // ">". Any other character inside the name makes it some other element, and a "<style" at the
107
91
  // very end of the input is dropped by the parser, so markup this does not match has no stylesheet.
108
92
  const STYLE_TAG = /<style[\s/>]/i;
109
-
110
93
  // The dimensions of the body. In mail they are page-layout leftovers ("width: 100%; height: 100%",
111
94
  // "min-height: 100vh") that would only pad or scroll the box the message is shown in, and they
112
95
  // were removed when the body was the outermost element, which consumers may rely on.
113
96
  const BODY_STYLES_RULE = styleRule(['width', 'min-width', 'max-width', 'height', 'min-height', 'max-height'], /width|height/i);
114
-
115
97
  // One sanitizer for the module. Every sanitize() call passes its full config, so calls do not
116
98
  // leak settings into each other.
117
99
  const DOMPurify = createDOMPurify(new JSDOM('').window);
118
-
119
100
  // Global worker pool instance (lazy initialization)
120
101
  let globalWorkerPool = null;
121
-
122
102
  function getWorkerPool(options = {}) {
123
103
  if (!globalWorkerPool) {
124
104
  globalWorkerPool = new WorkerPool({
@@ -130,58 +110,52 @@ function getWorkerPool(options = {}) {
130
110
  }
131
111
  return globalWorkerPool;
132
112
  }
133
-
134
113
  // define styles for plaintext emails
135
114
  // Wrapper for juice that runs in a worker thread with timeout protection
136
115
  async function juiceWithTimeout(html, options = {}) {
137
116
  const timeout = options.timeout || 5000;
138
117
  const usePool = options.useWorkerPool !== false; // Default to true
139
-
140
118
  if (usePool) {
141
119
  try {
142
120
  const pool = getWorkerPool(options);
143
121
  return await pool.process(html, { timeout });
144
- } catch (err) {
122
+ }
123
+ catch (err) {
145
124
  // Fall back to single worker if pool fails
146
125
  if (options.fallbackOnError === false) {
147
126
  throw err;
148
127
  }
149
128
  }
150
129
  }
151
-
152
130
  // Single worker implementation (fallback or when pool disabled)
153
131
  const timeoutMs = timeout;
154
132
  return new Promise((resolve, reject) => {
155
- const worker = new Worker(path.join(__dirname, 'juice-worker.js'));
133
+ const worker = new Worker(JUICE_WORKER_PATH);
156
134
  let timedOut = false;
157
-
158
135
  const timeout = setTimeout(() => {
159
136
  timedOut = true;
160
- worker.terminate();
137
+ void worker.terminate();
161
138
  reject(new Error('Juice processing timed out - likely due to unsupported CSS selectors'));
162
139
  }, timeoutMs);
163
-
164
- worker.on('message', msg => {
140
+ worker.on('message', (msg) => {
165
141
  clearTimeout(timeout);
166
- worker.terminate();
142
+ void worker.terminate();
167
143
  if (msg.success) {
168
144
  resolve(msg.result);
169
- } else {
145
+ }
146
+ else {
170
147
  reject(new Error(msg.error));
171
148
  }
172
149
  });
173
-
174
- worker.on('error', err => {
150
+ worker.on('error', (err) => {
175
151
  clearTimeout(timeout);
176
152
  if (!timedOut) {
177
153
  reject(err);
178
154
  }
179
155
  });
180
-
181
156
  worker.postMessage(html);
182
157
  });
183
158
  }
184
-
185
159
  const INLINE_STYLE_BLOCK = `<style>
186
160
 
187
161
  body, td, th, p {
@@ -216,11 +190,9 @@ blockquote blockquote blockquote blockquote{
216
190
  }
217
191
 
218
192
  </style>`;
219
-
220
193
  // Only this much <style> text is kept per message, the rest is dropped. juice and the selector
221
194
  // filter below scale with it, and no legitimate email needs more.
222
195
  const MAX_STYLE_TEXT = 256 * 1024;
223
-
224
196
  // juice matches every selector against every element, so its cost is the product of the two
225
197
  // counts: 1,000 rules on 10,000 paragraphs took 2.7 s and 10,000 on 10,000 took 25 s, on the
226
198
  // calling thread, and the style text cap alone leaves the body unbounded. Each bound sits well
@@ -232,7 +204,6 @@ const MAX_STYLE_TEXT = 256 * 1024;
232
204
  const MAX_INLINE_SELECTORS = 5000;
233
205
  const MAX_INLINE_ELEMENTS = 50000;
234
206
  const MAX_INLINE_WORK = 5000000;
235
-
236
207
  // Selectors juice would match against the document: every selector of every rule at the top level
237
208
  // of the style text. A rule nested in an at-rule is not inlined (juice keeps media queries as rules),
238
209
  // so it only costs its parse and is not counted. One linear pass
@@ -248,20 +219,22 @@ function countSelectors(css) {
248
219
  commas = 0;
249
220
  }
250
221
  depth++;
251
- } else if (c === '}') {
222
+ }
223
+ else if (c === '}') {
252
224
  if (depth > 0) {
253
225
  depth--;
254
226
  }
255
227
  commas = 0;
256
- } else if (c === ';' && depth === 0) {
228
+ }
229
+ else if (c === ';' && depth === 0) {
257
230
  commas = 0;
258
- } else if (c === ',' && depth === 0) {
231
+ }
232
+ else if (c === ',' && depth === 0) {
259
233
  commas++;
260
234
  }
261
235
  }
262
236
  return count;
263
237
  }
264
-
265
238
  // Rendered in place of the sanitized HTML when the pipeline cannot process the input at all, for
266
239
  // example when a hostile nesting depth overflows the stack of the parser or the serializer.
267
240
  function fallbackHtml(message) {
@@ -271,7 +244,6 @@ function fallbackHtml(message) {
271
244
  }
272
245
  return `<div style="overflow: auto;"><div style="white-space: pre-wrap;">${he.encode((text || '').toString(), { useNamedReferences: true })}</div></div>`;
273
246
  }
274
-
275
247
  // True when a :not( argument contains another colon. Such selectors, like :is(), :where() and
276
248
  // :has(), can make juice hang. A single pass with a paren counter keeps this linear, the regex
277
249
  // it replaces backtracked catastrophically on a few hundred bytes of repeated ":not(a:b)".
@@ -286,14 +258,16 @@ function hasNotWithPseudo(selector) {
286
258
  if (notDepth) {
287
259
  return true;
288
260
  }
289
- if (selector.substr(i + 1, 4).toLowerCase() === 'not(') {
261
+ if (selector.slice(i + 1, i + 5).toLowerCase() === 'not(') {
290
262
  depth++;
291
263
  notDepth = depth;
292
264
  i += 4;
293
265
  }
294
- } else if (c === '(') {
266
+ }
267
+ else if (c === '(') {
295
268
  depth++;
296
- } else if (c === ')') {
269
+ }
270
+ else if (c === ')') {
297
271
  if (depth === notDepth) {
298
272
  notDepth = 0;
299
273
  }
@@ -304,12 +278,10 @@ function hasNotWithPseudo(selector) {
304
278
  }
305
279
  return false;
306
280
  }
307
-
308
281
  function isProblematicSelector(selector) {
309
282
  const lower = selector.toLowerCase();
310
283
  return lower.includes(':is(') || lower.includes(':where(') || lower.includes(':has(') || hasNotWithPseudo(selector);
311
284
  }
312
-
313
285
  // Drops every rule whose selector could make juice hang. The text is split into rules on "}" and
314
286
  // the selector of each rule is the text between the last two "{" in it, so a rule nested in an
315
287
  // at-rule is dropped without its enclosing block.
@@ -329,39 +301,39 @@ function filterCss(css) {
329
301
  }
330
302
  return output.join('');
331
303
  }
332
-
333
304
  // Sanitizes the whole document and strips the CSS juice is known to choke on. Returns the HTML
334
305
  // to run juice on and whether any style text is left for juice to inline.
335
306
  function prepareHtml(message) {
336
- let html = message.html;
337
- if (!html && message.text) {
338
- html = textToHtml(message.text) || '';
339
- if (html) {
340
- html = INLINE_STYLE_BLOCK + html;
307
+ let source = message.html;
308
+ if (!source && message.text) {
309
+ source = textToHtml(message.text) || '';
310
+ if (source) {
311
+ source = INLINE_STYLE_BLOCK + source;
341
312
  }
342
313
  }
343
-
344
- html = (html || '').toString().replace(/^"data:[^"]+"/g, '');
345
-
314
+ const html = (source || '').toString().replace(/^"data:[^"]+"/g, '');
346
315
  if (exceedsNestingDepth(html, MAX_NESTING_DEPTH)) {
347
316
  throw new Error('HTML is nested too deep to process');
348
317
  }
349
-
350
318
  if (!STYLE_TAG.test(html)) {
351
319
  // No style element can come out of this markup, so there is nothing to inline. This pass
352
320
  // exists to find and filter the stylesheets before juice sees them, and without one it
353
321
  // would only add a parse and a serialization to the pass finishHtml() makes anyway.
354
322
  return { html, hasStyle: false, inliningSkipped: false };
355
323
  }
356
-
357
324
  // first pass, returns the <html> element of the fixed HTML page
358
- const root = DOMPurify.sanitize(html, { WHOLE_DOCUMENT: true, FORCE_BODY: false, USE_PROFILES, FORBID_TAGS, FORBID_ATTR, RETURN_DOM: true });
359
-
325
+ const root = DOMPurify.sanitize(html, {
326
+ WHOLE_DOCUMENT: true,
327
+ FORCE_BODY: false,
328
+ USE_PROFILES,
329
+ FORBID_TAGS,
330
+ FORBID_ATTR,
331
+ RETURN_DOM: true
332
+ });
360
333
  // Remove any style tags from within body content
361
334
  const body = root.querySelector('body');
362
335
  const bodyStyleTags = body ? body.querySelectorAll('style') : [];
363
336
  bodyStyleTags.forEach(tag => tag.remove());
364
-
365
337
  // Process remaining style tags to remove known problematic selectors
366
338
  let styleBudget = MAX_STYLE_TEXT;
367
339
  let hasStyle = false;
@@ -380,7 +352,6 @@ function prepareHtml(message) {
380
352
  selectors += countSelectors(cssContent);
381
353
  }
382
354
  });
383
-
384
355
  let inliningSkipped = false;
385
356
  if (hasStyle) {
386
357
  const elements = root.getElementsByTagName('*').length;
@@ -389,10 +360,8 @@ function prepareHtml(message) {
389
360
  inliningSkipped = true;
390
361
  }
391
362
  }
392
-
393
363
  return { html: root.outerHTML, hasStyle, inliningSkipped };
394
364
  }
395
-
396
365
  // Removes the properties of a style rule from an element the way a browser will read them, by the
397
366
  // CSSOM first and then by the attribute text the CSSOM did not parse.
398
367
  function removeStyleProperties(node, rule) {
@@ -413,14 +382,15 @@ function removeStyleProperties(node, rule) {
413
382
  const parsed = node.style.cssText;
414
383
  if (parsed) {
415
384
  node.setAttribute('style', parsed);
416
- } else {
385
+ }
386
+ else {
417
387
  node.removeAttribute('style');
418
388
  }
419
- } else if (!remaining.trim()) {
389
+ }
390
+ else if (!remaining.trim()) {
420
391
  node.removeAttribute('style');
421
392
  }
422
393
  }
423
-
424
394
  // The sanitization pass that produces the output (the second one when there was a stylesheet to
425
395
  // inline, the only one otherwise) and the post-processing of its result.
426
396
  function finishHtml(html, inliningSkipped) {
@@ -433,7 +403,6 @@ function finishHtml(html, inliningSkipped) {
433
403
  FORBID_TAGS: FORBID_TAGS_ALL,
434
404
  FORBID_ATTR
435
405
  });
436
-
437
406
  // Every link opens a new browsing context that gets neither an opener nor a referrer. Browsers
438
407
  // imply noopener for target="_blank", but rel="opener" opts back in and the sender writes rel,
439
408
  // and an opener can navigate the page the message is shown in. An <area> is a link too.
@@ -441,7 +410,6 @@ function finishHtml(html, inliningSkipped) {
441
410
  link.setAttribute('target', '_blank');
442
411
  link.setAttribute('rel', 'noopener noreferrer');
443
412
  }
444
-
445
413
  // remove specific style properties, touching the CSSOM only where the style text could name one
446
414
  for (const node of [dom, ...dom.querySelectorAll('[style]')]) {
447
415
  if (!node.style) {
@@ -457,9 +425,7 @@ function finishHtml(html, inliningSkipped) {
457
425
  removeStyleProperties(node, FORBID_STYLES_RULE);
458
426
  }
459
427
  removeStyleProperties(dom, BODY_STYLES_RULE);
460
-
461
428
  const bodyStyles = (dom.getAttribute('style') || '').trim();
462
-
463
429
  // Two wrappers. The outer one is ours alone and clips everything inside it; the inner one is
464
430
  // the message body with the styles it came with. While the body's styles sat on the clipping
465
431
  // element, a sender could undo the clipping (display: contents or inline, an overflow-x:
@@ -473,11 +439,10 @@ function finishHtml(html, inliningSkipped) {
473
439
  const body = bodyStyles ? `<div style="${he.encode(bodyStyles)}">` : '<div>';
474
440
  return `<div style="overflow: auto; contain: paint;"${marker}>${body}${dom.innerHTML.trim()}</div></div>`;
475
441
  }
476
-
477
442
  // Runs the pipeline and renders the plain text fallback when it fails, for example when a
478
443
  // hostile nesting depth overflows the stack of the parser or the serializer.
479
444
  function guarded(fn, message, rethrow) {
480
- const onError = err => {
445
+ const onError = (err) => {
481
446
  if (rethrow) {
482
447
  throw err;
483
448
  }
@@ -485,54 +450,46 @@ function guarded(fn, message, rethrow) {
485
450
  };
486
451
  try {
487
452
  const result = fn();
488
- return result && typeof result.then === 'function' ? result.catch(onError) : result;
489
- } catch (err) {
453
+ return (typeof result === 'string' ? result : result.catch(onError));
454
+ }
455
+ catch (err) {
490
456
  return onError(err);
491
457
  }
492
458
  }
493
-
494
459
  function mimeHtmlSync(message) {
495
- return guarded(
496
- () => {
497
- let { html, hasStyle, inliningSkipped } = 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
- }
460
+ return guarded(() => {
461
+ let { html, hasStyle, inliningSkipped } = prepareHtml(message);
462
+ if (hasStyle) {
463
+ try {
464
+ // Run juice synchronously - risky but backwards compatible
465
+ html = juice(html);
466
+ }
467
+ catch (_err) {
468
+ // not so important, so we'll ignore if style inlining fails
505
469
  }
506
- return finishHtml(html, inliningSkipped);
507
- },
508
- message,
509
- false
510
- );
470
+ }
471
+ return finishHtml(html, inliningSkipped);
472
+ }, message, false);
511
473
  }
512
-
513
474
  async function mimeHtmlAsync(message, options = {}) {
514
- return guarded(
515
- async () => {
516
- let { html, hasStyle, inliningSkipped } = 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
- }
475
+ return guarded(async () => {
476
+ let { html, hasStyle, inliningSkipped } = prepareHtml(message);
477
+ if (hasStyle) {
478
+ try {
479
+ // Use worker thread with timeout for juice - provides protection against ANY
480
+ // unexpected CSS that might cause hangs (beyond our known problematic selectors)
481
+ html = await juiceWithTimeout(html, options);
482
+ }
483
+ catch (err) {
484
+ // If juice fails or times out, continue without inlining styles
485
+ if (options.fallbackOnError === false) {
486
+ throw err;
527
487
  }
528
488
  }
529
- return finishHtml(html, inliningSkipped);
530
- },
531
- message,
532
- options.fallbackOnError === false
533
- );
489
+ }
490
+ return finishHtml(html, inliningSkipped);
491
+ }, message, options.fallbackOnError === false);
534
492
  }
535
-
536
493
  // Main export - maintains backward compatibility with sync API
537
494
  // Architecture:
538
495
  // - Both sync and async versions pre-filter known problematic CSS selectors
@@ -543,14 +500,11 @@ function mimeHtml(message) {
543
500
  // Use the sync version for backward compatibility
544
501
  return mimeHtmlSync(message);
545
502
  }
546
-
547
503
  // Export both versions
548
504
  mimeHtml.async = mimeHtmlAsync;
549
505
  mimeHtml.sync = mimeHtmlSync;
550
-
551
506
  // Exposed for tests only, not part of the public API
552
507
  mimeHtml._internal = { filterCss, hasNotWithPseudo };
553
-
554
508
  // Export function to manually close worker pool
555
509
  mimeHtml.closeWorkerPool = async () => {
556
510
  if (globalWorkerPool) {
@@ -558,7 +512,6 @@ mimeHtml.closeWorkerPool = async () => {
558
512
  globalWorkerPool = null;
559
513
  }
560
514
  };
561
-
562
515
  // Export function to get worker pool stats
563
516
  mimeHtml.getWorkerPoolStats = () => {
564
517
  if (globalWorkerPool) {
@@ -566,5 +519,4 @@ mimeHtml.getWorkerPoolStats = () => {
566
519
  }
567
520
  return null;
568
521
  };
569
-
570
- module.exports = mimeHtml;
522
+ export default mimeHtml;
@@ -0,0 +1 @@
1
+ export default function textToHtml(text?: string | Buffer | null): string | undefined;