@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.
- package/CHANGELOG.md +14 -0
- package/README.md +10 -0
- package/dist/cjs/index.d.ts +18 -0
- package/dist/cjs/index.js +28 -0
- package/dist/cjs/lib/commonjs.cjs +17 -0
- package/dist/cjs/lib/commonjs.d.cts +4 -0
- package/dist/cjs/lib/format-date.d.ts +15 -0
- package/dist/cjs/lib/format-date.js +60 -0
- package/dist/cjs/lib/html-guard.d.ts +3 -0
- package/dist/cjs/lib/html-guard.js +119 -0
- package/dist/cjs/lib/html-to-text.d.ts +1 -0
- package/dist/cjs/lib/html-to-text.js +49 -0
- package/dist/cjs/lib/inline-html.d.ts +19 -0
- package/dist/cjs/lib/inline-html.js +150 -0
- package/dist/cjs/lib/inline-text.d.ts +3 -0
- package/dist/cjs/lib/inline-text.js +111 -0
- package/dist/cjs/lib/juice-worker.d.ts +1 -0
- package/dist/cjs/lib/juice-worker.js +19 -0
- package/dist/cjs/lib/mime-html.d.ts +34 -0
- package/dist/cjs/lib/mime-html.js +529 -0
- package/dist/cjs/lib/text-to-html.d.ts +1 -0
- package/dist/cjs/lib/text-to-html.js +182 -0
- package/dist/cjs/lib/worker-pool.d.ts +61 -0
- package/dist/cjs/lib/worker-pool.js +229 -0
- package/dist/cjs/package.json +3 -0
- package/dist/esm/index.d.ts +18 -0
- package/dist/esm/index.js +12 -0
- package/dist/esm/lib/commonjs.cjs +17 -0
- package/dist/esm/lib/commonjs.d.cts +4 -0
- package/dist/esm/lib/format-date.d.ts +15 -0
- package/{lib → dist/esm/lib}/format-date.js +8 -16
- package/dist/esm/lib/html-guard.d.ts +3 -0
- package/{lib → dist/esm/lib}/html-guard.js +8 -24
- package/dist/esm/lib/html-to-text.d.ts +1 -0
- package/{lib → dist/esm/lib}/html-to-text.js +2 -14
- package/dist/esm/lib/inline-html.d.ts +19 -0
- package/{lib → dist/esm/lib}/inline-html.js +17 -55
- package/dist/esm/lib/inline-text.d.ts +3 -0
- package/{lib → dist/esm/lib}/inline-text.js +8 -31
- package/dist/esm/lib/juice-worker.d.ts +1 -0
- package/{lib → dist/esm/lib}/juice-worker.js +7 -8
- package/dist/esm/lib/mime-html.d.ts +34 -0
- package/{lib → dist/esm/lib}/mime-html.js +79 -127
- package/dist/esm/lib/text-to-html.d.ts +1 -0
- package/{lib → dist/esm/lib}/text-to-html.js +35 -63
- package/dist/esm/lib/worker-pool.d.ts +61 -0
- package/{lib → dist/esm/lib}/worker-pool.js +18 -64
- package/dist/esm/package.json +3 -0
- package/package.json +72 -10
- package/.eslintrc +0 -15
- package/.github/VULNERABILITY_REPORT.yml +0 -75
- package/.github/workflows/release.yaml +0 -37
- package/.github/workflows/test.yaml +0 -31
- package/.ncurc.js +0 -22
- package/.prettierignore +0 -14
- package/.prettierrc.js +0 -8
- package/.release-please-manifest.json +0 -3
- package/benchmark/benchmark.js +0 -358
- package/benchmark/concurrency-benchmark.js +0 -176
- package/benchmark/quick-benchmark.js +0 -200
- package/benchmark/quick-concurrency.js +0 -64
- package/benchmark/real-world-benchmark.js +0 -267
- package/docs/PERFORMANCE.md +0 -262
- package/eslint.config.js +0 -52
- package/examples/failing.html +0 -617
- package/examples/fixtures/message-1.json +0 -74
- package/examples/inline-html.js +0 -24
- package/examples/inline-text.js +0 -24
- package/examples/mime-html.js +0 -284
- package/index.js +0 -22
- package/release-please-config.json +0 -10
- package/test/comprehensive.test.js +0 -358
- package/test/containment.test.js +0 -128
- package/test/edge-cases.test.js +0 -62
- package/test/hardening.test.js +0 -504
- package/test/html-to-text.test.js +0 -253
- package/test/inline-html.test.js +0 -230
- package/test/inline-text.test.js +0 -204
- package/test/mime-html-extra.test.js +0 -39
- package/test/mime-html.test.js +0 -302
- package/test/text-to-html-extra.test.js +0 -42
- package/test/worker-pool-extra.test.js +0 -132
- package/test/worker-pool-regression.test.js +0 -128
- package/test/worker-pool.test.js +0 -282
|
@@ -1,18 +1,13 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
}
|
|
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(
|
|
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
|
-
}
|
|
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
|
-
}
|
|
222
|
+
}
|
|
223
|
+
else if (c === '}') {
|
|
252
224
|
if (depth > 0) {
|
|
253
225
|
depth--;
|
|
254
226
|
}
|
|
255
227
|
commas = 0;
|
|
256
|
-
}
|
|
228
|
+
}
|
|
229
|
+
else if (c === ';' && depth === 0) {
|
|
257
230
|
commas = 0;
|
|
258
|
-
}
|
|
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.
|
|
261
|
+
if (selector.slice(i + 1, i + 5).toLowerCase() === 'not(') {
|
|
290
262
|
depth++;
|
|
291
263
|
notDepth = depth;
|
|
292
264
|
i += 4;
|
|
293
265
|
}
|
|
294
|
-
}
|
|
266
|
+
}
|
|
267
|
+
else if (c === '(') {
|
|
295
268
|
depth++;
|
|
296
|
-
}
|
|
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
|
|
337
|
-
if (!
|
|
338
|
-
|
|
339
|
-
if (
|
|
340
|
-
|
|
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, {
|
|
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
|
-
}
|
|
385
|
+
}
|
|
386
|
+
else {
|
|
417
387
|
node.removeAttribute('style');
|
|
418
388
|
}
|
|
419
|
-
}
|
|
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
|
|
489
|
-
}
|
|
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
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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
|
-
|
|
507
|
-
|
|
508
|
-
|
|
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
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
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
|
-
|
|
530
|
-
|
|
531
|
-
|
|
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;
|