nodemailer 10.1.5 → 10.1.6

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