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