@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.
Files changed (43) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/dist/cjs/heading-level-BxCuIKkm.js +14 -0
  3. package/dist/cjs/limel-collapsible-section.cjs.entry.js +4 -9
  4. package/dist/cjs/limel-file-viewer.cjs.entry.js +422 -248
  5. package/dist/cjs/limel-form-header.cjs.entry.js +1 -1
  6. package/dist/cjs/limel-form.cjs.entry.js +23 -20
  7. package/dist/collection/components/collapsible-section/collapsible-section.js +11 -9
  8. package/dist/collection/components/form/form.js +8 -6
  9. package/dist/collection/components/form/form.test-schemas.js +68 -0
  10. package/dist/collection/components/form/templates/array-field-collapsible-item.js +13 -3
  11. package/dist/collection/components/form/templates/heading-level.js +0 -10
  12. package/dist/collection/components/form/templates/object-field.js +6 -3
  13. package/dist/collection/components/form-header/form-header.js +3 -3
  14. package/dist/collection/global/shared-types/heading-level.types.js +1 -0
  15. package/dist/collection/interface.js +1 -0
  16. package/dist/collection/util/heading-level.js +10 -0
  17. package/dist/esm/heading-level-B3C0kDpZ.js +12 -0
  18. package/dist/esm/limel-collapsible-section.entry.js +2 -7
  19. package/dist/esm/limel-file-viewer.entry.js +422 -248
  20. package/dist/esm/limel-form-header.entry.js +1 -1
  21. package/dist/esm/limel-form.entry.js +21 -18
  22. package/dist/lime-elements/lime-elements.esm.js +1 -1
  23. package/dist/lime-elements/p-49a53f0b.entry.js +1 -0
  24. package/dist/lime-elements/{p-72907ffd.entry.js → p-68129487.entry.js} +1 -1
  25. package/dist/lime-elements/{p-9436aba6.entry.js → p-74db50a8.entry.js} +4 -4
  26. package/dist/lime-elements/p-B3C0kDpZ.js +1 -0
  27. package/dist/lime-elements/p-e8255f0b.entry.js +1 -0
  28. package/dist/types/components/collapsible-section/collapsible-section.d.ts +2 -1
  29. package/dist/types/components/form/form.d.ts +4 -2
  30. package/dist/types/components/form/form.test-schemas.d.ts +3 -0
  31. package/dist/types/components/form/form.types.d.ts +0 -6
  32. package/dist/types/components/form/row/row.d.ts +1 -1
  33. package/dist/types/components/form/templates/array-field-collapsible-item.d.ts +5 -1
  34. package/dist/types/components/form/templates/heading-level.d.ts +1 -6
  35. package/dist/types/components/form/templates/object-field.d.ts +2 -0
  36. package/dist/types/components/form-header/form-header.d.ts +1 -1
  37. package/dist/types/components.d.ts +9 -7
  38. package/dist/types/global/shared-types/heading-level.types.d.ts +7 -0
  39. package/dist/types/interface.d.ts +2 -1
  40. package/dist/types/util/heading-level.d.ts +7 -0
  41. package/package.json +2 -2
  42. package/dist/lime-elements/p-1d13245d.entry.js +0 -1
  43. 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
