@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.
- package/CHANGELOG.md +16 -0
- package/dist/cjs/lime-elements.cjs.js +1 -1
- package/dist/cjs/limel-dock-button.cjs.entry.js +2 -4
- package/dist/cjs/limel-file-viewer.cjs.entry.js +476 -116
- package/dist/cjs/limel-markdown.cjs.entry.js +2 -2
- package/dist/cjs/limel-prosemirror-adapter.cjs.entry.js +1 -1
- package/dist/cjs/limel-tab-bar.cjs.entry.js +37 -14
- package/dist/cjs/limel-tab-panel.cjs.entry.js +7 -2
- package/dist/cjs/loader.cjs.js +1 -1
- package/dist/cjs/{markdown-exporter-C-UW43FF.js → markdown-exporter-VtX_2D1d.js} +40 -11
- package/dist/cjs/{markdown-parser-ssDij8_l.js → markdown-parser-BYtTHL4W.js} +11 -3
- package/dist/collection/components/dock/dock-button/dock-button.css +13 -0
- package/dist/collection/components/dock/dock-button/dock-button.js +1 -3
- package/dist/collection/components/tab-bar/tab-bar.css +79 -27
- package/dist/collection/components/tab-bar/tab-bar.js +60 -15
- package/dist/collection/components/tab-panel/tab-panel.css +9 -0
- package/dist/collection/components/tab-panel/tab-panel.js +27 -1
- package/dist/esm/lime-elements.js +1 -1
- package/dist/esm/limel-dock-button.entry.js +2 -4
- package/dist/esm/limel-file-viewer.entry.js +476 -116
- package/dist/esm/limel-markdown.entry.js +2 -2
- package/dist/esm/limel-prosemirror-adapter.entry.js +1 -1
- package/dist/esm/limel-tab-bar.entry.js +37 -14
- package/dist/esm/limel-tab-panel.entry.js +7 -2
- package/dist/esm/loader.js +1 -1
- package/dist/esm/{markdown-exporter-DblYRMhY.js → markdown-exporter-tb8ft8Gq.js} +40 -11
- package/dist/esm/{markdown-parser-CAPDx7i8.js → markdown-parser-DFP65EFu.js} +11 -3
- package/dist/lime-elements/lime-elements.esm.js +1 -1
- package/dist/lime-elements/p-14a28a08.entry.js +1 -0
- package/dist/lime-elements/{p-22fd1c87.entry.js → p-21ab902a.entry.js} +1 -1
- package/dist/lime-elements/p-6e3a4482.entry.js +1 -0
- package/dist/lime-elements/p-9088acea.entry.js +1 -0
- package/dist/lime-elements/{p-05038bbb.entry.js → p-91a572c8.entry.js} +1 -1
- package/dist/lime-elements/p-Bgn2bikC.js +1 -0
- package/dist/lime-elements/p-C0wwdPYT.js +7 -0
- package/dist/lime-elements/p-aa897903.entry.js +1 -0
- package/dist/types/components/tab-bar/tab-bar.d.ts +17 -4
- package/dist/types/components/tab-panel/tab-panel.d.ts +6 -0
- package/dist/types/components.d.ts +48 -10
- package/package.json +6 -6
- package/dist/lime-elements/p-2e33d2a4.entry.js +0 -1
- package/dist/lime-elements/p-Bvyt2bmV.js +0 -1
- package/dist/lime-elements/p-I0PrvthC.js +0 -7
- package/dist/lime-elements/p-a550ba18.entry.js +0 -1
- package/dist/lime-elements/p-b35a912c.entry.js +0 -1
- 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
|
-
|
|
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)
|
|
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
|
|
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
|
|
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
|
-
|
|
656
|
-
this.
|
|
657
|
-
this.
|
|
658
|
-
}
|
|
659
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
1119
|
-
//
|
|
1120
|
-
//
|
|
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
|
-
//
|
|
1125
|
-
|
|
1126
|
-
let
|
|
1127
|
-
|
|
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
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
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
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1163
|
-
|
|
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
|
-
|
|
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
|
|
1220
|
-
|
|
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
|
-
|
|
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.
|
|
1276
|
-
|
|
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
|
-
|
|
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
|
'‌': '\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 (`’`, `–`), 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
|
-
|
|
3547
|
-
|
|
3872
|
+
// The pattern matches the marker in either case, so the test has to as well. Reading
|
|
3873
|
+
// `A` 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
|
-
|
|
3556
|
-
|
|
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]
|
|
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
|
-
|
|
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()
|
|
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);
|