@limetech/lime-elements 40.6.3 → 40.7.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 (46) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/cjs/lime-elements.cjs.js +1 -1
  3. package/dist/cjs/limel-dock-button.cjs.entry.js +2 -4
  4. package/dist/cjs/limel-file-viewer.cjs.entry.js +476 -116
  5. package/dist/cjs/limel-markdown.cjs.entry.js +2 -2
  6. package/dist/cjs/limel-prosemirror-adapter.cjs.entry.js +1 -1
  7. package/dist/cjs/limel-tab-bar.cjs.entry.js +37 -14
  8. package/dist/cjs/limel-tab-panel.cjs.entry.js +7 -2
  9. package/dist/cjs/loader.cjs.js +1 -1
  10. package/dist/cjs/{markdown-exporter-C-UW43FF.js → markdown-exporter-VtX_2D1d.js} +40 -11
  11. package/dist/cjs/{markdown-parser-ssDij8_l.js → markdown-parser-BYtTHL4W.js} +11 -3
  12. package/dist/collection/components/dock/dock-button/dock-button.css +13 -0
  13. package/dist/collection/components/dock/dock-button/dock-button.js +1 -3
  14. package/dist/collection/components/tab-bar/tab-bar.css +79 -27
  15. package/dist/collection/components/tab-bar/tab-bar.js +60 -15
  16. package/dist/collection/components/tab-panel/tab-panel.css +9 -0
  17. package/dist/collection/components/tab-panel/tab-panel.js +27 -1
  18. package/dist/esm/lime-elements.js +1 -1
  19. package/dist/esm/limel-dock-button.entry.js +2 -4
  20. package/dist/esm/limel-file-viewer.entry.js +476 -116
  21. package/dist/esm/limel-markdown.entry.js +2 -2
  22. package/dist/esm/limel-prosemirror-adapter.entry.js +1 -1
  23. package/dist/esm/limel-tab-bar.entry.js +37 -14
  24. package/dist/esm/limel-tab-panel.entry.js +7 -2
  25. package/dist/esm/loader.js +1 -1
  26. package/dist/esm/{markdown-exporter-DblYRMhY.js → markdown-exporter-tb8ft8Gq.js} +40 -11
  27. package/dist/esm/{markdown-parser-CAPDx7i8.js → markdown-parser-DFP65EFu.js} +11 -3
  28. package/dist/lime-elements/lime-elements.esm.js +1 -1
  29. package/dist/lime-elements/p-14a28a08.entry.js +1 -0
  30. package/dist/lime-elements/{p-22fd1c87.entry.js → p-21ab902a.entry.js} +1 -1
  31. package/dist/lime-elements/p-6e3a4482.entry.js +1 -0
  32. package/dist/lime-elements/p-9088acea.entry.js +1 -0
  33. package/dist/lime-elements/{p-05038bbb.entry.js → p-91a572c8.entry.js} +1 -1
  34. package/dist/lime-elements/p-Bgn2bikC.js +1 -0
  35. package/dist/lime-elements/p-C0wwdPYT.js +7 -0
  36. package/dist/lime-elements/p-aa897903.entry.js +1 -0
  37. package/dist/types/components/tab-bar/tab-bar.d.ts +17 -4
  38. package/dist/types/components/tab-panel/tab-panel.d.ts +6 -0
  39. package/dist/types/components.d.ts +48 -10
  40. package/package.json +6 -6
  41. package/dist/lime-elements/p-2e33d2a4.entry.js +0 -1
  42. package/dist/lime-elements/p-Bvyt2bmV.js +0 -1
  43. package/dist/lime-elements/p-I0PrvthC.js +0 -7
  44. package/dist/lime-elements/p-a550ba18.entry.js +0 -1
  45. package/dist/lime-elements/p-b35a912c.entry.js +0 -1
  46. package/dist/lime-elements/p-f3cae1e7.entry.js +0 -1
@@ -125,6 +125,8 @@ class Fullscreen {
125
125
  }
126
126
 
127
127
  const textEncoder = new TextEncoder();
128
+ /** SP or HTAB, the only whitespace RFC 5322 and RFC 2045 give a meaning to in a line */
129
+ const isWsp = (c) => c === 0x20 || c === 0x09;
128
130
  const base64Chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
129
131
  // Use a lookup table to find the index.
130
132
  const base64Lookup = new Uint8Array(256);
