@limetech/lime-elements 40.17.0 → 40.17.1
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 +8 -0
- package/dist/cjs/heading-level-BxCuIKkm.js +14 -0
- package/dist/cjs/limel-collapsible-section.cjs.entry.js +4 -9
- package/dist/cjs/limel-file-viewer.cjs.entry.js +422 -248
- package/dist/cjs/limel-form-header.cjs.entry.js +1 -1
- package/dist/cjs/limel-form.cjs.entry.js +23 -20
- package/dist/collection/components/collapsible-section/collapsible-section.js +11 -9
- package/dist/collection/components/form/form.js +8 -6
- package/dist/collection/components/form/form.test-schemas.js +68 -0
- package/dist/collection/components/form/templates/array-field-collapsible-item.js +13 -3
- package/dist/collection/components/form/templates/heading-level.js +0 -10
- package/dist/collection/components/form/templates/object-field.js +6 -3
- package/dist/collection/components/form-header/form-header.js +3 -3
- package/dist/collection/global/shared-types/heading-level.types.js +1 -0
- package/dist/collection/interface.js +1 -0
- package/dist/collection/util/heading-level.js +10 -0
- package/dist/esm/heading-level-B3C0kDpZ.js +12 -0
- package/dist/esm/limel-collapsible-section.entry.js +2 -7
- package/dist/esm/limel-file-viewer.entry.js +422 -248
- package/dist/esm/limel-form-header.entry.js +1 -1
- package/dist/esm/limel-form.entry.js +21 -18
- package/dist/lime-elements/lime-elements.esm.js +1 -1
- package/dist/lime-elements/p-49a53f0b.entry.js +1 -0
- package/dist/lime-elements/{p-72907ffd.entry.js → p-68129487.entry.js} +1 -1
- package/dist/lime-elements/{p-9436aba6.entry.js → p-74db50a8.entry.js} +4 -4
- package/dist/lime-elements/p-B3C0kDpZ.js +1 -0
- package/dist/lime-elements/p-e8255f0b.entry.js +1 -0
- package/dist/types/components/collapsible-section/collapsible-section.d.ts +2 -1
- package/dist/types/components/form/form.d.ts +4 -2
- package/dist/types/components/form/form.test-schemas.d.ts +3 -0
- package/dist/types/components/form/form.types.d.ts +0 -6
- package/dist/types/components/form/row/row.d.ts +1 -1
- package/dist/types/components/form/templates/array-field-collapsible-item.d.ts +5 -1
- package/dist/types/components/form/templates/heading-level.d.ts +1 -6
- package/dist/types/components/form/templates/object-field.d.ts +2 -0
- package/dist/types/components/form-header/form-header.d.ts +1 -1
- package/dist/types/components.d.ts +9 -7
- package/dist/types/global/shared-types/heading-level.types.d.ts +7 -0
- package/dist/types/interface.d.ts +2 -1
- package/dist/types/util/heading-level.d.ts +7 -0
- package/package.json +2 -2
- package/dist/lime-elements/p-1d13245d.entry.js +0 -1
- package/dist/lime-elements/p-e6be5819.entry.js +0 -1
|
@@ -219,7 +219,8 @@ const codePagePattern = /^(?:cp|windows|ms|ibm)(\d+)$/;
|
|
|
219
219
|
function normalizeCharset(charset) {
|
|
220
220
|
return charset.replace(/^(?:x-ms-|x-|cs)/, '').replace(/[\s._-]+/g, '');
|
|
221
221
|
}
|
|
222
|
-
// Every label of the WHATWG Encoding Standard, https://encoding.spec.whatwg.org/encodings.json
|
|
222
|
+
// Every label of the WHATWG Encoding Standard, https://encoding.spec.whatwg.org/encodings.json,
|
|
223
|
+
// but for the UTF-16 family, which UTF16_LABELS resolves on its own.
|
|
223
224
|
//
|
|
224
225
|
// TextDecoder accepts these and nothing else, so a label outside the list is turned away
|
|
225
226
|
// without constructing a decoder for it. Letting the constructor throw instead costs
|
|
@@ -306,36 +307,92 @@ const ENCODING_LABELS = new Set([
|
|
|
306
307
|
'windows-949',
|
|
307
308
|
// replacement
|
|
308
309
|
'csiso2022kr hz-gb-2312 iso-2022-cn iso-2022-cn-ext iso-2022-kr replacement',
|
|
309
|
-
// UTF-16BE
|
|
310
|
-
'unicodefffe utf-16be',
|
|
311
|
-
// UTF-16LE
|
|
312
|
-
'csunicode iso-10646-ucs-2 ucs-2 unicode unicodefeff utf-16 utf-16le',
|
|
313
310
|
// x-user-defined
|
|
314
311
|
'x-user-defined'
|
|
315
312
|
]
|
|
316
313
|
.join(' ')
|
|
317
314
|
.split(' '));
|
|
318
|
-
//
|
|
319
|
-
//
|
|
320
|
-
//
|
|
315
|
+
// The labels the Encoding Standard maps to the replacement encoding: ISO-2022-KR,
|
|
316
|
+
// ISO-2022-CN and HZ-GB-2312, whose escape and shift sequences let the same bytes read as
|
|
317
|
+
// text to one decoder and as markup to another. A browser decodes a document in any of
|
|
318
|
+
// them to a single U+FFFD for that reason, and TextDecoder refuses to be constructed for
|
|
319
|
+
// them. They used to fall through to the UTF-8 or windows-1252 reading, which is the
|
|
320
|
+
// markup reading: a body of `ESC$)C SO <img onerror=…> SI` under charset=iso-2022-kr is
|
|
321
|
+
// Hangul to Python and a scanner built on it, and was an image tag here.
|
|
322
|
+
const REPLACEMENT_LABELS = new Set([
|
|
323
|
+
'csiso2022kr',
|
|
324
|
+
'hz-gb-2312',
|
|
325
|
+
'iso-2022-cn',
|
|
326
|
+
'iso-2022-cn-ext',
|
|
327
|
+
'iso-2022-kr',
|
|
328
|
+
'replacement'
|
|
329
|
+
]);
|
|
330
|
+
// The decoder of the replacement encoding: U+FFFD once there is a byte to replace
|
|
331
|
+
const replacementDecoder = {
|
|
332
|
+
encoding: 'replacement',
|
|
333
|
+
decode: input => (input && input.byteLength ? '�' : '')
|
|
334
|
+
};
|
|
335
|
+
// The UTF-16 labels honoured and the label of the standard each stands for: the spellings
|
|
336
|
+
// that Python and iconv-lite both decode, and nothing else. Matched as written only, never
|
|
337
|
+
// after normalization: `x-utf16` is known to neither.
|
|
338
|
+
//
|
|
339
|
+
// A label that one of the two reads and the other does not is the split this table
|
|
340
|
+
// exists to close: under it a scanner built on the other reads the bytes as single byte
|
|
341
|
+
// text, NUL interleaved and inert, where they decoded to markup here. So the standard's
|
|
342
|
+
// `unicode`, `csunicode`, `iso-10646-ucs-2`, `unicodefeff` and `unicodefffe`, which
|
|
343
|
+
// neither knows, are out, and so are `ucs-2`, `ucs2`, `utf16le` and `utf16be`, which
|
|
344
|
+
// iconv-lite alone knows (and `ucs-2` as little endian whatever mark the bytes carry).
|
|
345
|
+
// They all fall back like any unknown label.
|
|
346
|
+
const UTF16_LABELS = new Map([
|
|
347
|
+
['utf-16', 'utf-16'],
|
|
348
|
+
['utf16', 'utf-16'],
|
|
349
|
+
['utf_16', 'utf-16'],
|
|
350
|
+
['utf-16le', 'utf-16le'],
|
|
351
|
+
['utf-16be', 'utf-16be']
|
|
352
|
+
]);
|
|
353
|
+
/**
|
|
354
|
+
* The label of the Encoding Standard a charset resolves to, as written or through the
|
|
355
|
+
* alias tables, or null when it names no encoding the standard lists
|
|
356
|
+
*/
|
|
357
|
+
function resolveLabel(charset) {
|
|
358
|
+
charset = (charset || 'utf8').trim().toLowerCase();
|
|
359
|
+
// The label as written first, so the alias table only ever adds to what the standard
|
|
360
|
+
// lists instead of shadowing it
|
|
361
|
+
if (ENCODING_LABELS.has(charset)) {
|
|
362
|
+
return charset;
|
|
363
|
+
}
|
|
364
|
+
const utf16 = UTF16_LABELS.get(charset);
|
|
365
|
+
if (utf16) {
|
|
366
|
+
return utf16;
|
|
367
|
+
}
|
|
368
|
+
const normalized = normalizeCharset(charset);
|
|
369
|
+
const codePage = normalized.match(codePagePattern);
|
|
370
|
+
// The normalized label is itself the last candidate, which resolves everything that
|
|
371
|
+
// differed from a listed label only by a prefix or a separator, eg. x-big5.
|
|
372
|
+
const alias = (codePage && codePageAliases.get(codePage[1])) || charsetAliases.get(normalized) || normalized;
|
|
373
|
+
return ENCODING_LABELS.has(alias) ? alias : null;
|
|
374
|
+
}
|
|
375
|
+
// Decoders by label, or null where the runtime refuses the label (an encoding a runtime
|
|
376
|
+
// was built without). Only labels from the list above are stored, so the map can not grow
|
|
377
|
+
// past it however many labels a message makes up.
|
|
321
378
|
//
|
|
322
379
|
// Sharing a decoder is safe because nothing decodes with `stream: true`, so every
|
|
323
380
|
// decode() call starts from a fresh state.
|
|
324
381
|
const decoders = new Map();
|
|
325
|
-
function tryDecoder(
|
|
326
|
-
if (
|
|
327
|
-
return
|
|
382
|
+
function tryDecoder(label) {
|
|
383
|
+
if (REPLACEMENT_LABELS.has(label)) {
|
|
384
|
+
return replacementDecoder;
|
|
328
385
|
}
|
|
329
|
-
let decoder = decoders.get(
|
|
386
|
+
let decoder = decoders.get(label);
|
|
330
387
|
if (decoder === undefined) {
|
|
331
388
|
decoder = null;
|
|
332
389
|
try {
|
|
333
|
-
decoder = new TextDecoder(
|
|
390
|
+
decoder = new TextDecoder(label);
|
|
334
391
|
}
|
|
335
392
|
catch {
|
|
336
393
|
// not supported by this runtime
|
|
337
394
|
}
|
|
338
|
-
decoders.set(
|
|
395
|
+
decoders.set(label, decoder);
|
|
339
396
|
}
|
|
340
397
|
return decoder;
|
|
341
398
|
}
|
|
@@ -346,23 +403,32 @@ const utf8Decoder = new TextDecoder();
|
|
|
346
403
|
* Finds the decoder for a charset label, or null when the runtime has no encoding for it
|
|
347
404
|
*/
|
|
348
405
|
function findDecoder(charset) {
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
// runtime already supports instead of shadowing it.
|
|
352
|
-
const decoder = tryDecoder(charset);
|
|
353
|
-
if (decoder) {
|
|
354
|
-
return decoder;
|
|
355
|
-
}
|
|
356
|
-
const normalized = normalizeCharset(charset);
|
|
357
|
-
const codePage = normalized.match(codePagePattern);
|
|
358
|
-
// The normalized label is itself the last candidate, which resolves everything that
|
|
359
|
-
// differed from a supported label only by a prefix or a separator, eg. x-big5.
|
|
360
|
-
const alias = (codePage && codePageAliases.get(codePage[1])) || charsetAliases.get(normalized) || normalized;
|
|
361
|
-
return tryDecoder(alias);
|
|
406
|
+
const label = resolveLabel(charset);
|
|
407
|
+
return label ? tryDecoder(label) : null;
|
|
362
408
|
}
|
|
363
409
|
function getDecoder(charset) {
|
|
364
410
|
return findDecoder(charset) || tryDecoder('windows-1252') || utf8Decoder;
|
|
365
411
|
}
|
|
412
|
+
const UTF16_ENCODINGS = new Set(['utf-16le', 'utf-16be']);
|
|
413
|
+
// The label of UTF-16 that leaves the byte order to the bytes, which a byte order mark
|
|
414
|
+
// then decides, as it does for Python's utf-16 codec and for iconv-lite. The labels that
|
|
415
|
+
// name the order, utf-16le and utf-16be, are read as they say whatever mark the bytes
|
|
416
|
+
// carry, and no label honours a UTF-8 mark. The Encoding Standard lets any mark override
|
|
417
|
+
// any label, but Python and mailparser do not, and following it here rendered as markup
|
|
418
|
+
// under charset=utf-16be a body that a scanner built on either reads as CJK text.
|
|
419
|
+
const UTF16_UNORDERED_LABELS = new Set(['utf-16']);
|
|
420
|
+
/**
|
|
421
|
+
* The byte order a UTF-16 byte order mark at the start of the bytes names, or null without one
|
|
422
|
+
*/
|
|
423
|
+
function bomByteOrder(bytes) {
|
|
424
|
+
if (bytes[0] === 0xfe && bytes[1] === 0xff) {
|
|
425
|
+
return 'utf-16be';
|
|
426
|
+
}
|
|
427
|
+
if (bytes[0] === 0xff && bytes[1] === 0xfe) {
|
|
428
|
+
return 'utf-16le';
|
|
429
|
+
}
|
|
430
|
+
return null;
|
|
431
|
+
}
|
|
366
432
|
/**
|
|
367
433
|
* Whether the bytes are well formed UTF-8. A scan rather than a fatal TextDecoder, since
|
|
368
434
|
* the exception one throws costs more than the decode itself and a message decides how
|
|
@@ -436,8 +502,19 @@ function decodeUtf8OrWindows1252(bytes, utf8 = utf8Decoder) {
|
|
|
436
502
|
* @return Decoded text
|
|
437
503
|
*/
|
|
438
504
|
function decodeCharset(bytes, charset) {
|
|
439
|
-
const
|
|
440
|
-
|
|
505
|
+
const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
|
506
|
+
const label = resolveLabel(charset);
|
|
507
|
+
let decoder = label ? tryDecoder(label) : null;
|
|
508
|
+
if (label && UTF16_UNORDERED_LABELS.has(label)) {
|
|
509
|
+
// `utf-16` reads as little endian on its own, so a byte order mark decides the
|
|
510
|
+
// order. Read in the other order the bytes are swapped pairs of the characters
|
|
511
|
+
// every other reader got: FE FF and big endian text under charset=utf-16 was CJK
|
|
512
|
+
// text to Python and a browser, and here it was the little endian reading, which
|
|
513
|
+
// the sender picks to be markup.
|
|
514
|
+
const order = bomByteOrder(view);
|
|
515
|
+
decoder = (order && tryDecoder(order)) || decoder;
|
|
516
|
+
}
|
|
517
|
+
return decoder ? decoder.decode(view) : decodeUtf8OrWindows1252(view);
|
|
441
518
|
}
|
|
442
519
|
// The charset an html document names for itself, in a `<meta charset>` or in the
|
|
443
520
|
// Content-Type `<meta http-equiv>`, read from the first 1024 bytes like a browser's prescan.
|
|
@@ -446,7 +523,6 @@ function decodeCharset(bytes, charset) {
|
|
|
446
523
|
// nothing but `<meta ` took it quadratic
|
|
447
524
|
const META_PRESCAN_BYTES = 1024;
|
|
448
525
|
const CHARSET_ATTRIBUTE = /charset\s*=\s*["']?\s*([^\s"';>]+)/;
|
|
449
|
-
const UTF16_ENCODINGS = new Set(['utf-16le', 'utf-16be']);
|
|
450
526
|
function sniffHtmlCharset(bytes) {
|
|
451
527
|
const head = utf8Decoder.decode(bytes.subarray(0, META_PRESCAN_BYTES)).toLowerCase();
|
|
452
528
|
let pos = 0;
|
|
@@ -481,7 +557,9 @@ function decodeText(bytes, charset, html) {
|
|
|
481
557
|
// The HTML prescan has two overrides, and for a reason: a `<meta>` tag that could be
|
|
482
558
|
// read as ASCII proves the document is not UTF-16, so such a label is read as UTF-8,
|
|
483
559
|
// and x-user-defined is read as windows-1252. Without them a sender picks the encoding
|
|
484
|
-
// that turns bytes a scanner sees as inert into markup for this parser alone
|
|
560
|
+
// that turns bytes a scanner sees as inert into markup for this parser alone. A label
|
|
561
|
+
// of the replacement encoding is the replacement text, as it is for a browser, see
|
|
562
|
+
// tryDecoder
|
|
485
563
|
const sniffed = html ? sniffHtmlCharset(view) : null;
|
|
486
564
|
const decoder = sniffed ? findDecoder(sniffed) : null;
|
|
487
565
|
if (!decoder || UTF16_ENCODINGS.has(decoder.encoding)) {
|
|
@@ -1057,6 +1135,30 @@ class QPDecoder {
|
|
|
1057
1135
|
}
|
|
1058
1136
|
}
|
|
1059
1137
|
|
|
1138
|
+
// The error class each code is thrown as. An invalid option or input is a TypeError, as an
|
|
1139
|
+
// invalid option was before codes were added
|
|
1140
|
+
const ERROR_TYPES = {
|
|
1141
|
+
EINVALIDOPTION: TypeError,
|
|
1142
|
+
EINVALIDINPUT: TypeError,
|
|
1143
|
+
EPARSERREUSED: Error,
|
|
1144
|
+
EMAXNESTINGDEPTH: Error,
|
|
1145
|
+
EMAXHEADERSIZE: Error,
|
|
1146
|
+
EMAXPARTCOUNT: Error,
|
|
1147
|
+
EMAXFORWARDEDADDRESSES: Error
|
|
1148
|
+
};
|
|
1149
|
+
/**
|
|
1150
|
+
* Creates an error with a code
|
|
1151
|
+
*
|
|
1152
|
+
* @param code Error code
|
|
1153
|
+
* @param message Error message
|
|
1154
|
+
* @returns The error
|
|
1155
|
+
*/
|
|
1156
|
+
function createError(code, message) {
|
|
1157
|
+
const error = new ERROR_TYPES[code](message);
|
|
1158
|
+
error.code = code;
|
|
1159
|
+
return error;
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1060
1162
|
// Header lines are decoded with ignoreBOM so that a U+FEFF at the start of a line is
|
|
1061
1163
|
// kept as a character instead of being swallowed. A stripped BOM turns a line a strict
|
|
1062
1164
|
// parser skips into a genuine header, which is how a second `From:` gets smuggled past
|
|
@@ -1074,12 +1176,9 @@ const FLOWED_JOIN_INTERVAL = 4096;
|
|
|
1074
1176
|
// hundred megabytes. Counting every line for at least this much keeps the memory in step
|
|
1075
1177
|
// with the budget, 32768 lines at the default 2 MiB.
|
|
1076
1178
|
const HEADER_LINE_MIN_COST = 64;
|
|
1077
|
-
// Trims
|
|
1078
|
-
// also strips U+00A0, U+FEFF, U+2028 and the rest of the
|
|
1079
|
-
//
|
|
1080
|
-
// leading U+00A0 became a `from` header, and since the first occurrence of a header wins
|
|
1081
|
-
// it outranked the real sender. Leaving the character in the key keeps the line visible
|
|
1082
|
-
// without letting it collide with a genuine header.
|
|
1179
|
+
// Trims the SP and HTAB around a field body, the only whitespace RFC 5322 reads as padding
|
|
1180
|
+
// there. String.prototype.trim also strips U+00A0, U+FEFF, U+2028 and the rest of the
|
|
1181
|
+
// Unicode spaces, which a raw UTF-8 header (RFC 6532) may carry as content.
|
|
1083
1182
|
//
|
|
1084
1183
|
// An index scan rather than `/^[ \t]+|[ \t]+$/g`, which retries the trailing branch at
|
|
1085
1184
|
// every position of a blank run that is followed by other text, so a single header with
|
|
@@ -1095,12 +1194,11 @@ const trimWsp = (str) => {
|
|
|
1095
1194
|
}
|
|
1096
1195
|
return str.slice(start, end);
|
|
1097
1196
|
};
|
|
1098
|
-
//
|
|
1099
|
-
|
|
1100
|
-
//
|
|
1101
|
-
//
|
|
1102
|
-
|
|
1103
|
-
const FIELD_START = /^[!-9;-~]+[ \t]*:/;
|
|
1197
|
+
// The start of a header field, the shape Python's header pattern reads: a field name of
|
|
1198
|
+
// printable ASCII other than the colon (RFC 5322 3.6.8 ftext), an empty one included, and
|
|
1199
|
+
// the colon itself, with the whitespace in between that processHeaders tolerates as well,
|
|
1200
|
+
// or the `From ` of an mbox separator line, which every reader lets through
|
|
1201
|
+
const FIELD_START = /^(?:From |[!-9;-~]*[ \t]*:)/;
|
|
1104
1202
|
// Headers that decide how this part's body is read, see processHeaders
|
|
1105
1203
|
const CONTENT_HEADERS = new Set([
|
|
1106
1204
|
'content-type',
|
|
@@ -1119,7 +1217,7 @@ class MimeNode {
|
|
|
1119
1217
|
const limits = this.postalMime.limits;
|
|
1120
1218
|
this.postalMime.counters.partCount++;
|
|
1121
1219
|
if (this.postalMime.counters.partCount > limits.maxPartCount) {
|
|
1122
|
-
throw
|
|
1220
|
+
throw createError('EMAXPARTCOUNT', `Maximum MIME part count of ${limits.maxPartCount} parts exceeded`);
|
|
1123
1221
|
}
|
|
1124
1222
|
this.childNodes = [];
|
|
1125
1223
|
// Cursor into childNodes for finalizeChildNodes. Every new part of a multipart
|
|
@@ -1130,7 +1228,7 @@ class MimeNode {
|
|
|
1130
1228
|
this.parentNode = options.parentNode;
|
|
1131
1229
|
this.depth = this.parentNode.depth + 1;
|
|
1132
1230
|
if (this.depth > limits.maxNestingDepth) {
|
|
1133
|
-
throw
|
|
1231
|
+
throw createError('EMAXNESTINGDEPTH', `Maximum MIME nesting depth of ${limits.maxNestingDepth} levels exceeded`);
|
|
1134
1232
|
}
|
|
1135
1233
|
options.parentNode.childNodes.push(this);
|
|
1136
1234
|
}
|
|
@@ -1139,7 +1237,6 @@ class MimeNode {
|
|
|
1139
1237
|
}
|
|
1140
1238
|
this.state = 'header';
|
|
1141
1239
|
this.headerLines = [];
|
|
1142
|
-
this.lastLineBlank = false;
|
|
1143
1240
|
// RFC 2046 Section 5.1.5: multipart/digest defaults to message/rfc822
|
|
1144
1241
|
const parentMultipartType = options.parentMultipartType || null;
|
|
1145
1242
|
const defaultContentType = parentMultipartType === 'digest' ? 'message/rfc822' : 'text/plain';
|
|
@@ -1163,11 +1260,15 @@ class MimeNode {
|
|
|
1163
1260
|
this.contentDecoder = null;
|
|
1164
1261
|
this.content = null;
|
|
1165
1262
|
}
|
|
1263
|
+
// `transferEncoding` is the lowercased token of the header, see processHeaders. It is
|
|
1264
|
+
// matched whole: `x-base64` or `7bit-base64` is no encoding Python or mailparser
|
|
1265
|
+
// decode, and decoding it here rendered as markup a body every other reader keeps as
|
|
1266
|
+
// inert base64
|
|
1166
1267
|
setupContentDecoder(transferEncoding) {
|
|
1167
|
-
if (
|
|
1268
|
+
if (transferEncoding === 'base64') {
|
|
1168
1269
|
this.contentDecoder = new Base64Decoder();
|
|
1169
1270
|
}
|
|
1170
|
-
else if (
|
|
1271
|
+
else if (transferEncoding === 'quoted-printable') {
|
|
1171
1272
|
this.contentDecoder = new QPDecoder();
|
|
1172
1273
|
}
|
|
1173
1274
|
else {
|
|
@@ -1221,49 +1322,52 @@ class MimeNode {
|
|
|
1221
1322
|
let commentStart = -1;
|
|
1222
1323
|
// A parameter value starts at `=` and ends at the `;` that begins the next one
|
|
1223
1324
|
let inParameterValue = false;
|
|
1224
|
-
//
|
|
1225
|
-
//
|
|
1226
|
-
//
|
|
1325
|
+
// The characters kept are copied into the result as runs, a run ending at the
|
|
1326
|
+
// next character dropped. Appended one at a time they made a cons string of a
|
|
1327
|
+
// node per character, tens of megabytes of pointer chasing for the collector on a
|
|
1328
|
+
// header of a megabyte, and the parse grew faster than the header did
|
|
1329
|
+
let runStart = 0;
|
|
1330
|
+
const drop = (i) => {
|
|
1331
|
+
result += str.slice(runStart, i);
|
|
1332
|
+
runStart = i + 1;
|
|
1333
|
+
};
|
|
1334
|
+
// Whether anything has been kept, and whether the last character kept is SP or
|
|
1335
|
+
// HTAB. Kept up to date on every character, because testing `/[ \t]$/` against the
|
|
1336
|
+
// result flattens the whole string on each `(` and is quadratic in the length of
|
|
1337
|
+
// the header.
|
|
1338
|
+
let keptAny = false;
|
|
1227
1339
|
let endsWithWsp = false;
|
|
1228
|
-
const
|
|
1229
|
-
|
|
1230
|
-
endsWithWsp =
|
|
1340
|
+
const keep = (i) => {
|
|
1341
|
+
keptAny = true;
|
|
1342
|
+
endsWithWsp = isWsp(str.charCodeAt(i));
|
|
1231
1343
|
};
|
|
1232
1344
|
// A comment may only appear where linear whitespace is allowed, so inside a
|
|
1233
1345
|
// parameter value the parenthesis has to follow whitespace to open one. Outside
|
|
1234
1346
|
// one, eg. after the type itself, anything goes.
|
|
1235
|
-
const opensComment = () => !inParameterValue || !
|
|
1347
|
+
const opensComment = () => !inParameterValue || !keptAny || endsWithWsp;
|
|
1236
1348
|
for (let i = 0; i < str.length; i++) {
|
|
1237
1349
|
const chr = str.charAt(i);
|
|
1238
1350
|
if (escaped) {
|
|
1239
|
-
if (depth === 0) {
|
|
1240
|
-
append(chr);
|
|
1241
|
-
}
|
|
1242
1351
|
escaped = false;
|
|
1243
|
-
continue;
|
|
1244
1352
|
}
|
|
1245
|
-
if (chr === '\\') {
|
|
1353
|
+
else if (chr === '\\') {
|
|
1246
1354
|
escaped = true;
|
|
1247
|
-
if (depth === 0) {
|
|
1248
|
-
append(chr);
|
|
1249
|
-
}
|
|
1250
|
-
continue;
|
|
1251
1355
|
}
|
|
1252
|
-
if (chr === '"' && depth === 0) {
|
|
1356
|
+
else if (chr === '"' && depth === 0) {
|
|
1253
1357
|
inQuote = !inQuote;
|
|
1254
|
-
append(chr);
|
|
1255
|
-
continue;
|
|
1256
1358
|
}
|
|
1257
|
-
if (!inQuote) {
|
|
1359
|
+
else if (!inQuote) {
|
|
1258
1360
|
if (chr === '(' && opensComment()) {
|
|
1259
1361
|
if (depth === 0) {
|
|
1260
1362
|
commentStart = i;
|
|
1261
1363
|
}
|
|
1262
1364
|
depth++;
|
|
1365
|
+
drop(i);
|
|
1263
1366
|
continue;
|
|
1264
1367
|
}
|
|
1265
1368
|
if (chr === ')' && depth > 0) {
|
|
1266
1369
|
depth--;
|
|
1370
|
+
drop(i);
|
|
1267
1371
|
continue;
|
|
1268
1372
|
}
|
|
1269
1373
|
if (depth === 0) {
|
|
@@ -1276,16 +1380,19 @@ class MimeNode {
|
|
|
1276
1380
|
}
|
|
1277
1381
|
}
|
|
1278
1382
|
if (depth === 0) {
|
|
1279
|
-
|
|
1383
|
+
keep(i);
|
|
1384
|
+
}
|
|
1385
|
+
else {
|
|
1386
|
+
drop(i);
|
|
1280
1387
|
}
|
|
1281
1388
|
}
|
|
1282
1389
|
if (depth === 0) {
|
|
1283
|
-
return result;
|
|
1390
|
+
return result + str.slice(runStart);
|
|
1284
1391
|
}
|
|
1285
1392
|
// An unbalanced `(` is not a comment. Dropping everything after it would take any
|
|
1286
1393
|
// parameter that follows with it, including the boundary that holds the message
|
|
1287
1394
|
// together, so the dangling text is only discarded when nothing follows it.
|
|
1288
|
-
return str.indexOf(';', commentStart) < 0 ? result : str;
|
|
1395
|
+
return str.indexOf(';', commentStart) < 0 ? result + str.slice(runStart) : str;
|
|
1289
1396
|
}
|
|
1290
1397
|
parseStructuredHeader(str) {
|
|
1291
1398
|
// Strip RFC 822 comments before parsing
|
|
@@ -1315,9 +1422,22 @@ class MimeNode {
|
|
|
1315
1422
|
}
|
|
1316
1423
|
pendingSpace = '';
|
|
1317
1424
|
};
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1425
|
+
// The characters of the value are copied as runs, see stripComments. A run is the
|
|
1426
|
+
// characters from runStart on, none is open while it is -1, and it is closed by
|
|
1427
|
+
// whatever is not a character of the value
|
|
1428
|
+
let runStart = -1;
|
|
1429
|
+
const closeRun = (end) => {
|
|
1430
|
+
if (runStart >= 0) {
|
|
1431
|
+
value += str.slice(runStart, end);
|
|
1432
|
+
runStart = -1;
|
|
1433
|
+
}
|
|
1434
|
+
};
|
|
1435
|
+
// the character at i is part of the value
|
|
1436
|
+
const addChr = (i) => {
|
|
1437
|
+
if (runStart < 0) {
|
|
1438
|
+
flushSpace();
|
|
1439
|
+
runStart = i;
|
|
1440
|
+
}
|
|
1321
1441
|
};
|
|
1322
1442
|
const takeValue = () => {
|
|
1323
1443
|
const result = value;
|
|
@@ -1357,15 +1477,19 @@ class MimeNode {
|
|
|
1357
1477
|
switch (stage) {
|
|
1358
1478
|
case 'key':
|
|
1359
1479
|
if (chr === '=') {
|
|
1480
|
+
closeRun(i);
|
|
1360
1481
|
key = takeValue().trim().toLowerCase();
|
|
1361
1482
|
stage = 'value';
|
|
1362
1483
|
break;
|
|
1363
1484
|
}
|
|
1364
1485
|
if (chr === ';') {
|
|
1486
|
+
closeRun(i);
|
|
1365
1487
|
storeEmptyKey();
|
|
1366
1488
|
break;
|
|
1367
1489
|
}
|
|
1368
|
-
|
|
1490
|
+
if (runStart < 0) {
|
|
1491
|
+
runStart = i;
|
|
1492
|
+
}
|
|
1369
1493
|
break;
|
|
1370
1494
|
case 'value':
|
|
1371
1495
|
if (quoteClosed && chr !== ';') {
|
|
@@ -1385,37 +1509,43 @@ class MimeNode {
|
|
|
1385
1509
|
break;
|
|
1386
1510
|
}
|
|
1387
1511
|
if (escaped) {
|
|
1388
|
-
addChr(
|
|
1512
|
+
addChr(i);
|
|
1389
1513
|
}
|
|
1390
1514
|
else if (quote && chr === '\\') {
|
|
1391
1515
|
// backslash only escapes inside a quoted string, everywhere else
|
|
1392
1516
|
// it is an ordinary character. Treating it as an escape turns
|
|
1393
1517
|
// `filename=C:\Users\me\a.txt` into `C:Usersmea.txt`.
|
|
1518
|
+
closeRun(i);
|
|
1394
1519
|
escaped = true;
|
|
1395
1520
|
continue;
|
|
1396
1521
|
}
|
|
1397
1522
|
else if (quote && chr === quote) {
|
|
1523
|
+
closeRun(i);
|
|
1398
1524
|
quote = false;
|
|
1399
1525
|
quoteClosed = true;
|
|
1400
1526
|
}
|
|
1401
1527
|
else if (!quote && chr === '"') {
|
|
1528
|
+
closeRun(i);
|
|
1402
1529
|
quote = chr;
|
|
1403
1530
|
flushSpace();
|
|
1404
1531
|
}
|
|
1405
1532
|
else if (!quote && chr === ';') {
|
|
1533
|
+
closeRun(i);
|
|
1406
1534
|
storeValue();
|
|
1407
1535
|
stage = 'key';
|
|
1408
1536
|
}
|
|
1409
1537
|
else if (!quote && (chr === ' ' || chr === '\t')) {
|
|
1538
|
+
closeRun(i);
|
|
1410
1539
|
pendingSpace += chr;
|
|
1411
1540
|
}
|
|
1412
1541
|
else {
|
|
1413
|
-
addChr(
|
|
1542
|
+
addChr(i);
|
|
1414
1543
|
}
|
|
1415
1544
|
escaped = false;
|
|
1416
1545
|
break;
|
|
1417
1546
|
}
|
|
1418
1547
|
}
|
|
1548
|
+
closeRun(str.length);
|
|
1419
1549
|
// finalize remainder
|
|
1420
1550
|
if (stage === 'value') {
|
|
1421
1551
|
storeValue();
|
|
@@ -1502,7 +1632,10 @@ class MimeNode {
|
|
|
1502
1632
|
// which differ from one transfer encoding to the next. Text is normalized to LF so
|
|
1503
1633
|
// that a consumer does not get CRLF from one message and LF from another
|
|
1504
1634
|
let str = decodeText(this.content, this.contentType.parsed.params.charset, this.contentType.parsed.value === 'text/html').replace(/\r\n/g, '\n');
|
|
1505
|
-
|
|
1635
|
+
// format=flowed is defined for text/plain (RFC 3676), and a browser never unfolds
|
|
1636
|
+
// an html part. Unfolded here anyway, `<scri \r\npt>` under delsp=yes was joined
|
|
1637
|
+
// into a tag that neither Python nor a browser given the raw bytes sees
|
|
1638
|
+
if (this.contentType.parsed.value === 'text/plain' && /^flowed$/i.test(this.contentType.parsed.params.format)) {
|
|
1506
1639
|
str = this.decodeFlowedText(str, /^yes$/i.test(this.contentType.parsed.params.delsp));
|
|
1507
1640
|
}
|
|
1508
1641
|
return str;
|
|
@@ -1532,9 +1665,21 @@ class MimeNode {
|
|
|
1532
1665
|
// Second pass: process headers in document order
|
|
1533
1666
|
for (let parts of foldedLines) {
|
|
1534
1667
|
let rawLine = parts.join('\n');
|
|
1535
|
-
//
|
|
1668
|
+
// The field name is kept as it was written, whitespace included. RFC 5322 puts
|
|
1669
|
+
// the colon right after the name and only its obsolete syntax lets whitespace
|
|
1670
|
+
// come between the two, so `From :` names a different field than `From:`, as
|
|
1671
|
+
// Go reads it, and the first line of a message may start with whitespace while
|
|
1672
|
+
// it is a fold of nothing, a line Python drops and Go rejects the message for.
|
|
1673
|
+
// Trimming the name made such a line the canonical header, and since the first
|
|
1674
|
+
// occurrence of a header wins, a ` From:` first line or a `From :` ahead of the
|
|
1675
|
+
// genuine From named the sender here while a verifier reading the other had
|
|
1676
|
+
// authenticated the one this parser ignored. The same put a Content-Type ahead
|
|
1677
|
+
// of the one every other parser reads the body by. Leaving the whitespace in
|
|
1678
|
+
// the key keeps the line visible without letting it collide with a genuine
|
|
1679
|
+
// header. A line whose name holds anything else, a non-ASCII space say, is no
|
|
1680
|
+
// field at all and ends the headers, see feed.
|
|
1536
1681
|
let sep = rawLine.indexOf(':');
|
|
1537
|
-
let rawKey =
|
|
1682
|
+
let rawKey = sep < 0 ? rawLine : rawLine.slice(0, sep);
|
|
1538
1683
|
// Store raw line with lowercase key
|
|
1539
1684
|
this.rawHeaderLines.push({
|
|
1540
1685
|
key: rawKey.toLowerCase(),
|
|
@@ -1546,7 +1691,7 @@ class MimeNode {
|
|
|
1546
1691
|
// replaced the non-ASCII spaces that raw UTF-8 headers (RFC 6532) may carry.
|
|
1547
1692
|
let unfoldedLine = parts.join('');
|
|
1548
1693
|
sep = unfoldedLine.indexOf(':');
|
|
1549
|
-
let key =
|
|
1694
|
+
let key = sep < 0 ? unfoldedLine : unfoldedLine.slice(0, sep);
|
|
1550
1695
|
// A bare CR is not legal in a field body, see foldLineBreaks
|
|
1551
1696
|
let value = sep < 0 ? '' : trimWsp(foldLineBreaks(unfoldedLine.slice(sep + 1)));
|
|
1552
1697
|
this.headers.push({ key: key.toLowerCase(), originalKey: key, value });
|
|
@@ -1580,29 +1725,58 @@ class MimeNode {
|
|
|
1580
1725
|
this.contentType.multipart = /^multipart\//i.test(this.contentType.parsed.value)
|
|
1581
1726
|
? this.contentType.parsed.value.slice(this.contentType.parsed.value.indexOf('/') + 1)
|
|
1582
1727
|
: false;
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1728
|
+
// A boundary does not end in whitespace (RFC 2046 5.1.1, bcharsnospace), and one
|
|
1729
|
+
// written with some anyway, `boundary="abc "`, is `abc` to Python. Registered as
|
|
1730
|
+
// written it matched no delimiter line and the whole body was dropped without a
|
|
1731
|
+
// trace, an attachment included. A delimiter line written with the space still
|
|
1732
|
+
// matches, since a boundary line may end in whitespace. trimEnd rather than
|
|
1733
|
+
// trimWsp, because Python strips any whitespace here.
|
|
1734
|
+
//
|
|
1735
|
+
// A boundary is ASCII as well (RFC 2046 5.1.1, bchars), and Python matches no line
|
|
1736
|
+
// against one that is not. One holding a non-ASCII character came out of a header
|
|
1737
|
+
// line decoded as windows-1252 and re-encoded as UTF-8, or was written in UTF-8 to
|
|
1738
|
+
// begin with. Matched here, `boundary="ab\xE9cd"` found the parts under lines of
|
|
1739
|
+
// `--ab\xC3\xA9cd` that a scanner built on Python never saw, and with the same byte
|
|
1740
|
+
// on both sides nothing matched and the body was dropped without a trace. Such a
|
|
1741
|
+
// boundary counts as none, so the body reads as text below
|
|
1742
|
+
let boundary = this.contentType.multipart ? (this.contentType.parsed.params.boundary || '').trimEnd() : '';
|
|
1743
|
+
if (/[^\x00-\x7f]/.test(boundary)) {
|
|
1744
|
+
boundary = '';
|
|
1745
|
+
}
|
|
1746
|
+
if (this.contentType.multipart && !boundary) {
|
|
1747
|
+
// A multipart without the boundary RFC 2046 5.1.1 requires, or with one it does
|
|
1748
|
+
// not allow, has no parts to find, and its body used to be dropped without a
|
|
1749
|
+
// trace. RFC 2045 5.2 recommends reading an invalid Content-Type as plain text,
|
|
1750
|
+
// which is also what the body of such a message usually is
|
|
1588
1751
|
this.contentType.parsed.value = 'text/plain';
|
|
1589
1752
|
this.contentType.multipart = false;
|
|
1590
1753
|
}
|
|
1591
1754
|
if (this.contentType.multipart) {
|
|
1592
1755
|
// add self to boundary terminator listing
|
|
1593
1756
|
this.postalMime.boundaries.push({
|
|
1594
|
-
value: textEncoder.encode(
|
|
1757
|
+
value: textEncoder.encode(boundary),
|
|
1595
1758
|
node: this
|
|
1596
1759
|
});
|
|
1597
1760
|
}
|
|
1598
1761
|
this.contentDisposition.parsed = this.parseStructuredHeader(this.contentDisposition.value);
|
|
1599
|
-
//
|
|
1600
|
-
// `
|
|
1601
|
-
//
|
|
1602
|
-
//
|
|
1762
|
+
// The disposition type is a token, so it ends at the first whitespace. Read whole,
|
|
1763
|
+
// `attachment garbage` was neither attachment nor inline, and a text/html part
|
|
1764
|
+
// declaring it was rendered as the body while Python and mailparser keep it an
|
|
1765
|
+
// attachment. The type of a Content-Type stays as it is: `text/html garbage` is no
|
|
1766
|
+
// html to them either, and reading it as html here would be the reverse mistake
|
|
1767
|
+
const disposition = this.contentDisposition.parsed.value;
|
|
1768
|
+
const dispositionEnd = disposition.search(/[ \t]/);
|
|
1769
|
+
if (dispositionEnd >= 0) {
|
|
1770
|
+
this.contentDisposition.parsed.value = disposition.slice(0, dispositionEnd);
|
|
1771
|
+
}
|
|
1772
|
+
// The token at the start of the value, behind the whitespace a leading comment
|
|
1773
|
+
// leaves: `(comment) base64`, `base64 (comment)` and `base64 x` decode, as they do
|
|
1774
|
+
// for Python. A quoted token does not. `"base64"` is raw base64 to Python and to
|
|
1775
|
+
// mailparser, and decoding it here rendered as markup a body both hand through as
|
|
1776
|
+
// it is. Taking the first token anywhere in the value read it, and `x "base64"`.
|
|
1603
1777
|
this.contentTransferEncoding.encoding = (this.stripComments(this.contentTransferEncoding.value)
|
|
1604
1778
|
.toLowerCase()
|
|
1605
|
-
.match(
|
|
1779
|
+
.match(/^[ \t]*([\w-]+)/) || ['', ''])[1];
|
|
1606
1780
|
this.setupContentDecoder(this.contentTransferEncoding.encoding);
|
|
1607
1781
|
// the lines live on in rawHeaderLines and headers, so the raw copies are released
|
|
1608
1782
|
this.headerLines = [];
|
|
@@ -1628,24 +1802,25 @@ class MimeNode {
|
|
|
1628
1802
|
this.postalMime.counters.headerSize += Math.max(line.length, HEADER_LINE_MIN_COST);
|
|
1629
1803
|
const maxHeadersSize = this.postalMime.limits.maxHeadersSize;
|
|
1630
1804
|
if (this.postalMime.counters.headerSize > maxHeadersSize) {
|
|
1631
|
-
throw
|
|
1805
|
+
throw createError('EMAXHEADERSIZE', `Maximum header size of ${maxHeadersSize} bytes exceeded`);
|
|
1632
1806
|
}
|
|
1633
1807
|
const text = decodeUtf8OrWindows1252(line, headerDecoder);
|
|
1634
|
-
// A line
|
|
1635
|
-
//
|
|
1636
|
-
//
|
|
1637
|
-
//
|
|
1638
|
-
//
|
|
1639
|
-
//
|
|
1640
|
-
//
|
|
1641
|
-
//
|
|
1642
|
-
|
|
1643
|
-
|
|
1808
|
+
// A line that is neither a header field nor a fold ends the headers, as it
|
|
1809
|
+
// does for Python and for an MTA that stores the header block on its own,
|
|
1810
|
+
// and Go rejects the message for it. Read on as a header here, a Content-Type
|
|
1811
|
+
// or a From behind such a line was honoured where a scanner built on Python
|
|
1812
|
+
// had read it as the first line of a text/plain body.
|
|
1813
|
+
//
|
|
1814
|
+
// A line of nothing but whitespace is a fold (RFC 5322 4.2, obs-FWS), but
|
|
1815
|
+
// broken generators also put one where the empty line that ends the headers
|
|
1816
|
+
// belongs, and reading it as a fold used to turn the whole body into one
|
|
1817
|
+
// more header. The next line decides: a field or a fold keeps it a fold,
|
|
1818
|
+
// anything else ends the headers, as it does for Python
|
|
1819
|
+
if (!isWsp(line[0]) && !FIELD_START.test(text)) {
|
|
1644
1820
|
this.endHeaders();
|
|
1645
1821
|
return true;
|
|
1646
1822
|
}
|
|
1647
1823
|
this.headerLines.push(text);
|
|
1648
|
-
this.lastLineBlank = isBlankLine(line);
|
|
1649
1824
|
break;
|
|
1650
1825
|
}
|
|
1651
1826
|
case 'body':
|
|
@@ -3965,14 +4140,26 @@ function decodeHTMLEntities(str) {
|
|
|
3965
4140
|
return output;
|
|
3966
4141
|
});
|
|
3967
4142
|
}
|
|
4143
|
+
// two uppercase hex digits of a value below 256
|
|
4144
|
+
const hexByte = (value) => value.toString(16).toUpperCase().padStart(2, '0');
|
|
3968
4145
|
function escapeHtml(str) {
|
|
3969
|
-
return str.trim().replace(/[<>"'?&]/g, c =>
|
|
3970
|
-
|
|
3971
|
-
|
|
3972
|
-
|
|
3973
|
-
|
|
3974
|
-
|
|
3975
|
-
|
|
4146
|
+
return str.trim().replace(/[<>"'?&]/g, c => '&#x' + hexByte(c.charCodeAt(0)) + ';');
|
|
4147
|
+
}
|
|
4148
|
+
// The characters an address may carry into a mailto URL as they are (RFC 6068 2:
|
|
4149
|
+
// unreserved and some-delims). Anything else is percent encoded: a `?` or `&` starts the
|
|
4150
|
+
// header fields of the URL, so `help@bank.example?cc=attacker@evil.example&body=…` given
|
|
4151
|
+
// as the address of a forwarded message opened a compose window with a Cc and a body the
|
|
4152
|
+
// sender chose once the link was clicked. Html escaping alone does not help, the browser
|
|
4153
|
+
// decodes the entities back before it reads the URL
|
|
4154
|
+
const MAILTO_UNSAFE = /[^A-Za-z0-9\-._~!$'()*+,;:@]+/g;
|
|
4155
|
+
// the percent encoded form of every byte, so that an encoded run allocates nothing per byte
|
|
4156
|
+
const PERCENT_ENCODED = Array.from({ length: 256 }, (_, byte) => '%' + hexByte(byte));
|
|
4157
|
+
// Each unsafe run is encoded whole and the result is built flat by replace. Appended one
|
|
4158
|
+
// character at a time it was a cons string of a node per character, which escapeHtml then
|
|
4159
|
+
// flattened: a long address of percent signs in a forwarded header held some sixty bytes
|
|
4160
|
+
// of heap per byte of it until then
|
|
4161
|
+
function encodeMailto(address) {
|
|
4162
|
+
return address.replace(MAILTO_UNSAFE, run => Array.from(textEncoder.encode(run), byte => PERCENT_ENCODED[byte]).join(''));
|
|
3976
4163
|
}
|
|
3977
4164
|
function textToHtml(str) {
|
|
3978
4165
|
let html = escapeHtml(str).replace(/\n/g, '<br />');
|
|
@@ -3980,15 +4167,10 @@ function textToHtml(str) {
|
|
|
3980
4167
|
}
|
|
3981
4168
|
// htmlToText strips tags with regexes like `<br\b[^>]*>` and `<!--.*?-->`. Handed to
|
|
3982
4169
|
// String#replace as they are, the engine tries every candidate in turn and a candidate
|
|
3983
|
-
// that does not close scans on to the next
|
|
3984
|
-
//
|
|
3985
|
-
//
|
|
3986
|
-
//
|
|
3987
|
-
// Characters that `.` does not match, so a `.*` can not run across them
|
|
3988
|
-
const LINE_TERMINATOR = /[\n\r\u2028\u2029]/g;
|
|
3989
|
-
function lastLineTerminator(str) {
|
|
3990
|
-
return Math.max(str.lastIndexOf('\n'), str.lastIndexOf('\r'), str.lastIndexOf('\u2028'), str.lastIndexOf('\u2029'));
|
|
3991
|
-
}
|
|
4170
|
+
// that does not close scans on to the next `>` or the end of the input before giving up,
|
|
4171
|
+
// so an html part holding many unclosed `<`, `<a ` or `<!--` took seconds to minutes to
|
|
4172
|
+
// convert. The helpers below give the same results in linear time. They run on text
|
|
4173
|
+
// without line terminators, see htmlToText, so `.` matches every character of it.
|
|
3992
4174
|
/**
|
|
3993
4175
|
* Returns a function that finds the next match of a global regex at or after a position.
|
|
3994
4176
|
*
|
|
@@ -4090,7 +4272,7 @@ function replaceTags(str, prefix, pattern, replacement) {
|
|
|
4090
4272
|
* `str.replace(pattern, replacement)` for a pattern like `<!--.*?-->` or
|
|
4091
4273
|
* `<script\b[^>]*>.*?<\/script\b[^>]*>`, ie. an opener and a closer, either of which may
|
|
4092
4274
|
* continue with `[^>]*>`, and a lazy `.*?` between them. The engine rescans to the end of
|
|
4093
|
-
* the
|
|
4275
|
+
* the input for every opener that is not closed.
|
|
4094
4276
|
*
|
|
4095
4277
|
* @param str String to process
|
|
4096
4278
|
* @param open Global regex for the opener
|
|
@@ -4103,7 +4285,6 @@ function replaceTags(str, prefix, pattern, replacement) {
|
|
|
4103
4285
|
function replaceBlocks(str, open, openToGt, close, closeToGt, replacement) {
|
|
4104
4286
|
const nextGt = createFinder(str, />/g);
|
|
4105
4287
|
const nextClose = createFinder(str, close);
|
|
4106
|
-
const nextLineEnd = createFinder(str, LINE_TERMINATOR);
|
|
4107
4288
|
const parts = [];
|
|
4108
4289
|
let copied = 0;
|
|
4109
4290
|
let searchFrom = 0;
|
|
@@ -4130,11 +4311,6 @@ function replaceBlocks(str, open, openToGt, close, closeToGt, replacement) {
|
|
|
4130
4311
|
// no closer follows this opener, so none follows a later one either
|
|
4131
4312
|
break;
|
|
4132
4313
|
}
|
|
4133
|
-
// `.*?` can not reach the closer across a line terminator
|
|
4134
|
-
const lineEnd = nextLineEnd(from);
|
|
4135
|
-
if (lineEnd && lineEnd.index < closer.index) {
|
|
4136
|
-
continue;
|
|
4137
|
-
}
|
|
4138
4314
|
let end = closer.index + closer[0].length;
|
|
4139
4315
|
if (closeToGt) {
|
|
4140
4316
|
const gt = nextGt(end);
|
|
@@ -4153,16 +4329,15 @@ function replaceBlocks(str, open, openToGt, close, closeToGt, replacement) {
|
|
|
4153
4329
|
}
|
|
4154
4330
|
/**
|
|
4155
4331
|
* `str.replace(pattern, '')` for `^.*<tag\b[^>]*>`, ie. drops everything up to and
|
|
4156
|
-
* including the last such tag
|
|
4332
|
+
* including the last such tag.
|
|
4157
4333
|
*
|
|
4158
4334
|
* @param str String to process
|
|
4159
4335
|
* @param prefix Global regex for the start of the tag, which can not contain `>`
|
|
4160
4336
|
* @return Processed string
|
|
4161
4337
|
*/
|
|
4162
4338
|
function stripThroughLastTag(str, prefix) {
|
|
4163
|
-
|
|
4164
|
-
|
|
4165
|
-
const limit = Math.min(lineEnd < 0 ? str.length : lineEnd, str.lastIndexOf('>'));
|
|
4339
|
+
// `[^>]*>` needs a `>` after the tag
|
|
4340
|
+
const limit = str.lastIndexOf('>');
|
|
4166
4341
|
let last = -1;
|
|
4167
4342
|
prefix.lastIndex = 0;
|
|
4168
4343
|
let match;
|
|
@@ -4172,28 +4347,32 @@ function stripThroughLastTag(str, prefix) {
|
|
|
4172
4347
|
return last < 0 ? str : str.slice(str.indexOf('>', last) + 1);
|
|
4173
4348
|
}
|
|
4174
4349
|
/**
|
|
4175
|
-
* `str.replace(pattern, '')` for `<tag\b[^>]*>.*$`, ie. drops the first such tag
|
|
4176
|
-
*
|
|
4350
|
+
* `str.replace(pattern, '')` for `<tag\b[^>]*>.*$`, ie. drops the first such tag that is
|
|
4351
|
+
* closed, and everything after it.
|
|
4177
4352
|
*
|
|
4178
4353
|
* @param str String to process
|
|
4179
4354
|
* @param prefix Global regex for the start of the tag, which can not contain `>`
|
|
4180
4355
|
* @return Processed string
|
|
4181
4356
|
*/
|
|
4182
4357
|
function stripFromFirstTag(str, prefix) {
|
|
4183
|
-
|
|
4184
|
-
// tag has to start after the last `>` that comes before the last line terminator
|
|
4185
|
-
const lineEnd = lastLineTerminator(str);
|
|
4186
|
-
prefix.lastIndex = lineEnd < 0 ? 0 : str.lastIndexOf('>', lineEnd) + 1;
|
|
4358
|
+
prefix.lastIndex = 0;
|
|
4187
4359
|
const match = prefix.exec(str);
|
|
4188
4360
|
return match && match.index < str.lastIndexOf('>') ? str.slice(0, match.index) : str;
|
|
4189
4361
|
}
|
|
4190
4362
|
function htmlToText(str) {
|
|
4191
|
-
//
|
|
4192
|
-
|
|
4363
|
+
// Tags are not processed across lines, so every line terminator, every character `.`
|
|
4364
|
+
// does not match, becomes a placeholder first. Only `\r?\n` used to, and the patterns
|
|
4365
|
+
// below do not cross the others either: `.*?` stops at a bare CR or a U+2028, so a
|
|
4366
|
+
// script whose body held one was not removed as a block, its tags were stripped as
|
|
4367
|
+
// tags and the script itself landed in the text, which a filter reading the text then
|
|
4368
|
+
// took for content
|
|
4369
|
+
str = str.replace(/\r\n|[\n\r\u2028\u2029]/g, '\u0001');
|
|
4193
4370
|
str = replaceBlocks(str, /<!--/g, false, /-->/g, false, ' ');
|
|
4194
4371
|
str = replaceClosedTags(str, /<br\b[^>]*>/gi, '\n');
|
|
4195
4372
|
str = replaceClosedTags(str, /<\/?(p|div|table|tr|td|th)\b[^>]*>/gi, '\n\n');
|
|
4196
4373
|
str = replaceBlocks(str, /<script\b/gi, true, /<\/script\b/gi, true, ' ');
|
|
4374
|
+
// a stylesheet in the body is no text of the message either
|
|
4375
|
+
str = replaceBlocks(str, /<style\b/gi, true, /<\/style\b/gi, true, ' ');
|
|
4197
4376
|
str = stripThroughLastTag(str, /<body\b/gi);
|
|
4198
4377
|
str = stripThroughLastTag(str, /<\/head\b/gi);
|
|
4199
4378
|
str = stripThroughLastTag(str, /<!doctype\b/gi);
|
|
@@ -4219,6 +4398,12 @@ function htmlToText(str) {
|
|
|
4219
4398
|
str = decodeHTMLEntities(str);
|
|
4220
4399
|
return str;
|
|
4221
4400
|
}
|
|
4401
|
+
// The formatter the dates of forwarded headers are rendered with, created on first use.
|
|
4402
|
+
// One instance serves every date, a formatter is immutable. Constructing one per rendered
|
|
4403
|
+
// header held some 30 KB of ICU state outside the V8 heap for each, so a message of a few
|
|
4404
|
+
// thousand dated forwards took hundreds of megabytes that no heap limit saw, and several
|
|
4405
|
+
// times the time of the same message without the dates
|
|
4406
|
+
let dateFormatter = null;
|
|
4222
4407
|
// A message date is not always a date. PostalMime keeps the raw header value when it does
|
|
4223
4408
|
// not parse, and Intl.DateTimeFormat throws a RangeError on that, which used to reject the
|
|
4224
4409
|
// whole parse of any message carrying a forwarded copy with a broken Date header.
|
|
@@ -4230,7 +4415,7 @@ function formatDate(date) {
|
|
|
4230
4415
|
if (isNaN(parsed.getTime())) {
|
|
4231
4416
|
return date;
|
|
4232
4417
|
}
|
|
4233
|
-
|
|
4418
|
+
dateFormatter ??= new Intl.DateTimeFormat('default', {
|
|
4234
4419
|
year: 'numeric',
|
|
4235
4420
|
month: 'numeric',
|
|
4236
4421
|
day: 'numeric',
|
|
@@ -4238,7 +4423,8 @@ function formatDate(date) {
|
|
|
4238
4423
|
minute: 'numeric',
|
|
4239
4424
|
second: 'numeric',
|
|
4240
4425
|
hour12: false
|
|
4241
|
-
})
|
|
4426
|
+
});
|
|
4427
|
+
return dateFormatter.format(parsed);
|
|
4242
4428
|
}
|
|
4243
4429
|
function formatTextAddress(address) {
|
|
4244
4430
|
return (address.name ? [address.name, `<${address.address}>`] : [address.address]).join(' ');
|
|
@@ -4264,7 +4450,7 @@ function formatTextAddresses(addresses) {
|
|
|
4264
4450
|
return parts.join('');
|
|
4265
4451
|
}
|
|
4266
4452
|
function formatHtmlAddress(address) {
|
|
4267
|
-
return `<a href="mailto:${escapeHtml(address.address)}" class="postal-email-address">${escapeHtml(address.name || `<${address.address}>`)}</a>`;
|
|
4453
|
+
return `<a href="mailto:${escapeHtml(encodeMailto(address.address))}" class="postal-email-address">${escapeHtml(address.name || `<${address.address}>`)}</a>`;
|
|
4268
4454
|
}
|
|
4269
4455
|
function formatHtmlAddresses(addresses) {
|
|
4270
4456
|
let parts = [];
|
|
@@ -4918,40 +5104,6 @@ function stripAddressComments(address) {
|
|
|
4918
5104
|
}
|
|
4919
5105
|
return { address: result.trim(), comments: comments.filter(text => text) };
|
|
4920
5106
|
}
|
|
4921
|
-
/**
|
|
4922
|
-
* Checks whether text holds an angle bracket address, ie. `<` and `>` around a run
|
|
4923
|
-
* without other angle brackets that has an '@' with at least one character on each side.
|
|
4924
|
-
*
|
|
4925
|
-
* Same answer as `/<[^<>]+@[^<>]+>/.test(text)`, which backtracks quadratically on a `<`
|
|
4926
|
-
* followed by a long run of '@' and no closing `>`.
|
|
4927
|
-
*
|
|
4928
|
-
* @param text Text to check
|
|
4929
|
-
* @return true if an angle bracket address is present
|
|
4930
|
-
*/
|
|
4931
|
-
function hasAngleAddress(text) {
|
|
4932
|
-
// position right after the `<` that opened the current bracket, or -1
|
|
4933
|
-
let start = -1;
|
|
4934
|
-
// first '@' inside the bracket that has a character before it, or -1
|
|
4935
|
-
let at = -1;
|
|
4936
|
-
for (let i = 0; i < text.length; i++) {
|
|
4937
|
-
const chr = text.charAt(i);
|
|
4938
|
-
if (chr === '<') {
|
|
4939
|
-
start = i + 1;
|
|
4940
|
-
at = -1;
|
|
4941
|
-
}
|
|
4942
|
-
else if (chr === '>') {
|
|
4943
|
-
if (at >= 0 && at < i - 1) {
|
|
4944
|
-
return true;
|
|
4945
|
-
}
|
|
4946
|
-
start = -1;
|
|
4947
|
-
at = -1;
|
|
4948
|
-
}
|
|
4949
|
-
else if (chr === '@' && start >= 0 && at < 0 && i > start) {
|
|
4950
|
-
at = i;
|
|
4951
|
-
}
|
|
4952
|
-
}
|
|
4953
|
-
return false;
|
|
4954
|
-
}
|
|
4955
5107
|
/**
|
|
4956
5108
|
* Walks the tokens of one entry of an address list and sorts them into the runs of display
|
|
4957
5109
|
* text, angle-addr and comment they belong to
|
|
@@ -5074,17 +5226,14 @@ function groupName(tokens, from, to) {
|
|
|
5074
5226
|
return decodeWords(collectAddressParts(tokens, from, to).data.text.join(' '));
|
|
5075
5227
|
}
|
|
5076
5228
|
/**
|
|
5077
|
-
* Converts tokens for a single address into an address object
|
|
5078
|
-
* but an encoded word holding an angle-addr is parsed again once decoded, which can give
|
|
5079
|
-
* more than one entry.
|
|
5229
|
+
* Converts tokens for a single address into an address object
|
|
5080
5230
|
*
|
|
5081
5231
|
* @param tokens Tokens of the whole address list
|
|
5082
5232
|
* @param from Index of the first token of this address
|
|
5083
5233
|
* @param to Index just past the last token of this address
|
|
5084
|
-
* @
|
|
5085
|
-
* @return Address objects
|
|
5234
|
+
* @return The address object
|
|
5086
5235
|
*/
|
|
5087
|
-
function _handleAddress(tokens, from, to
|
|
5236
|
+
function _handleAddress(tokens, from, to) {
|
|
5088
5237
|
const collected = collectAddressParts(tokens, from, to);
|
|
5089
5238
|
const data = collected.data;
|
|
5090
5239
|
let textFromComment = collected.textFromComment;
|
|
@@ -5171,25 +5320,19 @@ function _handleAddress(tokens, from, to, depth) {
|
|
|
5171
5320
|
// Join values with spaces
|
|
5172
5321
|
let text = data.text.join(' ');
|
|
5173
5322
|
let addressValue = data.address.join(' ');
|
|
5323
|
+
// A field that is nothing but encoded words names the mailbox and is not one. The
|
|
5324
|
+
// decoded text used to be parsed again when it held an angle-addr, so a word decoding
|
|
5325
|
+
// to `CEO <ceo@victim.example>` became that mailbox. Nothing that authenticates the
|
|
5326
|
+
// message reads it that way: a DMARC verifier takes the author from the raw header,
|
|
5327
|
+
// where the word holds no mailbox, so the message has no author domain to check and
|
|
5328
|
+
// the sender it showed here was one no verifier had seen. nodemailer, mailparser and
|
|
5329
|
+
// Python read the field as a name as well.
|
|
5330
|
+
//
|
|
5174
5331
|
// `^=\?[^=]+?=$` could not match a base64 word whose padding puts an '=' inside it,
|
|
5175
5332
|
// so whether a bare encoded word was decoded or left to become the address itself
|
|
5176
5333
|
// came down to whether its payload happened to need padding.
|
|
5177
5334
|
if (!addressValue && isEncodedWordsOnly(text.trim())) {
|
|
5178
|
-
|
|
5179
|
-
const decodedText = decodeWords(text);
|
|
5180
|
-
// Security: only re-parse if decoded text contains angle-bracket addresses.
|
|
5181
|
-
// Without this, a bare encoded email (e.g. =?utf-8?B?dGVzdEBldmlsLmNv?=)
|
|
5182
|
-
// would be fabricated into an address from attacker-controlled input. A quoted
|
|
5183
|
-
// encoded word is never re-parsed, addresses are not taken out of quoted strings
|
|
5184
|
-
// and neither is one that came out of a comment
|
|
5185
|
-
if (!addressFromQuotedText && !textFromComment && hasAngleAddress(decodedText)) {
|
|
5186
|
-
const parsedSubAddresses = parseAddressList(decodedText, depth + 1);
|
|
5187
|
-
if (parsedSubAddresses.length) {
|
|
5188
|
-
return parsedSubAddresses;
|
|
5189
|
-
}
|
|
5190
|
-
}
|
|
5191
|
-
// No usable address found - treat decoded text as display name only
|
|
5192
|
-
return [{ address: '', name: decodedText }];
|
|
5335
|
+
return { address: '', name: decodeWords(text) };
|
|
5193
5336
|
}
|
|
5194
5337
|
if (addressFromQuotedText && text.indexOf('@') >= 0) {
|
|
5195
5338
|
// The mailbox is still sitting in the text, so it moves over here and is quoted
|
|
@@ -5227,7 +5370,7 @@ function _handleAddress(tokens, from, to, depth) {
|
|
|
5227
5370
|
address.address = '';
|
|
5228
5371
|
}
|
|
5229
5372
|
}
|
|
5230
|
-
return
|
|
5373
|
+
return address;
|
|
5231
5374
|
}
|
|
5232
5375
|
/**
|
|
5233
5376
|
* Tokenizer for address field strings
|
|
@@ -5552,12 +5695,6 @@ class Tokenizer {
|
|
|
5552
5695
|
this.innerEscaped = false;
|
|
5553
5696
|
}
|
|
5554
5697
|
}
|
|
5555
|
-
/**
|
|
5556
|
-
* Maximum depth of the re-parse of an address that is nothing but an encoded word holding
|
|
5557
|
-
* an angle-addr. Groups no longer recurse, but a decoded word could hold another one, so this
|
|
5558
|
-
* still bounds that.
|
|
5559
|
-
*/
|
|
5560
|
-
const MAX_NESTED_GROUP_DEPTH = 10;
|
|
5561
5698
|
/**
|
|
5562
5699
|
* Folds the fragments an unquoted display name holding a comma is split into back together.
|
|
5563
5700
|
*
|
|
@@ -5596,14 +5733,9 @@ function mergeNameFragments(list) {
|
|
|
5596
5733
|
* ends the group, nested or not, as before.
|
|
5597
5734
|
*
|
|
5598
5735
|
* @param str Address field
|
|
5599
|
-
* @param depth Current depth of the encoded word re-parse
|
|
5600
5736
|
* @return An array of address objects
|
|
5601
5737
|
*/
|
|
5602
|
-
function parseAddressList(str
|
|
5603
|
-
// Prevent stack overflow from encoded words decoding to more of them (DoS protection)
|
|
5604
|
-
if (depth > MAX_NESTED_GROUP_DEPTH) {
|
|
5605
|
-
return [];
|
|
5606
|
-
}
|
|
5738
|
+
function parseAddressList(str) {
|
|
5607
5739
|
const tokens = new Tokenizer(str).tokenize();
|
|
5608
5740
|
const parsedAddresses = [];
|
|
5609
5741
|
let group = null;
|
|
@@ -5630,21 +5762,7 @@ function parseAddressList(str, depth) {
|
|
|
5630
5762
|
if (i > from) {
|
|
5631
5763
|
// Appended in place. Rebuilding the list with concat() for every address copies
|
|
5632
5764
|
// everything parsed so far and is quadratic in the number of addresses.
|
|
5633
|
-
|
|
5634
|
-
if (!group) {
|
|
5635
|
-
parsedAddresses.push(address);
|
|
5636
|
-
}
|
|
5637
|
-
else if (address.group) {
|
|
5638
|
-
// an encoded word inside a group decoded to a group of its own, which is
|
|
5639
|
-
// flattened into this one as a nested group is
|
|
5640
|
-
for (const member of address.group) {
|
|
5641
|
-
members.push(member);
|
|
5642
|
-
}
|
|
5643
|
-
}
|
|
5644
|
-
else {
|
|
5645
|
-
members.push(address);
|
|
5646
|
-
}
|
|
5647
|
-
}
|
|
5765
|
+
(group ? members : parsedAddresses).push(_handleAddress(tokens, from, i));
|
|
5648
5766
|
}
|
|
5649
5767
|
from = i + 1;
|
|
5650
5768
|
if (group && (operator === ';' || i === len)) {
|
|
@@ -5671,7 +5789,7 @@ function parseAddressList(str, depth) {
|
|
|
5671
5789
|
* @return An array of address objects
|
|
5672
5790
|
*/
|
|
5673
5791
|
function addressParser(str, options) {
|
|
5674
|
-
const parsedAddresses = parseAddressList(str
|
|
5792
|
+
const parsedAddresses = parseAddressList(str);
|
|
5675
5793
|
return parsedAddresses;
|
|
5676
5794
|
}
|
|
5677
5795
|
|
|
@@ -5736,6 +5854,23 @@ function isReadableStream(value) {
|
|
|
5736
5854
|
function isBlob(value) {
|
|
5737
5855
|
return value instanceof Blob || Object.prototype.toString.call(value) === '[object Blob]';
|
|
5738
5856
|
}
|
|
5857
|
+
// Checked by tag rather than instanceof, which fails for a buffer from another realm
|
|
5858
|
+
function isArrayBuffer(value) {
|
|
5859
|
+
const tag = Object.prototype.toString.call(value);
|
|
5860
|
+
return tag === '[object ArrayBuffer]' || tag === '[object SharedArrayBuffer]';
|
|
5861
|
+
}
|
|
5862
|
+
const ATTACHMENT_ENCODINGS = new Set(['arraybuffer', 'base64', 'utf8']);
|
|
5863
|
+
// Checked up front, an unknown encoding used to reject only once the whole message had
|
|
5864
|
+
// been parsed
|
|
5865
|
+
function parseAttachmentEncoding(value) {
|
|
5866
|
+
const encoding = String(value || '')
|
|
5867
|
+
.replace(/[-_\s]/g, '')
|
|
5868
|
+
.toLowerCase() || 'arraybuffer';
|
|
5869
|
+
if (!ATTACHMENT_ENCODINGS.has(encoding)) {
|
|
5870
|
+
throw createError('EINVALIDOPTION', `Unknown attachment encoding ${encoding}`);
|
|
5871
|
+
}
|
|
5872
|
+
return encoding;
|
|
5873
|
+
}
|
|
5739
5874
|
// Renders one entry of a node as the requested text type. A text part of the other type
|
|
5740
5875
|
// is converted, and a nested message becomes a header block in that format
|
|
5741
5876
|
function renderEntry(textEntry, textType, convert) {
|
|
@@ -5782,12 +5917,12 @@ function countAddresses(addresses) {
|
|
|
5782
5917
|
// every `size > limit` comparison against such a value is false. Callers that
|
|
5783
5918
|
// forward a request supplied options object would otherwise hand an attacker a way
|
|
5784
5919
|
// to turn the limits off.
|
|
5785
|
-
function parseLimitOption(value, defaultValue, name) {
|
|
5920
|
+
function parseLimitOption(value, defaultValue, name, min = 0) {
|
|
5786
5921
|
if (value === undefined || value === null) {
|
|
5787
5922
|
return defaultValue;
|
|
5788
5923
|
}
|
|
5789
|
-
if (typeof value !== 'number' || !Number.isInteger(value) || value <
|
|
5790
|
-
throw
|
|
5924
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value < min) {
|
|
5925
|
+
throw createError('EINVALIDOPTION', `${name} must be a ${min ? 'positive' : 'non-negative'} integer`);
|
|
5791
5926
|
}
|
|
5792
5927
|
return value;
|
|
5793
5928
|
}
|
|
@@ -5795,7 +5930,10 @@ function parseLimits(options) {
|
|
|
5795
5930
|
return {
|
|
5796
5931
|
maxNestingDepth: parseLimitOption(options.maxNestingDepth, MAX_NESTING_DEPTH, 'maxNestingDepth'),
|
|
5797
5932
|
maxHeadersSize: parseLimitOption(options.maxHeadersSize, MAX_HEADERS_SIZE, 'maxHeadersSize'),
|
|
5798
|
-
|
|
5933
|
+
// The root part is always counted, so a limit of zero can parse nothing, not even
|
|
5934
|
+
// an empty message, and it threw EMAXPARTCOUNT from the constructor, where only
|
|
5935
|
+
// EINVALIDOPTION is thrown
|
|
5936
|
+
maxPartCount: parseLimitOption(options.maxPartCount, MAX_PART_COUNT, 'maxPartCount', 1),
|
|
5799
5937
|
// A limit of 0 disables inline parsing entirely, so every message/rfc822 part
|
|
5800
5938
|
// becomes an attachment.
|
|
5801
5939
|
maxRfc822NestingDepth: parseLimitOption(options.maxRfc822NestingDepth, MAX_RFC822_NESTING_DEPTH, 'maxRfc822NestingDepth'),
|
|
@@ -5832,12 +5970,7 @@ class PostalMime {
|
|
|
5832
5970
|
this.textTypes = new Set();
|
|
5833
5971
|
this.attachments = [];
|
|
5834
5972
|
this.forceRfc822Attachments = false;
|
|
5835
|
-
this.attachmentEncoding =
|
|
5836
|
-
(this.options.attachmentEncoding || '')
|
|
5837
|
-
.toString()
|
|
5838
|
-
.replace(/[-_\s]/g, '')
|
|
5839
|
-
.trim()
|
|
5840
|
-
.toLowerCase() || 'arraybuffer';
|
|
5973
|
+
this.attachmentEncoding = parseAttachmentEncoding(this.options.attachmentEncoding);
|
|
5841
5974
|
this.started = false;
|
|
5842
5975
|
}
|
|
5843
5976
|
/** @internal */
|
|
@@ -5850,8 +5983,16 @@ class PostalMime {
|
|
|
5850
5983
|
let boundaries = this.boundaries;
|
|
5851
5984
|
// check if this is a mime boundary
|
|
5852
5985
|
if (boundaries.length && line.length > 2 && line[0] === 0x2d && line[1] === 0x2d) {
|
|
5853
|
-
//
|
|
5854
|
-
|
|
5986
|
+
// Could be a boundary marker. The open boundaries are checked outermost first:
|
|
5987
|
+
// a boundary may not appear inside the parts it delimits (RFC 2046 5.1.1), and
|
|
5988
|
+
// when a nested multipart declares the boundary of an ancestor anyway, or one
|
|
5989
|
+
// that ancestor's terminator matches, the ancestor's delimiter is the one every
|
|
5990
|
+
// other parser reads the line as. Reading it as the nested one instead gave the
|
|
5991
|
+
// nested multipart parts, and an attachment after the ancestor's terminator, that
|
|
5992
|
+
// Python and Go leave in the epilogue, which hid it from a scanner built on
|
|
5993
|
+
// either while it was shown here. Legal boundaries match one line at most, the
|
|
5994
|
+
// order changes nothing for them.
|
|
5995
|
+
for (let i = 0; i < boundaries.length; i++) {
|
|
5855
5996
|
let boundary = boundaries[i];
|
|
5856
5997
|
// Line must be at least long enough for "--" + boundary
|
|
5857
5998
|
if (line.length < boundary.value.length + 2) {
|
|
@@ -5998,7 +6139,7 @@ class PostalMime {
|
|
|
5998
6139
|
// Attachments are encoded by the parent parser, so raw buffers are kept here.
|
|
5999
6140
|
attachmentEncoding: 'arraybuffer'
|
|
6000
6141
|
}, this);
|
|
6001
|
-
const subMessage =
|
|
6142
|
+
const subMessage = await subParser.parse(node.content || new ArrayBuffer(0));
|
|
6002
6143
|
// counted before the header block below is rendered into the text and html
|
|
6003
6144
|
this.counters.forwardedAddresses +=
|
|
6004
6145
|
countAddresses(subMessage.from) +
|
|
@@ -6006,13 +6147,18 @@ class PostalMime {
|
|
|
6006
6147
|
countAddresses(subMessage.cc) +
|
|
6007
6148
|
countAddresses(subMessage.bcc);
|
|
6008
6149
|
if (this.counters.forwardedAddresses > this.limits.maxForwardedAddresses) {
|
|
6009
|
-
throw
|
|
6150
|
+
throw createError('EMAXFORWARDEDADDRESSES', `Maximum forwarded address count of ${this.limits.maxForwardedAddresses} addresses exceeded`);
|
|
6010
6151
|
}
|
|
6152
|
+
// The nested parser renders no text, see parse, its text parts are taken over below
|
|
6153
|
+
// and rendered by the root parser along with everything else. What it holds of each
|
|
6154
|
+
// kind is read off its text types instead of its text and html
|
|
6155
|
+
const hasPlain = subParser.textTypes.has('plain');
|
|
6156
|
+
const hasHtml = subParser.textTypes.has('html');
|
|
6011
6157
|
// default to text if there is no content
|
|
6012
|
-
if (
|
|
6158
|
+
if (hasPlain || !hasHtml) {
|
|
6013
6159
|
this.addTextEntry(node, 'plain', { type: 'subMessage', value: subMessage });
|
|
6014
6160
|
}
|
|
6015
|
-
if (
|
|
6161
|
+
if (hasHtml) {
|
|
6016
6162
|
this.addTextEntry(node, 'html', { type: 'subMessage', value: subMessage });
|
|
6017
6163
|
}
|
|
6018
6164
|
subParser.textMap.forEach((subTextEntry, subTextNode) => {
|
|
@@ -6117,6 +6263,16 @@ class PostalMime {
|
|
|
6117
6263
|
if (this.forceRfc822Attachments || node.contentType.parsed.value !== 'message/rfc822') {
|
|
6118
6264
|
return false;
|
|
6119
6265
|
}
|
|
6266
|
+
// RFC 2046 5.2.1 allows no encoding but 7bit, 8bit and binary for a message/rfc822
|
|
6267
|
+
// body. Python parses the lines of a base64 or quoted-printable part as they are, so
|
|
6268
|
+
// the nested message it sees has no headers and holds the encoded text, and
|
|
6269
|
+
// mailparser keeps the part as one attachment. Decoding and inlining it here
|
|
6270
|
+
// rendered the html and listed the attachments of a message no scanner built on
|
|
6271
|
+
// either reads, so such a part is an attachment, with its content decoded
|
|
6272
|
+
const encoding = node.contentTransferEncoding.encoding;
|
|
6273
|
+
if (encoding === 'base64' || encoding === 'quoted-printable') {
|
|
6274
|
+
return false;
|
|
6275
|
+
}
|
|
6120
6276
|
let disposition = node.contentDisposition.parsed.value || (this.options.rfc822Attachments ? 'attachment' : 'inline');
|
|
6121
6277
|
return disposition === 'inline';
|
|
6122
6278
|
}
|
|
@@ -6148,8 +6304,16 @@ class PostalMime {
|
|
|
6148
6304
|
if (done) {
|
|
6149
6305
|
break;
|
|
6150
6306
|
}
|
|
6151
|
-
|
|
6152
|
-
|
|
6307
|
+
// A chunk that is not a byte view, eg. a string from a text stream, used to be
|
|
6308
|
+
// copied in as zeros
|
|
6309
|
+
if (!ArrayBuffer.isView(value)) {
|
|
6310
|
+
const error = createError('EINVALIDINPUT', 'Stream chunks must be Uint8Array values');
|
|
6311
|
+
// Not awaited, a source that fails or stalls on cancel must not hide this error
|
|
6312
|
+
reader.cancel(error).catch(() => { });
|
|
6313
|
+
throw error;
|
|
6314
|
+
}
|
|
6315
|
+
chunks.push(new Uint8Array(value.buffer, value.byteOffset, value.byteLength));
|
|
6316
|
+
chunkLen += value.byteLength;
|
|
6153
6317
|
}
|
|
6154
6318
|
const result = new Uint8Array(chunkLen);
|
|
6155
6319
|
let chunkPointer = 0;
|
|
@@ -6183,6 +6347,11 @@ class PostalMime {
|
|
|
6183
6347
|
if (ArrayBuffer.isView(input)) {
|
|
6184
6348
|
return input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength);
|
|
6185
6349
|
}
|
|
6350
|
+
// Anything else, eg. a number or a plain object, was handed to the Uint8Array
|
|
6351
|
+
// constructor as it is and parsed into an empty or zero filled message
|
|
6352
|
+
if (!isArrayBuffer(input)) {
|
|
6353
|
+
throw createError('EINVALIDINPUT', 'Unsupported email input type');
|
|
6354
|
+
}
|
|
6186
6355
|
return input;
|
|
6187
6356
|
}
|
|
6188
6357
|
// Properties are added in the order the output has always had them, so the required
|
|
@@ -6291,8 +6460,6 @@ class PostalMime {
|
|
|
6291
6460
|
}
|
|
6292
6461
|
break;
|
|
6293
6462
|
}
|
|
6294
|
-
default:
|
|
6295
|
-
throw new Error('Unknown attachment encoding');
|
|
6296
6463
|
}
|
|
6297
6464
|
}
|
|
6298
6465
|
/**
|
|
@@ -6303,7 +6470,7 @@ class PostalMime {
|
|
|
6303
6470
|
*/
|
|
6304
6471
|
async parse(buf) {
|
|
6305
6472
|
if (this.started) {
|
|
6306
|
-
throw
|
|
6473
|
+
throw createError('EPARSERREUSED', 'Can not reuse parser, create a new PostalMime object');
|
|
6307
6474
|
}
|
|
6308
6475
|
this.started = true;
|
|
6309
6476
|
this.buf = await this.resolveInput(buf);
|
|
@@ -6315,7 +6482,14 @@ class PostalMime {
|
|
|
6315
6482
|
}
|
|
6316
6483
|
this.forceRfc822Attachments = Boolean(this.options.forceRfc822Attachments) || this.hasReportParts();
|
|
6317
6484
|
await this.collectNode(this.root, false, false);
|
|
6318
|
-
|
|
6485
|
+
// A nested parser hands its text parts to its parent, see collectSubMessage, and the
|
|
6486
|
+
// root parser renders them all at once. Rendering at every level as well cost each
|
|
6487
|
+
// level the text and html of everything below it, and kept them until the root was
|
|
6488
|
+
// done: a chain of forwards as deep as maxRfc822NestingDepth allows took ten times
|
|
6489
|
+
// the time and the memory of the flat message it held
|
|
6490
|
+
if (this.rfc822NestingDepth === 0) {
|
|
6491
|
+
this.renderTextContent();
|
|
6492
|
+
}
|
|
6319
6493
|
const message = this.buildMessage();
|
|
6320
6494
|
this.encodeAttachments();
|
|
6321
6495
|
return message;
|