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