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