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