@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
package/test/hardening.test.js
DELETED
|
@@ -1,504 +0,0 @@
|
|
|
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('mimeHtml depth check is not bypassed by end tags that close nothing', () => {
|
|
235
|
-
// the check counted every end tag down, so "<b></i>" repeated passed it while the
|
|
236
|
-
// parser, which ignores the </i>, nested the <b> tags: 10,000 copies took 9.7 s
|
|
237
|
-
const { result, ms } = timed(() => mimeHtml({ html: '<b></i>'.repeat(10000) + 'deep' }));
|
|
238
|
-
assert.ok(result.startsWith(FALLBACK_PREFIX));
|
|
239
|
-
assert.ok(result.includes('deep'));
|
|
240
|
-
assert.ok(ms < 1500, `took ${ms} ms`);
|
|
241
|
-
// an end tag still closes what it names, including what was opened inside it
|
|
242
|
-
const nested = mimeHtml({ html: '<div><span><b>x</div>'.repeat(2000) + 'shallow' });
|
|
243
|
-
assert.ok(!nested.startsWith(FALLBACK_PREFIX));
|
|
244
|
-
assert.ok(nested.includes('shallow'));
|
|
245
|
-
});
|
|
246
|
-
|
|
247
|
-
it('htmlToText does not overflow the stack on deep nesting', () => {
|
|
248
|
-
for (const tag of ['div', 'b', 'blockquote']) {
|
|
249
|
-
const html = 'hello & <b>world</b>' + `<${tag}>`.repeat(20000) + 'deep' + `</${tag}>`.repeat(20000) + 'after';
|
|
250
|
-
const text = htmlToText(html);
|
|
251
|
-
assert.ok(text.startsWith('hello & world'), text);
|
|
252
|
-
assert.ok(text.includes('after'), text);
|
|
253
|
-
// content below the depth limit is dropped
|
|
254
|
-
assert.ok(!text.includes('deep'), text);
|
|
255
|
-
}
|
|
256
|
-
});
|
|
257
|
-
});
|
|
258
|
-
|
|
259
|
-
describe('CSS inlining caps', () => {
|
|
260
|
-
const SKIPPED_PREFIX = '<div style="overflow: auto; contain: paint;" data-css-inlining="skipped">';
|
|
261
|
-
|
|
262
|
-
function styled(css, body) {
|
|
263
|
-
return `<html><head><style>${css}</style></head><body>${body}</body></html>`;
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
function rules(count, selector) {
|
|
267
|
-
let css = '';
|
|
268
|
-
for (let i = 0; i < count; i++) {
|
|
269
|
-
css += `${selector || '.c' + i} { color: #${(i % 4096).toString(16).padStart(3, '0')}; }\n`;
|
|
270
|
-
}
|
|
271
|
-
return css;
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
it('skips inlining when the selectors times the elements is too much work', () => {
|
|
275
|
-
// 10,000 rules on 10,000 paragraphs took 25 s in juice, on the calling thread
|
|
276
|
-
const html = styled(rules(10000, 'p'), '<p>x</p>'.repeat(10000));
|
|
277
|
-
const { result, ms } = timed(() => mimeHtml({ html }));
|
|
278
|
-
assert.ok(ms < 3000, `took ${ms} ms`);
|
|
279
|
-
assert.ok(result.startsWith(SKIPPED_PREFIX), result.slice(0, 80));
|
|
280
|
-
assert.ok(result.includes('<p>x</p>'));
|
|
281
|
-
assert.doesNotMatch(result, /color:/);
|
|
282
|
-
});
|
|
283
|
-
|
|
284
|
-
it('counts every selector of a rule, not every rule', () => {
|
|
285
|
-
const html = styled(`${'p, '.repeat(9999)}p { color: red; }`, '<p>x</p>'.repeat(10000));
|
|
286
|
-
const result = mimeHtml({ html });
|
|
287
|
-
assert.ok(result.startsWith(SKIPPED_PREFIX), result.slice(0, 80));
|
|
288
|
-
});
|
|
289
|
-
|
|
290
|
-
it('skips inlining past the selector cap or the element cap on their own', () => {
|
|
291
|
-
const manyRules = mimeHtml({ html: styled(rules(5001), '<p class="c1">x</p>') });
|
|
292
|
-
assert.ok(manyRules.startsWith(SKIPPED_PREFIX), manyRules.slice(0, 80));
|
|
293
|
-
const manyElements = mimeHtml({ html: styled('p { color: red; }', '<i></i>'.repeat(50001)) });
|
|
294
|
-
assert.ok(manyElements.startsWith(SKIPPED_PREFIX), manyElements.slice(0, 80));
|
|
295
|
-
});
|
|
296
|
-
|
|
297
|
-
it('inlines a large but ordinary message', () => {
|
|
298
|
-
// rules nested in a media query are not matched against elements and do not count
|
|
299
|
-
const css = `${rules(2000)}p.x { color: blue; }\n@media screen { ${rules(4000)} }`;
|
|
300
|
-
const result = mimeHtml({ html: styled(css, '<p class="x">y</p>'.repeat(2000)) });
|
|
301
|
-
assert.ok(result.startsWith('<div style="overflow: auto; contain: paint;"><div><p'), result.slice(0, 80));
|
|
302
|
-
assert.match(result, /color: blue/);
|
|
303
|
-
});
|
|
304
|
-
|
|
305
|
-
it('mimeHtml.async skips the same way', async () => {
|
|
306
|
-
const html = styled(rules(10000, 'p'), '<p>x</p>'.repeat(10000));
|
|
307
|
-
const result = await mimeHtml.async({ html });
|
|
308
|
-
assert.ok(result.startsWith(SKIPPED_PREFIX), result.slice(0, 80));
|
|
309
|
-
assert.ok(result.includes('<p>x</p>'));
|
|
310
|
-
});
|
|
311
|
-
});
|
|
312
|
-
|
|
313
|
-
describe('inlineHtml parse guard', () => {
|
|
314
|
-
const FALLBACK_BODY = '<div style="white-space: pre-wrap;">';
|
|
315
|
-
|
|
316
|
-
it('does not spend seconds on tags left open in the quoted message', () => {
|
|
317
|
-
// 16 KB of "<b>x" took node-html-parser 10.6 s to clean up
|
|
318
|
-
const { result, ms } = timed(() => inlineHtml('reply', '<p>my reply</p>', { html: '<b>x'.repeat(4000) }));
|
|
319
|
-
assert.ok(ms < 1000, `took ${ms} ms`);
|
|
320
|
-
assert.ok(result.includes('my reply'));
|
|
321
|
-
assert.ok(result.includes(FALLBACK_BODY));
|
|
322
|
-
assert.ok(!result.includes('<b>'));
|
|
323
|
-
assert.ok(/x x x/.test(result));
|
|
324
|
-
});
|
|
325
|
-
|
|
326
|
-
for (const [label, html] of [
|
|
327
|
-
['end tags that close nothing', '<b></i>'.repeat(5000) + 'deep'],
|
|
328
|
-
['end tags the parser ignores', '<td><font>x</td>'.repeat(3000)],
|
|
329
|
-
['padded end tags', '<a><b><c>y</a></b>'.repeat(3000)],
|
|
330
|
-
['rows left open', '<table>' + '<tr><td>cell'.repeat(5000)]
|
|
331
|
-
]) {
|
|
332
|
-
it(`stays fast on ${label}`, () => {
|
|
333
|
-
const { result, ms } = timed(() => inlineHtml('forward', '<p>fyi</p>', { html }));
|
|
334
|
-
assert.ok(ms < 1000, `took ${ms} ms`);
|
|
335
|
-
assert.ok(result.includes('fyi'));
|
|
336
|
-
assert.ok(result.includes('<blockquote'));
|
|
337
|
-
});
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
it('guards the new content the same way', () => {
|
|
341
|
-
const { result, ms } = timed(() => inlineHtml('reply', '<b>x'.repeat(4000), { html: '<p>Original</p>' }));
|
|
342
|
-
assert.ok(ms < 1000, `took ${ms} ms`);
|
|
343
|
-
assert.ok(result.includes('<p>Original</p>'));
|
|
344
|
-
assert.ok(result.includes(FALLBACK_BODY));
|
|
345
|
-
});
|
|
346
|
-
|
|
347
|
-
it('keeps the quoted body of ordinary and sloppy markup', () => {
|
|
348
|
-
const result = inlineHtml('reply', '<p>r</p>', { html: '<html><body><div><b>bold text<p>para</body></html>' });
|
|
349
|
-
assert.ok(result.includes('bold text'));
|
|
350
|
-
assert.ok(result.includes('<b>'));
|
|
351
|
-
// only the reply template's own body element is left
|
|
352
|
-
assert.equal((result.match(/<body/g) || []).length, 1);
|
|
353
|
-
assert.ok(!result.includes(FALLBACK_BODY));
|
|
354
|
-
});
|
|
355
|
-
});
|
|
356
|
-
|
|
357
|
-
describe('sanitizer policy', () => {
|
|
358
|
-
it('removes forms and form controls but keeps their text', () => {
|
|
359
|
-
const html =
|
|
360
|
-
'<form action="https://evil.example/collect" method="post"><p>Please log in</p>' +
|
|
361
|
-
'<input type="password" name="p"><select><option>a</option></select>' +
|
|
362
|
-
'<textarea>typed</textarea><button formaction="https://evil.example/x">Send</button></form>';
|
|
363
|
-
const result = mimeHtml({ html });
|
|
364
|
-
assert.doesNotMatch(result, /<form|<input|<select|<textarea|<button/i);
|
|
365
|
-
assert.doesNotMatch(result, /evil\.example/);
|
|
366
|
-
assert.ok(result.includes('Please log in'));
|
|
367
|
-
assert.ok(result.includes('Send'));
|
|
368
|
-
});
|
|
369
|
-
|
|
370
|
-
it('removes forbidden style properties, including from styles juice inlined', () => {
|
|
371
|
-
const html =
|
|
372
|
-
'<html><head><style>p.x { float: left; color: red; }</style></head><body style="position: fixed">' +
|
|
373
|
-
'<div style="Position: absolute; top: 0">a</div><span style="small: 1; color: blue">b</span><p class="x">c</p></body></html>';
|
|
374
|
-
const result = mimeHtml({ html });
|
|
375
|
-
assert.doesNotMatch(result, /position|float/i);
|
|
376
|
-
assert.ok(result.includes('top: 0'));
|
|
377
|
-
assert.ok(result.includes('color: red'));
|
|
378
|
-
assert.ok(result.includes('color: blue'));
|
|
379
|
-
});
|
|
380
|
-
|
|
381
|
-
it('removes popover and form submission attributes', () => {
|
|
382
|
-
const html = '<div popover id="p">overlay</div><a href="https://example.com/" popovertarget="p" formaction="https://evil.example/">link</a>';
|
|
383
|
-
const result = mimeHtml({ html });
|
|
384
|
-
assert.doesNotMatch(result, /popover|formaction/i);
|
|
385
|
-
assert.ok(result.includes('overlay'));
|
|
386
|
-
assert.ok(result.includes('href="https://example.com/"'));
|
|
387
|
-
});
|
|
388
|
-
});
|
|
389
|
-
|
|
390
|
-
describe('reply/forward date header', () => {
|
|
391
|
-
const date = new Date('2024-06-15T14:30:00Z');
|
|
392
|
-
const hasLocale = locale => Intl.DateTimeFormat.supportedLocalesOf([locale]).length > 0;
|
|
393
|
-
|
|
394
|
-
it('formats in English by default', () => {
|
|
395
|
-
assert.equal(formatDate(date, { tz: 'UTC' }), 'Sat, Jun 15, 2024, 2:30 PM');
|
|
396
|
-
});
|
|
397
|
-
|
|
398
|
-
it('honors the time zone', () => {
|
|
399
|
-
assert.equal(formatDate(date, { tz: 'America/New_York' }), 'Sat, Jun 15, 2024, 10:30 AM');
|
|
400
|
-
assert.equal(formatDate(date, { tz: 'Asia/Tokyo' }), 'Sat, Jun 15, 2024, 11:30 PM');
|
|
401
|
-
});
|
|
402
|
-
|
|
403
|
-
it('honors the locale', t => {
|
|
404
|
-
if (!hasLocale('de') || !hasLocale('et') || !hasLocale('ja')) {
|
|
405
|
-
t.skip('Node built without full ICU data');
|
|
406
|
-
return;
|
|
407
|
-
}
|
|
408
|
-
assert.equal(formatDate(date, { locale: 'de', tz: 'Europe/Berlin' }), 'Sa., 15. Juni 2024, 16:30');
|
|
409
|
-
assert.equal(formatDate(date, { locale: 'et', tz: 'Europe/Tallinn' }), 'L, 15. juuni 2024, 17:30');
|
|
410
|
-
assert.ok(formatDate(date, { locale: 'ja', tz: 'Asia/Tokyo' }).includes('2024'));
|
|
411
|
-
assert.ok(formatDate(date, { locale: 'ja', tz: 'Asia/Tokyo' }).includes('23:30'));
|
|
412
|
-
});
|
|
413
|
-
|
|
414
|
-
it('falls back to English on an invalid or unknown locale', () => {
|
|
415
|
-
assert.equal(formatDate(date, { locale: 'not a locale!!', tz: 'UTC' }), 'Sat, Jun 15, 2024, 2:30 PM');
|
|
416
|
-
assert.equal(formatDate(date, { locale: 'xx', tz: 'UTC' }), 'Sat, Jun 15, 2024, 2:30 PM');
|
|
417
|
-
});
|
|
418
|
-
|
|
419
|
-
it('falls back to UTC on an unknown time zone', () => {
|
|
420
|
-
assert.equal(formatDate(date, { tz: 'Not/AZone' }), 'Sat, Jun 15, 2024, 2:30 PM');
|
|
421
|
-
});
|
|
422
|
-
|
|
423
|
-
it('accepts date strings and passes invalid dates through', () => {
|
|
424
|
-
assert.equal(formatDate('2024-06-15T14:30:00Z', { tz: 'UTC' }), 'Sat, Jun 15, 2024, 2:30 PM');
|
|
425
|
-
assert.equal(formatDate('Sat, 15 Jun 2024 14:30:00 +0000', { tz: 'UTC' }), 'Sat, Jun 15, 2024, 2:30 PM');
|
|
426
|
-
assert.equal(formatDate('garbage', {}), 'garbage');
|
|
427
|
-
});
|
|
428
|
-
|
|
429
|
-
it('is used by inlineHtml and inlineText', () => {
|
|
430
|
-
const data = { date, from: { address: 'a@example.com' }, subject: 'S', html: '<p>Body</p>', text: 'Body' };
|
|
431
|
-
assert.ok(inlineHtml('forward', '', data, { tz: 'UTC' }).includes('Sat, Jun 15, 2024, 2:30 PM'));
|
|
432
|
-
assert.ok(inlineText('forward', '', data, { tz: 'UTC' }).includes('Date: Sat, Jun 15, 2024, 2:30 PM'));
|
|
433
|
-
});
|
|
434
|
-
});
|
|
435
|
-
|
|
436
|
-
describe('lazy loading', () => {
|
|
437
|
-
it('requiring the package does not load jsdom until mimeHtml is used', () => {
|
|
438
|
-
// A fresh process, since this test process has already loaded jsdom
|
|
439
|
-
const script = [
|
|
440
|
-
"const tools = require('.');",
|
|
441
|
-
"const loaded = () => Object.keys(require.cache).some(k => k.includes('/node_modules/jsdom/'));",
|
|
442
|
-
'const before = loaded();',
|
|
443
|
-
"tools.htmlToText('<p>x</p>');",
|
|
444
|
-
'const afterText = loaded();',
|
|
445
|
-
"const out = tools.mimeHtml({ html: '<p>x</p>' });",
|
|
446
|
-
'console.log(JSON.stringify([before, afterText, loaded(), typeof tools.mimeHtml.async, out]));'
|
|
447
|
-
].join('\n');
|
|
448
|
-
const output = execFileSync(process.execPath, ['-e', script], { cwd: path.join(__dirname, '..') }).toString();
|
|
449
|
-
const [before, afterText, afterMime, asyncType, out] = JSON.parse(output);
|
|
450
|
-
assert.equal(before, false);
|
|
451
|
-
assert.equal(afterText, false);
|
|
452
|
-
assert.equal(afterMime, true);
|
|
453
|
-
assert.equal(asyncType, 'function');
|
|
454
|
-
assert.equal(out, '<div style="overflow: auto; contain: paint;"><div><p>x</p></div></div>');
|
|
455
|
-
});
|
|
456
|
-
|
|
457
|
-
it('keeps mimeHtml enumerable and destructurable', () => {
|
|
458
|
-
const tools = require('..');
|
|
459
|
-
assert.ok(Object.keys(tools).includes('mimeHtml'));
|
|
460
|
-
const { mimeHtml: fn } = tools;
|
|
461
|
-
assert.equal(fn, mimeHtml);
|
|
462
|
-
});
|
|
463
|
-
});
|
|
464
|
-
});
|
|
465
|
-
|
|
466
|
-
describe('forbidden style properties behind CSS escapes', () => {
|
|
467
|
-
it('strips a position property whose name uses CSS escapes', () => {
|
|
468
|
-
// a browser reads p\osition and \70 osition as position, jsdom's CSSOM does not
|
|
469
|
-
const html = '<div style="p\\osition: fixed; color: red; \\70 osition: absolute; float: left">x</div>';
|
|
470
|
-
const out = mimeHtml({ html });
|
|
471
|
-
assert.ok(!/position/i.test(out), out);
|
|
472
|
-
assert.ok(!/float/i.test(out), out);
|
|
473
|
-
assert.ok(/color:\s*red/i.test(out), out);
|
|
474
|
-
});
|
|
475
|
-
});
|
|
476
|
-
|
|
477
|
-
describe('forbidden style properties the CSSOM could not parse', () => {
|
|
478
|
-
it('drops a style attribute cssstyle rejected that names a forbidden property', () => {
|
|
479
|
-
// cssstyle 5 parses nothing from an attribute whose background shorthand with a url() is
|
|
480
|
-
// followed by more declarations, so the raw text went out verbatim and a browser, which
|
|
481
|
-
// parses it fine, honored the position
|
|
482
|
-
const out = mimeHtml({ html: '<div style="background:url(https://example.com/x.gif) no-repeat; position:fixed; top:0">x</div>' });
|
|
483
|
-
assert.ok(!/position|fixed|top:/i.test(out), out);
|
|
484
|
-
assert.ok(out.includes('<div>x</div>'), out);
|
|
485
|
-
});
|
|
486
|
-
|
|
487
|
-
it('keeps the declarations the CSSOM parsed around one it could not', () => {
|
|
488
|
-
const out = mimeHtml({ html: '<div style="color:red;}position:fixed;top:0">x</div>' });
|
|
489
|
-
assert.ok(!/position|fixed/i.test(out), out);
|
|
490
|
-
assert.match(out, /color: red/);
|
|
491
|
-
assert.match(out, /top: 0/);
|
|
492
|
-
});
|
|
493
|
-
|
|
494
|
-
it('leaves an attribute that names no forbidden property as it was, parsed or not', () => {
|
|
495
|
-
const out = mimeHtml({ html: '<div style="background:url(clear.gif); color:red">x</div>' });
|
|
496
|
-
assert.ok(out.includes('style="background:url(clear.gif); color:red"'), out);
|
|
497
|
-
});
|
|
498
|
-
|
|
499
|
-
it('does not take a longer property name for a forbidden one', () => {
|
|
500
|
-
const out = mimeHtml({ html: '<div style="background-position: center top; text-underline-offset: 2px; color:red">x</div>' });
|
|
501
|
-
assert.ok(out.includes('background-position: center top'), out);
|
|
502
|
-
assert.ok(out.includes('text-underline-offset: 2px'), out);
|
|
503
|
-
});
|
|
504
|
-
});
|