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