@limetech/lime-elements 40.7.0 → 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.
@@ -125,6 +125,8 @@ class Fullscreen {
125
125
  }
126
126
 
127
127
  const textEncoder = new TextEncoder();
128
+ /** SP or HTAB, the only whitespace RFC 5322 and RFC 2045 give a meaning to in a line */
129
+ const isWsp = (c) => c === 0x20 || c === 0x09;
128
130
  const base64Chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
129
131
  // Use a lookup table to find the index.
130
132
  const base64Lookup = new Uint8Array(256);
@@ -342,7 +344,10 @@ function tryDecoder(charset) {
342
344
  // Every runtime has utf-8, so a decoder is always returned even where windows-1252 is
343
345
  // missing, rather than letting the caller fail on a null decoder
344
346
  const utf8Decoder = new TextDecoder();
345
- function getDecoder(charset) {
347
+ /**
348
+ * Finds the decoder for a charset label, or null when the runtime has no encoding for it
349
+ */
350
+ function findDecoder(charset) {
346
351
  charset = (charset || 'utf8').trim().toLowerCase();
347
352
  // Try the label as written first, so the alias table only ever adds to what the
348
353
  // runtime already supports instead of shadowing it.
@@ -355,7 +360,136 @@ function getDecoder(charset) {
355
360
  // The normalized label is itself the last candidate, which resolves everything that
356
361
  // differed from a supported label only by a prefix or a separator, eg. x-big5.
357
362
  const alias = (codePage && codePageAliases.get(codePage[1])) || charsetAliases.get(normalized) || normalized;
358
- return tryDecoder(alias) || tryDecoder('windows-1252') || utf8Decoder;
363
+ return tryDecoder(alias);
364
+ }
365
+ function getDecoder(charset) {
366
+ return findDecoder(charset) || tryDecoder('windows-1252') || utf8Decoder;
367
+ }
368
+ /**
369
+ * Whether the bytes are well formed UTF-8. A scan rather than a fatal TextDecoder, since
370
+ * the exception one throws costs more than the decode itself and a message decides how
371
+ * many lines throw it.
372
+ */
373
+ function isUtf8(bytes) {
374
+ for (let i = 0; i < bytes.length;) {
375
+ const lead = bytes[i];
376
+ if (lead < 0x80) {
377
+ i++;
378
+ continue;
379
+ }
380
+ // The number of continuation bytes, and the range the first of them may take,
381
+ // which is what rules out overlong forms, surrogates and code points past U+10FFFF
382
+ let count;
383
+ if (lead >= 0xc2 && lead <= 0xdf) {
384
+ count = 1;
385
+ }
386
+ else if (lead >= 0xe0 && lead <= 0xef) {
387
+ count = 2;
388
+ }
389
+ else if (lead >= 0xf0 && lead <= 0xf4) {
390
+ count = 3;
391
+ }
392
+ else {
393
+ return false;
394
+ }
395
+ if (i + count >= bytes.length) {
396
+ return false;
397
+ }
398
+ const second = bytes[i + 1];
399
+ const min = lead === 0xe0 ? 0xa0 : lead === 0xf0 ? 0x90 : 0x80;
400
+ const max = lead === 0xed ? 0x9f : lead === 0xf4 ? 0x8f : 0xbf;
401
+ if (second < min || second > max) {
402
+ return false;
403
+ }
404
+ for (let k = 2; k <= count; k++) {
405
+ if ((bytes[i + k] & 0xc0) !== 0x80) {
406
+ return false;
407
+ }
408
+ }
409
+ i += count + 1;
410
+ }
411
+ return true;
412
+ }
413
+ /**
414
+ * Decodes bytes whose encoding is not known: as UTF-8 when they are well formed UTF-8, as
415
+ * windows-1252 otherwise.
416
+ *
417
+ * A message that declares no charset, or one the runtime has never heard of, usually
418
+ * predates RFC 6532 or comes from a generator that never heard of it either, and its 8-bit
419
+ * bytes are a legacy single byte encoding far more often than broken UTF-8. windows-1252
420
+ * is the fallback the WHATWG Encoding Standard prescribes and what a mail client's default
421
+ * charset amounts to, and it reads such text rather than filling it with replacement
422
+ * characters.
423
+ *
424
+ * @param bytes Bytes to decode
425
+ * @param utf8 The UTF-8 decoder to use, header lines bring one that keeps a BOM
426
+ * @return Decoded text
427
+ */
428
+ function decodeUtf8OrWindows1252(bytes, utf8 = utf8Decoder) {
429
+ const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
430
+ return isUtf8(view) ? utf8.decode(view) : getDecoder('windows-1252').decode(view);
431
+ }
432
+ /**
433
+ * Decodes bytes in a named charset. A label the runtime does not know is no charset at
434
+ * all, see decodeUtf8OrWindows1252.
435
+ *
436
+ * @param bytes Bytes to decode
437
+ * @param charset Charset label
438
+ * @return Decoded text
439
+ */
440
+ function decodeCharset(bytes, charset) {
441
+ const decoder = findDecoder(charset);
442
+ return decoder ? decoder.decode(bytes) : decodeUtf8OrWindows1252(bytes);
443
+ }
444
+ // The charset an html document names for itself, in a `<meta charset>` or in the
445
+ // Content-Type `<meta http-equiv>`, read from the first 1024 bytes like a browser's prescan.
446
+ // Tags are found with indexOf and the attribute is matched inside one tag at a time: a
447
+ // single pattern over the whole head rescans it once for every `<meta` in it, and a head of
448
+ // nothing but `<meta ` took it quadratic
449
+ const META_PRESCAN_BYTES = 1024;
450
+ const CHARSET_ATTRIBUTE = /charset\s*=\s*["']?\s*([^\s"';>]+)/;
451
+ const UTF16_ENCODINGS = new Set(['utf-16le', 'utf-16be']);
452
+ function sniffHtmlCharset(bytes) {
453
+ const head = utf8Decoder.decode(bytes.subarray(0, META_PRESCAN_BYTES)).toLowerCase();
454
+ let pos = 0;
455
+ while ((pos = head.indexOf('<meta', pos)) >= 0) {
456
+ let end = head.indexOf('>', pos);
457
+ if (end < 0) {
458
+ end = head.length;
459
+ }
460
+ const match = CHARSET_ATTRIBUTE.exec(head.slice(pos, end));
461
+ if (match) {
462
+ return match[1];
463
+ }
464
+ pos = end;
465
+ }
466
+ return null;
467
+ }
468
+ /**
469
+ * Decodes the bytes of a text part: in the declared charset, otherwise in the one an html
470
+ * part names for itself, otherwise as UTF-8 or windows-1252 depending on what the bytes
471
+ * turn out to be, see decodeUtf8OrWindows1252.
472
+ *
473
+ * @param bytes Decoded body of the part
474
+ * @param charset The charset parameter of the Content-Type, if any
475
+ * @param html Whether the part is html, which may name its own charset
476
+ * @return The text of the part
477
+ */
478
+ function decodeText(bytes, charset, html) {
479
+ const view = new Uint8Array(bytes);
480
+ if (charset) {
481
+ return decodeCharset(view, charset);
482
+ }
483
+ // The HTML prescan has two overrides, and for a reason: a `<meta>` tag that could be
484
+ // read as ASCII proves the document is not UTF-16, so such a label is read as UTF-8,
485
+ // and x-user-defined is read as windows-1252. Without them a sender picks the encoding
486
+ // that turns bytes a scanner sees as inert into markup for this parser alone
487
+ const sniffed = html ? sniffHtmlCharset(view) : null;
488
+ const decoder = sniffed ? findDecoder(sniffed) : null;
489
+ if (!decoder || UTF16_ENCODINGS.has(decoder.encoding)) {
490
+ return decodeUtf8OrWindows1252(view);
491
+ }
492
+ return (decoder.encoding === 'x-user-defined' ? getDecoder('windows-1252') : decoder).decode(view);
359
493
  }
360
494
  /**
361
495
  * Converts a Blob into an ArrayBuffer
@@ -395,6 +529,25 @@ function hexNibble(c) {
395
529
  }
396
530
  return -1;
397
531
  }
532
+ /**
533
+ * Folds the line breaks of a header value into spaces.
534
+ *
535
+ * A header value holds no line breaks. The raw value is folded when the headers are
536
+ * processed, see processHeaders in mime-node.ts, an RFC 2047 encoded word is folded as it
537
+ * is decoded, and an RFC 2231 filename is folded where the attachment is built. Otherwise a
538
+ * decoded name, subject or filename written into a header of an outgoing message by the
539
+ * consumer ends the header early and starts another one with the rest of the value.
540
+ *
541
+ * The RFC 2231 percent decoder itself does not fold: it also produces the boundary, and a
542
+ * boundary holding a line break can never match a line. That has to stay so, or a message
543
+ * that has no parts for every other parser would have them here.
544
+ *
545
+ * @param str Header value
546
+ * @return The value with every run of CR and LF replaced by a space
547
+ */
548
+ function foldLineBreaks(str) {
549
+ return str.replace(/[\r\n]+/g, ' ');
550
+ }
398
551
  /**
399
552
  * Decode a complete mime word encoded string
400
553
  *
@@ -447,7 +600,7 @@ function decodeWord(charset, encoding, str) {
447
600
  // keep as is, convert ArrayBuffer to unicode string, assume utf8
448
601
  byteStr = textEncoder.encode(str);
449
602
  }
450
- return getDecoder(charset).decode(byteStr);
603
+ return foldLineBreaks(decodeCharset(byteStr, charset));
451
604
  }
452
605
  // A charset label runs to the next '?' so that labels containing punctuation, eg.
453
606
  // ISO_8859-1:1987, are recognised. Whitespace is excluded so a stray '=?' in running text
@@ -591,7 +744,7 @@ function decodeURIComponentWithCharset(encodedStr, charset) {
591
744
  for (let i = 0, len = encodedBytes.length; i < len; i++) {
592
745
  dataView.setUint8(i, encodedBytes[i]);
593
746
  }
594
- return getDecoder(charset).decode(byteStr);
747
+ return decodeCharset(byteStr, charset);
595
748
  }
596
749
  function decodeParameterValueContinuations(header) {
597
750
  // handle parameter value continuations
@@ -658,27 +811,72 @@ function decodeParameterValueContinuations(header) {
658
811
  });
659
812
  }
660
813
 
814
+ // Hands the body through as it is, the line endings included.
815
+ //
816
+ // The line break is written when the next line arrives, not after each line. The CRLF in
817
+ // front of a multipart boundary belongs to the boundary (RFC 2046 5.1.1), so the last line
818
+ // of a part ended by a boundary must not carry one.
819
+ //
820
+ // The break written is the one the line came with. Writing LF for every line rewrote the
821
+ // bytes of every 7bit, 8bit and binary part: an attached message lost each of its CR and a
822
+ // binary attachment came out corrupted. Text is normalized to LF where it is read as text
823
+ // instead, see MimeNode.getTextContent.
824
+ //
825
+ // The lines of a part, breaks included, are one contiguous range of the message, so the
826
+ // decoder keeps the bounds of that range rather than a chunk per line: a Blob of two parts
827
+ // per line took 60 times longer to assemble than one of a single part.
661
828
  class PassThroughDecoder {
662
829
  constructor() {
663
830
  this.chunks = [];
664
- this.pendingLineBreak = false;
665
- }
666
- // The line break is written when the next line arrives, not after each line. The
667
- // CRLF in front of a multipart boundary belongs to the boundary (RFC 2046 5.1.1), so
668
- // the last line of a part ended by a boundary must not carry one.
669
- update(line) {
670
- if (this.pendingLineBreak) {
671
- this.chunks.push('\n');
831
+ this.runBuffer = null;
832
+ this.runStart = 0;
833
+ this.runEnd = 0;
834
+ this.pendingLineBreak = null;
835
+ }
836
+ // Whether the bytes sit in the run's buffer right at the end of the run
837
+ continuesRun(bytes) {
838
+ return bytes.buffer === this.runBuffer && bytes.byteOffset === this.runEnd;
839
+ }
840
+ flushRun() {
841
+ if (this.runBuffer && this.runEnd > this.runStart) {
842
+ this.chunks.push(new Uint8Array(this.runBuffer, this.runStart, this.runEnd - this.runStart));
843
+ }
844
+ this.runBuffer = null;
845
+ }
846
+ update(line, lineBreak) {
847
+ const pending = this.pendingLineBreak;
848
+ if (pending &&
849
+ this.continuesRun(pending) &&
850
+ line.buffer === this.runBuffer &&
851
+ line.byteOffset === this.runEnd + pending.byteLength) {
852
+ // the break follows the run and the line follows the break, so the run grows
853
+ this.runEnd = line.byteOffset + line.byteLength;
854
+ }
855
+ else {
856
+ this.flushRun();
857
+ if (pending) {
858
+ this.chunks.push(pending);
859
+ }
860
+ this.runBuffer = line.buffer;
861
+ this.runStart = line.byteOffset;
862
+ this.runEnd = line.byteOffset + line.byteLength;
672
863
  }
673
- this.chunks.push(line);
674
- this.pendingLineBreak = true;
864
+ this.pendingLineBreak = lineBreak;
675
865
  }
676
866
  // A part ended by the end of the message has no boundary to own its final line
677
867
  // break, so there it stays content
678
868
  finalize(boundaryEnded = false) {
679
- if (!boundaryEnded && this.pendingLineBreak) {
680
- this.chunks.push('\n');
869
+ const pending = this.pendingLineBreak;
870
+ if (!boundaryEnded && pending) {
871
+ if (this.continuesRun(pending)) {
872
+ this.runEnd += pending.byteLength;
873
+ }
874
+ else {
875
+ this.flushRun();
876
+ this.chunks.push(pending);
877
+ }
681
878
  }
879
+ this.flushRun();
682
880
  // convert an array of arraybuffers into a blob and then back into a single arraybuffer
683
881
  return blobToArrayBuffer(new Blob(this.chunks, { type: 'application/octet-stream' }));
684
882
  }
@@ -727,14 +925,18 @@ class Base64Decoder {
727
925
  }
728
926
 
729
927
  const CHR_EQUALS = 0x3d;
730
- const CHR_LF = 0x0a;
731
928
  class QPDecoder {
732
929
  constructor() {
733
930
  this.maxChunkSize = 100 * 1024;
734
931
  this.buffer = new Uint8Array(this.maxChunkSize);
735
932
  this.bufferPos = 0;
736
933
  this.chunks = [];
737
- this.pendingLineBreak = false;
934
+ this.pendingLineBreak = null;
935
+ }
936
+ writeLineBreak() {
937
+ if (this.pendingLineBreak) {
938
+ this.writeBytes(this.pendingLineBreak, 0, this.pendingLineBreak.length);
939
+ }
738
940
  }
739
941
  writeByte(byte) {
740
942
  if (this.bufferPos >= this.buffer.length) {
@@ -767,12 +969,17 @@ class QPDecoder {
767
969
  // content that decoded correctly in base64 came out as mojibake in quoted-printable.
768
970
  //
769
971
  // As in PassThroughDecoder, a hard line break is only written once the next line
770
- // arrives, so the CRLF that belongs to a following boundary is not decoded as content.
771
- update(line) {
772
- if (this.pendingLineBreak) {
773
- this.writeByte(CHR_LF);
774
- }
972
+ // arrives, so the CRLF that belongs to a following boundary is not decoded as content,
973
+ // and the break written is the one the line came with.
974
+ update(line, lineBreak) {
975
+ this.writeLineBreak();
775
976
  let len = line.length;
977
+ // Trailing white space on an encoded line was added in transit and is deleted (RFC
978
+ // 2045 6.7 rule 3). Before the soft line break is looked for, since the `=` it
979
+ // consists of may have had white space appended the same way
980
+ while (len > 0 && isWsp(line[len - 1])) {
981
+ len--;
982
+ }
776
983
  // a line ending in '=' is a soft line break, the newline is not part of the content
777
984
  const softBreak = len > 0 && line[len - 1] === CHR_EQUALS;
778
985
  if (softBreak) {
@@ -795,12 +1002,12 @@ class QPDecoder {
795
1002
  literalStart = i + 1;
796
1003
  }
797
1004
  this.writeBytes(line, literalStart, len);
798
- this.pendingLineBreak = !softBreak;
1005
+ this.pendingLineBreak = softBreak ? null : lineBreak;
799
1006
  }
800
1007
  // see PassThroughDecoder.finalize
801
1008
  finalize(boundaryEnded = false) {
802
- if (!boundaryEnded && this.pendingLineBreak) {
803
- this.writeByte(CHR_LF);
1009
+ if (!boundaryEnded) {
1010
+ this.writeLineBreak();
804
1011
  }
805
1012
  this.flushBuffer();
806
1013
  // convert an array of arraybuffers into a blob and then back into a single arraybuffer
@@ -812,6 +1019,10 @@ class QPDecoder {
812
1019
  // kept as a character instead of being swallowed. A stripped BOM turns a line a strict
813
1020
  // parser skips into a genuine header, which is how a second `From:` gets smuggled past
814
1021
  // anything that inspects the raw message.
1022
+ //
1023
+ // Raw 8-bit header text is UTF-8 (RFC 6532), and a line that is not is legacy mail in a
1024
+ // single byte charset, read as windows-1252 rather than as replacement characters, see
1025
+ // decodeUtf8OrWindows1252.
815
1026
  const headerDecoder = new TextDecoder('utf-8', { ignoreBOM: true });
816
1027
  // Trims only the whitespace RFC 5322 allows around a field name. String.prototype.trim
817
1028
  // also strips U+00A0, U+FEFF, U+2028 and the rest of the Unicode spaces, which turns a
@@ -823,7 +1034,6 @@ const headerDecoder = new TextDecoder('utf-8', { ignoreBOM: true });
823
1034
  // An index scan rather than `/^[ \t]+|[ \t]+$/g`, which retries the trailing branch at
824
1035
  // every position of a blank run that is followed by other text, so a single header with
825
1036
  // a long run of spaces in the middle took seconds to trim.
826
- const isWsp = (c) => c === 0x20 || c === 0x09;
827
1037
  const trimWsp = (str) => {
828
1038
  let start = 0;
829
1039
  let end = str.length;
@@ -835,6 +1045,12 @@ const trimWsp = (str) => {
835
1045
  }
836
1046
  return str.slice(start, end);
837
1047
  };
1048
+ // A line of nothing but SP and HTAB, see feed
1049
+ const isBlankLine = (line) => line.every(isWsp);
1050
+ // The start of a header field: a field name of printable ASCII other than the colon
1051
+ // (RFC 5322 3.6.8 ftext) and the colon itself, with the whitespace in between that
1052
+ // processHeaders tolerates as well
1053
+ const FIELD_START = /^[!-9;-~]+[ \t]*:/;
838
1054
  // Headers that decide how this part's body is read, see processHeaders
839
1055
  const CONTENT_HEADERS = new Set([
840
1056
  'content-type',
@@ -847,6 +1063,12 @@ class MimeNode {
847
1063
  constructor(options) {
848
1064
  this.options = options;
849
1065
  this.postalMime = options.postalMime;
1066
+ // Counted across the whole message like the header size, so that a multipart can
1067
+ // not carry the budget again for every part it declares
1068
+ this.postalMime.partCount++;
1069
+ if (this.postalMime.partCount > options.maxPartCount) {
1070
+ throw new Error(`Maximum MIME part count of ${options.maxPartCount} parts exceeded`);
1071
+ }
850
1072
  this.childNodes = [];
851
1073
  // Cursor into childNodes for finalizeChildNodes. Every new part of a multipart
852
1074
  // finalizes its parent's children, so re-walking the whole array each time is
@@ -865,6 +1087,7 @@ class MimeNode {
865
1087
  }
866
1088
  this.state = 'header';
867
1089
  this.headerLines = [];
1090
+ this.lastLineBlank = false;
868
1091
  // RFC 2046 Section 5.1.5: multipart/digest defaults to message/rfc822
869
1092
  const parentMultipartType = options.parentMultipartType || null;
870
1093
  const defaultContentType = parentMultipartType === 'digest' ? 'message/rfc822' : 'text/plain';
@@ -906,7 +1129,7 @@ class MimeNode {
906
1129
  return;
907
1130
  }
908
1131
  if (this.state === 'header') {
909
- this.processHeaders();
1132
+ this.endHeaders();
910
1133
  }
911
1134
  // remove self from boundary listing
912
1135
  let boundaries = this.postalMime.boundaries;
@@ -1157,55 +1380,51 @@ class MimeNode {
1157
1380
  decodeParameterValueContinuations(response);
1158
1381
  return response;
1159
1382
  }
1383
+ // Unfolds format=flowed text (RFC 3676 4.1). A line is read as its quote marks, one
1384
+ // space of stuffing and its content, in that order, and a line whose content ends in a
1385
+ // space is flowed: the next line of the same quote depth continues its paragraph. The
1386
+ // paragraph ends at a fixed line, at a change of quote depth and at the signature
1387
+ // separator, which is neither flowed nor fixed. Joining a quoted paragraph used to
1388
+ // leave the quote marks of the continuation lines inside the text, so a quoted reply
1389
+ // read `> one > two` instead of `> one two`.
1390
+ //
1391
+ // The quote marks and stuffing of the first line of a paragraph are kept as they were
1392
+ // written, so a fixed quoted line comes out exactly as it went in. An unquoted stuffed
1393
+ // line loses its stuffing, which is all the stuffing is for.
1160
1394
  decodeFlowedText(str, delSp) {
1161
- // Pieces of the result, joined once at the end. Growing a single string and
1162
- // calling endsWith() on it for every line flattens the whole paragraph again on
1163
- // each line, which is quadratic in the length of a paragraph.
1164
- // Empty pieces are never stored, so the last piece always holds the last
1165
- // character of the result.
1395
+ // Pieces of the result, joined once at the end. Growing a single string instead
1396
+ // flattens the whole paragraph again on each line, which is quadratic in the
1397
+ // length of a paragraph.
1166
1398
  const parts = [];
1167
- // The unfolded line being built, ie. everything after the last hard line break,
1168
- // starts at this index of parts and is this many characters long
1169
- let lineStart = 0;
1170
- let lineLength = 0;
1171
- const lines = str.split(/\r?\n/);
1399
+ // whether the previous line was flowed, and the quote depth of its paragraph
1400
+ let flowed = false;
1401
+ let depth = 0;
1402
+ const lines = str.split('\n');
1172
1403
  for (let i = 0; i < lines.length; i++) {
1173
- let line = lines[i];
1174
- // remove whitespace stuffing before anything else
1175
- // http://tools.ietf.org/html/rfc3676#section-4.4
1176
- // doing it after the join leaves the stuffed space of a continuation line
1177
- // sitting in the middle of the joined paragraph
1178
- if (line.charAt(0) === ' ') {
1179
- line = line.slice(1);
1404
+ const line = lines[i];
1405
+ let quoteDepth = 0;
1406
+ while (line.charCodeAt(quoteDepth) === 0x3e /* > */) {
1407
+ quoteDepth++;
1180
1408
  }
1181
- if (i) {
1182
- const last = parts.length ? parts[parts.length - 1] : '';
1183
- // soft linebreaks are added after space symbols, except for the signature
1184
- // separator which is a line of its own
1185
- const isSignature = lineLength === 3 && parts.slice(lineStart).join('') === '-- ';
1186
- if (last.endsWith(' ') && !isSignature) {
1187
- if (delSp) {
1188
- // delsp adds space to text to be able to fold it
1189
- // these spaces can be removed once the text is unfolded
1190
- if (last.length > 1) {
1191
- parts[parts.length - 1] = last.slice(0, -1);
1192
- }
1193
- else {
1194
- parts.pop();
1195
- }
1196
- lineLength--;
1197
- }
1409
+ const contentStart = line.charCodeAt(quoteDepth) === 0x20 ? quoteDepth + 1 : quoteDepth;
1410
+ const content = line.slice(contentStart);
1411
+ const isSignature = content === '-- ';
1412
+ if (flowed && quoteDepth === depth && !isSignature) {
1413
+ if (delSp) {
1414
+ // delsp adds the space to make the text foldable, so it is not content
1415
+ const last = parts[parts.length - 1];
1416
+ parts[parts.length - 1] = last.slice(0, -1);
1198
1417
  }
1199
- else {
1418
+ parts.push(content);
1419
+ }
1420
+ else {
1421
+ if (i) {
1200
1422
  parts.push('\n');
1201
- lineStart = parts.length;
1202
- lineLength = 0;
1203
1423
  }
1424
+ parts.push(quoteDepth ? line : content);
1204
1425
  }
1205
- if (line) {
1206
- parts.push(line);
1207
- lineLength += line.length;
1208
- }
1426
+ flowed = !isSignature && content.endsWith(' ');
1427
+ depth = quoteDepth;
1209
1428
  }
1210
1429
  return parts.join('');
1211
1430
  }
@@ -1213,7 +1432,10 @@ class MimeNode {
1213
1432
  if (!this.content) {
1214
1433
  return '';
1215
1434
  }
1216
- let str = getDecoder(this.contentType.parsed.params.charset).decode(this.content);
1435
+ // The bytes keep the line endings the part was sent with, see PassThroughDecoder,
1436
+ // which differ from one transfer encoding to the next. Text is normalized to LF so
1437
+ // that a consumer does not get CRLF from one message and LF from another
1438
+ let str = decodeText(this.content, this.contentType.parsed.params.charset, this.contentType.parsed.value === 'text/html').replace(/\r\n/g, '\n');
1217
1439
  if (/^flowed$/i.test(this.contentType.parsed.params.format)) {
1218
1440
  str = this.decodeFlowedText(str, /^yes$/i.test(this.contentType.parsed.params.delsp));
1219
1441
  }
@@ -1259,10 +1481,8 @@ class MimeNode {
1259
1481
  let unfoldedLine = parts.join('');
1260
1482
  sep = unfoldedLine.indexOf(':');
1261
1483
  let key = trimWsp(sep < 0 ? unfoldedLine : unfoldedLine.slice(0, sep));
1262
- // A bare CR is not legal in a field body. It used to be folded into a space by
1263
- // the whitespace collapse, and passing it through would hand consumers that
1264
- // write the value back out a line of their own.
1265
- let value = sep < 0 ? '' : trimWsp(unfoldedLine.slice(sep + 1).replace(/[\r\n]+/g, ' '));
1484
+ // A bare CR is not legal in a field body, see foldLineBreaks
1485
+ let value = sep < 0 ? '' : trimWsp(foldLineBreaks(unfoldedLine.slice(sep + 1)));
1266
1486
  this.headers.push({ key: key.toLowerCase(), originalKey: key, value });
1267
1487
  // A header that decides how the body is read must resolve the same way every
1268
1488
  // time it is duplicated, otherwise a message can present one Content-Type to a
@@ -1294,7 +1514,15 @@ class MimeNode {
1294
1514
  this.contentType.multipart = /^multipart\//i.test(this.contentType.parsed.value)
1295
1515
  ? this.contentType.parsed.value.slice(this.contentType.parsed.value.indexOf('/') + 1)
1296
1516
  : false;
1297
- if (this.contentType.multipart && this.contentType.parsed.params.boundary) {
1517
+ if (this.contentType.multipart && !this.contentType.parsed.params.boundary) {
1518
+ // A multipart without the boundary RFC 2046 5.1.1 requires has no parts to find,
1519
+ // and its body used to be dropped without a trace. RFC 2045 5.2 recommends
1520
+ // reading an invalid Content-Type as plain text, which is also what the body of
1521
+ // such a message usually is
1522
+ this.contentType.parsed.value = 'text/plain';
1523
+ this.contentType.multipart = false;
1524
+ }
1525
+ if (this.contentType.multipart) {
1298
1526
  // add self to boundary terminator listing
1299
1527
  this.postalMime.boundaries.push({
1300
1528
  value: textEncoder.encode(this.contentType.parsed.params.boundary),
@@ -1311,13 +1539,20 @@ class MimeNode {
1311
1539
  .match(/[\w-]+/) || [''])[0];
1312
1540
  this.setupContentDecoder(this.contentTransferEncoding.encoding);
1313
1541
  }
1314
- feed(line) {
1542
+ // Ends the header section and sets up the body
1543
+ endHeaders() {
1544
+ this.state = 'body';
1545
+ this.processHeaders();
1546
+ }
1547
+ // Returns true when the line was not consumed: the headers ended with the line before
1548
+ // it, and the parser has to read it again, since the boundary this part declares was
1549
+ // not known yet when the line was checked for one
1550
+ feed(line, lineBreak) {
1315
1551
  switch (this.state) {
1316
- case 'header':
1552
+ case 'header': {
1317
1553
  if (!line.length) {
1318
- this.state = 'body';
1319
- this.processHeaders();
1320
- return;
1554
+ this.endHeaders();
1555
+ return false;
1321
1556
  }
1322
1557
  // Counted across the whole message, not per part. A per-node budget lets a
1323
1558
  // multipart carry the limit again for every part it declares, so a message
@@ -1327,17 +1562,34 @@ class MimeNode {
1327
1562
  let error = new Error(`Maximum header size of ${this.options.maxHeadersSize} bytes exceeded`);
1328
1563
  throw error;
1329
1564
  }
1330
- this.headerLines.push(headerDecoder.decode(line));
1565
+ const text = decodeUtf8OrWindows1252(line, headerDecoder);
1566
+ // A line of nothing but whitespace is legal folding (RFC 5322 4.2, obs-FWS),
1567
+ // but broken generators also put one where the empty line that ends the
1568
+ // headers belongs, and reading it as a fold then turned the whole body into
1569
+ // one more header. So the next line decides what it was: a continuation or
1570
+ // another header keep it a fold, anything else means the headers ended with
1571
+ // it, and it is taken back out of the header lines. Python and Go read such
1572
+ // a line as the end of the headers outright, so the body is where the other
1573
+ // parsers find it.
1574
+ if (this.lastLineBlank && !isWsp(line[0]) && !FIELD_START.test(text)) {
1575
+ this.headerLines.pop();
1576
+ this.endHeaders();
1577
+ return true;
1578
+ }
1579
+ this.headerLines.push(text);
1580
+ this.lastLineBlank = isBlankLine(line);
1331
1581
  break;
1582
+ }
1332
1583
  case 'body':
1333
1584
  // add line to body. processHeaders installs a decoder before the state
1334
1585
  // becomes body, and the only time the decoder is released is finalize,
1335
1586
  // which moves the state on to finished, so the guard only narrows the type
1336
1587
  if (this.contentDecoder) {
1337
- this.contentDecoder.update(line);
1588
+ this.contentDecoder.update(line, lineBreak);
1338
1589
  }
1339
1590
  break;
1340
1591
  }
1592
+ return false;
1341
1593
  }
1342
1594
  }
1343
1595
 
@@ -3576,6 +3828,39 @@ const htmlEntities = {
3576
3828
  '&zwnj;': '\u200C'
3577
3829
  };
3578
3830
 
3831
+ // What a numeric character reference to the C1 control range stands for, per the HTML
3832
+ // numeric character reference end state: the windows-1252 character at that byte. This is
3833
+ // how Outlook and Word write their curly quotes and dashes (`&#146;`, `&#150;`), and the
3834
+ // raw control character they decoded to otherwise is invisible in the text.
3835
+ const C1_ENTITY_REPLACEMENTS = new Map([
3836
+ [0x80, 0x20ac],
3837
+ [0x82, 0x201a],
3838
+ [0x83, 0x0192],
3839
+ [0x84, 0x201e],
3840
+ [0x85, 0x2026],
3841
+ [0x86, 0x2020],
3842
+ [0x87, 0x2021],
3843
+ [0x88, 0x02c6],
3844
+ [0x89, 0x2030],
3845
+ [0x8a, 0x0160],
3846
+ [0x8b, 0x2039],
3847
+ [0x8c, 0x0152],
3848
+ [0x8e, 0x017d],
3849
+ [0x91, 0x2018],
3850
+ [0x92, 0x2019],
3851
+ [0x93, 0x201c],
3852
+ [0x94, 0x201d],
3853
+ [0x95, 0x2022],
3854
+ [0x96, 0x2013],
3855
+ [0x97, 0x2014],
3856
+ [0x98, 0x02dc],
3857
+ [0x99, 0x2122],
3858
+ [0x9a, 0x0161],
3859
+ [0x9b, 0x203a],
3860
+ [0x9c, 0x0153],
3861
+ [0x9e, 0x017e],
3862
+ [0x9f, 0x0178]
3863
+ ]);
3579
3864
  function decodeHTMLEntities(str) {
3580
3865
  return str.replace(/&(#\d+|#x[a-f0-9]+|[a-z]+\d*);?/gi, (match, entity) => {
3581
3866
  if (typeof htmlEntities[match] === 'string') {
@@ -3586,19 +3871,23 @@ function decodeHTMLEntities(str) {
3586
3871
  return match;
3587
3872
  }
3588
3873
  let codePoint;
3589
- if (entity.charAt(1) === 'x') {
3590
- // hex
3874
+ // The pattern matches the marker in either case, so the test has to as well. Reading
3875
+ // `&#X41;` as decimal gave NaN, which passes every range check below and comes out
3876
+ // of String.fromCharCode as a NUL character
3877
+ if (entity.charAt(1).toLowerCase() === 'x') {
3591
3878
  codePoint = parseInt(entity.slice(2), 16);
3592
3879
  }
3593
3880
  else {
3594
- // dec
3595
3881
  codePoint = parseInt(entity.slice(1), 10);
3596
3882
  }
3597
3883
  let output = '';
3598
- if ((codePoint >= 0xd800 && codePoint <= 0xdfff) || codePoint > 0x10ffff) {
3599
- // Invalid range, return a replacement character instead
3884
+ // A NUL reference, a surrogate or a code point out of range is a replacement
3885
+ // character, like in a browser. A NUL in particular would truncate the text in
3886
+ // whatever reads it next
3887
+ if (codePoint === 0 || (codePoint >= 0xd800 && codePoint <= 0xdfff) || codePoint > 0x10ffff) {
3600
3888
  return '\ufffd';
3601
3889
  }
3890
+ codePoint = C1_ENTITY_REPLACEMENTS.get(codePoint) ?? codePoint;
3602
3891
  if (codePoint > 0xffff) {
3603
3892
  codePoint -= 0x10000;
3604
3893
  output += String.fromCharCode(((codePoint >>> 10) & 0x3ff) | 0xd800);
@@ -3697,13 +3986,17 @@ function replaceTags(str, prefix, pattern, replacement) {
3697
3986
  break;
3698
3987
  }
3699
3988
  const start = candidate.index;
3989
+ // Looked up before the pattern runs. The engine finds out on its own that there is
3990
+ // no `>` to close on, but only after it has retried the tail of the pattern at every
3991
+ // `href` in the rest of the input and rescanned to the end each time, which made an
3992
+ // html part of many unclosed `<a href` tags quadratic to convert
3993
+ const gt = str.indexOf('>', start);
3994
+ if (gt < 0) {
3995
+ break;
3996
+ }
3700
3997
  pattern.lastIndex = start;
3701
3998
  const match = pattern.exec(str);
3702
3999
  if (!match) {
3703
- const gt = str.indexOf('>', start);
3704
- if (gt < 0) {
3705
- break;
3706
- }
3707
4000
  searchFrom = gt + 1;
3708
4001
  continue;
3709
4002
  }
@@ -4729,7 +5022,11 @@ class Tokenizer {
4729
5022
  this.escaped = false;
4730
5023
  return;
4731
5024
  }
4732
- else if (this.operatorExpecting === '"' && chr === '\\') {
5025
+ else if ((this.operatorExpecting === '"' || this.operatorExpecting === ')') && chr === '\\') {
5026
+ // A backslash escapes the next character inside a quoted string and inside a
5027
+ // comment alike (RFC 5322 3.2.2, ccontent includes quoted-pair). Honouring it
5028
+ // only in quoted strings let an escaped parenthesis close the comment, which
5029
+ // released the rest of it, an address included, into the header
4733
5030
  this.escaped = true;
4734
5031
  return;
4735
5032
  }
@@ -4914,8 +5211,14 @@ function renderEntry(textEntry, textType, convert) {
4914
5211
  }
4915
5212
  return textType === 'html' ? textToHtml(textEntry.value) : htmlToText(textEntry.value);
4916
5213
  }
5214
+ // The line break given to the last line of a message that ends without one, see readLine
5215
+ const LINE_FEED = new Uint8Array([0x0a]);
4917
5216
  const MAX_NESTING_DEPTH = 256;
4918
5217
  const MAX_HEADERS_SIZE = 2 * 1024 * 1024;
5218
+ // The depth limit does not bound the size of the tree. An empty part costs six bytes of
5219
+ // input (`--b` and a blank line) and over a kilobyte of parser state at its peak, so a
5220
+ // flat multipart of a million empty parts took a 7 MB message to 1.4 GB of memory.
5221
+ const MAX_PART_COUNT = 10000;
4919
5222
  // Inline message/rfc822 parts are parsed recursively. Without a dedicated limit
4920
5223
  // each nesting level spawns a new parser that retains the full nested message,
4921
5224
  // so a small crafted email can exhaust memory (OOM crash). Cap the recursion and
@@ -4953,7 +5256,8 @@ class PostalMime {
4953
5256
  this.options = options || {};
4954
5257
  this.mimeOptions = {
4955
5258
  maxNestingDepth: parseLimitOption(this.options.maxNestingDepth, MAX_NESTING_DEPTH, 'maxNestingDepth'),
4956
- maxHeadersSize: parseLimitOption(this.options.maxHeadersSize, MAX_HEADERS_SIZE, 'maxHeadersSize')
5259
+ maxHeadersSize: parseLimitOption(this.options.maxHeadersSize, MAX_HEADERS_SIZE, 'maxHeadersSize'),
5260
+ maxPartCount: parseLimitOption(this.options.maxPartCount, MAX_PART_COUNT, 'maxPartCount')
4957
5261
  };
4958
5262
  // A limit of 0 disables inline parsing entirely, so every message/rfc822 part
4959
5263
  // becomes an attachment.
@@ -4962,13 +5266,15 @@ class PostalMime {
4962
5266
  // It is deliberately not an option, so that forwarding a caller supplied options
4963
5267
  // object can not seed it and switch the recursion limit off.
4964
5268
  this.rfc822NestingDepth = 0;
5269
+ // Header bytes seen across every part of this message, see MimeNode.feed, and the
5270
+ // number of parts created for it, the root included, see the MimeNode constructor
5271
+ this.headerSize = 0;
5272
+ this.partCount = 0;
4965
5273
  this.root = this.currentNode = new MimeNode({
4966
5274
  postalMime: this,
4967
5275
  ...this.mimeOptions
4968
5276
  });
4969
5277
  this.boundaries = [];
4970
- // Header bytes seen across every part of this message, see MimeNode.feed
4971
- this.headerSize = 0;
4972
5278
  this.textContent = {};
4973
5279
  this.textMap = new Map();
4974
5280
  this.textTypes = new Set();
@@ -4988,7 +5294,7 @@ class PostalMime {
4988
5294
  await this.root.finalize();
4989
5295
  }
4990
5296
  /** @internal */
4991
- async processLine(line, isFinal) {
5297
+ async processLine(line, lineBreak, isFinal) {
4992
5298
  let boundaries = this.boundaries;
4993
5299
  // check if this is a mime boundary
4994
5300
  if (boundaries.length && line.length > 2 && line[0] === 0x2d && line[1] === 0x2d) {
@@ -5022,7 +5328,7 @@ class PostalMime {
5022
5328
  // RFC 2046: boundary line may have trailing whitespace (space/tab) before CRLF
5023
5329
  let hasValidTrailing = true;
5024
5330
  for (let j = boundaryEnd; j < line.length; j++) {
5025
- if (line[j] !== 0x20 && line[j] !== 0x09) {
5331
+ if (!isWsp(line[j])) {
5026
5332
  hasValidTrailing = false;
5027
5333
  break;
5028
5334
  }
@@ -5050,11 +5356,18 @@ class PostalMime {
5050
5356
  return;
5051
5357
  }
5052
5358
  }
5053
- this.currentNode.feed(line);
5359
+ if (this.currentNode.feed(line, lineBreak)) {
5360
+ // The headers ended with the line before this one, so the boundary this part
5361
+ // declares was not registered when the line was checked above. Read it again
5362
+ return this.processLine(line, lineBreak, isFinal);
5363
+ }
5054
5364
  if (isFinal) {
5055
5365
  return this.finalize();
5056
5366
  }
5057
5367
  }
5368
+ // Reads the next line without its line break. The break itself, the exact bytes between
5369
+ // the line and the next one, is returned alongside, because the body decoders write it
5370
+ // back out: a part has to keep the line endings it was sent with, see PassThroughDecoder.
5058
5371
  /** @internal */
5059
5372
  readLine() {
5060
5373
  let startPos = this.readPos;
@@ -5067,12 +5380,16 @@ class PostalMime {
5067
5380
  if (c === 0x0a) {
5068
5381
  return {
5069
5382
  bytes: new Uint8Array(this.buf, startPos, endPos - startPos),
5383
+ lineBreak: this.av.subarray(endPos, this.readPos),
5070
5384
  done: this.readPos >= this.av.length
5071
5385
  };
5072
5386
  }
5073
5387
  }
5388
+ // A message that ends without a line break still gets one after its last line, the
5389
+ // decoders write it out unless a boundary ended the part
5074
5390
  return {
5075
5391
  bytes: new Uint8Array(this.buf, startPos, endPos - startPos),
5392
+ lineBreak: LINE_FEED,
5076
5393
  done: this.readPos >= this.av.length
5077
5394
  };
5078
5395
  }
@@ -5153,7 +5470,9 @@ class PostalMime {
5153
5470
  const filename = node.contentDisposition.parsed.params.filename || node.contentType.parsed.params.name || null;
5154
5471
  // `content` is filled in below once the part type is known, hence the cast
5155
5472
  const attachment = {
5156
- filename: filename ? decodeWords(filename) : null,
5473
+ // An RFC 2231 filename is percent decoded without folding, since the same decoder
5474
+ // produces the boundary, so the fold happens here, see foldLineBreaks
5475
+ filename: filename ? decodeWords(foldLineBreaks(filename)) : null,
5157
5476
  mimeType: node.contentType.parsed.value,
5158
5477
  disposition: node.contentDisposition.parsed.value || null
5159
5478
  };
@@ -5186,7 +5505,7 @@ class PostalMime {
5186
5505
  // Enforce into unicode, ending in exactly one newline. The trailing
5187
5506
  // newlines are counted rather than replaced with `/\n*$/`, which
5188
5507
  // retries at every newline of a run that does not end the text.
5189
- const decodedText = node.getTextContent().replace(/\r?\n/g, '\n');
5508
+ const decodedText = node.getTextContent();
5190
5509
  let end = decodedText.length;
5191
5510
  while (end > 0 && decodedText.charCodeAt(end - 1) === 0x0a) {
5192
5511
  end--;
@@ -5435,7 +5754,7 @@ class PostalMime {
5435
5754
  this.readPos = 0;
5436
5755
  while (this.readPos < this.av.length) {
5437
5756
  const line = this.readLine();
5438
- await this.processLine(line.bytes, line.done);
5757
+ await this.processLine(line.bytes, line.lineBreak, line.done);
5439
5758
  }
5440
5759
  this.forceRfc822Attachments = Boolean(this.options.forceRfc822Attachments) || this.hasReportParts();
5441
5760
  await this.collectNode(this.root, false, false);