nodemailer 10.1.5 → 10.1.7
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 +14 -0
- package/dist/cjs/addressparser/index.js +233 -206
- package/dist/cjs/mime-funcs/index.js +8 -2
- package/dist/cjs/package-info.d.ts +1 -1
- package/dist/cjs/package-info.js +1 -1
- package/dist/esm/addressparser/index.js +233 -206
- package/dist/esm/mime-funcs/index.js +8 -2
- package/dist/esm/package-info.d.ts +1 -1
- package/dist/esm/package-info.js +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# CHANGELOG
|
|
2
2
|
|
|
3
|
+
## [10.1.7](https://github.com/nodemailer/nodemailer/compare/v10.1.6...v10.1.7) (2026-10-18)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* **mime-funcs:** encode parameter values that look like encoded words ([651d79b](https://github.com/nodemailer/nodemailer/commit/651d79b0e1067b93b0ae8fc1afbdf383a46c214d))
|
|
9
|
+
|
|
10
|
+
## [10.1.6](https://github.com/nodemailer/nodemailer/compare/v10.1.5...v10.1.6) (2026-10-18)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Bug Fixes
|
|
14
|
+
|
|
15
|
+
* **addressparser:** read groups in the same pass as the rest of the list ([ddf0de1](https://github.com/nodemailer/nodemailer/commit/ddf0de1e7615908101bf593c62edcadccc05c8d3))
|
|
16
|
+
|
|
3
17
|
## [10.1.5](https://github.com/nodemailer/nodemailer/compare/v10.1.4...v10.1.5) (2026-10-18)
|
|
4
18
|
|
|
5
19
|
|
|
@@ -532,22 +532,19 @@ function _stripAddressComments(address) {
|
|
|
532
532
|
return { address: result.trim(), comments: comments.filter(text => text) };
|
|
533
533
|
}
|
|
534
534
|
/**
|
|
535
|
-
*
|
|
535
|
+
* Walks the tokens of one entry of an address list and sorts them into the runs of display
|
|
536
|
+
* text, angle-addr and comment they belong to
|
|
536
537
|
*
|
|
537
538
|
* @param tokens Tokens of the whole address list
|
|
538
|
-
* @param from Index of the first token of
|
|
539
|
-
* @param to Index just past the last token of
|
|
540
|
-
* @
|
|
541
|
-
* @return Address object
|
|
539
|
+
* @param from Index of the first token of the entry
|
|
540
|
+
* @param to Index just past the last token of the entry
|
|
541
|
+
* @return The collected runs, and whether the display text came out of a comment
|
|
542
542
|
*/
|
|
543
|
-
function
|
|
544
|
-
let isGroup = false;
|
|
543
|
+
function _collectAddressParts(tokens, from, to) {
|
|
545
544
|
let state = 'text';
|
|
546
|
-
const addresses = [];
|
|
547
545
|
const data = {
|
|
548
546
|
address: [],
|
|
549
547
|
comment: [],
|
|
550
|
-
group: [],
|
|
551
548
|
text: [],
|
|
552
549
|
textWasQuoted: [],
|
|
553
550
|
quotedLocalAt: []
|
|
@@ -561,7 +558,7 @@ function _handleAddress(tokens, from, to, depth) {
|
|
|
561
558
|
// every token, which is quadratic over an address built from many comment-joined atoms
|
|
562
559
|
// (GHSA-prgh-xp8r-p3m5). A run only ever grows by the token appended below, so the
|
|
563
560
|
// character is carried along instead of re-read.
|
|
564
|
-
const lastChars = { address: '', comment: '',
|
|
561
|
+
const lastChars = { address: '', comment: '', text: '' };
|
|
565
562
|
// Filter out <addresses>, (comments) and regular text
|
|
566
563
|
for (let i = from; i < to; i++) {
|
|
567
564
|
const value = tokens.value(i);
|
|
@@ -578,11 +575,6 @@ function _handleAddress(tokens, from, to, depth) {
|
|
|
578
575
|
state = 'comment';
|
|
579
576
|
insideQuotes = false;
|
|
580
577
|
break;
|
|
581
|
-
case ':':
|
|
582
|
-
state = 'group';
|
|
583
|
-
isGroup = true;
|
|
584
|
-
insideQuotes = false;
|
|
585
|
-
break;
|
|
586
578
|
case '"':
|
|
587
579
|
insideQuotes = !insideQuotes;
|
|
588
580
|
state = 'text';
|
|
@@ -643,153 +635,156 @@ function _handleAddress(tokens, from, to, depth) {
|
|
|
643
635
|
data.comment = [];
|
|
644
636
|
textFromComment = true;
|
|
645
637
|
}
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
638
|
+
return { data, textFromComment };
|
|
639
|
+
}
|
|
640
|
+
/**
|
|
641
|
+
* Name of a group, from the tokens in front of its ':'. A comment names it when no text does,
|
|
642
|
+
* as it names a mailbox.
|
|
643
|
+
*
|
|
644
|
+
* @param tokens Tokens of the whole address list
|
|
645
|
+
* @param from Index of the first token in front of the ':'
|
|
646
|
+
* @param to Index of the ':'
|
|
647
|
+
* @return Name of the group
|
|
648
|
+
*/
|
|
649
|
+
function _groupName(tokens, from, to) {
|
|
650
|
+
// http://tools.ietf.org/html/rfc2822#appendix-A.1.3
|
|
651
|
+
return _collectAddressParts(tokens, from, to).data.text.join(' ');
|
|
652
|
+
}
|
|
653
|
+
/**
|
|
654
|
+
* Converts tokens for a single address into an address object
|
|
655
|
+
*
|
|
656
|
+
* @param tokens Tokens of the whole address list
|
|
657
|
+
* @param from Index of the first token of this address
|
|
658
|
+
* @param to Index just past the last token of this address
|
|
659
|
+
* @return Address object
|
|
660
|
+
*/
|
|
661
|
+
function _handleAddress(tokens, from, to) {
|
|
662
|
+
const collected = _collectAddressParts(tokens, from, to);
|
|
663
|
+
const data = collected.data;
|
|
664
|
+
let textFromComment = collected.textFromComment;
|
|
665
|
+
// Comments come out of the angle-addr before anything asks whether one was found, so
|
|
666
|
+
// that brackets holding nothing but a comment read the same as empty ones
|
|
667
|
+
const addressComments = [];
|
|
668
|
+
const addressParts = [];
|
|
669
|
+
for (const part of data.address) {
|
|
670
|
+
const stripped = _stripAddressComments(part);
|
|
671
|
+
for (const comment of stripped.comments) {
|
|
672
|
+
addressComments.push(comment);
|
|
673
|
+
}
|
|
674
|
+
if (stripped.address) {
|
|
675
|
+
addressParts.push(stripped.address);
|
|
661
676
|
}
|
|
662
|
-
addresses.push({
|
|
663
|
-
name: data.text || '',
|
|
664
|
-
group: groupMembers
|
|
665
|
-
});
|
|
666
677
|
}
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
678
|
+
data.address = addressParts;
|
|
679
|
+
// If no address was found, try to detect one from regular text
|
|
680
|
+
if (!data.address.length && data.text.length && !textFromComment) {
|
|
681
|
+
// Whitespace beside the '@' is CFWS, so 'user @ good.com' is read as one run. Both
|
|
682
|
+
// tiers read the collapsed runs, and only a run an address comes out of is replaced
|
|
683
|
+
// by its collapsed form, the display text keeps its spacing
|
|
684
|
+
const candidates = data.text.map((part) => _collapseAtSpacing(part));
|
|
685
|
+
// The first addr-spec in the text is the mailbox, as it is for a conformant parser
|
|
686
|
+
// reading as far as it can. Taking the last one let a comment part two of them and
|
|
687
|
+
// pick the second, 'user@good.com (x) evil@evil.com' went to evil.com, while the
|
|
688
|
+
// same two without the comment, one run, went to good.com
|
|
689
|
+
for (let i = 0; i < data.text.length; i++) {
|
|
690
|
+
// Security: Do not extract email addresses from quoted strings.
|
|
691
|
+
// RFC 5321 allows @ inside quoted local-parts like "user@domain"@example.com.
|
|
692
|
+
// Extracting emails from quoted text leads to misrouting vulnerabilities.
|
|
693
|
+
// A run that is a quoted local part with its '@' and domain is an addr-spec of
|
|
694
|
+
// its own, with the quoting put back on, and takes its turn like any other
|
|
695
|
+
const local = data.quotedLocalAt[i];
|
|
696
|
+
const quotedLocal = data.textWasQuoted[i] && local >= 0 && QUOTED_LOCAL_DOMAIN.test(data.text[i].slice(local))
|
|
697
|
+
? _quoteLocalPart(data.text[i])
|
|
698
|
+
: '';
|
|
699
|
+
if (quotedLocal || (!data.textWasQuoted[i] && _isAddrSpec(candidates[i]))) {
|
|
700
|
+
data.address = [quotedLocal || candidates[i]];
|
|
701
|
+
data.text.splice(i, 1);
|
|
702
|
+
data.textWasQuoted.splice(i, 1);
|
|
703
|
+
data.quotedLocalAt.splice(i, 1);
|
|
704
|
+
break;
|
|
679
705
|
}
|
|
680
706
|
}
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
// Whitespace beside the '@' is CFWS, so 'user @ good.com' is read as one run. Both
|
|
685
|
-
// tiers read the collapsed runs, and only a run an address comes out of is replaced
|
|
686
|
-
// by its collapsed form, the display text keeps its spacing
|
|
687
|
-
const candidates = data.text.map((part) => _collapseAtSpacing(part));
|
|
688
|
-
// The first addr-spec in the text is the mailbox, as it is for a conformant parser
|
|
689
|
-
// reading as far as it can. Taking the last one let a comment part two of them and
|
|
690
|
-
// pick the second, 'user@good.com (x) evil@evil.com' went to evil.com, while the
|
|
691
|
-
// same two without the comment, one run, went to good.com
|
|
707
|
+
// Try a looser regex match if strict match found nothing
|
|
708
|
+
if (!data.address.length) {
|
|
709
|
+
let extracted = false;
|
|
692
710
|
for (let i = 0; i < data.text.length; i++) {
|
|
693
|
-
// Security: Do not extract email addresses from quoted strings
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
data.quotedLocalAt.splice(i, 1);
|
|
707
|
-
break;
|
|
708
|
-
}
|
|
709
|
-
}
|
|
710
|
-
// Try a looser regex match if strict match found nothing
|
|
711
|
-
if (!data.address.length) {
|
|
712
|
-
let extracted = false;
|
|
713
|
-
for (let i = 0; i < data.text.length; i++) {
|
|
714
|
-
// Security: Do not extract email addresses from quoted strings
|
|
715
|
-
if (!data.textWasQuoted[i]) {
|
|
716
|
-
// the strict tier found nothing, so it spliced nothing and the indexes still line up
|
|
717
|
-
const part = candidates[i];
|
|
718
|
-
let remainder = data.text[i];
|
|
719
|
-
const at = _looseAddressStart(part);
|
|
720
|
-
if (at >= 0) {
|
|
721
|
-
LOOSE_TEXT_ADDR.lastIndex = at;
|
|
722
|
-
const match = LOOSE_TEXT_ADDR.exec(part);
|
|
723
|
-
if (match) {
|
|
724
|
-
data.address = [match[0].trim()];
|
|
725
|
-
extracted = true;
|
|
726
|
-
remainder = part.slice(0, at) + ' ' + part.slice(at + match[0].length);
|
|
727
|
-
}
|
|
728
|
-
}
|
|
729
|
-
data.text[i] = remainder.trim();
|
|
730
|
-
if (extracted) {
|
|
731
|
-
break;
|
|
711
|
+
// Security: Do not extract email addresses from quoted strings
|
|
712
|
+
if (!data.textWasQuoted[i]) {
|
|
713
|
+
// the strict tier found nothing, so it spliced nothing and the indexes still line up
|
|
714
|
+
const part = candidates[i];
|
|
715
|
+
let remainder = data.text[i];
|
|
716
|
+
const at = _looseAddressStart(part);
|
|
717
|
+
if (at >= 0) {
|
|
718
|
+
LOOSE_TEXT_ADDR.lastIndex = at;
|
|
719
|
+
const match = LOOSE_TEXT_ADDR.exec(part);
|
|
720
|
+
if (match) {
|
|
721
|
+
data.address = [match[0].trim()];
|
|
722
|
+
extracted = true;
|
|
723
|
+
remainder = part.slice(0, at) + ' ' + part.slice(at + match[0].length);
|
|
732
724
|
}
|
|
733
725
|
}
|
|
726
|
+
data.text[i] = remainder.trim();
|
|
727
|
+
if (extracted) {
|
|
728
|
+
break;
|
|
729
|
+
}
|
|
734
730
|
}
|
|
735
731
|
}
|
|
736
732
|
}
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
733
|
+
}
|
|
734
|
+
// If there's still no text but a comment exists, replace the two
|
|
735
|
+
if (!data.text.length && data.comment.length) {
|
|
736
|
+
data.text = data.comment;
|
|
737
|
+
data.comment = [];
|
|
738
|
+
}
|
|
739
|
+
// Keep only the first address occurrence, push others to regular text
|
|
740
|
+
if (data.address.length > 1) {
|
|
741
|
+
data.text = data.text.concat(data.address.splice(1));
|
|
742
|
+
}
|
|
743
|
+
// An address is only taken from unquoted text, so anything left in the text at this
|
|
744
|
+
// point that still has to serve as the address carries its quoting in this flag
|
|
745
|
+
const addressFromQuotedText = !data.address.length && data.textWasQuoted.some(wasQuoted => wasQuoted);
|
|
746
|
+
// Join values with spaces
|
|
747
|
+
data.text = data.text.join(' ');
|
|
748
|
+
data.address = data.address.join(' ');
|
|
749
|
+
if (addressFromQuotedText && data.text.indexOf('@') >= 0) {
|
|
750
|
+
// A quoted run with no '@' in it is a display name and nothing else, so it stays
|
|
751
|
+
// where it is. Moving it over anyway made a comment the mailbox was read from:
|
|
752
|
+
// '"Display Name" <(a comment)>' handed on 'Display Name' as the address with the
|
|
753
|
+
// comment as the name, and a quoted name holding a ',' or a ';' became an address
|
|
754
|
+
// that reads as two recipients once a consumer writes it back into a header.
|
|
755
|
+
//
|
|
756
|
+
// The mailbox is still sitting in the text, so it moves over here and is quoted
|
|
757
|
+
// before the recovery below rather than after it. Anything else the text holds
|
|
758
|
+
// came along with it: a comment ends the domain but leaves the atoms behind it in
|
|
759
|
+
// the same text, and '"user"@example.com(x)evil.com' was handed on as the address
|
|
760
|
+
// 'user@example.com evil.com', a second domain riding into the envelope recipient
|
|
761
|
+
// on a value that is no addr-spec at all (GHSA-g57g-f23g-4646). Putting the quotes
|
|
762
|
+
// back first is what lets the recovery tell the whitespace an addr-spec may carry
|
|
763
|
+
// from the wreckage trailing one, as only a quoted local part may hold whitespace
|
|
764
|
+
// The quotes are gone at this point, so the '@' the domain splits off at is the
|
|
765
|
+
// last one, not the single one _collapseAtSpacing insists on
|
|
766
|
+
data.address = _quoteLocalPart(_joinAtSpacing(data.text, _domainAt(data.text)));
|
|
767
|
+
data.text = '';
|
|
768
|
+
}
|
|
769
|
+
_recoverAddrSpec(data);
|
|
770
|
+
// a comment names the mailbox only when nothing else does, as one outside the brackets
|
|
771
|
+
if (!data.text && addressComments.length) {
|
|
772
|
+
data.text = addressComments.join(' ');
|
|
773
|
+
textFromComment = true;
|
|
774
|
+
}
|
|
775
|
+
const address = {
|
|
776
|
+
address: data.address || (textFromComment ? '' : data.text) || '',
|
|
777
|
+
name: data.text || data.address || ''
|
|
778
|
+
};
|
|
779
|
+
if (address.address === address.name) {
|
|
780
|
+
if (/@/.test(address.address || '')) {
|
|
781
|
+
address.name = '';
|
|
777
782
|
}
|
|
778
|
-
|
|
779
|
-
address
|
|
780
|
-
name: data.text || data.address || ''
|
|
781
|
-
};
|
|
782
|
-
if (address.address === address.name) {
|
|
783
|
-
if (/@/.test(address.address || '')) {
|
|
784
|
-
address.name = '';
|
|
785
|
-
}
|
|
786
|
-
else {
|
|
787
|
-
address.address = '';
|
|
788
|
-
}
|
|
783
|
+
else {
|
|
784
|
+
address.address = '';
|
|
789
785
|
}
|
|
790
|
-
addresses.push(address);
|
|
791
786
|
}
|
|
792
|
-
return
|
|
787
|
+
return address;
|
|
793
788
|
}
|
|
794
789
|
/* eslint-disable no-bitwise -- see TokenList */
|
|
795
790
|
/**
|
|
@@ -807,6 +802,7 @@ class Tokenizer {
|
|
|
807
802
|
this.escaped = false;
|
|
808
803
|
this.inDomainLiteral = false;
|
|
809
804
|
this.afterAt = false;
|
|
805
|
+
this.inGroup = false;
|
|
810
806
|
this.innerQuoted = false;
|
|
811
807
|
this.innerComment = 0;
|
|
812
808
|
this.innerLiteral = false;
|
|
@@ -820,7 +816,9 @@ class Tokenizer {
|
|
|
820
816
|
'(': ')',
|
|
821
817
|
'<': '>',
|
|
822
818
|
',': '',
|
|
823
|
-
':'
|
|
819
|
+
// A group is read in the same pass as the rest of the list, so its ':' only marks
|
|
820
|
+
// where the name ends and its members are tokenized like any other address
|
|
821
|
+
':': '',
|
|
824
822
|
// Semicolons are not a legal delimiter per the RFC2822 grammar other
|
|
825
823
|
// than for terminating a group, but they are also not valid for any
|
|
826
824
|
// other use in this context. Given that some mail clients have
|
|
@@ -946,15 +944,23 @@ class Tokenizer {
|
|
|
946
944
|
this.inDomainLiteral = false;
|
|
947
945
|
}
|
|
948
946
|
}
|
|
949
|
-
// The closer of an angle-addr
|
|
950
|
-
//
|
|
951
|
-
//
|
|
952
|
-
//
|
|
953
|
-
//
|
|
954
|
-
// collected as they are, the angle-addr and the group content are read again later
|
|
947
|
+
// The closer of an angle-addr is only read outside the quoted strings, comments and
|
|
948
|
+
// domain literals it holds. Looking for nothing but the closer let a '>' inside a
|
|
949
|
+
// quoted local part end the angle-addr, '<"a@evil.com>"@good.com>' was read as an
|
|
950
|
+
// address at evil.com. The characters are still collected as they are, the angle-addr
|
|
951
|
+
// is read again later
|
|
955
952
|
let nested = false;
|
|
956
|
-
if (this.operatorExpecting === '>'
|
|
953
|
+
if (this.operatorExpecting === '>') {
|
|
957
954
|
nested = this._trackInner(chr);
|
|
955
|
+
if (!nested && chr === ';' && this.inGroup) {
|
|
956
|
+
// The ';' of a group ends an angle-addr left open inside it, as it always did
|
|
957
|
+
// when a group was cut out of the field at its ';' and parsed on its own. Read
|
|
958
|
+
// as text instead, an unclosed '<' swallowed the end of the group and every
|
|
959
|
+
// recipient after it
|
|
960
|
+
this._closeText(i);
|
|
961
|
+
this.operatorExpecting = '';
|
|
962
|
+
this._resetInner();
|
|
963
|
+
}
|
|
958
964
|
}
|
|
959
965
|
else if (this.operatorExpecting === ')' && !this.escaped) {
|
|
960
966
|
// Comments nest (RFC 5322 3.2.2). Ending one at the first ')' let the ')' of a
|
|
@@ -987,6 +993,12 @@ class Tokenizer {
|
|
|
987
993
|
else if (!this.operatorExpecting && !(this.inDomainLiteral && chr === ':') && chr in this.operators) {
|
|
988
994
|
this._closeText(i);
|
|
989
995
|
this.list.push(TOKEN_OPERATOR, i, i + 1);
|
|
996
|
+
if (chr === ':') {
|
|
997
|
+
this.inGroup = true;
|
|
998
|
+
}
|
|
999
|
+
else if (chr === ';') {
|
|
1000
|
+
this.inGroup = false;
|
|
1001
|
+
}
|
|
990
1002
|
if (chr !== '(') {
|
|
991
1003
|
this.afterAt = false;
|
|
992
1004
|
}
|
|
@@ -1032,13 +1044,11 @@ class Tokenizer {
|
|
|
1032
1044
|
this.escaped = false;
|
|
1033
1045
|
}
|
|
1034
1046
|
/**
|
|
1035
|
-
* Tracks the quoted strings, comments and domain literals inside an angle-addr
|
|
1036
|
-
*
|
|
1037
|
-
*
|
|
1038
|
-
*
|
|
1039
|
-
*
|
|
1040
|
-
* they do, so a literal is not tracked there and an unclosed "[" can not swallow the
|
|
1041
|
-
* recipients after it.
|
|
1047
|
+
* Tracks the quoted strings, comments and domain literals inside an angle-addr, which its
|
|
1048
|
+
* closer must not be read from. A backslash escapes the next character inside a quoted
|
|
1049
|
+
* string and a comment. A domain literal opens right after an "@" and ends at the "]".
|
|
1050
|
+
* dtext holds the list separators as well, and inside the brackets they never split the
|
|
1051
|
+
* list anyway.
|
|
1042
1052
|
*
|
|
1043
1053
|
* @param chr Character from the address field
|
|
1044
1054
|
* @return true while the character sits inside one of them, the one that closes it included
|
|
@@ -1083,7 +1093,7 @@ class Tokenizer {
|
|
|
1083
1093
|
this.innerComment = 1;
|
|
1084
1094
|
return true;
|
|
1085
1095
|
}
|
|
1086
|
-
if (chr === '[' && this.afterAt
|
|
1096
|
+
if (chr === '[' && this.afterAt) {
|
|
1087
1097
|
this.innerLiteral = true;
|
|
1088
1098
|
return true;
|
|
1089
1099
|
}
|
|
@@ -1106,32 +1116,51 @@ class Tokenizer {
|
|
|
1106
1116
|
}
|
|
1107
1117
|
/* eslint-enable no-bitwise */
|
|
1108
1118
|
/**
|
|
1109
|
-
*
|
|
1110
|
-
*
|
|
1111
|
-
*
|
|
1112
|
-
*
|
|
1119
|
+
* Folds the fragments an unquoted display name holding a comma is split into back together.
|
|
1120
|
+
*
|
|
1121
|
+
* "Joe Foo, PhD <joe@example.com>" is split on the comma into
|
|
1122
|
+
* [{name:"Joe Foo", address:""}, {name:"PhD", address:"joe@example.com"}]. A name-only entry
|
|
1123
|
+
* followed by an entry with both name and address is recombined. Walked back to front so
|
|
1124
|
+
* that a run of fragments folds into one entry in a single pass. Splicing each fragment out
|
|
1125
|
+
* of the list instead would cost O(n^2).
|
|
1126
|
+
*
|
|
1127
|
+
* @param list Parsed entries, in order
|
|
1128
|
+
* @return The entries with the fragments folded in
|
|
1113
1129
|
*/
|
|
1114
|
-
|
|
1130
|
+
function _mergeNameFragments(list) {
|
|
1131
|
+
const merged = [];
|
|
1132
|
+
for (let i = list.length - 1; i >= 0; i--) {
|
|
1133
|
+
const current = list[i];
|
|
1134
|
+
const next = merged.length ? merged[merged.length - 1] : null;
|
|
1135
|
+
if (next && current.address === '' && current.name && !current.group && next.address && next.name) {
|
|
1136
|
+
next.name = current.name + ', ' + next.name;
|
|
1137
|
+
}
|
|
1138
|
+
else {
|
|
1139
|
+
merged.push(current);
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
return merged.reverse();
|
|
1143
|
+
}
|
|
1115
1144
|
/**
|
|
1116
|
-
* Parses an address list,
|
|
1145
|
+
* Parses an address list, groups included, in a single pass over its tokens.
|
|
1117
1146
|
*
|
|
1118
|
-
*
|
|
1119
|
-
*
|
|
1120
|
-
*
|
|
1121
|
-
*
|
|
1147
|
+
* A group used to be cut out of the field at the ';' that ends it and parsed again as an
|
|
1148
|
+
* address list of its own, recursively, so every level of nesting parsed what was left of
|
|
1149
|
+
* the field once more and the depth had to be capped. RFC 5322 does not nest groups at all,
|
|
1150
|
+
* and the nested ones were flattened anyway. The tokens are now walked once: a ':' opens a
|
|
1151
|
+
* group, its members are read as they come, a ';' closes it. A ':' inside an open group
|
|
1152
|
+
* stands for a group nested in it, whose name is dropped and whose members belong to the
|
|
1153
|
+
* outer one, which is how they were flattened before. The first ';' ends the group, nested
|
|
1154
|
+
* or not, as it did.
|
|
1122
1155
|
*
|
|
1123
1156
|
* @param str Address field
|
|
1124
|
-
* @param depth Current recursion depth for nested group protection
|
|
1125
1157
|
* @return An array of address objects
|
|
1126
1158
|
*/
|
|
1127
|
-
function _parseAddressList(str
|
|
1128
|
-
|
|
1129
|
-
if (depth > MAX_NESTED_GROUP_DEPTH) {
|
|
1130
|
-
return [];
|
|
1131
|
-
}
|
|
1132
|
-
const tokenizer = new Tokenizer(str);
|
|
1133
|
-
const tokens = tokenizer.tokenize();
|
|
1159
|
+
function _parseAddressList(str) {
|
|
1160
|
+
const tokens = new Tokenizer(str).tokenize();
|
|
1134
1161
|
const parsedAddresses = [];
|
|
1162
|
+
let group = null;
|
|
1163
|
+
let members = [];
|
|
1135
1164
|
// Every address is handed over as the range of the token list it covers, as soon as the
|
|
1136
1165
|
// separator after it is seen. Copying the tokens of each address into an array of its own,
|
|
1137
1166
|
// and collecting those before handling any, held a second structure as large as the token
|
|
@@ -1140,41 +1169,39 @@ function _parseAddressList(str, depth) {
|
|
|
1140
1169
|
let from = 0;
|
|
1141
1170
|
for (let i = 0, len = tokens.length; i <= len; i++) {
|
|
1142
1171
|
const operator = i < len ? tokens.operatorAt(i) : '';
|
|
1143
|
-
if (i < len && operator !== ',' && operator !== ';') {
|
|
1172
|
+
if (i < len && operator !== ',' && operator !== ';' && operator !== ':') {
|
|
1173
|
+
continue;
|
|
1174
|
+
}
|
|
1175
|
+
if (operator === ':') {
|
|
1176
|
+
if (!group) {
|
|
1177
|
+
group = { name: _groupName(tokens, from, i), group: [] };
|
|
1178
|
+
members = [];
|
|
1179
|
+
}
|
|
1180
|
+
from = i + 1;
|
|
1144
1181
|
continue;
|
|
1145
1182
|
}
|
|
1146
1183
|
if (i > from) {
|
|
1147
|
-
const handled = _handleAddress(tokens, from, i, depth);
|
|
1148
1184
|
// Appended in place. Rebuilding the accumulator with concat() would copy every
|
|
1149
1185
|
// entry collected so far on each address, making a flat list cost O(n^2).
|
|
1150
|
-
|
|
1151
|
-
|
|
1186
|
+
const address = _handleAddress(tokens, from, i);
|
|
1187
|
+
if (group) {
|
|
1188
|
+
members.push(address);
|
|
1189
|
+
}
|
|
1190
|
+
else {
|
|
1191
|
+
parsedAddresses.push(address);
|
|
1152
1192
|
}
|
|
1153
1193
|
}
|
|
1154
1194
|
from = i + 1;
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
// Recombine: a name-only entry followed by an entry with both name and address.
|
|
1160
|
-
// Walked back to front so that a run of fragments folds into one entry in a single
|
|
1161
|
-
// pass. Splicing each fragment out of the list instead would cost O(n^2).
|
|
1162
|
-
const mergedAddresses = [];
|
|
1163
|
-
for (let i = parsedAddresses.length - 1; i >= 0; i--) {
|
|
1164
|
-
const current = parsedAddresses[i];
|
|
1165
|
-
const next = mergedAddresses.length ? mergedAddresses[mergedAddresses.length - 1] : null;
|
|
1166
|
-
if (next && current.address === '' && current.name && !current.group && next.address && next.name) {
|
|
1167
|
-
next.name = current.name + ', ' + next.name;
|
|
1168
|
-
}
|
|
1169
|
-
else {
|
|
1170
|
-
mergedAddresses.push(current);
|
|
1195
|
+
if (group && (operator === ';' || i === len)) {
|
|
1196
|
+
group.group = _mergeNameFragments(members);
|
|
1197
|
+
parsedAddresses.push(group);
|
|
1198
|
+
group = null;
|
|
1171
1199
|
}
|
|
1172
1200
|
}
|
|
1173
|
-
|
|
1174
|
-
return mergedAddresses;
|
|
1201
|
+
return _mergeNameFragments(parsedAddresses);
|
|
1175
1202
|
}
|
|
1176
1203
|
function addressparser(str, options) {
|
|
1177
|
-
const parsedAddresses = _parseAddressList(str
|
|
1204
|
+
const parsedAddresses = _parseAddressList(str);
|
|
1178
1205
|
if (options?.flatten) {
|
|
1179
1206
|
const flatAddresses = [];
|
|
1180
1207
|
const walkAddressList = (list) => {
|
|
@@ -70,6 +70,12 @@ function isPlainText(value, isParam) {
|
|
|
70
70
|
const re = isParam ? /[\x00-\x1f\x7f"\u0080-\uFFFF]/ : /[\x00-\x08\x0b\x0c\x0e-\x1f\u0080-\uFFFF]/;
|
|
71
71
|
return typeof value === 'string' && !re.test(value);
|
|
72
72
|
}
|
|
73
|
+
// A parameter value goes out as is only when it is plain text that does not look like an encoded
|
|
74
|
+
// word. Receivers decode encoded words found in parameter values (libmime does so for unencoded
|
|
75
|
+
// continuations), so as plain text it would come back as something else
|
|
76
|
+
function isPlainParamValue(value) {
|
|
77
|
+
return isPlainText(value, true) && !value.includes('=?');
|
|
78
|
+
}
|
|
73
79
|
/**
|
|
74
80
|
* Wraps a value into a quoted-string. Inside one a quote would end the string early
|
|
75
81
|
* and a backslash would escape whatever follows it, so both go out as quoted-pairs.
|
|
@@ -237,7 +243,7 @@ function buildHeaderValue(structured) {
|
|
|
237
243
|
const value = structured.params[key];
|
|
238
244
|
// a parameter name is a token too and it is emitted without any quoting around it
|
|
239
245
|
const param = key.replace(/[\x00-\x1f\x7f]/g, '');
|
|
240
|
-
if (!
|
|
246
|
+
if (!isPlainParamValue(value) || value.length >= 75) {
|
|
241
247
|
buildHeaderParam(param, value, 50).forEach(encodedParam => {
|
|
242
248
|
if (!/[\s"\\;:/=(),<>@[\]?]|^[-']|'$/.test(encodedParam.value) || encodedParam.key.substr(-1) === '*') {
|
|
243
249
|
paramsArray.push(encodedParam.key + '=' + encodedParam.value);
|
|
@@ -286,7 +292,7 @@ function buildHeaderParam(key, data, maxLength) {
|
|
|
286
292
|
let i, len;
|
|
287
293
|
maxLength = maxLength || 50;
|
|
288
294
|
// process ascii only text
|
|
289
|
-
if (
|
|
295
|
+
if (isPlainParamValue(data)) {
|
|
290
296
|
// check if conversion is even needed
|
|
291
297
|
if (encodedStr.length <= maxLength) {
|
|
292
298
|
return [
|