nodemailer 10.1.2 → 10.1.4

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.
@@ -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.2';
6
+ exports.version = '10.1.4';
7
7
  exports.homepage = 'https://nodemailer.com/';
@@ -58,10 +58,27 @@ const punycode = __importStar(require("../punycode/index.js"));
58
58
  // Matches a "scheme:" not followed by "//" (and with something after it), used
59
59
  // to re-insert the authority separator the legacy parser did not require.
60
60
  const SLASHLESS_AUTHORITY = /^([a-zA-Z][a-zA-Z0-9+.-]*:)(?!\/\/)([\s\S]+)$/;
61
- // Leading and trailing C0 controls and spaces, which the WHATWG parser strips before it
62
- // looks at the input. Stripped up front so that the slash-less form is recognized in a
63
- // value read from a file with a trailing newline as well.
64
- const SURROUNDING_WHITESPACE = /^[\x00-\x20]+|[\x00-\x20]+$/g;
61
+ /**
62
+ * Drops the leading and trailing C0 controls and spaces, which the WHATWG parser strips
63
+ * before it looks at the input. Stripped up front so that the slash-less form is recognized
64
+ * in a value read from a file with a trailing newline as well. An index scan, the regex
65
+ * /^[\x00-\x20]+|[\x00-\x20]+$/g retried a run of spaces inside the value from every
66
+ * position in it
67
+ *
68
+ * @param input URL string
69
+ * @returns The string without the surrounding controls and spaces
70
+ */
71
+ function stripSurroundingWhitespace(input) {
72
+ let start = 0;
73
+ let end = input.length;
74
+ while (start < end && input.charCodeAt(start) <= 0x20) {
75
+ start++;
76
+ }
77
+ while (end > start && input.charCodeAt(end - 1) <= 0x20) {
78
+ end--;
79
+ }
80
+ return input.slice(start, end);
81
+ }
65
82
  // Leading characters legacy url.parse() skips before it reads the scheme
66
83
  const LEGACY_TRIM = /^[\x00-\x20\u00a0\ufeff]+/;
67
84
  // The authority of a "scheme://authority/..." or a scheme-relative "//authority/..."
@@ -156,7 +173,7 @@ function normalizeHostname(raw, href) {
156
173
  return mapped;
157
174
  }
