@limetech/lime-elements 40.7.0 → 40.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/cjs/lime-elements.cjs.js +1 -1
  3. package/dist/cjs/limel-action-bar_3.cjs.entry.js +1 -1
  4. package/dist/cjs/limel-ai-avatar.cjs.entry.js +1 -1
  5. package/dist/cjs/limel-callout.cjs.entry.js +1 -1
  6. package/dist/cjs/limel-chart.cjs.entry.js +1 -1
  7. package/dist/cjs/limel-chip-set.cjs.entry.js +1 -1
  8. package/dist/cjs/limel-chip_2.cjs.entry.js +1 -1
  9. package/dist/cjs/limel-code-diff.cjs.entry.js +1 -1
  10. package/dist/cjs/limel-code-editor.cjs.entry.js +1 -1
  11. package/dist/cjs/limel-collapsible-section.cjs.entry.js +1 -1
  12. package/dist/cjs/limel-dock-button.cjs.entry.js +2 -4
  13. package/dist/cjs/limel-dock.cjs.entry.js +11 -3
  14. package/dist/cjs/limel-drag-handle.cjs.entry.js +1 -1
  15. package/dist/cjs/limel-email-viewer.cjs.entry.js +1 -1
  16. package/dist/cjs/limel-file-viewer.cjs.entry.js +421 -102
  17. package/dist/cjs/limel-file.cjs.entry.js +1 -1
  18. package/dist/cjs/limel-flatpickr-adapter.cjs.entry.js +1 -1
  19. package/dist/cjs/limel-list-item.cjs.entry.js +1 -1
  20. package/dist/cjs/limel-pagination-jump.cjs.entry.js +1 -1
  21. package/dist/cjs/limel-pagination.cjs.entry.js +1 -1
  22. package/dist/cjs/limel-picker.cjs.entry.js +1 -1
  23. package/dist/cjs/limel-profile-picture.cjs.entry.js +1 -1
  24. package/dist/cjs/limel-prosemirror-adapter.cjs.entry.js +1 -1
  25. package/dist/cjs/limel-slider.cjs.entry.js +1 -1
  26. package/dist/cjs/limel-snackbar.cjs.entry.js +1 -1
  27. package/dist/cjs/limel-table.cjs.entry.js +1 -1
  28. package/dist/cjs/limel-text-editor-link-menu.cjs.entry.js +1 -1
  29. package/dist/cjs/loader.cjs.js +1 -1
  30. package/dist/cjs/{translations-CNTWud2a.js → translations-DDyw5Zzz.js} +8 -0
  31. package/dist/collection/components/dock/dock-button/dock-button.css +13 -0
  32. package/dist/collection/components/dock/dock-button/dock-button.js +1 -3
  33. package/dist/collection/components/dock/dock.js +37 -3
  34. package/dist/collection/translations/da.js +1 -0
  35. package/dist/collection/translations/de.js +1 -0
  36. package/dist/collection/translations/en.js +1 -0
  37. package/dist/collection/translations/fi.js +1 -0
  38. package/dist/collection/translations/fr.js +1 -0
  39. package/dist/collection/translations/nl.js +1 -0
  40. package/dist/collection/translations/no.js +1 -0
  41. package/dist/collection/translations/sv.js +1 -0
  42. package/dist/esm/lime-elements.js +1 -1
  43. package/dist/esm/limel-action-bar_3.entry.js +1 -1
  44. package/dist/esm/limel-ai-avatar.entry.js +1 -1
  45. package/dist/esm/limel-callout.entry.js +1 -1
  46. package/dist/esm/limel-chart.entry.js +1 -1
  47. package/dist/esm/limel-chip-set.entry.js +1 -1
  48. package/dist/esm/limel-chip_2.entry.js +1 -1
  49. package/dist/esm/limel-code-diff.entry.js +1 -1
  50. package/dist/esm/limel-code-editor.entry.js +1 -1
  51. package/dist/esm/limel-collapsible-section.entry.js +1 -1
  52. package/dist/esm/limel-dock-button.entry.js +2 -4
  53. package/dist/esm/limel-dock.entry.js +11 -3
  54. package/dist/esm/limel-drag-handle.entry.js +1 -1
  55. package/dist/esm/limel-email-viewer.entry.js +1 -1
  56. package/dist/esm/limel-file-viewer.entry.js +421 -102
  57. package/dist/esm/limel-file.entry.js +1 -1
  58. package/dist/esm/limel-flatpickr-adapter.entry.js +1 -1
  59. package/dist/esm/limel-list-item.entry.js +1 -1
  60. package/dist/esm/limel-pagination-jump.entry.js +1 -1
  61. package/dist/esm/limel-pagination.entry.js +1 -1
  62. package/dist/esm/limel-picker.entry.js +1 -1
  63. package/dist/esm/limel-profile-picture.entry.js +1 -1
  64. package/dist/esm/limel-prosemirror-adapter.entry.js +1 -1
  65. package/dist/esm/limel-slider.entry.js +1 -1
  66. package/dist/esm/limel-snackbar.entry.js +1 -1
  67. package/dist/esm/limel-table.entry.js +1 -1
  68. package/dist/esm/limel-text-editor-link-menu.entry.js +1 -1
  69. package/dist/esm/loader.js +1 -1
  70. package/dist/esm/{translations-Y4i8PVc9.js → translations-UrIr-5_D.js} +8 -0
  71. package/dist/lime-elements/lime-elements.esm.js +1 -1
  72. package/dist/lime-elements/{p-6d9961f7.entry.js → p-006cfb3d.entry.js} +1 -1
  73. package/dist/lime-elements/{p-45c39fb4.entry.js → p-00c36c9e.entry.js} +1 -1
  74. package/dist/lime-elements/{p-4a88d365.entry.js → p-288ef79a.entry.js} +1 -1
  75. package/dist/lime-elements/{p-a42f8c2d.entry.js → p-303edb1a.entry.js} +1 -1
  76. package/dist/lime-elements/p-33b58741.entry.js +1 -0
  77. package/dist/lime-elements/{p-48afa6b7.entry.js → p-3d05e21f.entry.js} +1 -1
  78. package/dist/lime-elements/{p-fe492ff3.entry.js → p-4cbcafae.entry.js} +1 -1
  79. package/dist/lime-elements/{p-3d3bdf45.entry.js → p-526ede01.entry.js} +1 -1
  80. package/dist/lime-elements/{p-2b2253d4.entry.js → p-540b2b24.entry.js} +1 -1
  81. package/dist/lime-elements/{p-2a902e77.entry.js → p-5da47a8a.entry.js} +1 -1
  82. package/dist/lime-elements/{p-a65eb58e.entry.js → p-657bfa36.entry.js} +1 -1
  83. package/dist/lime-elements/{p-120ca628.entry.js → p-727b6c11.entry.js} +1 -1
  84. package/dist/lime-elements/p-9088acea.entry.js +1 -0
  85. package/dist/lime-elements/{p-5df21b25.entry.js → p-96246494.entry.js} +1 -1
  86. package/dist/lime-elements/{p-9218fa6a.entry.js → p-9992b5f9.entry.js} +1 -1
  87. package/dist/lime-elements/{p-5e448070.entry.js → p-9e016212.entry.js} +1 -1
  88. package/dist/lime-elements/{p-3cfe0eaf.entry.js → p-a736ea08.entry.js} +1 -1
  89. package/dist/lime-elements/{p-54a9f48f.entry.js → p-a98cbd1f.entry.js} +1 -1
  90. package/dist/lime-elements/{p-02a06cf7.entry.js → p-ab91cfff.entry.js} +1 -1
  91. package/dist/lime-elements/{p-21ab902a.entry.js → p-bc70090d.entry.js} +1 -1
  92. package/dist/lime-elements/p-cf982d06.entry.js +1 -0
  93. package/dist/lime-elements/{p-f82f72e7.entry.js → p-d37fbbd2.entry.js} +1 -1
  94. package/dist/lime-elements/{p-45fde7a1.entry.js → p-d5800180.entry.js} +1 -1
  95. package/dist/lime-elements/{p-4ecbc9f4.entry.js → p-e2b074aa.entry.js} +1 -1
  96. package/dist/lime-elements/{p-0ece667e.entry.js → p-e752dc7f.entry.js} +1 -1
  97. package/dist/lime-elements/{p-95ccd9e7.entry.js → p-efaf2616.entry.js} +1 -1
  98. package/dist/lime-elements/p-w3yKYfBQ.js +1 -0
  99. package/dist/types/components/dock/dock.d.ts +7 -0
  100. package/dist/types/components.d.ts +11 -0
  101. package/dist/types/translations/da.d.ts +1 -0
  102. package/dist/types/translations/de.d.ts +1 -0
  103. package/dist/types/translations/en.d.ts +1 -0
  104. package/dist/types/translations/fi.d.ts +1 -0
  105. package/dist/types/translations/fr.d.ts +1 -0
  106. package/dist/types/translations/nl.d.ts +1 -0
  107. package/dist/types/translations/no.d.ts +1 -0
  108. package/dist/types/translations/sv.d.ts +1 -0
  109. package/package.json +4 -4
  110. package/dist/lime-elements/p-1740cb87.entry.js +0 -1
  111. package/dist/lime-elements/p-B3YP9GOq.js +0 -1
  112. package/dist/lime-elements/p-e02e2c5f.entry.js +0 -1
  113. package/dist/lime-elements/p-f3cae1e7.entry.js +0 -1
