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