- // Decoders by label, or null where the runtime refuses the label (the replacement
321
- // encoding, or an encoding a runtime was built without). Only labels from the list above
322
- // are stored, so the map can not grow past it however many labels a message makes up.
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(charset) {
328
- if (!ENCODING_LABELS.has(charset)) {
329
- return null;
384
+ function tryDecoder(label) {
385
+ if (REPLACEMENT_LABELS.has(label)) {
386
+ return replacementDecoder;
330
387
  }
331
- let decoder = decoders.get(charset);
388
+ let decoder = decoders.get(label);
332
389
  if (decoder === undefined) {
333
390
  decoder = null;
334
391
  try {
335
- decoder = new TextDecoder(charset);
392
+ decoder = new TextDecoder(label);
336
393
  }
337
394
  catch {
338
395
  // not supported by this runtime
339
396
  }
340
- decoders.set(charset, decoder);
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
- charset = (charset || 'utf8').trim().toLowerCase();
352
- // Try the label as written first, so the alias table only ever adds to what the
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 decoder = findDecoder(charset);
442
- return decoder ? decoder.decode(bytes) : decodeUtf8OrWindows1252(bytes);
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 only the whitespace RFC 5322 allows around a field name. String.prototype.trim
1080
- // also strips U+00A0, U+FEFF, U+2028 and the rest of the Unicode spaces, which turns a
1081
- // line that a strict parser rejects into a canonical field name: a `From:` line with a
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
- // A line of nothing but SP and HTAB, see feed
1101
- const isBlankLine = (line) => line.every(isWsp);
1102
- // The start of a header field: a field name of printable ASCII other than the colon
1103
- // (RFC 5322 3.6.8 ftext) and the colon itself, with the whitespace in between that
1104
- // processHeaders tolerates as well
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 new Error(`Maximum MIME part count of ${limits.maxPartCount} parts exceeded`);
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 new Error(`Maximum MIME nesting depth of ${limits.maxNestingDepth} levels exceeded`);
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 (/base64/i.test(transferEncoding)) {
1270
+ if (transferEncoding === 'base64') {
1170
1271
  this.contentDecoder = new Base64Decoder();
1171
1272
  }
1172
- else if (/quoted-printable/i.test(transferEncoding)) {
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
- // Whether result ends in SP or HTAB. Kept up to date on every append, because
1227
- // testing `/[ \t]$/` against result flattens the whole string on each `(` and is
1228
- // quadratic in the length of the header.
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 append = (c) => {
1231
- result += c;
1232
- endsWithWsp = c === ' ' || c === '\t';
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 || !result.length || endsWithWsp;
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
- append(chr);
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
- const addChr = (c) => {
1321
- flushSpace();
1322
- value += c;
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
- value += chr;
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(chr);
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(chr);
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
- if (/^flowed$/i.test(this.contentType.parsed.params.format)) {
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
- // Extract key from raw line for rawHeaderLines
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 = trimWsp(sep < 0 ? rawLine : rawLine.slice(0, sep));
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 = trimWsp(sep < 0 ? unfoldedLine : unfoldedLine.slice(0, sep));
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
- if (this.contentType.multipart && !this.contentType.parsed.params.boundary) {
1586
- // A multipart without the boundary RFC 2046 5.1.1 requires has no parts to find,
1587
- // and its body used to be dropped without a trace. RFC 2045 5.2 recommends
1588
- // reading an invalid Content-Type as plain text, which is also what the body of
1589
- // such a message usually is
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(this.contentType.parsed.params.boundary),
1759
+ value: textEncoder.encode(boundary),
1597
1760
  node: this
1598
1761
  });
1599
1762
  }
1600
1763
  this.contentDisposition.parsed = this.parseStructuredHeader(this.contentDisposition.value);
1601
- // Take the first token rather than splitting on the first non-token character.
1602
- // `split()` returns an empty string for anything that does not start with a word
1603
- // character, so `(comment) base64` and `"base64"` used to fall through to the
1604
- // pass-through decoder and hand the caller undecoded base64 as the message body.
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(/[\w-]+/) || [''])[0];
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 new Error(`Maximum header size of ${maxHeadersSize} bytes exceeded`);
1807
+ throw createError('EMAXHEADERSIZE', `Maximum header size of ${maxHeadersSize} bytes exceeded`);
1634
1808
  }
1635
1809
  const text = decodeUtf8OrWindows1252(line, headerDecoder);
1636
- // A line of nothing but whitespace is legal folding (RFC 5322 4.2, obs-FWS),
1637
- // but broken generators also put one where the empty line that ends the
1638
- // headers belongs, and reading it as a fold then turned the whole body into
1639
- // one more header. So the next line decides what it was: a continuation or
1640
- // another header keep it a fold, anything else means the headers ended with
1641
- // it, and it is taken back out of the header lines. Python and Go read such
1642
- // a line as the end of the headers outright, so the body is where the other
1643
- // parsers find it.
1644
- if (this.lastLineBlank && !isWsp(line[0]) && !FIELD_START.test(text)) {
1645
- this.headerLines.pop();
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
- let hex = c.charCodeAt(0).toString(16);
3973
- if (hex.length < 2) {
3974
- hex = '0' + hex;
3975
- }
3976
- return '&#x' + hex.toUpperCase() + ';';
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 `>`, the end of the line or the end of the
3986
- // input before giving up, so an html part holding many unclosed `<`, `<a ` or `<!--`
3987
- // took seconds to minutes to convert. The helpers below give the same results in linear
3988
- // time.
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 line for every opener that is not closed.
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 on the first line.
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
- const lineEnd = str.search(LINE_TERMINATOR);
4166
- // `.*` stops at the first line terminator, and `[^>]*>` needs a `>` after the tag
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 whose
4178
- * `>` is on the last line, and everything after it.
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
- // `.*$` has to reach the end of the string without crossing a line terminator, so the
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
- // we can't process tags on multiple lines so remove newlines first
4194
- str = str.replace(/\r?\n/g, '\u0001');
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
- return new Intl.DateTimeFormat('default', {
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
- }).format(parsed);
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. An address that is nothing
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
- * @param depth Current recursion depth of the encoded word re-parse
5087
- * @return Address objects
5236
+ * @return The address object
5088
5237
  */
5089
- function _handleAddress(tokens, from, to, depth) {
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
- // try to extract words from text content
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 [address];
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, depth) {
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
- for (const address of _handleAddress(tokens, from, i, depth)) {
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, 0);
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 < 0) {
5792
- throw new TypeError(`${name} must be a non-negative integer`);
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
- maxPartCount: parseLimitOption(options.maxPartCount, MAX_PART_COUNT, 'maxPartCount'),
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
- // could be a boundary marker
5856
- for (let i = boundaries.length - 1; i >= 0; i--) {
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 = (node.subMessage = await subParser.parse(node.content || new ArrayBuffer(0)));
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 new Error(`Maximum forwarded address count of ${this.limits.maxForwardedAddresses} addresses exceeded`);
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 (subMessage.text || !subMessage.html) {
6160
+ if (hasPlain || !hasHtml) {
6015
6161
  this.addTextEntry(node, 'plain', { type: 'subMessage', value: subMessage });
6016
6162
  }
6017
- if (subMessage.html) {
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
- chunks.push(value);
6154
- chunkLen += value.length;
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 new Error('Can not reuse parser, create a new PostalMime object');
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
- this.renderTextContent();
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;