@@ -342,7 +344,10 @@ function tryDecoder(charset) {
342
344
  // Every runtime has utf-8, so a decoder is always returned even where windows-1252 is
343
345
  // missing, rather than letting the caller fail on a null decoder
344
346
  const utf8Decoder = new TextDecoder();
345
- function getDecoder(charset) {
347
+ /**
348
+ * Finds the decoder for a charset label, or null when the runtime has no encoding for it
349
+ */
350
+ function findDecoder(charset) {
346
351
  charset = (charset || 'utf8').trim().toLowerCase();
347
352
  // Try the label as written first, so the alias table only ever adds to what the
348
353
  // runtime already supports instead of shadowing it.
@@ -355,7 +360,136 @@ function getDecoder(charset) {
355
360
  // The normalized label is itself the last candidate, which resolves everything that
356
361
  // differed from a supported label only by a prefix or a separator, eg. x-big5.
357
362
  const alias = (codePage && codePageAliases.get(codePage[1])) || charsetAliases.get(normalized) || normalized;
358
- return tryDecoder(alias) || tryDecoder('windows-1252') || utf8Decoder;
363
+ return tryDecoder(alias);
364
+ }
365
+ function getDecoder(charset) {
366
+ return findDecoder(charset) || tryDecoder('windows-1252') || utf8Decoder;
367
+ }
368
+ /**
369
+ * Whether the bytes are well formed UTF-8. A scan rather than a fatal TextDecoder, since
370
+ * the exception one throws costs more than the decode itself and a message decides how
371
+ * many lines throw it.
372
+ */
373
+ function isUtf8(bytes) {
374
+ for (let i = 0; i < bytes.length;) {
375
+ const lead = bytes[i];
376
+ if (lead < 0x80) {
377
+ i++;
378
+ continue;
379
+ }
380
+ // The number of continuation bytes, and the range the first of them may take,
381
+ // which is what rules out overlong forms, surrogates and code points past U+10FFFF
382
+ let count;
383
+ if (lead >= 0xc2 && lead <= 0xdf) {
384
+ count = 1;
385
+ }
386
+ else if (lead >= 0xe0 && lead <= 0xef) {
387
+ count = 2;
388
+ }
389
+ else if (lead >= 0xf0 && lead <= 0xf4) {
390
+ count = 3;
391
+ }
392
+ else {
393
+ return false;
394
+ }
395
+ if (i + count >= bytes.length) {
396
+ return false;
397
+ }
398
+ const second = bytes[i + 1];
399
+ const min = lead === 0xe0 ? 0xa0 : lead === 0xf0 ? 0x90 : 0x80;
400
+ const max = lead === 0xed ? 0x9f : lead === 0xf4 ? 0x8f : 0xbf;
401
+ if (second < min || second > max) {
402
+ return false;
403
+ }
404
+ for (let k = 2; k <= count; k++) {
405
+ if ((bytes[i + k] & 0xc0) !== 0x80) {
406
+ return false;
407
+ }
408
+ }
409
+ i += count + 1;
410
+ }
411
+ return true;
412
+ }
413
+ /**
414
+ * Decodes bytes whose encoding is not known: as UTF-8 when they are well formed UTF-8, as
415
+ * windows-1252 otherwise.
416
+ *
417
+ * A message that declares no charset, or one the runtime has never heard of, usually
418
+ * predates RFC 6532 or comes from a generator that never heard of it either, and its 8-bit
419
+ * bytes are a legacy single byte encoding far more often than broken UTF-8. windows-1252
420
+ * is the fallback the WHATWG Encoding Standard prescribes and what a mail client's default
421
+ * charset amounts to, and it reads such text rather than filling it with replacement
422
+ * characters.
423
+ *
424
+ * @param bytes Bytes to decode
425
+ * @param utf8 The UTF-8 decoder to use, header lines bring one that keeps a BOM
426
+ * @return Decoded text
427
+ */
428
+ function decodeUtf8OrWindows1252(bytes, utf8 = utf8Decoder) {
429
+ const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
430
+ return isUtf8(view) ? utf8.decode(view) : getDecoder('windows-1252').decode(view);
431
+ }
432
+ /**
433
+ * Decodes bytes in a named charset. A label the runtime does not know is no charset at
434
+ * all, see decodeUtf8OrWindows1252.
435
+ *
436
+ * @param bytes Bytes to decode
437
+ * @param charset Charset label
438
+ * @return Decoded text
439
+ */
440
+ function decodeCharset(bytes, charset) {
441
+ const decoder = findDecoder(charset);
442
+ return decoder ? decoder.decode(bytes) : decodeUtf8OrWindows1252(bytes);
443
+ }
444
+ // The charset an html document names for itself, in a `<meta charset>` or in the
445
+ // Content-Type `<meta http-equiv>`, read from the first 1024 bytes like a browser's prescan.
446
+ // Tags are found with indexOf and the attribute is matched inside one tag at a time: a
447
+ // single pattern over the whole head rescans it once for every `<meta` in it, and a head of
448
+ // nothing but `<meta ` took it quadratic
449
+ const META_PRESCAN_BYTES = 1024;
450
+ const CHARSET_ATTRIBUTE = /charset\s*=\s*["']?\s*([^\s"';>]+)/;
451
+ const UTF16_ENCODINGS = new Set(['utf-16le', 'utf-16be']);
452
+ function sniffHtmlCharset(bytes) {
453
+ const head = utf8Decoder.decode(bytes.subarray(0, META_PRESCAN_BYTES)).toLowerCase();
454
+ let pos = 0;
455
+ while ((pos = head.indexOf('<meta', pos)) >= 0) {
456
+ let end = head.indexOf('>', pos);
457
+ if (end < 0) {
458
+ end = head.length;
459
+ }
460
+ const match = CHARSET_ATTRIBUTE.exec(head.slice(pos, end));
461
+ if (match) {
462
+ return match[1];
463
+ }
464
+ pos = end;
465
+ }
466
+ return null;
467
+ }
468
+ /**
469
+ * Decodes the bytes of a text part: in the declared charset, otherwise in the one an html
470
+ * part names for itself, otherwise as UTF-8 or windows-1252 depending on what the bytes
471
+ * turn out to be, see decodeUtf8OrWindows1252.
472
+ *
473
+ * @param bytes Decoded body of the part
474
+ * @param charset The charset parameter of the Content-Type, if any
475
+ * @param html Whether the part is html, which may name its own charset
476
+ * @return The text of the part
477
+ */
478
+ function decodeText(bytes, charset, html) {
479
+ const view = new Uint8Array(bytes);
480
+ if (charset) {
481
+ return decodeCharset(view, charset);
482
+ }
483
+ // The HTML prescan has two overrides, and for a reason: a `<meta>` tag that could be
484
+ // read as ASCII proves the document is not UTF-16, so such a label is read as UTF-8,
485
+ // 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
487
+ const sniffed = html ? sniffHtmlCharset(view) : null;
488
+ const decoder = sniffed ? findDecoder(sniffed) : null;
489
+ if (!decoder || UTF16_ENCODINGS.has(decoder.encoding)) {
490
+ return decodeUtf8OrWindows1252(view);
491
+ }
492
+ return (decoder.encoding === 'x-user-defined' ? getDecoder('windows-1252') : decoder).decode(view);
359
493
  }
360
494
  /**
361
495
  * Converts a Blob into an ArrayBuffer
@@ -395,6 +529,25 @@ function hexNibble(c) {
395
529
  }
396
530
  return -1;
397
531
  }
532
+ /**
533
+ * Folds the line breaks of a header value into spaces.
534
+ *
535
+ * A header value holds no line breaks. The raw value is folded when the headers are
536
+ * processed, see processHeaders in mime-node.ts, an RFC 2047 encoded word is folded as it
537
+ * is decoded, and an RFC 2231 filename is folded where the attachment is built. Otherwise a
538
+ * decoded name, subject or filename written into a header of an outgoing message by the
539
+ * consumer ends the header early and starts another one with the rest of the value.
540
+ *
541
+ * The RFC 2231 percent decoder itself does not fold: it also produces the boundary, and a
542
+ * boundary holding a line break can never match a line. That has to stay so, or a message
543
+ * that has no parts for every other parser would have them here.
544
+ *
545
+ * @param str Header value
546
+ * @return The value with every run of CR and LF replaced by a space
547
+ */
548
+ function foldLineBreaks(str) {
549
+ return str.replace(/[\r\n]+/g, ' ');
550
+ }
398
551
  /**
399
552
  * Decode a complete mime word encoded string
400
553
  *
@@ -447,7 +600,7 @@ function decodeWord(charset, encoding, str) {
447
600
  // keep as is, convert ArrayBuffer to unicode string, assume utf8
448
601
  byteStr = textEncoder.encode(str);
449
602
  }
450
- return getDecoder(charset).decode(byteStr);
603
+ return foldLineBreaks(decodeCharset(byteStr, charset));
451
604
  }
452
605
  // A charset label runs to the next '?' so that labels containing punctuation, eg.
453
606
  // ISO_8859-1:1987, are recognised. Whitespace is excluded so a stray '=?' in running text
@@ -591,7 +744,7 @@ function decodeURIComponentWithCharset(encodedStr, charset) {
591
744
  for (let i = 0, len = encodedBytes.length; i < len; i++) {
592
745
  dataView.setUint8(i, encodedBytes[i]);
593
746
  }
594
- return getDecoder(charset).decode(byteStr);
747
+ return decodeCharset(byteStr, charset);
595
748
  }
596
749
  function decodeParameterValueContinuations(header) {
597
750
  // handle parameter value continuations
@@ -628,6 +781,14 @@ function decodeParameterValueContinuations(header) {
628
781
  delete header.params[key];
629
782
  });
630
783
  paramKeys.forEach((paramVal, key) => {
784
+ if (Object.prototype.hasOwnProperty.call(header.params, key)) {
785
+ // The same name was also given as a plain parameter, which the starred keys were
786
+ // just deleted from around, so this write would be the only one here to override a
787
+ // name already taken. `filename="plain"; filename*0="cont"` resolves to the plain
788
+ // parameter either way round, so the reading does not come down to which of the
789
+ // two spellings the sender put first. See storeParam in mime-node.ts
790
+ return;
791
+ }
631
792
  let result = '';
632
793
  // Adjacent encoded sections are decoded together, because a single multi byte
633
794
  // character may be percent encoded across a section boundary.
@@ -650,15 +811,72 @@ function decodeParameterValueContinuations(header) {
650
811
  });
651
812
  }
652
813
 
814
+ // Hands the body through as it is, the line endings included.
815
+ //
816
+ // The line break is written when the next line arrives, not after each line. The CRLF in
817
+ // front of a multipart boundary belongs to the boundary (RFC 2046 5.1.1), so the last line
818
+ // of a part ended by a boundary must not carry one.
819
+ //
820
+ // The break written is the one the line came with. Writing LF for every line rewrote the
821
+ // bytes of every 7bit, 8bit and binary part: an attached message lost each of its CR and a
822
+ // binary attachment came out corrupted. Text is normalized to LF where it is read as text
823
+ // instead, see MimeNode.getTextContent.
824
+ //
825
+ // The lines of a part, breaks included, are one contiguous range of the message, so the
826
+ // decoder keeps the bounds of that range rather than a chunk per line: a Blob of two parts
827
+ // per line took 60 times longer to assemble than one of a single part.
653
828
  class PassThroughDecoder {
654
829
  constructor() {
655
830
  this.chunks = [];
656
- }
657
- update(line) {
658
- this.chunks.push(line);
659
- this.chunks.push('\n');
660
- }
661
- finalize() {
831
+ this.runBuffer = null;
832
+ this.runStart = 0;
833
+ this.runEnd = 0;
834
+ this.pendingLineBreak = null;
835
+ }
836
+ // Whether the bytes sit in the run's buffer right at the end of the run
837
+ continuesRun(bytes) {
838
+ return bytes.buffer === this.runBuffer && bytes.byteOffset === this.runEnd;
839
+ }
840
+ flushRun() {
841
+ if (this.runBuffer && this.runEnd > this.runStart) {
842
+ this.chunks.push(new Uint8Array(this.runBuffer, this.runStart, this.runEnd - this.runStart));
843
+ }
844
+ this.runBuffer = null;
845
+ }
846
+ update(line, lineBreak) {
847
+ const pending = this.pendingLineBreak;
848
+ if (pending &&
849
+ this.continuesRun(pending) &&
850
+ line.buffer === this.runBuffer &&
851
+ line.byteOffset === this.runEnd + pending.byteLength) {
852
+ // the break follows the run and the line follows the break, so the run grows
853
+ this.runEnd = line.byteOffset + line.byteLength;
854
+ }
855
+ else {
856
+ this.flushRun();
857
+ if (pending) {
858
+ this.chunks.push(pending);
859
+ }
860
+ this.runBuffer = line.buffer;
861
+ this.runStart = line.byteOffset;
862
+ this.runEnd = line.byteOffset + line.byteLength;
863
+ }
864
+ this.pendingLineBreak = lineBreak;
865
+ }
866
+ // A part ended by the end of the message has no boundary to own its final line
867
+ // break, so there it stays content
868
+ finalize(boundaryEnded = false) {
869
+ const pending = this.pendingLineBreak;
870
+ if (!boundaryEnded && pending) {
871
+ if (this.continuesRun(pending)) {
872
+ this.runEnd += pending.byteLength;
873
+ }
874
+ else {
875
+ this.flushRun();
876
+ this.chunks.push(pending);
877
+ }
878
+ }
879
+ this.flushRun();
662
880
  // convert an array of arraybuffers into a blob and then back into a single arraybuffer
663
881
  return blobToArrayBuffer(new Blob(this.chunks, { type: 'application/octet-stream' }));
664
882
  }
@@ -707,13 +925,18 @@ class Base64Decoder {
707
925
  }
708
926
 
709
927
  const CHR_EQUALS = 0x3d;
710
- const CHR_LF = 0x0a;
711
928
  class QPDecoder {
712
929
  constructor() {
713
930
  this.maxChunkSize = 100 * 1024;
714
931
  this.buffer = new Uint8Array(this.maxChunkSize);
715
932
  this.bufferPos = 0;
716
933
  this.chunks = [];
934
+ this.pendingLineBreak = null;
935
+ }
936
+ writeLineBreak() {
937
+ if (this.pendingLineBreak) {
938
+ this.writeBytes(this.pendingLineBreak, 0, this.pendingLineBreak.length);
939
+ }
717
940
  }
718
941
  writeByte(byte) {
719
942
  if (this.bufferPos >= this.buffer.length) {
@@ -744,8 +967,19 @@ class QPDecoder {
744
967
  // the result is handed on as bytes. Running the body charset over the encoded source
745
968
  // instead corrupted every part whose charset was not ASCII compatible: the same
746
969
  // content that decoded correctly in base64 came out as mojibake in quoted-printable.
747
- update(line) {
970
+ //
971
+ // As in PassThroughDecoder, a hard line break is only written once the next line
972
+ // arrives, so the CRLF that belongs to a following boundary is not decoded as content,
973
+ // and the break written is the one the line came with.
974
+ update(line, lineBreak) {
975
+ this.writeLineBreak();
748
976
  let len = line.length;
977
+ // Trailing white space on an encoded line was added in transit and is deleted (RFC
978
+ // 2045 6.7 rule 3). Before the soft line break is looked for, since the `=` it
979
+ // consists of may have had white space appended the same way
980
+ while (len > 0 && isWsp(line[len - 1])) {
981
+ len--;
982
+ }
749
983
  // a line ending in '=' is a soft line break, the newline is not part of the content
750
984
  const softBreak = len > 0 && line[len - 1] === CHR_EQUALS;
751
985
  if (softBreak) {
@@ -768,11 +1002,13 @@ class QPDecoder {
768
1002
  literalStart = i + 1;
769
1003
  }
770
1004
  this.writeBytes(line, literalStart, len);
771
- if (!softBreak) {
772
- this.writeByte(CHR_LF);
773
- }
1005
+ this.pendingLineBreak = softBreak ? null : lineBreak;
774
1006
  }
775
- finalize() {
1007
+ // see PassThroughDecoder.finalize
1008
+ finalize(boundaryEnded = false) {
1009
+ if (!boundaryEnded) {
1010
+ this.writeLineBreak();
1011
+ }
776
1012
  this.flushBuffer();
777
1013
  // convert an array of arraybuffers into a blob and then back into a single arraybuffer
778
1014
  return blobToArrayBuffer(new Blob(this.chunks, { type: 'application/octet-stream' }));
@@ -783,6 +1019,10 @@ class QPDecoder {
783
1019
  // kept as a character instead of being swallowed. A stripped BOM turns a line a strict
784
1020
  // parser skips into a genuine header, which is how a second `From:` gets smuggled past
785
1021
  // anything that inspects the raw message.
1022
+ //
1023
+ // Raw 8-bit header text is UTF-8 (RFC 6532), and a line that is not is legacy mail in a
1024
+ // single byte charset, read as windows-1252 rather than as replacement characters, see
1025
+ // decodeUtf8OrWindows1252.
786
1026
  const headerDecoder = new TextDecoder('utf-8', { ignoreBOM: true });
787
1027
  // Trims only the whitespace RFC 5322 allows around a field name. String.prototype.trim
788
1028
  // also strips U+00A0, U+FEFF, U+2028 and the rest of the Unicode spaces, which turns a
@@ -794,7 +1034,6 @@ const headerDecoder = new TextDecoder('utf-8', { ignoreBOM: true });
794
1034
  // An index scan rather than `/^[ \t]+|[ \t]+$/g`, which retries the trailing branch at
795
1035
  // every position of a blank run that is followed by other text, so a single header with
796
1036
  // a long run of spaces in the middle took seconds to trim.
797
- const isWsp = (c) => c === 0x20 || c === 0x09;
798
1037
  const trimWsp = (str) => {
799
1038
  let start = 0;
800
1039
  let end = str.length;
@@ -806,6 +1045,12 @@ const trimWsp = (str) => {
806
1045
  }
807
1046
  return str.slice(start, end);
808
1047
  };
1048
+ // A line of nothing but SP and HTAB, see feed
1049
+ const isBlankLine = (line) => line.every(isWsp);
1050
+ // The start of a header field: a field name of printable ASCII other than the colon
1051
+ // (RFC 5322 3.6.8 ftext) and the colon itself, with the whitespace in between that
1052
+ // processHeaders tolerates as well
1053
+ const FIELD_START = /^[!-9;-~]+[ \t]*:/;
809
1054
  // Headers that decide how this part's body is read, see processHeaders
810
1055
  const CONTENT_HEADERS = new Set([
811
1056
  'content-type',
@@ -818,6 +1063,12 @@ class MimeNode {
818
1063
  constructor(options) {
819
1064
  this.options = options;
820
1065
  this.postalMime = options.postalMime;
1066
+ // Counted across the whole message like the header size, so that a multipart can
1067
+ // not carry the budget again for every part it declares
1068
+ this.postalMime.partCount++;
1069
+ if (this.postalMime.partCount > options.maxPartCount) {
1070
+ throw new Error(`Maximum MIME part count of ${options.maxPartCount} parts exceeded`);
1071
+ }
821
1072
  this.childNodes = [];
822
1073
  // Cursor into childNodes for finalizeChildNodes. Every new part of a multipart
823
1074
  // finalizes its parent's children, so re-walking the whole array each time is
@@ -836,6 +1087,7 @@ class MimeNode {
836
1087
  }
837
1088
  this.state = 'header';
838
1089
  this.headerLines = [];
1090
+ this.lastLineBlank = false;
839
1091
  // RFC 2046 Section 5.1.5: multipart/digest defaults to message/rfc822
840
1092
  const parentMultipartType = options.parentMultipartType || null;
841
1093
  const defaultContentType = parentMultipartType === 'digest' ? 'message/rfc822' : 'text/plain';
@@ -870,12 +1122,14 @@ class MimeNode {
870
1122
  this.contentDecoder = new PassThroughDecoder();
871
1123
  }
872
1124
  }
873
- async finalize() {
1125
+ // `boundaryEnded` tells that the part was ended by a multipart boundary, which owns
1126
+ // the line break after the last line of the part (RFC 2046 5.1.1)
1127
+ async finalize(boundaryEnded = false) {
874
1128
  if (this.state === 'finished') {
875
1129
  return;
876
1130
  }
877
1131
  if (this.state === 'header') {
878
- this.processHeaders();
1132
+ this.endHeaders();
879
1133
  }
880
1134
  // remove self from boundary listing
881
1135
  let boundaries = this.postalMime.boundaries;
@@ -886,19 +1140,19 @@ class MimeNode {
886
1140
  break;
887
1141
  }
888
1142
  }
889
- await this.finalizeChildNodes();
890
- this.content = this.contentDecoder ? await this.contentDecoder.finalize() : null;
1143
+ await this.finalizeChildNodes(boundaryEnded);
1144
+ this.content = this.contentDecoder ? await this.contentDecoder.finalize(boundaryEnded) : null;
891
1145
  // The decoder buffers every body line it received, so keeping it around
892
1146
  // retains a second copy of the content for the lifetime of the node.
893
1147
  // Nothing reads it once the node is finished, so release it here.
894
1148
  this.contentDecoder = null;
895
1149
  this.state = 'finished';
896
1150
  }
897
- async finalizeChildNodes() {
1151
+ async finalizeChildNodes(boundaryEnded = false) {
898
1152
  // Children are only ever appended, so everything before the cursor is already
899
1153
  // finished and re-visiting it only costs time.
900
1154
  while (this.finalizedChildCount < this.childNodes.length) {
901
- await this.childNodes[this.finalizedChildCount++].finalize();
1155
+ await this.childNodes[this.finalizedChildCount++].finalize(boundaryEnded);
902
1156
  }
903
1157
  }
904
1158
  // Strip RFC 822 comments (parenthesized text) from structured header values.
@@ -1001,11 +1255,16 @@ class MimeNode {
1001
1255
  let quote = false;
1002
1256
  let escaped = false;
1003
1257
  let chr;
1004
- const addChr = (c) => {
1258
+ // Whitespace ahead of the first character of a value is padding and is dropped, the
1259
+ // whitespace between two characters of it is content
1260
+ const flushSpace = () => {
1005
1261
  if (value.length) {
1006
1262
  value += pendingSpace;
1007
1263
  }
1008
1264
  pendingSpace = '';
1265
+ };
1266
+ const addChr = (c) => {
1267
+ flushSpace();
1009
1268
  value += c;
1010
1269
  };
1011
1270
  const takeValue = () => {
@@ -1019,9 +1278,10 @@ class MimeNode {
1019
1278
  // headers are resolved. Letting the last one win means `boundary="b"; boundary="c"`
1020
1279
  // registers a boundary that no delimiter in the message matches, which drops the
1021
1280
  // body without an error. hasOwnProperty, because a parameter may be named
1022
- // `constructor` or `toString`.
1281
+ // `constructor` or `toString`. A name is a token and a token is never empty, so the
1282
+ // nameless parameter of `text/plain; =v` names nothing and is dropped.
1023
1283
  const storeParam = (name, result) => {
1024
- if (!Object.prototype.hasOwnProperty.call(response.params, name)) {
1284
+ if (name && !Object.prototype.hasOwnProperty.call(response.params, name)) {
1025
1285
  response.params[name] = result;
1026
1286
  }
1027
1287
  };
@@ -1038,10 +1298,7 @@ class MimeNode {
1038
1298
  // next one. Without this the name would keep growing across the `;` and swallow
1039
1299
  // whatever followed, which is how `x=1; flag; boundary="AAA"` loses its boundary.
1040
1300
  const storeEmptyKey = () => {
1041
- const name = takeValue().trim();
1042
- if (name) {
1043
- storeParam(name.toLowerCase(), '');
1044
- }
1301
+ storeParam(takeValue().trim().toLowerCase(), '');
1045
1302
  };
1046
1303
  for (let i = 0, len = str.length; i < len; i++) {
1047
1304
  chr = str.charAt(i);
@@ -1059,6 +1316,22 @@ class MimeNode {
1059
1316
  value += chr;
1060
1317
  break;
1061
1318
  case 'value':
1319
+ if (quoteClosed && chr !== ';') {
1320
+ // Nothing behind a closed quoted string reaches the value. RFC 2045
1321
+ // says a parameter value is a token or a quoted string, not both, so
1322
+ // what follows one is junk and only the `;` that ends the parameter
1323
+ // still counts. Tested ahead of the branches rather than beside the
1324
+ // append at the foot of them, where each branch above was a way
1325
+ // around it. A second `"` reopened quoting, which both carried the
1326
+ // whitespace in front of it into the value and took the `;` along, so
1327
+ // `boundary="AAA" "; boundary=BBB"` registered a boundary of `AAA `
1328
+ // that no delimiter matches and the whole body was dropped without an
1329
+ // error; the escape branch appended arbitrary characters the same way.
1330
+ // quoteClosed is only ever set while no quote is open, and this is
1331
+ // what keeps one from being opened afterwards.
1332
+ escaped = false;
1333
+ break;
1334
+ }
1062
1335
  if (escaped) {
1063
1336
  addChr(chr);
1064
1337
  }
@@ -1075,12 +1348,7 @@ class MimeNode {
1075
1348
  }
1076
1349
  else if (!quote && chr === '"') {
1077
1350
  quote = chr;
1078
- // whitespace before a quote that opens the value is padding, but
1079
- // between a token and a quoted string it is content
1080
- if (value.length) {
1081
- value += pendingSpace;
1082
- }
1083
- pendingSpace = '';
1351
+ flushSpace();
1084
1352
  }
1085
1353
  else if (!quote && chr === ';') {
1086
1354
  storeValue();
@@ -1089,13 +1357,9 @@ class MimeNode {
1089
1357
  else if (!quote && (chr === ' ' || chr === '\t')) {
1090
1358
  pendingSpace += chr;
1091
1359
  }
1092
- else if (!quoteClosed) {
1360
+ else {
1093
1361
  addChr(chr);
1094
1362
  }
1095
- // Anything else is trailing junk after a closed quoted string. RFC 2045
1096
- // says a parameter value is a token or a quoted string, not both, and
1097
- // appending the junk is how `boundary="AAA" (unterminated comment`
1098
- // turned into a boundary that no delimiter in the message matches.
1099
1363
  escaped = false;
1100
1364
  break;
1101
1365
  }
@@ -1116,55 +1380,51 @@ class MimeNode {
1116
1380
  decodeParameterValueContinuations(response);
1117
1381
  return response;
1118
1382
  }
1383
+ // Unfolds format=flowed text (RFC 3676 4.1). A line is read as its quote marks, one
1384
+ // space of stuffing and its content, in that order, and a line whose content ends in a
1385
+ // space is flowed: the next line of the same quote depth continues its paragraph. The
1386
+ // paragraph ends at a fixed line, at a change of quote depth and at the signature
1387
+ // separator, which is neither flowed nor fixed. Joining a quoted paragraph used to
1388
+ // leave the quote marks of the continuation lines inside the text, so a quoted reply
1389
+ // read `> one > two` instead of `> one two`.
1390
+ //
1391
+ // The quote marks and stuffing of the first line of a paragraph are kept as they were
1392
+ // written, so a fixed quoted line comes out exactly as it went in. An unquoted stuffed
1393
+ // line loses its stuffing, which is all the stuffing is for.
1119
1394
  decodeFlowedText(str, delSp) {
1120
- // Pieces of the result, joined once at the end. Growing a single string and
1121
- // calling endsWith() on it for every line flattens the whole paragraph again on
1122
- // each line, which is quadratic in the length of a paragraph.
1123
- // Empty pieces are never stored, so the last piece always holds the last
1124
- // character of the result.
1395
+ // Pieces of the result, joined once at the end. Growing a single string instead
1396
+ // flattens the whole paragraph again on each line, which is quadratic in the
1397
+ // length of a paragraph.
1125
1398
  const parts = [];
1126
- // The unfolded line being built, ie. everything after the last hard line break,
1127
- // starts at this index of parts and is this many characters long
1128
- let lineStart = 0;
1129
- let lineLength = 0;
1130
- const lines = str.split(/\r?\n/);
1399
+ // whether the previous line was flowed, and the quote depth of its paragraph
1400
+ let flowed = false;
1401
+ let depth = 0;
1402
+ const lines = str.split('\n');
1131
1403
  for (let i = 0; i < lines.length; i++) {
1132
- let line = lines[i];
1133
- // remove whitespace stuffing before anything else
1134
- // http://tools.ietf.org/html/rfc3676#section-4.4
1135
- // doing it after the join leaves the stuffed space of a continuation line
1136
- // sitting in the middle of the joined paragraph
1137
- if (line.charAt(0) === ' ') {
1138
- line = line.slice(1);
1404
+ const line = lines[i];
1405
+ let quoteDepth = 0;
1406
+ while (line.charCodeAt(quoteDepth) === 0x3e /* > */) {
1407
+ quoteDepth++;
1139
1408
  }
1140
- if (i) {
1141
- const last = parts.length ? parts[parts.length - 1] : '';
1142
- // soft linebreaks are added after space symbols, except for the signature
1143
- // separator which is a line of its own
1144
- const isSignature = lineLength === 3 && parts.slice(lineStart).join('') === '-- ';
1145
- if (last.endsWith(' ') && !isSignature) {
1146
- if (delSp) {
1147
- // delsp adds space to text to be able to fold it
1148
- // these spaces can be removed once the text is unfolded
1149
- if (last.length > 1) {
1150
- parts[parts.length - 1] = last.slice(0, -1);
1151
- }
1152
- else {
1153
- parts.pop();
1154
- }
1155
- lineLength--;
1156
- }
1409
+ const contentStart = line.charCodeAt(quoteDepth) === 0x20 ? quoteDepth + 1 : quoteDepth;
1410
+ const content = line.slice(contentStart);
1411
+ const isSignature = content === '-- ';
1412
+ if (flowed && quoteDepth === depth && !isSignature) {
1413
+ if (delSp) {
1414
+ // delsp adds the space to make the text foldable, so it is not content
1415
+ const last = parts[parts.length - 1];
1416
+ parts[parts.length - 1] = last.slice(0, -1);
1157
1417
  }
1158
- else {
1418
+ parts.push(content);
1419
+ }
1420
+ else {
1421
+ if (i) {
1159
1422
  parts.push('\n');
1160
- lineStart = parts.length;
1161
- lineLength = 0;
1162
1423
  }
1424
+ parts.push(quoteDepth ? line : content);
1163
1425
  }
1164
- if (line) {
1165
- parts.push(line);
1166
- lineLength += line.length;
1167
- }
1426
+ flowed = !isSignature && content.endsWith(' ');
1427
+ depth = quoteDepth;
1168
1428
  }
1169
1429
  return parts.join('');
1170
1430
  }
@@ -1172,7 +1432,10 @@ class MimeNode {
1172
1432
  if (!this.content) {
1173
1433
  return '';
1174
1434
  }
1175
- let str = getDecoder(this.contentType.parsed.params.charset).decode(this.content);
1435
+ // The bytes keep the line endings the part was sent with, see PassThroughDecoder,
1436
+ // which differ from one transfer encoding to the next. Text is normalized to LF so
1437
+ // that a consumer does not get CRLF from one message and LF from another
1438
+ let str = decodeText(this.content, this.contentType.parsed.params.charset, this.contentType.parsed.value === 'text/html').replace(/\r\n/g, '\n');
1176
1439
  if (/^flowed$/i.test(this.contentType.parsed.params.format)) {
1177
1440
  str = this.decodeFlowedText(str, /^yes$/i.test(this.contentType.parsed.params.delsp));
1178
1441
  }
@@ -1218,10 +1481,8 @@ class MimeNode {
1218
1481
  let unfoldedLine = parts.join('');
1219
1482
  sep = unfoldedLine.indexOf(':');
1220
1483
  let key = trimWsp(sep < 0 ? unfoldedLine : unfoldedLine.slice(0, sep));
1221
- // A bare CR is not legal in a field body. It used to be folded into a space by
1222
- // the whitespace collapse, and passing it through would hand consumers that
1223
- // write the value back out a line of their own.
1224
- let value = sep < 0 ? '' : trimWsp(unfoldedLine.slice(sep + 1).replace(/[\r\n]+/g, ' '));
1484
+ // A bare CR is not legal in a field body, see foldLineBreaks
1485
+ let value = sep < 0 ? '' : trimWsp(foldLineBreaks(unfoldedLine.slice(sep + 1)));
1225
1486
  this.headers.push({ key: key.toLowerCase(), originalKey: key, value });
1226
1487
  // A header that decides how the body is read must resolve the same way every
1227
1488
  // time it is duplicated, otherwise a message can present one Content-Type to a
@@ -1253,7 +1514,15 @@ class MimeNode {
1253
1514
  this.contentType.multipart = /^multipart\//i.test(this.contentType.parsed.value)
1254
1515
  ? this.contentType.parsed.value.slice(this.contentType.parsed.value.indexOf('/') + 1)
1255
1516
  : false;
1256
- if (this.contentType.multipart && this.contentType.parsed.params.boundary) {
1517
+ if (this.contentType.multipart && !this.contentType.parsed.params.boundary) {
1518
+ // A multipart without the boundary RFC 2046 5.1.1 requires has no parts to find,
1519
+ // and its body used to be dropped without a trace. RFC 2045 5.2 recommends
1520
+ // reading an invalid Content-Type as plain text, which is also what the body of
1521
+ // such a message usually is
1522
+ this.contentType.parsed.value = 'text/plain';
1523
+ this.contentType.multipart = false;
1524
+ }
1525
+ if (this.contentType.multipart) {
1257
1526
  // add self to boundary terminator listing
1258
1527
  this.postalMime.boundaries.push({
1259
1528
  value: textEncoder.encode(this.contentType.parsed.params.boundary),
@@ -1270,13 +1539,20 @@ class MimeNode {
1270
1539
  .match(/[\w-]+/) || [''])[0];
1271
1540
  this.setupContentDecoder(this.contentTransferEncoding.encoding);
1272
1541
  }
1273
- feed(line) {
1542
+ // Ends the header section and sets up the body
1543
+ endHeaders() {
1544
+ this.state = 'body';
1545
+ this.processHeaders();
1546
+ }
1547
+ // Returns true when the line was not consumed: the headers ended with the line before
1548
+ // it, and the parser has to read it again, since the boundary this part declares was
1549
+ // not known yet when the line was checked for one
1550
+ feed(line, lineBreak) {
1274
1551
  switch (this.state) {
1275
- case 'header':
1552
+ case 'header': {
1276
1553
  if (!line.length) {
1277
- this.state = 'body';
1278
- this.processHeaders();
1279
- return;
1554
+ this.endHeaders();
1555
+ return false;
1280
1556
  }
1281
1557
  // Counted across the whole message, not per part. A per-node budget lets a
1282
1558
  // multipart carry the limit again for every part it declares, so a message
@@ -1286,17 +1562,34 @@ class MimeNode {
1286
1562
  let error = new Error(`Maximum header size of ${this.options.maxHeadersSize} bytes exceeded`);
1287
1563
  throw error;
1288
1564
  }
1289
- this.headerLines.push(headerDecoder.decode(line));
1565
+ const text = decodeUtf8OrWindows1252(line, headerDecoder);
1566
+ // A line of nothing but whitespace is legal folding (RFC 5322 4.2, obs-FWS),
1567
+ // but broken generators also put one where the empty line that ends the
1568
+ // headers belongs, and reading it as a fold then turned the whole body into
1569
+ // one more header. So the next line decides what it was: a continuation or
1570
+ // another header keep it a fold, anything else means the headers ended with
1571
+ // it, and it is taken back out of the header lines. Python and Go read such
1572
+ // a line as the end of the headers outright, so the body is where the other
1573
+ // parsers find it.
1574
+ if (this.lastLineBlank && !isWsp(line[0]) && !FIELD_START.test(text)) {
1575
+ this.headerLines.pop();
1576
+ this.endHeaders();
1577
+ return true;
1578
+ }
1579
+ this.headerLines.push(text);
1580
+ this.lastLineBlank = isBlankLine(line);
1290
1581
  break;
1582
+ }
1291
1583
  case 'body':
1292
1584
  // add line to body. processHeaders installs a decoder before the state
1293
1585
  // becomes body, and the only time the decoder is released is finalize,
1294
1586
  // which moves the state on to finished, so the guard only narrows the type
1295
1587
  if (this.contentDecoder) {
1296
- this.contentDecoder.update(line);
1588
+ this.contentDecoder.update(line, lineBreak);
1297
1589
  }
1298
1590
  break;
1299
1591
  }
1592
+ return false;
1300
1593
  }
1301
1594
  }
1302
1595
 
@@ -3535,6 +3828,39 @@ const htmlEntities = {
3535
3828
  '&zwnj;': '\u200C'
3536
3829
  };
3537
3830
 
3831
+ // What a numeric character reference to the C1 control range stands for, per the HTML
3832
+ // numeric character reference end state: the windows-1252 character at that byte. This is
3833
+ // how Outlook and Word write their curly quotes and dashes (`&#146;`, `&#150;`), and the
3834
+ // raw control character they decoded to otherwise is invisible in the text.
3835
+ const C1_ENTITY_REPLACEMENTS = new Map([
3836
+ [0x80, 0x20ac],
3837
+ [0x82, 0x201a],
3838
+ [0x83, 0x0192],
3839
+ [0x84, 0x201e],
3840
+ [0x85, 0x2026],
3841
+ [0x86, 0x2020],
3842
+ [0x87, 0x2021],
3843
+ [0x88, 0x02c6],
3844
+ [0x89, 0x2030],
3845
+ [0x8a, 0x0160],
3846
+ [0x8b, 0x2039],
3847
+ [0x8c, 0x0152],
3848
+ [0x8e, 0x017d],
3849
+ [0x91, 0x2018],
3850
+ [0x92, 0x2019],
3851
+ [0x93, 0x201c],
3852
+ [0x94, 0x201d],
3853
+ [0x95, 0x2022],
3854
+ [0x96, 0x2013],
3855
+ [0x97, 0x2014],
3856
+ [0x98, 0x02dc],
3857
+ [0x99, 0x2122],
3858
+ [0x9a, 0x0161],
3859
+ [0x9b, 0x203a],
3860
+ [0x9c, 0x0153],
3861
+ [0x9e, 0x017e],
3862
+ [0x9f, 0x0178]
3863
+ ]);
3538
3864
  function decodeHTMLEntities(str) {
3539
3865
  return str.replace(/&(#\d+|#x[a-f0-9]+|[a-z]+\d*);?/gi, (match, entity) => {
3540
3866
  if (typeof htmlEntities[match] === 'string') {
@@ -3545,19 +3871,23 @@ function decodeHTMLEntities(str) {
3545
3871
  return match;
3546
3872
  }
3547
3873
  let codePoint;
3548
- if (entity.charAt(1) === 'x') {
3549
- // hex
3874
+ // The pattern matches the marker in either case, so the test has to as well. Reading
3875
+ // `&#X41;` as decimal gave NaN, which passes every range check below and comes out
3876
+ // of String.fromCharCode as a NUL character
3877
+ if (entity.charAt(1).toLowerCase() === 'x') {
3550
3878
  codePoint = parseInt(entity.slice(2), 16);
3551
3879
  }
3552
3880
  else {
3553
- // dec
3554
3881
  codePoint = parseInt(entity.slice(1), 10);
3555
3882
  }
3556
3883
  let output = '';
3557
- if ((codePoint >= 0xd800 && codePoint <= 0xdfff) || codePoint > 0x10ffff) {
3558
- // Invalid range, return a replacement character instead
3884
+ // A NUL reference, a surrogate or a code point out of range is a replacement
3885
+ // character, like in a browser. A NUL in particular would truncate the text in
3886
+ // whatever reads it next
3887
+ if (codePoint === 0 || (codePoint >= 0xd800 && codePoint <= 0xdfff) || codePoint > 0x10ffff) {
3559
3888
  return '\ufffd';
3560
3889
  }
3890
+ codePoint = C1_ENTITY_REPLACEMENTS.get(codePoint) ?? codePoint;
3561
3891
  if (codePoint > 0xffff) {
3562
3892
  codePoint -= 0x10000;
3563
3893
  output += String.fromCharCode(((codePoint >>> 10) & 0x3ff) | 0xd800);
@@ -3656,13 +3986,17 @@ function replaceTags(str, prefix, pattern, replacement) {
3656
3986
  break;
3657
3987
  }
3658
3988
  const start = candidate.index;
3989
+ // Looked up before the pattern runs. The engine finds out on its own that there is
3990
+ // no `>` to close on, but only after it has retried the tail of the pattern at every
3991
+ // `href` in the rest of the input and rescanned to the end each time, which made an
3992
+ // html part of many unclosed `<a href` tags quadratic to convert
3993
+ const gt = str.indexOf('>', start);
3994
+ if (gt < 0) {
3995
+ break;
3996
+ }
3659
3997
  pattern.lastIndex = start;
3660
3998
  const match = pattern.exec(str);
3661
3999
  if (!match) {
3662
- const gt = str.indexOf('>', start);
3663
- if (gt < 0) {
3664
- break;
3665
- }
3666
4000
  searchFrom = gt + 1;
3667
4001
  continue;
3668
4002
  }
@@ -4688,7 +5022,11 @@ class Tokenizer {
4688
5022
  this.escaped = false;
4689
5023
  return;
4690
5024
  }
4691
- else if (this.operatorExpecting === '"' && chr === '\\') {
5025
+ else if ((this.operatorExpecting === '"' || this.operatorExpecting === ')') && chr === '\\') {
5026
+ // A backslash escapes the next character inside a quoted string and inside a
5027
+ // comment alike (RFC 5322 3.2.2, ccontent includes quoted-pair). Honouring it
5028
+ // only in quoted strings let an escaped parenthesis close the comment, which
5029
+ // released the rest of it, an address included, into the header
4692
5030
  this.escaped = true;
4693
5031
  return;
4694
5032
  }
@@ -4873,8 +5211,14 @@ function renderEntry(textEntry, textType, convert) {
4873
5211
  }
4874
5212
  return textType === 'html' ? textToHtml(textEntry.value) : htmlToText(textEntry.value);
4875
5213
  }
5214
+ // The line break given to the last line of a message that ends without one, see readLine
5215
+ const LINE_FEED = new Uint8Array([0x0a]);
4876
5216
  const MAX_NESTING_DEPTH = 256;
4877
5217
  const MAX_HEADERS_SIZE = 2 * 1024 * 1024;
5218
+ // The depth limit does not bound the size of the tree. An empty part costs six bytes of
5219
+ // input (`--b` and a blank line) and over a kilobyte of parser state at its peak, so a
5220
+ // flat multipart of a million empty parts took a 7 MB message to 1.4 GB of memory.
5221
+ const MAX_PART_COUNT = 10000;
4878
5222
  // Inline message/rfc822 parts are parsed recursively. Without a dedicated limit
4879
5223
  // each nesting level spawns a new parser that retains the full nested message,
4880
5224
  // so a small crafted email can exhaust memory (OOM crash). Cap the recursion and
@@ -4912,7 +5256,8 @@ class PostalMime {
4912
5256
  this.options = options || {};
4913
5257
  this.mimeOptions = {
4914
5258
  maxNestingDepth: parseLimitOption(this.options.maxNestingDepth, MAX_NESTING_DEPTH, 'maxNestingDepth'),
4915
- maxHeadersSize: parseLimitOption(this.options.maxHeadersSize, MAX_HEADERS_SIZE, 'maxHeadersSize')
5259
+ maxHeadersSize: parseLimitOption(this.options.maxHeadersSize, MAX_HEADERS_SIZE, 'maxHeadersSize'),
5260
+ maxPartCount: parseLimitOption(this.options.maxPartCount, MAX_PART_COUNT, 'maxPartCount')
4916
5261
  };
4917
5262
  // A limit of 0 disables inline parsing entirely, so every message/rfc822 part
4918
5263
  // becomes an attachment.
@@ -4921,13 +5266,15 @@ class PostalMime {
4921
5266
  // It is deliberately not an option, so that forwarding a caller supplied options
4922
5267
  // object can not seed it and switch the recursion limit off.
4923
5268
  this.rfc822NestingDepth = 0;
5269
+ // Header bytes seen across every part of this message, see MimeNode.feed, and the
5270
+ // number of parts created for it, the root included, see the MimeNode constructor
5271
+ this.headerSize = 0;
5272
+ this.partCount = 0;
4924
5273
  this.root = this.currentNode = new MimeNode({
4925
5274
  postalMime: this,
4926
5275
  ...this.mimeOptions
4927
5276
  });
4928
5277
  this.boundaries = [];
4929
- // Header bytes seen across every part of this message, see MimeNode.feed
4930
- this.headerSize = 0;
4931
5278
  this.textContent = {};
4932
5279
  this.textMap = new Map();
4933
5280
  this.textTypes = new Set();
@@ -4947,7 +5294,7 @@ class PostalMime {
4947
5294
  await this.root.finalize();
4948
5295
  }
4949
5296
  /** @internal */
4950
- async processLine(line, isFinal) {
5297
+ async processLine(line, lineBreak, isFinal) {
4951
5298
  let boundaries = this.boundaries;
4952
5299
  // check if this is a mime boundary
4953
5300
  if (boundaries.length && line.length > 2 && line[0] === 0x2d && line[1] === 0x2d) {
@@ -4981,7 +5328,7 @@ class PostalMime {
4981
5328
  // RFC 2046: boundary line may have trailing whitespace (space/tab) before CRLF
4982
5329
  let hasValidTrailing = true;
4983
5330
  for (let j = boundaryEnd; j < line.length; j++) {
4984
- if (line[j] !== 0x20 && line[j] !== 0x09) {
5331
+ if (!isWsp(line[j])) {
4985
5332
  hasValidTrailing = false;
4986
5333
  break;
4987
5334
  }
@@ -4990,12 +5337,12 @@ class PostalMime {
4990
5337
  continue;
4991
5338
  }
4992
5339
  if (isTerminator) {
4993
- await boundary.node.finalize();
5340
+ await boundary.node.finalize(true);
4994
5341
  this.currentNode = boundary.node.parentNode || this.root;
4995
5342
  }
4996
5343
  else {
4997
5344
  // finalize any open child nodes (should be just one though)
4998
- await boundary.node.finalizeChildNodes();
5345
+ await boundary.node.finalizeChildNodes(true);
4999
5346
  this.currentNode = new MimeNode({
5000
5347
  postalMime: this,
5001
5348
  parentNode: boundary.node,
@@ -5009,11 +5356,18 @@ class PostalMime {
5009
5356
  return;
5010
5357
  }
5011
5358
  }
5012
- this.currentNode.feed(line);
5359
+ if (this.currentNode.feed(line, lineBreak)) {
5360
+ // The headers ended with the line before this one, so the boundary this part
5361
+ // declares was not registered when the line was checked above. Read it again
5362
+ return this.processLine(line, lineBreak, isFinal);
5363
+ }
5013
5364
  if (isFinal) {
5014
5365
  return this.finalize();
5015
5366
  }
5016
5367
  }
5368
+ // Reads the next line without its line break. The break itself, the exact bytes between
5369
+ // the line and the next one, is returned alongside, because the body decoders write it
5370
+ // back out: a part has to keep the line endings it was sent with, see PassThroughDecoder.
5017
5371
  /** @internal */
5018
5372
  readLine() {
5019
5373
  let startPos = this.readPos;
@@ -5026,12 +5380,16 @@ class PostalMime {
5026
5380
  if (c === 0x0a) {
5027
5381
  return {
5028
5382
  bytes: new Uint8Array(this.buf, startPos, endPos - startPos),
5383
+ lineBreak: this.av.subarray(endPos, this.readPos),
5029
5384
  done: this.readPos >= this.av.length
5030
5385
  };
5031
5386
  }
5032
5387
  }
5388
+ // A message that ends without a line break still gets one after its last line, the
5389
+ // decoders write it out unless a boundary ended the part
5033
5390
  return {
5034
5391
  bytes: new Uint8Array(this.buf, startPos, endPos - startPos),
5392
+ lineBreak: LINE_FEED,
5035
5393
  done: this.readPos >= this.av.length
5036
5394
  };
5037
5395
  }
@@ -5112,7 +5470,9 @@ class PostalMime {
5112
5470
  const filename = node.contentDisposition.parsed.params.filename || node.contentType.parsed.params.name || null;
5113
5471
  // `content` is filled in below once the part type is known, hence the cast
5114
5472
  const attachment = {
5115
- filename: filename ? decodeWords(filename) : null,
5473
+ // An RFC 2231 filename is percent decoded without folding, since the same decoder
5474
+ // produces the boundary, so the fold happens here, see foldLineBreaks
5475
+ filename: filename ? decodeWords(foldLineBreaks(filename)) : null,
5116
5476
  mimeType: node.contentType.parsed.value,
5117
5477
  disposition: node.contentDisposition.parsed.value || null
5118
5478
  };
@@ -5145,7 +5505,7 @@ class PostalMime {
5145
5505
  // Enforce into unicode, ending in exactly one newline. The trailing
5146
5506
  // newlines are counted rather than replaced with `/\n*$/`, which
5147
5507
  // retries at every newline of a run that does not end the text.
5148
- const decodedText = node.getTextContent().replace(/\r?\n/g, '\n');
5508
+ const decodedText = node.getTextContent();
5149
5509
  let end = decodedText.length;
5150
5510
  while (end > 0 && decodedText.charCodeAt(end - 1) === 0x0a) {
5151
5511
  end--;
@@ -5394,7 +5754,7 @@ class PostalMime {
5394
5754
  this.readPos = 0;
5395
5755
  while (this.readPos < this.av.length) {
5396
5756
  const line = this.readLine();
5397
- await this.processLine(line.bytes, line.done);
5757
+ await this.processLine(line.bytes, line.lineBreak, line.done);
5398
5758
  }
5399
5759
  this.forceRfc822Attachments = Boolean(this.options.forceRfc822Attachments) || this.hasReportParts();
5400
5760
  await this.collectNode(this.root, false, false);