@@ -1,5 +1,5 @@
1
1
  import { r as registerInstance, c as createEvent, h, F as Fragment, a as getElement } from './index-BU9IJqli.js';
2
- import { t as translations } from './translations-Y4i8PVc9.js';
2
+ import { t as translations } from './translations-UrIr-5_D.js';
3
3
  import { d as defaultSchema, u as unified, r as rehypeParse, a as rehypeSanitize, v as visit, b as rehypeStringify } from './index-BN64nlbO.js';
4
4
  import { I as ImageTemplate } from './image.template-BGjw97m_.js';
5
5
  import './language-ChagAaNf.js';
@@ -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
@@ -656,27 +809,72 @@ function decodeParameterValueContinuations(header) {
656
809
  });
657
810
  }
658
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.
659
826
  class PassThroughDecoder {
660
827
  constructor() {
661
828
  this.chunks = [];
662
- this.pendingLineBreak = false;
663
- }
664
- // The line break is written when the next line arrives, not after each line. The
665
- // CRLF in front of a multipart boundary belongs to the boundary (RFC 2046 5.1.1), so
666
- // the last line of a part ended by a boundary must not carry one.
667
- update(line) {
668
- if (this.pendingLineBreak) {
669
- this.chunks.push('\n');
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;
670
861
  }
671
- this.chunks.push(line);
672
- this.pendingLineBreak = true;
862
+ this.pendingLineBreak = lineBreak;
673
863
  }
674
864
  // A part ended by the end of the message has no boundary to own its final line
675
865
  // break, so there it stays content
676
866
  finalize(boundaryEnded = false) {
677
- if (!boundaryEnded && this.pendingLineBreak) {
678
- this.chunks.push('\n');
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
+ }
679
876
  }
877
+ this.flushRun();
680
878
  // convert an array of arraybuffers into a blob and then back into a single arraybuffer
681
879
  return blobToArrayBuffer(new Blob(this.chunks, { type: 'application/octet-stream' }));
682
880
  }
