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