@postalsys/email-text-tools 2.4.26 → 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 +7 -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 +67 -10
  50. package/.github/VULNERABILITY_REPORT.yml +0 -75
  51. package/.github/workflows/release.yaml +0 -38
  52. package/.github/workflows/test.yaml +0 -35
  53. package/.ncurc.js +0 -28
  54. package/.prettierignore +0 -14
  55. package/.prettierrc.js +0 -10
  56. package/.release-please-manifest.json +0 -3
  57. package/CLAUDE.md +0 -105
  58. package/benchmark/benchmark.js +0 -348
  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 -257
  63. package/docs/PERFORMANCE.md +0 -262
  64. package/eslint.config.js +0 -71
  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
@@ -0,0 +1,34 @@
1
+ import type { WorkerPoolStats } from './worker-pool.js';
2
+ /** The message to render */
3
+ export interface MimeHtmlMessage {
4
+ html?: string | Buffer | null | undefined;
5
+ text?: string | Buffer | null | undefined;
6
+ }
7
+ export interface MimeHtmlAsyncOptions {
8
+ /** How long juice may run in the worker, in milliseconds (default 5000) */
9
+ timeout?: number | undefined;
10
+ /** Run juice in the shared worker pool (default) or in a worker of its own */
11
+ useWorkerPool?: boolean | undefined;
12
+ /** false rethrows a juice failure or a pipeline error instead of falling back */
13
+ fallbackOnError?: boolean | undefined;
14
+ /** Size of the shared worker pool, used when the pool is created */
15
+ minWorkers?: number | undefined;
16
+ maxWorkers?: number | undefined;
17
+ idleTimeout?: number | undefined;
18
+ }
19
+ declare function hasNotWithPseudo(selector: string): boolean;
20
+ declare function filterCss(css: string): string;
21
+ declare function mimeHtmlSync(message: MimeHtmlMessage): string;
22
+ declare function mimeHtmlAsync(message: MimeHtmlMessage, options?: MimeHtmlAsyncOptions): Promise<string>;
23
+ declare function mimeHtml(message: MimeHtmlMessage): string;
24
+ declare namespace mimeHtml {
25
+ var async: typeof mimeHtmlAsync;
26
+ var sync: typeof mimeHtmlSync;
27
+ var _internal: {
28
+ filterCss: typeof filterCss;
29
+ hasNotWithPseudo: typeof hasNotWithPseudo;
30
+ };
31
+ var closeWorkerPool: () => Promise<void>;
32
+ var getWorkerPoolStats: () => WorkerPoolStats | null;
33
+ }
34
+ export default mimeHtml;
@@ -0,0 +1,529 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const dompurify_1 = __importDefault(require("dompurify"));
7
+ const jsdom_1 = require("jsdom");
8
+ // Pinned to juice 11.x: juice 12+ is pure ESM (requires Node >=22.12) and breaks pkg builds.
9
+ const juice_1 = __importDefault(require("juice"));
10
+ const he_1 = __importDefault(require("he"));
11
+ const node_worker_threads_1 = require("node:worker_threads");
12
+ const worker_pool_js_1 = __importDefault(require("./worker-pool.js"));
13
+ const html_guard_js_1 = require("./html-guard.js");
14
+ const commonjs_cjs_1 = require("./commonjs.cjs");
15
+ const text_to_html_js_1 = __importDefault(require("./text-to-html.js"));
16
+ const FORBID_TAGS = [
17
+ 'title',
18
+ 'link',
19
+ 'meta',
20
+ 'base',
21
+ 'basefont',
22
+ 'frame',
23
+ 'iframe',
24
+ 'frameset',
25
+ 'template',
26
+ 'script',
27
+ 'noframes',
28
+ 'noscript',
29
+ 'object',
30
+ 'embed',
31
+ 'dialog',
32
+ 'canvas',
33
+ 'audio',
34
+ 'video',
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'
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
+ const FORBID_STYLES = [
46
+ 'all',
47
+ 'position',
48
+ 'clip',
49
+ 'animation',
50
+ 'float',
51
+ 'clear',
52
+ 'animation-delay',
53
+ 'animation-direction',
54
+ 'animation-duration',
55
+ 'animation-fill-mode',
56
+ 'animation-iteration-count',
57
+ 'animation-name',
58
+ 'animation-play-state',
59
+ 'animation-timing-function',
60
+ 'offset',
61
+ 'offset-anchor',
62
+ 'offset-distance',
63
+ 'offset-path',
64
+ 'offset-position',
65
+ 'offset-rotate',
66
+ 'paint-order',
67
+ 'scrollbar-color',
68
+ 'scrollbar-gutter',
69
+ 'scrollbar-width',
70
+ 'zoom'
71
+ ];
72
+ function styleRule(properties, hint) {
73
+ return { properties, hint, declaration: new RegExp('(?:^|[^a-z0-9-])(?:' + properties.join('|') + ')\\s*:', 'i') };
74
+ }
75
+ const FORBID_STYLES_RULE = styleRule(FORBID_STYLES, /position|float|clip|zoom|anim|offset|scrollbar|paint|clear|\ball\b/i);
76
+ // Resolves CSS escape sequences (\6F, \o) the way a browser does before it matches a property
77
+ // name, so that a forbidden property cannot hide behind an escaped character
78
+ function decodeCssEscapes(value) {
79
+ return value.replace(/\\([0-9a-f]{1,6})[ \t\n\r\f]?|\\(.)/gi, (match, hex, chr) => {
80
+ if (hex) {
81
+ let code = parseInt(hex, 16);
82
+ return code && code <= 0x10ffff ? String.fromCodePoint(code) : '�';
83
+ }
84
+ return chr;
85
+ });
86
+ }
87
+ // keep the style at first
88
+ const FORBID_TAGS_ALL = ['style'].concat(FORBID_TAGS);
89
+ // HTML only. The default profiles admit SVG and MathML too, and the namespace confusion between
90
+ // the three is where most sanitizer bypasses have come from: markup one parser closes inside an
91
+ // <svg> or <mglyph> and another reads as HTML. Mail has no use for either, the major webmail
92
+ // clients strip inline SVG and MathML never reached a mail client, so both go.
93
+ const USE_PROFILES = { html: true };
94
+ // A start tag the HTML parser reads as a style element: the name followed by whitespace, "/" or
95
+ // ">". Any other character inside the name makes it some other element, and a "<style" at the
96
+ // very end of the input is dropped by the parser, so markup this does not match has no stylesheet.
97
+ const STYLE_TAG = /<style[\s/>]/i;
98
+ // The dimensions of the body. In mail they are page-layout leftovers ("width: 100%; height: 100%",
99
+ // "min-height: 100vh") that would only pad or scroll the box the message is shown in, and they
100
+ // were removed when the body was the outermost element, which consumers may rely on.
101
+ const BODY_STYLES_RULE = styleRule(['width', 'min-width', 'max-width', 'height', 'min-height', 'max-height'], /width|height/i);
102
+ // One sanitizer for the module. Every sanitize() call passes its full config, so calls do not
103
+ // leak settings into each other.
104
+ const DOMPurify = (0, dompurify_1.default)(new jsdom_1.JSDOM('').window);
105
+ // Global worker pool instance (lazy initialization)
106
+ let globalWorkerPool = null;
107
+ function getWorkerPool(options = {}) {
108
+ if (!globalWorkerPool) {
109
+ globalWorkerPool = new worker_pool_js_1.default({
110
+ minWorkers: options.minWorkers || 2,
111
+ maxWorkers: options.maxWorkers || 4,
112
+ workerTimeout: options.timeout || 5000,
113
+ idleTimeout: options.idleTimeout || 30000
114
+ });
115
+ }
116
+ return globalWorkerPool;
117
+ }
118
+ // define styles for plaintext emails
119
+ // Wrapper for juice that runs in a worker thread with timeout protection
120
+ async function juiceWithTimeout(html, options = {}) {
121
+ const timeout = options.timeout || 5000;
122
+ const usePool = options.useWorkerPool !== false; // Default to true
123
+ if (usePool) {
124
+ try {
125
+ const pool = getWorkerPool(options);
126
+ return await pool.process(html, { timeout });
127
+ }
128
+ catch (err) {
129
+ // Fall back to single worker if pool fails
130
+ if (options.fallbackOnError === false) {
131
+ throw err;
132
+ }
133
+ }
134
+ }
135
+ // Single worker implementation (fallback or when pool disabled)
136
+ const timeoutMs = timeout;
137
+ return new Promise((resolve, reject) => {
138
+ const worker = new node_worker_threads_1.Worker(commonjs_cjs_1.JUICE_WORKER_PATH);
139
+ let timedOut = false;
140
+ const timeout = setTimeout(() => {
141
+ timedOut = true;
142
+ void worker.terminate();
143
+ reject(new Error('Juice processing timed out - likely due to unsupported CSS selectors'));
144
+ }, timeoutMs);
145
+ worker.on('message', (msg) => {
146
+ clearTimeout(timeout);
147
+ void worker.terminate();
148
+ if (msg.success) {
149
+ resolve(msg.result);
150
+ }
151
+ else {
152
+ reject(new Error(msg.error));
153
+ }
154
+ });
155
+ worker.on('error', (err) => {
156
+ clearTimeout(timeout);
157
+ if (!timedOut) {
158
+ reject(err);
159
+ }
160
+ });
161
+ worker.postMessage(html);
162
+ });
163
+ }
164
+ const INLINE_STYLE_BLOCK = `<style>
165
+
166
+ body, td, th, p {
167
+ font-family: sans-serif;
168
+ font-size: 12px;
169
+ }
170
+
171
+ blockquote {
172
+ border-left-width: 2px;
173
+ border-left-style: solid;
174
+
175
+ border-left-color: darkblue;
176
+ color: darkblue;
177
+
178
+ margin: 1rem 0;
179
+ padding-left: 1rem;
180
+ }
181
+
182
+ blockquote blockquote{
183
+ border-left-color: royalblue;
184
+ color: royalblue;
185
+ }
186
+
187
+ blockquote blockquote blockquote{
188
+ border-left-color: dodgerblue;
189
+ color: dodgerblue;
190
+ }
191
+
192
+ blockquote blockquote blockquote blockquote{
193
+ border-left-color: darkblue;
194
+ color: darkblue;
195
+ }
196
+
197
+ </style>`;
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
+ // juice matches every selector against every element, so its cost is the product of the two
202
+ // counts: 1,000 rules on 10,000 paragraphs took 2.7 s and 10,000 on 10,000 took 25 s, on the
203
+ // calling thread, and the style text cap alone leaves the body unbounded. Each bound sits well
204
+ // above real mail (an Outlook thread of fifty replies is a few hundred selectors on some fifteen
205
+ // thousand elements, a heavy newsletter a thousand on a few thousand) and the product bound keeps
206
+ // the worst case around a second: 0.25 us per pair measured when every selector matches every
207
+ // element, a quarter of that when none does. Past any of them the sanitized HTML is returned
208
+ // without inlined CSS, and the wrapper says so.
209
+ const MAX_INLINE_SELECTORS = 5000;
210
+ const MAX_INLINE_ELEMENTS = 50000;
211
+ const MAX_INLINE_WORK = 5000000;
212
+ // Selectors juice would match against the document: every selector of every rule at the top level
213
+ // of the style text. A rule nested in an at-rule is not inlined (juice keeps media queries as rules),
214
+ // so it only costs its parse and is not counted. One linear pass
215
+ function countSelectors(css) {
216
+ let count = 0;
217
+ let depth = 0;
218
+ let commas = 0;
219
+ for (let i = 0; i < css.length; i++) {
220
+ const c = css.charAt(i);
221
+ if (c === '{') {
222
+ if (depth === 0) {
223
+ count += commas + 1;
224
+ commas = 0;
225
+ }
226
+ depth++;
227
+ }
228
+ else if (c === '}') {
229
+ if (depth > 0) {
230
+ depth--;
231
+ }
232
+ commas = 0;
233
+ }
234
+ else if (c === ';' && depth === 0) {
235
+ commas = 0;
236
+ }
237
+ else if (c === ',' && depth === 0) {
238
+ commas++;
239
+ }
240
+ }
241
+ return count;
242
+ }
243
+ // Rendered in place of the sanitized HTML when the pipeline cannot process the input at all, for
244
+ // example when a hostile nesting depth overflows the stack of the parser or the serializer.
245
+ function fallbackHtml(message) {
246
+ let text = message.text;
247
+ if (!text && message.html) {
248
+ text = (0, html_guard_js_1.fallbackText)(message.html.toString());
249
+ }
250
+ return `<div style="overflow: auto;"><div style="white-space: pre-wrap;">${he_1.default.encode((text || '').toString(), { useNamedReferences: true })}</div></div>`;
251
+ }
252
+ // True when a :not( argument contains another colon. Such selectors, like :is(), :where() and
253
+ // :has(), can make juice hang. A single pass with a paren counter keeps this linear, the regex
254
+ // it replaces backtracked catastrophically on a few hundred bytes of repeated ":not(a:b)".
255
+ function hasNotWithPseudo(selector) {
256
+ // paren depth just inside the open :not(, 0 when there is none. Any colon inside it is a
257
+ // match, so a nested :not( never needs tracking.
258
+ let notDepth = 0;
259
+ let depth = 0;
260
+ for (let i = 0; i < selector.length; i++) {
261
+ const c = selector.charAt(i);
262
+ if (c === ':') {
263
+ if (notDepth) {
264
+ return true;
265
+ }
266
+ if (selector.slice(i + 1, i + 5).toLowerCase() === 'not(') {
267
+ depth++;
268
+ notDepth = depth;
269
+ i += 4;
270
+ }
271
+ }
272
+ else if (c === '(') {
273
+ depth++;
274
+ }
275
+ else if (c === ')') {
276
+ if (depth === notDepth) {
277
+ notDepth = 0;
278
+ }
279
+ if (depth > 0) {
280
+ depth--;
281
+ }
282
+ }
283
+ }
284
+ return false;
285
+ }
286
+ function isProblematicSelector(selector) {
287
+ const lower = selector.toLowerCase();
288
+ return lower.includes(':is(') || lower.includes(':where(') || lower.includes(':has(') || hasNotWithPseudo(selector);
289
+ }
290
+ // Drops every rule whose selector could make juice hang. The text is split into rules on "}" and
291
+ // the selector of each rule is the text between the last two "{" in it, so a rule nested in an
292
+ // at-rule is dropped without its enclosing block.
293
+ function filterCss(css) {
294
+ if (!/:(is|where|has|not)\(/i.test(css)) {
295
+ return css;
296
+ }
297
+ const pieces = css.split('}');
298
+ const output = [];
299
+ for (let i = 0; i < pieces.length; i++) {
300
+ const piece = pieces[i];
301
+ const isLast = i === pieces.length - 1;
302
+ const ruleStart = isLast ? -1 : piece.lastIndexOf('{');
303
+ const selectorStart = ruleStart < 0 ? 0 : piece.lastIndexOf('{', ruleStart - 1) + 1;
304
+ const drop = ruleStart >= 0 && isProblematicSelector(piece.substring(selectorStart, ruleStart));
305
+ output.push(drop ? piece.substring(0, selectorStart) : isLast ? piece : piece + '}');
306
+ }
307
+ return output.join('');
308
+ }
309
+ // Sanitizes the whole document and strips the CSS juice is known to choke on. Returns the HTML
310
+ // to run juice on and whether any style text is left for juice to inline.
311
+ function prepareHtml(message) {
312
+ let source = message.html;
313
+ if (!source && message.text) {
314
+ source = (0, text_to_html_js_1.default)(message.text) || '';
315
+ if (source) {
316
+ source = INLINE_STYLE_BLOCK + source;
317
+ }
318
+ }
319
+ const html = (source || '').toString().replace(/^"data:[^"]+"/g, '');
320
+ if ((0, html_guard_js_1.exceedsNestingDepth)(html, html_guard_js_1.MAX_NESTING_DEPTH)) {
321
+ throw new Error('HTML is nested too deep to process');
322
+ }
323
+ if (!STYLE_TAG.test(html)) {
324
+ // No style element can come out of this markup, so there is nothing to inline. This pass
325
+ // exists to find and filter the stylesheets before juice sees them, and without one it
326
+ // would only add a parse and a serialization to the pass finishHtml() makes anyway.
327
+ return { html, hasStyle: false, inliningSkipped: false };
328
+ }
329
+ // first pass, returns the <html> element of the fixed HTML page
330
+ const root = DOMPurify.sanitize(html, {
331
+ WHOLE_DOCUMENT: true,
332
+ FORCE_BODY: false,
333
+ USE_PROFILES,
334
+ FORBID_TAGS,
335
+ FORBID_ATTR,
336
+ RETURN_DOM: true
337
+ });
338
+ // Remove any style tags from within body content
339
+ const body = root.querySelector('body');
340
+ const bodyStyleTags = body ? body.querySelectorAll('style') : [];
341
+ bodyStyleTags.forEach(tag => tag.remove());
342
+ // Process remaining style tags to remove known problematic selectors
343
+ let styleBudget = MAX_STYLE_TEXT;
344
+ let hasStyle = false;
345
+ let selectors = 0;
346
+ root.querySelectorAll('style').forEach(styleTag => {
347
+ let cssContent = styleTag.textContent || '';
348
+ if (cssContent.length > styleBudget) {
349
+ // cut after the last complete rule, a partial rule would skip the selector filter
350
+ cssContent = styleBudget > 0 ? cssContent.substring(0, cssContent.lastIndexOf('}', styleBudget - 1) + 1) : '';
351
+ }
352
+ styleBudget -= cssContent.length;
353
+ cssContent = filterCss(cssContent);
354
+ styleTag.textContent = cssContent;
355
+ if (cssContent.trim()) {
356
+ hasStyle = true;
357
+ selectors += countSelectors(cssContent);
358
+ }
359
+ });
360
+ let inliningSkipped = false;
361
+ if (hasStyle) {
362
+ const elements = root.getElementsByTagName('*').length;
363
+ if (selectors > MAX_INLINE_SELECTORS || elements > MAX_INLINE_ELEMENTS || selectors * elements > MAX_INLINE_WORK) {
364
+ hasStyle = false;
365
+ inliningSkipped = true;
366
+ }
367
+ }
368
+ return { html: root.outerHTML, hasStyle, inliningSkipped };
369
+ }
370
+ // Removes the properties of a style rule from an element the way a browser will read them, by the
371
+ // CSSOM first and then by the attribute text the CSSOM did not parse.
372
+ function removeStyleProperties(node, rule) {
373
+ if (!rule.hint.test(node.getAttribute('style') || '')) {
374
+ return;
375
+ }
376
+ for (const property of rule.properties) {
377
+ node.style.removeProperty(property);
378
+ }
379
+ // removeProperty() rewrites the attribute from the CSSOM, which only holds the declarations
380
+ // cssstyle managed to parse. A forbidden property still named in the attribute after that was
381
+ // never parsed and would go out verbatim for the browser to read its own way: cssstyle 5 parses
382
+ // nothing from an attribute whose "background: url()" shorthand is followed by more
383
+ // declarations, a browser reads every one of them. Keep only what the CSSOM parsed, which may
384
+ // be nothing
385
+ const remaining = node.getAttribute('style') || '';
386
+ if (rule.declaration.test(remaining)) {
387
+ const parsed = node.style.cssText;
388
+ if (parsed) {
389
+ node.setAttribute('style', parsed);
390
+ }
391
+ else {
392
+ node.removeAttribute('style');
393
+ }
394
+ }
395
+ else if (!remaining.trim()) {
396
+ node.removeAttribute('style');
397
+ }
398
+ }
399
+ // The sanitization pass that produces the output (the second one when there was a stylesheet to
400
+ // inline, the only one otherwise) and the post-processing of its result.
401
+ function finishHtml(html, inliningSkipped) {
402
+ // outputs a BODY DOM element
403
+ const dom = DOMPurify.sanitize(html, {
404
+ RETURN_DOM: true,
405
+ WHOLE_DOCUMENT: false,
406
+ FORCE_BODY: true,
407
+ USE_PROFILES,
408
+ FORBID_TAGS: FORBID_TAGS_ALL,
409
+ FORBID_ATTR
410
+ });
411
+ // Every link opens a new browsing context that gets neither an opener nor a referrer. Browsers
412
+ // imply noopener for target="_blank", but rel="opener" opts back in and the sender writes rel,
413
+ // and an opener can navigate the page the message is shown in. An <area> is a link too.
414
+ for (const link of dom.querySelectorAll('a, area')) {
415
+ link.setAttribute('target', '_blank');
416
+ link.setAttribute('rel', 'noopener noreferrer');
417
+ }
418
+ // remove specific style properties, touching the CSSOM only where the style text could name one
419
+ for (const node of [dom, ...dom.querySelectorAll('[style]')]) {
420
+ if (!node.style) {
421
+ continue;
422
+ }
423
+ const styleText = node.getAttribute('style') || '';
424
+ if (styleText.indexOf('\\') >= 0) {
425
+ // A browser resolves CSS escapes in property names (p\osition is position) but jsdom's
426
+ // CSSOM does not, so the checks below would miss them. Decode first and write the
427
+ // decoded text back so the CSSOM sees the same properties the browser will
428
+ node.setAttribute('style', decodeCssEscapes(styleText));
429
+ }
430
+ removeStyleProperties(node, FORBID_STYLES_RULE);
431
+ }
432
+ removeStyleProperties(dom, BODY_STYLES_RULE);
433
+ const bodyStyles = (dom.getAttribute('style') || '').trim();
434
+ // Two wrappers. The outer one is ours alone and clips everything inside it; the inner one is
435
+ // the message body with the styles it came with. While the body's styles sat on the clipping
436
+ // element, a sender could undo the clipping (display: contents or inline, an overflow-x:
437
+ // visible written after our overflow) or move the element itself over the page around it (a
438
+ // negative margin, a box-shadow or an outline a few thousand pixels wide). Position is the
439
+ // one property that escapes an overflow clip, and it is forbidden above; paint containment
440
+ // makes the outer box the containing block of a fixed or absolute descendant as well, so a
441
+ // position that slipped past the filter would still be clipped by it. The attribute tells
442
+ // the caller the CSS inlining caps were hit
443
+ const marker = inliningSkipped ? ' data-css-inlining="skipped"' : '';
444
+ const body = bodyStyles ? `<div style="${he_1.default.encode(bodyStyles)}">` : '<div>';
445
+ return `<div style="overflow: auto; contain: paint;"${marker}>${body}${dom.innerHTML.trim()}</div></div>`;
446
+ }
447
+ // Runs the pipeline and renders the plain text fallback when it fails, for example when a
448
+ // hostile nesting depth overflows the stack of the parser or the serializer.
449
+ function guarded(fn, message, rethrow) {
450
+ const onError = (err) => {
451
+ if (rethrow) {
452
+ throw err;
453
+ }
454
+ return fallbackHtml(message);
455
+ };
456
+ try {
457
+ const result = fn();
458
+ return (typeof result === 'string' ? result : result.catch(onError));
459
+ }
460
+ catch (err) {
461
+ return onError(err);
462
+ }
463
+ }
464
+ function mimeHtmlSync(message) {
465
+ return guarded(() => {
466
+ let { html, hasStyle, inliningSkipped } = prepareHtml(message);
467
+ if (hasStyle) {
468
+ try {
469
+ // Run juice synchronously - risky but backwards compatible
470
+ html = (0, juice_1.default)(html);
471
+ }
472
+ catch (_err) {
473
+ // not so important, so we'll ignore if style inlining fails
474
+ }
475
+ }
476
+ return finishHtml(html, inliningSkipped);
477
+ }, message, false);
478
+ }
479
+ async function mimeHtmlAsync(message, options = {}) {
480
+ return guarded(async () => {
481
+ let { html, hasStyle, inliningSkipped } = prepareHtml(message);
482
+ if (hasStyle) {
483
+ try {
484
+ // Use worker thread with timeout for juice - provides protection against ANY
485
+ // unexpected CSS that might cause hangs (beyond our known problematic selectors)
486
+ html = await juiceWithTimeout(html, options);
487
+ }
488
+ catch (err) {
489
+ // If juice fails or times out, continue without inlining styles
490
+ if (options.fallbackOnError === false) {
491
+ throw err;
492
+ }
493
+ }
494
+ }
495
+ return finishHtml(html, inliningSkipped);
496
+ }, message, options.fallbackOnError === false);
497
+ }
498
+ // Main export - maintains backward compatibility with sync API
499
+ // Architecture:
500
+ // - Both sync and async versions pre-filter known problematic CSS selectors
501
+ // - Sync version: Pre-filters selectors, then runs juice synchronously (can still hang on unknown issues)
502
+ // - Async version: Pre-filters selectors, then runs juice in worker with timeout (fully protected)
503
+ // The timeout in async is a safety net for unknown edge cases, not the primary defense
504
+ function mimeHtml(message) {
505
+ // Use the sync version for backward compatibility
506
+ return mimeHtmlSync(message);
507
+ }
508
+ // Export both versions
509
+ mimeHtml.async = mimeHtmlAsync;
510
+ mimeHtml.sync = mimeHtmlSync;
511
+ // Exposed for tests only, not part of the public API
512
+ mimeHtml._internal = { filterCss, hasNotWithPseudo };
513
+ // Export function to manually close worker pool
514
+ mimeHtml.closeWorkerPool = async () => {
515
+ if (globalWorkerPool) {
516
+ await globalWorkerPool.close();
517
+ globalWorkerPool = null;
518
+ }
519
+ };
520
+ // Export function to get worker pool stats
521
+ mimeHtml.getWorkerPoolStats = () => {
522
+ if (globalWorkerPool) {
523
+ return globalWorkerPool.getStats();
524
+ }
525
+ return null;
526
+ };
527
+ exports.default = mimeHtml;
528
+ module.exports = exports.default;
529
+ Object.defineProperty(module.exports, 'default', { value: exports.default, enumerable: false, writable: true, configurable: true });
@@ -0,0 +1 @@
1
+ export default function textToHtml(text?: string | Buffer | null): string | undefined;