@@ -725,14 +923,18 @@ class Base64Decoder {
725
923
  }
726
924
 
727
925
  const CHR_EQUALS = 0x3d;
728
- const CHR_LF = 0x0a;
729
926
  class QPDecoder {
730
927
  constructor() {
731
928
  this.maxChunkSize = 100 * 1024;
732
929
  this.buffer = new Uint8Array(this.maxChunkSize);
733
930
  this.bufferPos = 0;
734
931
  this.chunks = [];
735
- this.pendingLineBreak = false;
932
+ this.pendingLineBreak = null;
933
+ }
934
+ writeLineBreak() {
935
+ if (this.pendingLineBreak) {
936
+ this.writeBytes(this.pendingLineBreak, 0, this.pendingLineBreak.length);
937
+ }
736
938
  }
737
939
  writeByte(byte) {
738
940
  if (this.bufferPos >= this.buffer.length) {
@@ -765,12 +967,17 @@ class QPDecoder {
765
967
  // content that decoded correctly in base64 came out as mojibake in quoted-printable.
766
968
  //
767
969
  // As in PassThroughDecoder, a hard line break is only written once the next line
768
- // arrives, so the CRLF that belongs to a following boundary is not decoded as content.
769
- update(line) {
770
- if (this.pendingLineBreak) {
771
- this.writeByte(CHR_LF);
772
- }
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();
773
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
+ }
774
981
  // a line ending in '=' is a soft line break, the newline is not part of the content
775
982
  const softBreak = len > 0 && line[len - 1] === CHR_EQUALS;
776
983
  if (softBreak) {
@@ -793,12 +1000,12 @@ class QPDecoder {
793
1000
  literalStart = i + 1;
794
1001
  }
795
1002
  this.writeBytes(line, literalStart, len);
796
- this.pendingLineBreak = !softBreak;
1003
+ this.pendingLineBreak = softBreak ? null : lineBreak;
797
1004
  }
798
1005
  // see PassThroughDecoder.finalize
799
1006
  finalize(boundaryEnded = false) {
800
- if (!boundaryEnded && this.pendingLineBreak) {
801
- this.writeByte(CHR_LF);
1007
+ if (!boundaryEnded) {
1008
+ this.writeLineBreak();
802
1009
  }
803
1010
  this.flushBuffer();
804
1011
  // convert an array of arraybuffers into a blob and then back into a single arraybuffer
@@ -810,6 +1017,10 @@ class QPDecoder {
810
1017
  // kept as a character instead of being swallowed. A stripped BOM turns a line a strict
811
1018
  // parser skips into a genuine header, which is how a second `From:` gets smuggled past
812
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.
813
1024
  const headerDecoder = new TextDecoder('utf-8', { ignoreBOM: true });
814
1025
  // Trims only the whitespace RFC 5322 allows around a field name. String.prototype.trim
815
1026
  // also strips U+00A0, U+FEFF, U+2028 and the rest of the Unicode spaces, which turns a
@@ -821,7 +1032,6 @@ const headerDecoder = new TextDecoder('utf-8', { ignoreBOM: true });
821
1032
  // An index scan rather than `/^[ \t]+|[ \t]+$/g`, which retries the trailing branch at
822
1033
  // every position of a blank run that is followed by other text, so a single header with
823
1034
  // a long run of spaces in the middle took seconds to trim.
824
- const isWsp = (c) => c === 0x20 || c === 0x09;
825
1035
  const trimWsp = (str) => {
826
1036
  let start = 0;
827
1037
  let end = str.length;
@@ -833,6 +1043,12 @@ const trimWsp = (str) => {
833
1043
  }
834
1044
  return str.slice(start, end);
835
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]*:/;
836
1052
  // Headers that decide how this part's body is read, see processHeaders
837
1053
  const CONTENT_HEADERS = new Set([
838
1054
  'content-type',
@@ -845,6 +1061,12 @@ class MimeNode {
845
1061
  constructor(options) {
846
1062
  this.options = options;
847
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
+ }
848
1070
  this.childNodes = [];
849
1071
  // Cursor into childNodes for finalizeChildNodes. Every new part of a multipart
850
1072
  // finalizes its parent's children, so re-walking the whole array each time is
@@ -863,6 +1085,7 @@ class MimeNode {
863
1085
  }
864
1086
  this.state = 'header';
865
1087
  this.headerLines = [];
1088
+ this.lastLineBlank = false;
866
1089
  // RFC 2046 Section 5.1.5: multipart/digest defaults to message/rfc822
867
1090
  const parentMultipartType = options.parentMultipartType || null;
868
1091
  const defaultContentType = parentMultipartType === 'digest' ? 'message/rfc822' : 'text/plain';
@@ -904,7 +1127,7 @@ class MimeNode {
904
1127
  return;
905
1128
  }
906
1129
  if (this.state === 'header') {
907
- this.processHeaders();
1130
+ this.endHeaders();
908
1131
  }
909
1132
  // remove self from boundary listing
910
1133
  let boundaries = this.postalMime.boundaries;
@@ -1155,55 +1378,51 @@ class MimeNode {
1155
1378
  decodeParameterValueContinuations(response);
1156
1379
  return response;
1157
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.
1158
1392
  decodeFlowedText(str, delSp) {
1159
- // Pieces of the result, joined once at the end. Growing a single string and
1160
- // calling endsWith() on it for every line flattens the whole paragraph again on
1161
- // each line, which is quadratic in the length of a paragraph.
1162
- // Empty pieces are never stored, so the last piece always holds the last
1163
- // 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.
1164
1396
  const parts = [];
1165
- // The unfolded line being built, ie. everything after the last hard line break,
1166
- // starts at this index of parts and is this many characters long
1167
- let lineStart = 0;
1168
- let lineLength = 0;
1169
- 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');
1170
1401
  for (let i = 0; i < lines.length; i++) {
1171
- let line = lines[i];
1172
- // remove whitespace stuffing before anything else
1173
- // http://tools.ietf.org/html/rfc3676#section-4.4
1174
- // doing it after the join leaves the stuffed space of a continuation line
1175
- // sitting in the middle of the joined paragraph
1176
- if (line.charAt(0) === ' ') {
1177
- line = line.slice(1);
1402
+ const line = lines[i];
1403
+ let quoteDepth = 0;
1404
+ while (line.charCodeAt(quoteDepth) === 0x3e /* > */) {
1405
+ quoteDepth++;
1178
1406
  }
1179
- if (i) {
1180
- const last = parts.length ? parts[parts.length - 1] : '';
1181
- // soft linebreaks are added after space symbols, except for the signature
1182
- // separator which is a line of its own
1183
- const isSignature = lineLength === 3 && parts.slice(lineStart).join('') === '-- ';
1184
- if (last.endsWith(' ') && !isSignature) {
1185
- if (delSp) {
1186
- // delsp adds space to text to be able to fold it
1187
- // these spaces can be removed once the text is unfolded
1188
- if (last.length > 1) {
1189
- parts[parts.length - 1] = last.slice(0, -1);
1190
- }
1191
- else {
1192
- parts.pop();
1193
- }
1194
- lineLength--;
1195
- }
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);
1196
1415
  }
1197
- else {
1416
+ parts.push(content);
1417
+ }
1418
+ else {
1419
+ if (i) {
1198
1420
  parts.push('\n');
1199
- lineStart = parts.length;
1200
- lineLength = 0;
1201
1421
  }
1422
+ parts.push(quoteDepth ? line : content);
1202
1423
  }
1203
- if (line) {
1204
- parts.push(line);
1205
- lineLength += line.length;
1206
- }
1424
+ flowed = !isSignature && content.endsWith(' ');
1425
+ depth = quoteDepth;
1207
1426
  }
1208
1427
  return parts.join('');
1209
1428
  }
@@ -1211,7 +1430,10 @@ class MimeNode {
1211
1430
  if (!this.content) {
1212
1431
  return '';
1213
1432
  }
1214
- 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');
1215
1437
  if (/^flowed$/i.test(this.contentType.parsed.params.format)) {
1216
1438
  str = this.decodeFlowedText(str, /^yes$/i.test(this.contentType.parsed.params.delsp));
1217
1439
  }
@@ -1257,10 +1479,8 @@ class MimeNode {
1257
1479
  let unfoldedLine = parts.join('');
1258
1480
  sep = unfoldedLine.indexOf(':');
1259
1481
  let key = trimWsp(sep < 0 ? unfoldedLine : unfoldedLine.slice(0, sep));
1260
- // A bare CR is not legal in a field body. It used to be folded into a space by
1261
- // the whitespace collapse, and passing it through would hand consumers that
1262
- // write the value back out a line of their own.
1263
- 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)));
1264
1484
  this.headers.push({ key: key.toLowerCase(), originalKey: key, value });
1265
1485
  // A header that decides how the body is read must resolve the same way every
1266
1486
  // time it is duplicated, otherwise a message can present one Content-Type to a
@@ -1292,7 +1512,15 @@ class MimeNode {
1292
1512
  this.contentType.multipart = /^multipart\//i.test(this.contentType.parsed.value)
1293
1513
  ? this.contentType.parsed.value.slice(this.contentType.parsed.value.indexOf('/') + 1)
1294
1514
  : false;
1295
- 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) {
1296
1524
  // add self to boundary terminator listing
1297
1525
  this.postalMime.boundaries.push({
1298
1526
  value: textEncoder.encode(this.contentType.parsed.params.boundary),
@@ -1309,13 +1537,20 @@ class MimeNode {
1309
1537
  .match(/[\w-]+/) || [''])[0];
1310
1538
  this.setupContentDecoder(this.contentTransferEncoding.encoding);
1311
1539
  }
1312
- 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) {
1313
1549
  switch (this.state) {
1314
- case 'header':
1550
+ case 'header': {
1315
1551
  if (!line.length) {
1316
- this.state = 'body';
1317
- this.processHeaders();
1318
- return;
1552
+ this.endHeaders();
1553
+ return false;
1319
1554
  }
1320
1555
  // Counted across the whole message, not per part. A per-node budget lets a
1321
1556
  // multipart carry the limit again for every part it declares, so a message
@@ -1325,17 +1560,34 @@ class MimeNode {
1325
1560
  let error = new Error(`Maximum header size of ${this.options.maxHeadersSize} bytes exceeded`);
1326
1561
  throw error;
1327
1562
  }
1328
- 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);
1329
1579
  break;
1580
+ }
1330
1581
  case 'body':
1331
1582
  // add line to body. processHeaders installs a decoder before the state
1332
1583
  // becomes body, and the only time the decoder is released is finalize,
1333
1584
  // which moves the state on to finished, so the guard only narrows the type
1334
1585
  if (this.contentDecoder) {
1335
- this.contentDecoder.update(line);
1586
+ this.contentDecoder.update(line, lineBreak);
1336
1587
  }
1337
1588
  break;
1338
1589
  }
1590
+ return false;
1339
1591
  }
1340
1592
  }
1341
1593
 
@@ -3574,6 +3826,39 @@ const htmlEntities = {
3574
3826
  '&zwnj;': '\u200C'
3575
3827
  };
3576
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
+ ]);
3577
3862
  function decodeHTMLEntities(str) {
3578
3863
  return str.replace(/&(#\d+|#x[a-f0-9]+|[a-z]+\d*);?/gi, (match, entity) => {
3579
3864
  if (typeof htmlEntities[match] === 'string') {
@@ -3584,19 +3869,23 @@ function decodeHTMLEntities(str) {
3584
3869
  return match;
3585
3870
  }
3586
3871
  let codePoint;
3587
- if (entity.charAt(1) === 'x') {
3588
- // 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') {
3589
3876
  codePoint = parseInt(entity.slice(2), 16);
3590
3877
  }
3591
3878
  else {
3592
- // dec
3593
3879
  codePoint = parseInt(entity.slice(1), 10);
3594
3880
  }
3595
3881
  let output = '';
3596
- if ((codePoint >= 0xd800 && codePoint <= 0xdfff) || codePoint > 0x10ffff) {
3597
- // 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) {
3598
3886
  return '\ufffd';
3599
3887
  }
3888
+ codePoint = C1_ENTITY_REPLACEMENTS.get(codePoint) ?? codePoint;
3600
3889
  if (codePoint > 0xffff) {
3601
3890
  codePoint -= 0x10000;
3602
3891
  output += String.fromCharCode(((codePoint >>> 10) & 0x3ff) | 0xd800);
@@ -3695,13 +3984,17 @@ function replaceTags(str, prefix, pattern, replacement) {
3695
3984
  break;
3696
3985
  }
3697
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
+ }
3698
3995
  pattern.lastIndex = start;
3699
3996
  const match = pattern.exec(str);
3700
3997
  if (!match) {
3701
- const gt = str.indexOf('>', start);
3702
- if (gt < 0) {
3703
- break;
3704
- }
3705
3998
  searchFrom = gt + 1;
3706
3999
  continue;
3707
4000
  }
@@ -4727,7 +5020,11 @@ class Tokenizer {
4727
5020
  this.escaped = false;
4728
5021
  return;
4729
5022
  }
4730
- 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
4731
5028
  this.escaped = true;
4732
5029
  return;
4733
5030
  }
@@ -4912,8 +5209,14 @@ function renderEntry(textEntry, textType, convert) {
4912
5209
  }
4913
5210
  return textType === 'html' ? textToHtml(textEntry.value) : htmlToText(textEntry.value);
4914
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]);
4915
5214
  const MAX_NESTING_DEPTH = 256;
4916
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;
4917
5220
  // Inline message/rfc822 parts are parsed recursively. Without a dedicated limit
4918
5221
  // each nesting level spawns a new parser that retains the full nested message,
4919
5222
  // so a small crafted email can exhaust memory (OOM crash). Cap the recursion and
@@ -4951,7 +5254,8 @@ class PostalMime {
4951
5254
  this.options = options || {};
4952
5255
  this.mimeOptions = {
4953
5256
  maxNestingDepth: parseLimitOption(this.options.maxNestingDepth, MAX_NESTING_DEPTH, 'maxNestingDepth'),
4954
- 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')
4955
5259
  };
4956
5260
  // A limit of 0 disables inline parsing entirely, so every message/rfc822 part
4957
5261
  // becomes an attachment.
@@ -4960,13 +5264,15 @@ class PostalMime {
4960
5264
  // It is deliberately not an option, so that forwarding a caller supplied options
4961
5265
  // object can not seed it and switch the recursion limit off.
4962
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;
4963
5271
  this.root = this.currentNode = new MimeNode({
4964
5272
  postalMime: this,
4965
5273
  ...this.mimeOptions
4966
5274
  });
4967
5275
  this.boundaries = [];
4968
- // Header bytes seen across every part of this message, see MimeNode.feed
4969
- this.headerSize = 0;
4970
5276
  this.textContent = {};
4971
5277
  this.textMap = new Map();
4972
5278
  this.textTypes = new Set();
@@ -4986,7 +5292,7 @@ class PostalMime {
4986
5292
  await this.root.finalize();
4987
5293
  }
4988
5294
  /** @internal */
4989
- async processLine(line, isFinal) {
5295
+ async processLine(line, lineBreak, isFinal) {
4990
5296
  let boundaries = this.boundaries;
4991
5297
  // check if this is a mime boundary
4992
5298
  if (boundaries.length && line.length > 2 && line[0] === 0x2d && line[1] === 0x2d) {
@@ -5020,7 +5326,7 @@ class PostalMime {
5020
5326
  // RFC 2046: boundary line may have trailing whitespace (space/tab) before CRLF
5021
5327
  let hasValidTrailing = true;
5022
5328
  for (let j = boundaryEnd; j < line.length; j++) {
5023
- if (line[j] !== 0x20 && line[j] !== 0x09) {
5329
+ if (!isWsp(line[j])) {
5024
5330
  hasValidTrailing = false;
5025
5331
  break;
5026
5332
  }
@@ -5048,11 +5354,18 @@ class PostalMime {
5048
5354
  return;
5049
5355
  }
5050
5356
  }
5051
- 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
+ }
5052
5362
  if (isFinal) {
5053
5363
  return this.finalize();
5054
5364
  }
5055
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.
5056
5369
  /** @internal */
5057
5370
  readLine() {
5058
5371
  let startPos = this.readPos;
@@ -5065,12 +5378,16 @@ class PostalMime {
5065
5378
  if (c === 0x0a) {
5066
5379
  return {
5067
5380
  bytes: new Uint8Array(this.buf, startPos, endPos - startPos),
5381
+ lineBreak: this.av.subarray(endPos, this.readPos),
5068
5382
  done: this.readPos >= this.av.length
5069
5383
  };
5070
5384
  }
5071
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
5072
5388
  return {
5073
5389
  bytes: new Uint8Array(this.buf, startPos, endPos - startPos),
5390
+ lineBreak: LINE_FEED,
5074
5391
  done: this.readPos >= this.av.length
5075
5392
  };
5076
5393
  }
@@ -5151,7 +5468,9 @@ class PostalMime {
5151
5468
  const filename = node.contentDisposition.parsed.params.filename || node.contentType.parsed.params.name || null;
5152
5469
  // `content` is filled in below once the part type is known, hence the cast
5153
5470
  const attachment = {
5154
- 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,
5155
5474
  mimeType: node.contentType.parsed.value,
5156
5475
  disposition: node.contentDisposition.parsed.value || null
5157
5476
  };
@@ -5184,7 +5503,7 @@ class PostalMime {
5184
5503
  // Enforce into unicode, ending in exactly one newline. The trailing
5185
5504
  // newlines are counted rather than replaced with `/\n*$/`, which
5186
5505
  // retries at every newline of a run that does not end the text.
5187
- const decodedText = node.getTextContent().replace(/\r?\n/g, '\n');
5506
+ const decodedText = node.getTextContent();
5188
5507
  let end = decodedText.length;
5189
5508
  while (end > 0 && decodedText.charCodeAt(end - 1) === 0x0a) {
5190
5509
  end--;
@@ -5433,7 +5752,7 @@ class PostalMime {
5433
5752
  this.readPos = 0;
5434
5753
  while (this.readPos < this.av.length) {
5435
5754
  const line = this.readLine();
5436
- await this.processLine(line.bytes, line.done);
5755
+ await this.processLine(line.bytes, line.lineBreak, line.done);
5437
5756
  }
5438
5757
  this.forceRfc822Attachments = Boolean(this.options.forceRfc822Attachments) || this.hasReportParts();
5439
5758
  await this.collectNode(this.root, false, false);