158
175
  const parse = (input, parseQueryString) => {
159
- input = (input || '').replace(SURROUNDING_WHITESPACE, '');
176
+ input = stripSurroundingWhitespace(input || '');
160
177
  // Legacy url.parse() parses a "user:pass@host:port" authority that follows
161
178
  // the scheme even without the "//" separator, for schemes outside its
162
179
  // built-in slashed-protocol list (smtp:/smtps:/socks:/...). The WHATWG
@@ -1,3 +1,19 @@
1
+ /**
2
+ * A domain literal that ends an address. dtext includes '@' (RFC 5322 3.4.1), so the '@'
3
+ * in front of the literal is the one that splits the domain off, not the last one: reading
4
+ * 'a@[x@evil.com]' at its last '@' made it an address at evil.com.
5
+ */
6
+ const DOMAIN_LITERAL_TAIL = /@(\[[^[\]\\]*\])$/;
7
+ /**
8
+ * Offset of the '@' that splits the domain off an address, or -1 when there is none
9
+ *
10
+ * @param address Address to split
11
+ * @return Offset of the '@'
12
+ */
13
+ function _domainAt(address) {
14
+ const literal = address.charAt(address.length - 1) === ']' ? DOMAIN_LITERAL_TAIL.exec(address) : null;
15
+ return literal ? address.length - literal[1].length - 1 : address.lastIndexOf('@');
16
+ }
1
17
  /**
2
18
  * Restores the quoting of a local part that was read out of a quoted string.
3
19
  *
@@ -16,7 +32,7 @@
16
32
  * @return Address with the local part as a quoted-string
17
33
  */
18
34
  function _quoteLocalPart(address) {
19
- const lastAt = address.lastIndexOf('@');
35
+ const lastAt = _domainAt(address);
20
36
  if (lastAt < 0) {
21
37
  // no domain to split off, nothing can be misrouted
22
38
  return address;
@@ -42,6 +58,23 @@ const QUOTED_LOCAL_ADDR = /^("(?:[^"\\]|\\[\s\S])*"@\S+)(?:\s+([\s\S]+))?$/;
42
58
  * One run holding a single '@' and no whitespace, the shape an addr-spec has to have.
43
59
  */
44
60
  const ADDR_SPEC = /^[^@\s]+@[^@\s]+$/;
61
+ /**
62
+ * The same with a domain literal for the domain, which may hold an '@' of its own.
63
+ */
64
+ const LITERAL_ADDR_SPEC = /^[^@\s]+@\[[^[\]\\\s]*\]$/;
65
+ /**
66
+ * The '@' and domain that follow a quoted local part in the same run.
67
+ */
68
+ const QUOTED_LOCAL_DOMAIN = /^@(?:[^@\s"]+|\[[^[\]\\\s]*\])$/;
69
+ /**
70
+ * Whether a whitespace free run reads as an addr-spec under the strict grammar
71
+ *
72
+ * @param value Run to check
73
+ * @return true when it does
74
+ */
75
+ function _isAddrSpec(value) {
76
+ return ADDR_SPEC.test(value) || LITERAL_ADDR_SPEC.test(value);
77
+ }
45
78
  /**
46
79
  * The looser reading applied once the strict one finds nothing, which tolerates the
47
80
  * further '@' that a domain should not have but malformed headers carry anyway.
@@ -108,6 +141,62 @@ function _indexOfAt(text, from, to) {
108
141
  }
109
142
  return -1;
110
143
  }
144
+ /**
145
+ * A quoted-string a value opens with.
146
+ */
147
+ const LEADING_QUOTED_STRING = /^"(?:[^"\\]|\\[\s\S])*"/;
148
+ /**
149
+ * Drops the whitespace on either side of the '@' at an offset.
150
+ *
151
+ * obs-local-part and obs-domain allow CFWS around the '@' (RFC 5322 4.4), so 'user @ good.com'
152
+ * names user@good.com. Kept, the whitespace went into the envelope as '"user "@ good.com'.
153
+ * The scan is by index, a pattern such as /\s+@/ backtracks over a long whitespace run that
154
+ * no '@' follows.
155
+ *
156
+ * @param value Value holding the '@'
157
+ * @param at Offset of the '@', or -1
158
+ * @return The value with the whitespace beside that '@' removed
159
+ */
160
+ function _joinAtSpacing(value, at) {
161
+ if (at < 0) {
162
+ return value;
163
+ }
164
+ let start = at;
165
+ while (start > 0 && _isSpaceCode(value.charCodeAt(start - 1))) {
166
+ start--;
167
+ }
168
+ let end = at + 1;
169
+ while (end < value.length && _isSpaceCode(value.charCodeAt(end))) {
170
+ end++;
171
+ }
172
+ return start === at && end === at + 1 ? value : value.slice(0, start) + '@' + value.slice(end);
173
+ }
174
+ /**
175
+ * Drops the whitespace beside the '@' that splits the domain off a value. That is the '@'
176
+ * right after a quoted local part the value opens with, or else the only '@' it holds outside
177
+ * any quoted string. With several it is not clear which one the whitespace belongs to, and
178
+ * '<x@evil.com @good.com>' is left to the addr-spec recovery to read as it always has.
179
+ *
180
+ * @param value Value to clean up
181
+ * @return The value with the whitespace beside that '@' removed
182
+ */
183
+ function _collapseAtSpacing(value) {
184
+ const quoted = LEADING_QUOTED_STRING.exec(value);
185
+ if (quoted) {
186
+ let at = quoted[0].length;
187
+ while (at < value.length && _isSpaceCode(value.charCodeAt(at))) {
188
+ at++;
189
+ }
190
+ if (value.charAt(at) === '@' && value.indexOf('@', at + 1) < 0) {
191
+ return _joinAtSpacing(value, at);
192
+ }
193
+ }
194
+ const at = value.indexOf('@');
195
+ if (at < 0 || value.indexOf('@', at + 1) >= 0 || value.lastIndexOf('"') > at) {
196
+ return value;
197
+ }
198
+ return _joinAtSpacing(value, at);
199
+ }
111
200
  /**
112
201
  * Finds the offset LOOSE_TEXT_ADDR matches at, or -1 when it does not match at all.
113
202
  *
@@ -197,6 +286,7 @@ function _looseAddressStart(text) {
197
286
  * @param data Collected address parts, mutated in place
198
287
  */
199
288
  function _recoverAddrSpec(data) {
289
+ data.address = _collapseAtSpacing(data.address);
200
290
  if (!HAS_WHITESPACE.test(data.address)) {
201
291
  return;
202
292
  }
@@ -223,7 +313,7 @@ function _recoverAddrSpec(data) {
223
313
  return;
224
314
  }
225
315
  const parts = data.address.split(/\s+/);
226
- let addrIndex = parts.findIndex(part => ADDR_SPEC.test(part));
316
+ let addrIndex = parts.findIndex(_isAddrSpec);
227
317
  if (addrIndex < 0) {
228
318
  addrIndex = parts.findIndex(part => LOOSE_ADDR_SPEC.test(part));
229
319
  }
@@ -250,6 +340,15 @@ function _recoverAddrSpec(data) {
250
340
  * A comment is folding whitespace, so it is read as a space that splits two atoms and as
251
341
  * nothing next to an '@', the same rule the token walk applies outside the brackets. Quoted
252
342
  * strings and domain literals are copied through untouched, a '(' in there is not a comment.
343
+ * An unexpected '<' outside them drops everything in front of it, comments included.
344
+ *
345
+ * RFC 822 also let an angle-addr name the relays to go through in front of the mailbox,
346
+ * '<@relay1,@relay2:user@example.com>', which RFC 5322 still parses as obs-route. The route
347
+ * is not part of the mailbox, and folded into the local part it made
348
+ * '"@relay1,@relay2:user"@example.com' a recipient that does not exist. A local part can not
349
+ * start with '@', so contents that do, up to the first ':' outside a domain literal and in
350
+ * front of any quoted string, are a route and are dropped, as long as a mailbox with an '@'
351
+ * of its own follows.
253
352
  *
254
353
  * @param address Contents of the angle brackets
255
354
  * @return The address with the comments removed, and the text of the comments
@@ -263,6 +362,16 @@ function _stripAddressComments(address) {
263
362
  // carried along rather than read back off the growing result, which would flatten it on
264
363
  // every comment (see lastChars in _handleAddress)
265
364
  let lastChar = '';
365
+ // A comment that just closed stands for a space, written once the next character shows
366
+ // whether one is wanted: not beside an '@', and not on top of whitespace that follows.
367
+ // Deciding on the spot saw only the character right after the ')', so a second comment
368
+ // in a row hid the '@' behind it and 'a(x)(y)@b' read as 'a @b'
369
+ let pendingSpace = false;
370
+ // first character of the result and whether a quoted string has opened since, which
371
+ // together tell a route from a local part
372
+ let firstChar = '';
373
+ let quoteSeen = false;
374
+ let routeEnd = -1;
266
375
  for (let i = 0, len = address.length; i < len; i++) {
267
376
  const chr = address.charAt(i);
268
377
  if (depth) {
@@ -276,16 +385,20 @@ function _stripAddressComments(address) {
276
385
  else if (chr === ')' && !--depth) {
277
386
  comments.push(comment.trim());
278
387
  comment = '';
279
- if (lastChar !== '@' && address.charAt(i + 1) !== '@') {
280
- result += ' ';
281
- lastChar = ' ';
282
- }
388
+ pendingSpace = lastChar !== '@';
283
389
  }
284
390
  else {
285
391
  comment += chr;
286
392
  }
287
393
  continue;
288
394
  }
395
+ if (pendingSpace && chr !== '(') {
396
+ pendingSpace = false;
397
+ if (chr !== '@' && chr !== '<' && !_isSpaceCode(address.charCodeAt(i))) {
398
+ result += ' ';
399
+ lastChar = ' ';
400
+ }
401
+ }
289
402
  if (closer) {
290
403
  if (chr === '\\' && closer === '"' && i < len - 1) {
291
404
  result += chr + address.charAt(++i);
@@ -298,6 +411,10 @@ function _stripAddressComments(address) {
298
411
  }
299
412
  else if (chr === '"') {
300
413
  closer = '"';
414
+ quoteSeen = true;
415
+ }
416
+ else if (chr === ':' && routeEnd < 0 && firstChar === '@' && !quoteSeen) {
417
+ routeEnd = result.length + 1;
301
418
  }
302
419
  else if (chr === '[') {
303
420
  closer = ']';
@@ -306,8 +423,27 @@ function _stripAddressComments(address) {
306
423
  depth = 1;
307
424
  continue;
308
425
  }
426
+ else if (chr === '<') {
427
+ // Apple Mail drops everything between an unexpected '<' and the address after it.
428
+ // Read here rather than off the raw brackets so that a '<' inside a quoted string,
429
+ // a comment or a domain literal stays where it is: '<"a<b"@victim.com>' cut at
430
+ // the first '<' left the address 'b"@victim.com'
431
+ result = '';
432
+ lastChar = '';
433
+ comments.length = 0;
434
+ firstChar = '';
435
+ quoteSeen = false;
436
+ routeEnd = -1;
437
+ continue;
438
+ }
309
439
  result += chr;
310
440
  lastChar = chr;
441
+ if (!firstChar && !_isSpaceCode(address.charCodeAt(i))) {
442
+ firstChar = chr;
443
+ }
444
+ }
445
+ if (routeEnd >= 0 && result.indexOf('@', routeEnd) >= 0) {
446
+ result = result.slice(routeEnd);
311
447
  }
312
448
  if (depth) {
313
449
  // an unterminated comment runs to the end of the address
@@ -331,9 +467,13 @@ function _handleAddress(tokens, depth) {
331
467
  comment: [],
332
468
  group: [],
333
469
  text: [],
334
- textWasQuoted: []
470
+ textWasQuoted: [],
471
+ quotedLocalAt: []
335
472
  };
336
473
  let insideQuotes = false;
474
+ // whether the last text run holds nothing but quoted text so far, which is what a quoted
475
+ // local part is until the '@' after it joins the run
476
+ let lastTextPureQuoted = false;
337
477
  // Last character of the run each state is currently accumulating. Reading it back off
338
478
  // the accumulator with slice(-1) makes the engine flatten the whole growing string on
339
479
  // every token, which is quadratic over an address built from many comment-joined atoms
@@ -383,11 +523,6 @@ function _handleAddress(tokens, depth) {
383
523
  !!prevToken.noBreak &&
384
524
  prevPrevToken?.type === 'operator' &&
385
525
  prevPrevToken.value === '"';
386
- if (state === 'address') {
387
- // Handle unquoted name that includes a "<".
388
- // Apple Mail truncates everything between an unexpected < and an address.
389
- token.value = token.value.replace(/^[^<]*<\s*/, '');
390
- }
391
526
  // A comment is folding whitespace. It may sit inside an addr-spec, on either side
392
527
  // of the '@', but it cannot join two atoms into one: gluing across it would read
393
528
  // 'user@example.com(x)evil.com' as the single domain 'example.comevil.com' and
@@ -398,12 +533,19 @@ function _handleAddress(tokens, depth) {
398
533
  parts.length &&
399
534
  (prevToken.value !== ')' || lastChars[state] === '@' || token.value.charAt(0) === '@');
400
535
  if (joins) {
401
- data[state][data[state].length - 1] += token.value;
536
+ const last = data[state].length - 1;
537
+ if (state === 'text' && !insideQuotes) {
538
+ if (lastTextPureQuoted && token.value.charAt(0) === '@') {
539
+ data.quotedLocalAt[last] = data.text[last].length;
540
+ }
541
+ lastTextPureQuoted = false;
542
+ }
543
+ data[state][last] += token.value;
402
544
  if (token.value) {
403
545
  lastChars[state] = token.value.charAt(token.value.length - 1);
404
546
  }
405
547
  if (state === 'text' && insideQuotes) {
406
- data.textWasQuoted[data.textWasQuoted.length - 1] = true;
548
+ data.textWasQuoted[last] = true;
407
549
  }
408
550
  }
409
551
  else {
@@ -411,14 +553,21 @@ function _handleAddress(tokens, depth) {
411
553
  lastChars[state] = token.value.charAt(token.value.length - 1);
412
554
  if (state === 'text') {
413
555
  data.textWasQuoted.push(insideQuotes || opensAfterEmptyQuotedString);
556
+ // an empty quoted string leaves no text of its own, so the '@' opens the run
557
+ data.quotedLocalAt.push(opensAfterEmptyQuotedString && token.value.charAt(0) === '@' ? 0 : -1);
558
+ lastTextPureQuoted = insideQuotes;
414
559
  }
415
560
  }
416
561
  }
417
562
  }
418
- // If there is no text but a comment, replace the two
563
+ // If there is no text but a comment, replace the two. A comment is never part of a
564
+ // mailbox, so text that came out of one only ever names it: reading an address out of it
565
+ // made '(a@evil.com)' a recipient where no conformant reading has a mailbox at all
566
+ let textFromComment = false;
419
567
  if (!data.text.length && data.comment.length) {
420
568
  data.text = data.comment;
421
569
  data.comment = [];
570
+ textFromComment = true;
422
571
  }
423
572
  if (isGroup) {
424
573
  // http://tools.ietf.org/html/rfc2822#appendix-A.1.3
@@ -447,10 +596,6 @@ function _handleAddress(tokens, depth) {
447
596
  const addressComments = [];
448
597
  const addressParts = [];
449
598
  for (const part of data.address) {
450
- if (part.indexOf('(') < 0) {
451
- addressParts.push(part);
452
- continue;
453
- }
454
599
  const stripped = _stripAddressComments(part);
455
600
  for (const comment of stripped.comments) {
456
601
  addressComments.push(comment);
@@ -461,25 +606,42 @@ function _handleAddress(tokens, depth) {
461
606
  }
462
607
  data.address = addressParts;
463
608
  // If no address was found, try to detect one from regular text
464
- if (!data.address.length && data.text.length) {
465
- for (let i = data.text.length - 1; i >= 0; i--) {
609
+ if (!data.address.length && data.text.length && !textFromComment) {
610
+ // Whitespace beside the '@' is CFWS, so 'user @ good.com' is read as one run. Both
611
+ // tiers read the collapsed runs, and only a run an address comes out of is replaced
612
+ // by its collapsed form, the display text keeps its spacing
613
+ const candidates = data.text.map((part) => _collapseAtSpacing(part));
614
+ // The first addr-spec in the text is the mailbox, as it is for a conformant parser
615
+ // reading as far as it can. Taking the last one let a comment part two of them and
616
+ // pick the second, 'user@good.com (x) evil@evil.com' went to evil.com, while the
617
+ // same two without the comment, one run, went to good.com
618
+ for (let i = 0; i < data.text.length; i++) {
466
619
  // Security: Do not extract email addresses from quoted strings.
467
620
  // RFC 5321 allows @ inside quoted local-parts like "user@domain"@example.com.
468
621
  // Extracting emails from quoted text leads to misrouting vulnerabilities.
469
- if (!data.textWasQuoted[i] && ADDR_SPEC.test(data.text[i])) {
470
- data.address = data.text.splice(i, 1);
622
+ // A run that is a quoted local part with its '@' and domain is an addr-spec of
623
+ // its own, with the quoting put back on, and takes its turn like any other
624
+ const local = data.quotedLocalAt[i];
625
+ const quotedLocal = data.textWasQuoted[i] && local >= 0 && QUOTED_LOCAL_DOMAIN.test(data.text[i].slice(local))
626
+ ? _quoteLocalPart(data.text[i])
627
+ : '';
628
+ if (quotedLocal || (!data.textWasQuoted[i] && _isAddrSpec(candidates[i]))) {
629
+ data.address = [quotedLocal || candidates[i]];
630
+ data.text.splice(i, 1);
471
631
  data.textWasQuoted.splice(i, 1);
632
+ data.quotedLocalAt.splice(i, 1);
472
633
  break;
473
634
  }
474
635
  }
475
636
  // Try a looser regex match if strict match found nothing
476
637
  if (!data.address.length) {
477
638
  let extracted = false;
478
- for (let i = data.text.length - 1; i >= 0; i--) {
639
+ for (let i = 0; i < data.text.length; i++) {
479
640
  // Security: Do not extract email addresses from quoted strings
480
641
  if (!data.textWasQuoted[i]) {
481
- const part = data.text[i];
482
- let remainder = part;
642
+ // the strict tier found nothing, so it spliced nothing and the indexes still line up
643
+ const part = candidates[i];
644
+ let remainder = data.text[i];
483
645
  const at = _looseAddressStart(part);
484
646
  if (at >= 0) {
485
647
  LOOSE_TEXT_ADDR.lastIndex = at;
@@ -528,16 +690,19 @@ function _handleAddress(tokens, depth) {
528
690
  // on a value that is no addr-spec at all (GHSA-g57g-f23g-4646). Putting the quotes
529
691
  // back first is what lets the recovery tell the whitespace an addr-spec may carry
530
692
  // from the wreckage trailing one, as only a quoted local part may hold whitespace
531
- data.address = _quoteLocalPart(data.text);
693
+ // The quotes are gone at this point, so the '@' the domain splits off at is the
694
+ // last one, not the single one _collapseAtSpacing insists on
695
+ data.address = _quoteLocalPart(_joinAtSpacing(data.text, _domainAt(data.text)));
532
696
  data.text = '';
533
697
  }
534
698
  _recoverAddrSpec(data);
535
699
  // a comment names the mailbox only when nothing else does, as one outside the brackets
536
700
  if (!data.text && addressComments.length) {
537
701
  data.text = addressComments.join(' ');
702
+ textFromComment = true;
538
703
  }
539
704
  const address = {
540
- address: data.address || data.text || '',
705
+ address: data.address || (textFromComment ? '' : data.text) || '',
541
706
  name: data.text || data.address || ''
542
707
  };
543
708
  if (address.address === address.name) {
@@ -567,6 +732,10 @@ class Tokenizer {
567
732
  this.escaped = false;
568
733
  this.inDomainLiteral = false;
569
734
  this.afterAt = false;
735
+ this.innerQuoted = false;
736
+ this.innerComment = 0;
737
+ this.innerLiteral = false;
738
+ this.innerEscaped = false;
570
739
  this.list = [];
571
740
  /**
572
741
  * Operator tokens and which tokens are expected to end the sequence
@@ -633,10 +802,32 @@ class Tokenizer {
633
802
  this.inDomainLiteral = false;
634
803
  }
635
804
  }
805
+ // The closer of an angle-addr or a group is only read outside the quoted strings,
806
+ // comments and domain literals it holds. Looking for nothing but the closer let a '>'
807
+ // inside a quoted local part end the angle-addr, '<"a@evil.com>"@good.com>' was read
808
+ // as an address at evil.com, and a ';' inside a quoted display name or a comment end
809
+ // the group and release the rest of that text as recipients. The characters are still
810
+ // collected as they are, the angle-addr and the group content are read again later
811
+ let nested = false;
812
+ if (this.operatorExpecting === '>' || this.operatorExpecting === ';') {
813
+ nested = this._trackInner(chr);
814
+ }
815
+ else if (this.operatorExpecting === ')' && !this.escaped) {
816
+ // Comments nest (RFC 5322 3.2.2). Ending one at the first ')' let the ')' of a
817
+ // nested comment close the outer one and released the rest of it, an address
818
+ // included, into the header
819
+ if (chr === '(') {
820
+ this.innerComment++;
821
+ }
822
+ else if (chr === ')' && this.innerComment) {
823
+ this.innerComment--;
824
+ nested = true;
825
+ }
826
+ }
636
827
  if (this.escaped) {
637
828
  // ignore next condition blocks
638
829
  }
639
- else if (chr === this.operatorExpecting) {
830
+ else if (chr === this.operatorExpecting && !nested) {
640
831
  this.node = {
641
832
  type: 'operator',
642
833
  value: chr
@@ -652,6 +843,7 @@ class Tokenizer {
652
843
  }
653
844
  this.operatorExpecting = '';
654
845
  this.escaped = false;
846
+ this._resetInner();
655
847
  return;
656
848
  }
657
849
  else if (!this.operatorExpecting && !(this.inDomainLiteral && chr === ':') && chr in this.operators) {
@@ -666,6 +858,7 @@ class Tokenizer {
666
858
  }
667
859
  this.operatorExpecting = this.operators[chr];
668
860
  this.escaped = false;
861
+ this._resetInner();
669
862
  return;
670
863
  }
671
864
  else if (['"', "'", ')'].includes(this.operatorExpecting) && chr === '\\') {
@@ -699,13 +892,86 @@ class Tokenizer {
699
892
  }
700
893
  this.escaped = false;
701
894
  }
895
+ /**
896
+ * Tracks the quoted strings, comments and domain literals inside an angle-addr or a
897
+ * group, which the closer of either must not be read from. A backslash escapes the next
898
+ * character inside a quoted string and a comment. A domain literal is only tracked in an
899
+ * angle-addr, where it opens right after an "@" and ends at the "]". dtext holds the list
900
+ * separators as well, and inside the brackets they never split the list anyway. In a group
901
+ * they do, so a literal is not tracked there and an unclosed "[" can not swallow the
902
+ * recipients after it.
903
+ *
904
+ * @param chr Character from the address field
905
+ * @return true while the character sits inside one of them, the one that closes it included
906
+ */
907
+ _trackInner(chr) {
908
+ if (this.innerEscaped) {
909
+ this.innerEscaped = false;
910
+ return true;
911
+ }
912
+ if (this.innerQuoted) {
913
+ if (chr === '\\') {
914
+ this.innerEscaped = true;
915
+ }
916
+ else if (chr === '"') {
917
+ this.innerQuoted = false;
918
+ }
919
+ return true;
920
+ }
921
+ if (this.innerComment) {
922
+ if (chr === '\\') {
923
+ this.innerEscaped = true;
924
+ }
925
+ else if (chr === '(') {
926
+ this.innerComment++;
927
+ }
928
+ else if (chr === ')') {
929
+ this.innerComment--;
930
+ }
931
+ return true;
932
+ }
933
+ if (this.innerLiteral) {
934
+ if (chr === ']') {
935
+ this.innerLiteral = false;
936
+ }
937
+ return true;
938
+ }
939
+ if (chr === '"') {
940
+ this.innerQuoted = true;
941
+ return true;
942
+ }
943
+ if (chr === '(') {
944
+ this.innerComment = 1;
945
+ return true;
946
+ }
947
+ if (chr === '[' && this.afterAt && this.operatorExpecting === '>') {
948
+ this.innerLiteral = true;
949
+ return true;
950
+ }
951
+ // afterAt is otherwise only kept outside the operators and is cleared when one opens
952
+ // or ends, so inside an angle-addr it serves the literal tracked here
953
+ if (!_isSpaceCode(chr.charCodeAt(0))) {
954
+ this.afterAt = chr === '@';
955
+ }
956
+ return false;
957
+ }
958
+ /**
959
+ * Forgets the nesting collected for the operator that just opened or ended
960
+ */
961
+ _resetInner() {
962
+ this.innerQuoted = false;
963
+ this.innerComment = 0;
964
+ this.innerLiteral = false;
965
+ this.innerEscaped = false;
966
+ }
702
967
  }
703
968
  /**
704
969
  * Maximum recursion depth for parsing nested groups.
705
970
  * RFC 5322 doesn't allow nested groups, so this is a safeguard against
706
- * malicious input that could cause stack overflow.
971
+ * malicious input that could cause stack overflow. Every level parses what is left of the
972
+ * value once more, so the limit is also the factor a crafted header multiplies the parse by.
707
973
  */
708
- const MAX_NESTED_GROUP_DEPTH = 50;
974
+ const MAX_NESTED_GROUP_DEPTH = 10;
709
975
  /**
710
976
  * Parses an address list, recursing into the groups it holds.
711
977
  *
@@ -145,7 +145,10 @@ export default class MailMessage {
145
145
  const values = [data.html, data.text, data.watchHtml, data.amp, data.raw, data.icalEvent];
146
146
  for (const key of ['attachments', 'alternatives']) {
147
147
  if (Array.isArray(data[key])) {
148
- values.push(...data[key]);
148
+ // one at a time, spreading a long enough list into the arguments throws
149
+ for (const entry of data[key]) {
150
+ values.push(entry);
151
+ }
149
152
  }
150
153
  }
151
154
  for (const value of values) {