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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## [10.1.4](https://github.com/nodemailer/nodemailer/compare/v10.1.3...v10.1.4) (2026-10-18)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **addressparser:** apply the unexpected "<" rule outside quotes and comments ([ee35f57](https://github.com/nodemailer/nodemailer/commit/ee35f5745658bf46952651fa53207255e9dc3406))
9
+ * **addressparser:** bound group nesting at ten levels ([ce63303](https://github.com/nodemailer/nodemailer/commit/ce633031d58f8d2e9c9b0f5b671944a1afe71f95))
10
+ * **addressparser:** drop the obsolete route from an angle-addr ([3dbc730](https://github.com/nodemailer/nodemailer/commit/3dbc7304b0cfa06551543fa2dc01222447c2b554))
11
+ * **addressparser:** end a comment only at the ")" that closes the outermost one ([0a87617](https://github.com/nodemailer/nodemailer/commit/0a8761762ac78f2db2ba37f712f2a52a404ce011))
12
+ * **addressparser:** honour quoted strings and comments inside an angle-addr and a group ([1cd57d2](https://github.com/nodemailer/nodemailer/commit/1cd57d2b8c49dff048c21cdbafdb54439a7f07e2))
13
+ * **addressparser:** never read a mailbox out of a comment ([a1538ea](https://github.com/nodemailer/nodemailer/commit/a1538ea39c76c48a4134806c41e31e59a258b1e5))
14
+ * **addressparser:** read CFWS beside the "@" of an addr-spec as nothing ([ba3c095](https://github.com/nodemailer/nodemailer/commit/ba3c0952542581f95cb20d9322cc9e27baa470a2))
15
+ * **addressparser:** read the first addr-spec of the display text ([038940a](https://github.com/nodemailer/nodemailer/commit/038940a069a2cecb64a70d0e81b17eb8ce200d3a))
16
+ * split a domain literal off at the "@" in front of it ([d5983b0](https://github.com/nodemailer/nodemailer/commit/d5983b0ae234cd22ab45be817fefd434e9a53d58))
17
+
18
+ ## [10.1.3](https://github.com/nodemailer/nodemailer/compare/v10.1.2...v10.1.3) (2026-10-18)
19
+
20
+
21
+ ### Bug Fixes
22
+
23
+ * format References and strip URLs in linear time, release any number of streams ([2f18641](https://github.com/nodemailer/nodemailer/commit/2f186418cf6c8fff18f235d4318892fd42f810fa))
24
+
3
25
  ## [10.1.2](https://github.com/nodemailer/nodemailer/compare/v10.1.1...v10.1.2) (2026-10-16)
4
26
 
5
27
 
@@ -1,5 +1,21 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ /**
4
+ * A domain literal that ends an address. dtext includes '@' (RFC 5322 3.4.1), so the '@'
5
+ * in front of the literal is the one that splits the domain off, not the last one: reading
6
+ * 'a@[x@evil.com]' at its last '@' made it an address at evil.com.
7
+ */
8
+ const DOMAIN_LITERAL_TAIL = /@(\[[^[\]\\]*\])$/;
9
+ /**
10
+ * Offset of the '@' that splits the domain off an address, or -1 when there is none
11
+ *
12
+ * @param address Address to split
13
+ * @return Offset of the '@'
14
+ */
15
+ function _domainAt(address) {
16
+ const literal = address.charAt(address.length - 1) === ']' ? DOMAIN_LITERAL_TAIL.exec(address) : null;
17
+ return literal ? address.length - literal[1].length - 1 : address.lastIndexOf('@');
18
+ }
3
19
  /**
4
20
  * Restores the quoting of a local part that was read out of a quoted string.
5
21
  *
@@ -18,7 +34,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
18
34
  * @return Address with the local part as a quoted-string
19
35
  */
20
36
  function _quoteLocalPart(address) {
21
- const lastAt = address.lastIndexOf('@');
37
+ const lastAt = _domainAt(address);
22
38
  if (lastAt < 0) {
23
39
  // no domain to split off, nothing can be misrouted
24
40
  return address;
@@ -44,6 +60,23 @@ const QUOTED_LOCAL_ADDR = /^("(?:[^"\\]|\\[\s\S])*"@\S+)(?:\s+([\s\S]+))?$/;
44
60
  * One run holding a single '@' and no whitespace, the shape an addr-spec has to have.
45
61
  */
46
62
  const ADDR_SPEC = /^[^@\s]+@[^@\s]+$/;
63
+ /**
64
+ * The same with a domain literal for the domain, which may hold an '@' of its own.
65
+ */
66
+ const LITERAL_ADDR_SPEC = /^[^@\s]+@\[[^[\]\\\s]*\]$/;
67
+ /**
68
+ * The '@' and domain that follow a quoted local part in the same run.
69
+ */
70
+ const QUOTED_LOCAL_DOMAIN = /^@(?:[^@\s"]+|\[[^[\]\\\s]*\])$/;
71
+ /**
72
+ * Whether a whitespace free run reads as an addr-spec under the strict grammar
73
+ *
74
+ * @param value Run to check
75
+ * @return true when it does
76
+ */
77
+ function _isAddrSpec(value) {
78
+ return ADDR_SPEC.test(value) || LITERAL_ADDR_SPEC.test(value);
79
+ }
47
80
  /**
48
81
  * The looser reading applied once the strict one finds nothing, which tolerates the
49
82
  * further '@' that a domain should not have but malformed headers carry anyway.
@@ -110,6 +143,62 @@ function _indexOfAt(text, from, to) {
110
143
  }
111
144
  return -1;
112
145
  }
146
+ /**
147
+ * A quoted-string a value opens with.
148
+ */
149
+ const LEADING_QUOTED_STRING = /^"(?:[^"\\]|\\[\s\S])*"/;
150
+ /**
151
+ * Drops the whitespace on either side of the '@' at an offset.
152
+ *
153
+ * obs-local-part and obs-domain allow CFWS around the '@' (RFC 5322 4.4), so 'user @ good.com'
154
+ * names user@good.com. Kept, the whitespace went into the envelope as '"user "@ good.com'.
155
+ * The scan is by index, a pattern such as /\s+@/ backtracks over a long whitespace run that
156
+ * no '@' follows.
157
+ *
158
+ * @param value Value holding the '@'
159
+ * @param at Offset of the '@', or -1
160
+ * @return The value with the whitespace beside that '@' removed
161
+ */
162
+ function _joinAtSpacing(value, at) {
163
+ if (at < 0) {
164
+ return value;
165
+ }
166
+ let start = at;
167
+ while (start > 0 && _isSpaceCode(value.charCodeAt(start - 1))) {
168
+ start--;
169
+ }
170
+ let end = at + 1;
171
+ while (end < value.length && _isSpaceCode(value.charCodeAt(end))) {
172
+ end++;
173
+ }
174
+ return start === at && end === at + 1 ? value : value.slice(0, start) + '@' + value.slice(end);
175
+ }
176
+ /**
177
+ * Drops the whitespace beside the '@' that splits the domain off a value. That is the '@'
178
+ * right after a quoted local part the value opens with, or else the only '@' it holds outside
179
+ * any quoted string. With several it is not clear which one the whitespace belongs to, and
180
+ * '<x@evil.com @good.com>' is left to the addr-spec recovery to read as it always has.
181
+ *
182
+ * @param value Value to clean up
183
+ * @return The value with the whitespace beside that '@' removed
184
+ */
185
+ function _collapseAtSpacing(value) {
186
+ const quoted = LEADING_QUOTED_STRING.exec(value);
187
+ if (quoted) {
188
+ let at = quoted[0].length;
189
+ while (at < value.length && _isSpaceCode(value.charCodeAt(at))) {
190
+ at++;
191
+ }
192
+ if (value.charAt(at) === '@' && value.indexOf('@', at + 1) < 0) {
193
+ return _joinAtSpacing(value, at);
194
+ }
195
+ }
196
+ const at = value.indexOf('@');
197
+ if (at < 0 || value.indexOf('@', at + 1) >= 0 || value.lastIndexOf('"') > at) {
198
+ return value;
199
+ }
200
+ return _joinAtSpacing(value, at);
201
+ }
113
202
  /**
114
203
  * Finds the offset LOOSE_TEXT_ADDR matches at, or -1 when it does not match at all.
115
204
  *
@@ -199,6 +288,7 @@ function _looseAddressStart(text) {
199
288
  * @param data Collected address parts, mutated in place
200
289
  */
201
290
  function _recoverAddrSpec(data) {
291
+ data.address = _collapseAtSpacing(data.address);
202
292
  if (!HAS_WHITESPACE.test(data.address)) {
203
293
  return;
204
294
  }
@@ -225,7 +315,7 @@ function _recoverAddrSpec(data) {
225
315
  return;
226
316
  }
227
317
  const parts = data.address.split(/\s+/);
228
- let addrIndex = parts.findIndex(part => ADDR_SPEC.test(part));
318
+ let addrIndex = parts.findIndex(_isAddrSpec);
229
319
  if (addrIndex < 0) {
230
320
  addrIndex = parts.findIndex(part => LOOSE_ADDR_SPEC.test(part));
231
321
  }
@@ -252,6 +342,15 @@ function _recoverAddrSpec(data) {
252
342
  * A comment is folding whitespace, so it is read as a space that splits two atoms and as
253
343
  * nothing next to an '@', the same rule the token walk applies outside the brackets. Quoted
254
344
  * strings and domain literals are copied through untouched, a '(' in there is not a comment.
345
+ * An unexpected '<' outside them drops everything in front of it, comments included.
346
+ *
347
+ * RFC 822 also let an angle-addr name the relays to go through in front of the mailbox,
348
+ * '<@relay1,@relay2:user@example.com>', which RFC 5322 still parses as obs-route. The route
349
+ * is not part of the mailbox, and folded into the local part it made
350
+ * '"@relay1,@relay2:user"@example.com' a recipient that does not exist. A local part can not
351
+ * start with '@', so contents that do, up to the first ':' outside a domain literal and in
352
+ * front of any quoted string, are a route and are dropped, as long as a mailbox with an '@'
353
+ * of its own follows.
255
354
  *
256
355
  * @param address Contents of the angle brackets
257
356
  * @return The address with the comments removed, and the text of the comments
@@ -265,6 +364,16 @@ function _stripAddressComments(address) {
265
364
  // carried along rather than read back off the growing result, which would flatten it on
266
365
  // every comment (see lastChars in _handleAddress)
267
366
  let lastChar = '';
367
+ // A comment that just closed stands for a space, written once the next character shows
368
+ // whether one is wanted: not beside an '@', and not on top of whitespace that follows.
369
+ // Deciding on the spot saw only the character right after the ')', so a second comment
370
+ // in a row hid the '@' behind it and 'a(x)(y)@b' read as 'a @b'
371
+ let pendingSpace = false;
372
+ // first character of the result and whether a quoted string has opened since, which
373
+ // together tell a route from a local part
374
+ let firstChar = '';
375
+ let quoteSeen = false;
376
+ let routeEnd = -1;
268
377
  for (let i = 0, len = address.length; i < len; i++) {
269
378
  const chr = address.charAt(i);
270
379
  if (depth) {
@@ -278,16 +387,20 @@ function _stripAddressComments(address) {
278
387
  else if (chr === ')' && !--depth) {
279
388
  comments.push(comment.trim());
280
389
  comment = '';
281
- if (lastChar !== '@' && address.charAt(i + 1) !== '@') {
282
- result += ' ';
283
- lastChar = ' ';
284
- }
390
+ pendingSpace = lastChar !== '@';
285
391
  }
286
392
  else {
287
393
  comment += chr;
288
394
  }
289
395
  continue;
290
396
  }
397
+ if (pendingSpace && chr !== '(') {
398
+ pendingSpace = false;
399
+ if (chr !== '@' && chr !== '<' && !_isSpaceCode(address.charCodeAt(i))) {
400
+ result += ' ';
401
+ lastChar = ' ';
402
+ }
403
+ }
291
404
  if (closer) {
292
405
  if (chr === '\\' && closer === '"' && i < len - 1) {
293
406
  result += chr + address.charAt(++i);
@@ -300,6 +413,10 @@ function _stripAddressComments(address) {
300
413
  }
301
414
  else if (chr === '"') {
302
415
  closer = '"';
416
+ quoteSeen = true;
417
+ }
418
+ else if (chr === ':' && routeEnd < 0 && firstChar === '@' && !quoteSeen) {
419
+ routeEnd = result.length + 1;
303
420
  }
304
421
  else if (chr === '[') {
305
422
  closer = ']';
@@ -308,8 +425,27 @@ function _stripAddressComments(address) {
308
425
  depth = 1;
309
426
  continue;
310
427
  }
428
+ else if (chr === '<') {
429
+ // Apple Mail drops everything between an unexpected '<' and the address after it.
430
+ // Read here rather than off the raw brackets so that a '<' inside a quoted string,
431
+ // a comment or a domain literal stays where it is: '<"a<b"@victim.com>' cut at
432
+ // the first '<' left the address 'b"@victim.com'
433
+ result = '';
434
+ lastChar = '';
435
+ comments.length = 0;
436
+ firstChar = '';
437
+ quoteSeen = false;
438
+ routeEnd = -1;
439
+ continue;
440
+ }
311
441
  result += chr;
312
442
  lastChar = chr;
443
+ if (!firstChar && !_isSpaceCode(address.charCodeAt(i))) {
444
+ firstChar = chr;
445
+ }
446
+ }
447
+ if (routeEnd >= 0 && result.indexOf('@', routeEnd) >= 0) {
448
+ result = result.slice(routeEnd);
313
449
  }
314
450
  if (depth) {
315
451
  // an unterminated comment runs to the end of the address
@@ -333,9 +469,13 @@ function _handleAddress(tokens, depth) {
333
469
  comment: [],
334
470
  group: [],
335
471
  text: [],
336
- textWasQuoted: []
472
+ textWasQuoted: [],
473
+ quotedLocalAt: []
337
474
  };
338
475
  let insideQuotes = false;
476
+ // whether the last text run holds nothing but quoted text so far, which is what a quoted
477
+ // local part is until the '@' after it joins the run
478
+ let lastTextPureQuoted = false;
339
479
  // Last character of the run each state is currently accumulating. Reading it back off
340
480
  // the accumulator with slice(-1) makes the engine flatten the whole growing string on
341
481
  // every token, which is quadratic over an address built from many comment-joined atoms
@@ -385,11 +525,6 @@ function _handleAddress(tokens, depth) {
385
525
  !!prevToken.noBreak &&
386
526
  prevPrevToken?.type === 'operator' &&
387
527
  prevPrevToken.value === '"';
388
- if (state === 'address') {
389
- // Handle unquoted name that includes a "<".
390
- // Apple Mail truncates everything between an unexpected < and an address.
391
- token.value = token.value.replace(/^[^<]*<\s*/, '');
392
- }
393
528
  // A comment is folding whitespace. It may sit inside an addr-spec, on either side
394
529
  // of the '@', but it cannot join two atoms into one: gluing across it would read
395
530
  // 'user@example.com(x)evil.com' as the single domain 'example.comevil.com' and
@@ -400,12 +535,19 @@ function _handleAddress(tokens, depth) {
400
535
  parts.length &&
401
536
  (prevToken.value !== ')' || lastChars[state] === '@' || token.value.charAt(0) === '@');
402
537
  if (joins) {
403
- data[state][data[state].length - 1] += token.value;
538
+ const last = data[state].length - 1;
539
+ if (state === 'text' && !insideQuotes) {
540
+ if (lastTextPureQuoted && token.value.charAt(0) === '@') {
541
+ data.quotedLocalAt[last] = data.text[last].length;
542
+ }
543
+ lastTextPureQuoted = false;
544
+ }
545
+ data[state][last] += token.value;
404
546
  if (token.value) {
405
547
  lastChars[state] = token.value.charAt(token.value.length - 1);
406
548
  }
407
549
  if (state === 'text' && insideQuotes) {
408
- data.textWasQuoted[data.textWasQuoted.length - 1] = true;
550
+ data.textWasQuoted[last] = true;
409
551
  }
410
552
  }
411
553
  else {
@@ -413,14 +555,21 @@ function _handleAddress(tokens, depth) {
413
555
  lastChars[state] = token.value.charAt(token.value.length - 1);
414
556
  if (state === 'text') {
415
557
  data.textWasQuoted.push(insideQuotes || opensAfterEmptyQuotedString);
558
+ // an empty quoted string leaves no text of its own, so the '@' opens the run
559
+ data.quotedLocalAt.push(opensAfterEmptyQuotedString && token.value.charAt(0) === '@' ? 0 : -1);
560
+ lastTextPureQuoted = insideQuotes;
416
561
  }
417
562
  }
418
563
  }
419
564
  }
420
- // If there is no text but a comment, replace the two
565
+ // If there is no text but a comment, replace the two. A comment is never part of a
566
+ // mailbox, so text that came out of one only ever names it: reading an address out of it
567
+ // made '(a@evil.com)' a recipient where no conformant reading has a mailbox at all
568
+ let textFromComment = false;
421
569
  if (!data.text.length && data.comment.length) {
422
570
  data.text = data.comment;
423
571
  data.comment = [];
572
+ textFromComment = true;
424
573
  }
425
574
  if (isGroup) {
426
575
  // http://tools.ietf.org/html/rfc2822#appendix-A.1.3
@@ -449,10 +598,6 @@ function _handleAddress(tokens, depth) {
449
598
  const addressComments = [];
450
599
  const addressParts = [];
451
600
  for (const part of data.address) {
452
- if (part.indexOf('(') < 0) {
453
- addressParts.push(part);
454
- continue;
455
- }
456
601
  const stripped = _stripAddressComments(part);
457
602
  for (const comment of stripped.comments) {
458
603
  addressComments.push(comment);
@@ -463,25 +608,42 @@ function _handleAddress(tokens, depth) {
463
608
  }
464
609
  data.address = addressParts;
465
610
  // If no address was found, try to detect one from regular text
466
- if (!data.address.length && data.text.length) {
467
- for (let i = data.text.length - 1; i >= 0; i--) {
611
+ if (!data.address.length && data.text.length && !textFromComment) {
612
+ // Whitespace beside the '@' is CFWS, so 'user @ good.com' is read as one run. Both
613
+ // tiers read the collapsed runs, and only a run an address comes out of is replaced
614
+ // by its collapsed form, the display text keeps its spacing
615
+ const candidates = data.text.map((part) => _collapseAtSpacing(part));
616
+ // The first addr-spec in the text is the mailbox, as it is for a conformant parser
617
+ // reading as far as it can. Taking the last one let a comment part two of them and
618
+ // pick the second, 'user@good.com (x) evil@evil.com' went to evil.com, while the
619
+ // same two without the comment, one run, went to good.com
620
+ for (let i = 0; i < data.text.length; i++) {
468
621
  // Security: Do not extract email addresses from quoted strings.
469
622
  // RFC 5321 allows @ inside quoted local-parts like "user@domain"@example.com.
470
623
  // Extracting emails from quoted text leads to misrouting vulnerabilities.
471
- if (!data.textWasQuoted[i] && ADDR_SPEC.test(data.text[i])) {
472
- data.address = data.text.splice(i, 1);
624
+ // A run that is a quoted local part with its '@' and domain is an addr-spec of
625
+ // its own, with the quoting put back on, and takes its turn like any other
626
+ const local = data.quotedLocalAt[i];
627
+ const quotedLocal = data.textWasQuoted[i] && local >= 0 && QUOTED_LOCAL_DOMAIN.test(data.text[i].slice(local))
628
+ ? _quoteLocalPart(data.text[i])
629
+ : '';
630
+ if (quotedLocal || (!data.textWasQuoted[i] && _isAddrSpec(candidates[i]))) {
631
+ data.address = [quotedLocal || candidates[i]];
632
+ data.text.splice(i, 1);
473
633
  data.textWasQuoted.splice(i, 1);
634
+ data.quotedLocalAt.splice(i, 1);
474
635
  break;
475
636
  }
476
637
  }
477
638
  // Try a looser regex match if strict match found nothing
478
639
  if (!data.address.length) {
479
640
  let extracted = false;
480
- for (let i = data.text.length - 1; i >= 0; i--) {
641
+ for (let i = 0; i < data.text.length; i++) {
481
642
  // Security: Do not extract email addresses from quoted strings
482
643
  if (!data.textWasQuoted[i]) {
483
- const part = data.text[i];
484
- let remainder = part;
644
+ // the strict tier found nothing, so it spliced nothing and the indexes still line up
645
+ const part = candidates[i];
646
+ let remainder = data.text[i];
485
647
  const at = _looseAddressStart(part);
486
648
  if (at >= 0) {
487
649
  LOOSE_TEXT_ADDR.lastIndex = at;
@@ -530,16 +692,19 @@ function _handleAddress(tokens, depth) {
530
692
  // on a value that is no addr-spec at all (GHSA-g57g-f23g-4646). Putting the quotes
531
693
  // back first is what lets the recovery tell the whitespace an addr-spec may carry
532
694
  // from the wreckage trailing one, as only a quoted local part may hold whitespace
533
- data.address = _quoteLocalPart(data.text);
695
+ // The quotes are gone at this point, so the '@' the domain splits off at is the
696
+ // last one, not the single one _collapseAtSpacing insists on
697
+ data.address = _quoteLocalPart(_joinAtSpacing(data.text, _domainAt(data.text)));
534
698
  data.text = '';
535
699
  }
536
700
  _recoverAddrSpec(data);
537
701
  // a comment names the mailbox only when nothing else does, as one outside the brackets
538
702
  if (!data.text && addressComments.length) {
539
703
  data.text = addressComments.join(' ');
704
+ textFromComment = true;
540
705
  }
541
706
  const address = {
542
- address: data.address || data.text || '',
707
+ address: data.address || (textFromComment ? '' : data.text) || '',
543
708
  name: data.text || data.address || ''
544
709
  };
545
710
  if (address.address === address.name) {
@@ -569,6 +734,10 @@ class Tokenizer {
569
734
  this.escaped = false;
570
735
  this.inDomainLiteral = false;
571
736
  this.afterAt = false;
737
+ this.innerQuoted = false;
738
+ this.innerComment = 0;
739
+ this.innerLiteral = false;
740
+ this.innerEscaped = false;
572
741
  this.list = [];
573
742
  /**
574
743
  * Operator tokens and which tokens are expected to end the sequence
@@ -635,10 +804,32 @@ class Tokenizer {
635
804
  this.inDomainLiteral = false;
636
805
  }
637
806
  }
807
+ // The closer of an angle-addr or a group is only read outside the quoted strings,
808
+ // comments and domain literals it holds. Looking for nothing but the closer let a '>'
809
+ // inside a quoted local part end the angle-addr, '<"a@evil.com>"@good.com>' was read
810
+ // as an address at evil.com, and a ';' inside a quoted display name or a comment end
811
+ // the group and release the rest of that text as recipients. The characters are still
812
+ // collected as they are, the angle-addr and the group content are read again later
813
+ let nested = false;
814
+ if (this.operatorExpecting === '>' || this.operatorExpecting === ';') {
815
+ nested = this._trackInner(chr);
816
+ }
817
+ else if (this.operatorExpecting === ')' && !this.escaped) {
818
+ // Comments nest (RFC 5322 3.2.2). Ending one at the first ')' let the ')' of a
819
+ // nested comment close the outer one and released the rest of it, an address
820
+ // included, into the header
821
+ if (chr === '(') {
822
+ this.innerComment++;
823
+ }
824
+ else if (chr === ')' && this.innerComment) {
825
+ this.innerComment--;
826
+ nested = true;
827
+ }
828
+ }
638
829
  if (this.escaped) {
639
830
  // ignore next condition blocks
640
831
  }
641
- else if (chr === this.operatorExpecting) {
832
+ else if (chr === this.operatorExpecting && !nested) {
642
833
  this.node = {
643
834
  type: 'operator',
644
835
  value: chr
@@ -654,6 +845,7 @@ class Tokenizer {
654
845
  }
655
846
  this.operatorExpecting = '';
656
847
  this.escaped = false;
848
+ this._resetInner();
657
849
  return;
658
850
  }
659
851
  else if (!this.operatorExpecting && !(this.inDomainLiteral && chr === ':') && chr in this.operators) {
@@ -668,6 +860,7 @@ class Tokenizer {
668
860
  }
669
861
  this.operatorExpecting = this.operators[chr];
670
862
  this.escaped = false;
863
+ this._resetInner();
671
864
  return;
672
865
  }
673
866
  else if (['"', "'", ')'].includes(this.operatorExpecting) && chr === '\\') {
@@ -701,13 +894,86 @@ class Tokenizer {
701
894
  }
702
895
  this.escaped = false;
703
896
  }
897
+ /**
898
+ * Tracks the quoted strings, comments and domain literals inside an angle-addr or a
899
+ * group, which the closer of either must not be read from. A backslash escapes the next
900
+ * character inside a quoted string and a comment. A domain literal is only tracked in an
901
+ * angle-addr, where it opens right after an "@" and ends at the "]". dtext holds the list
902
+ * separators as well, and inside the brackets they never split the list anyway. In a group
903
+ * they do, so a literal is not tracked there and an unclosed "[" can not swallow the
904
+ * recipients after it.
905
+ *
906
+ * @param chr Character from the address field
907
+ * @return true while the character sits inside one of them, the one that closes it included
908
+ */
909
+ _trackInner(chr) {
910
+ if (this.innerEscaped) {
911
+ this.innerEscaped = false;
912
+ return true;
913
+ }
914
+ if (this.innerQuoted) {
915
+ if (chr === '\\') {
916
+ this.innerEscaped = true;
917
+ }
918
+ else if (chr === '"') {
919
+ this.innerQuoted = false;
920
+ }
921
+ return true;
922
+ }
923
+ if (this.innerComment) {
924
+ if (chr === '\\') {
925
+ this.innerEscaped = true;
926
+ }
927
+ else if (chr === '(') {
928
+ this.innerComment++;
929
+ }
930
+ else if (chr === ')') {
931
+ this.innerComment--;
932
+ }
933
+ return true;
934
+ }
935
+ if (this.innerLiteral) {
936
+ if (chr === ']') {
937
+ this.innerLiteral = false;
938
+ }
939
+ return true;
940
+ }
941
+ if (chr === '"') {
942
+ this.innerQuoted = true;
943
+ return true;
944
+ }
945
+ if (chr === '(') {
946
+ this.innerComment = 1;
947
+ return true;
948
+ }
949
+ if (chr === '[' && this.afterAt && this.operatorExpecting === '>') {
950
+ this.innerLiteral = true;
951
+ return true;
952
+ }
953
+ // afterAt is otherwise only kept outside the operators and is cleared when one opens
954
+ // or ends, so inside an angle-addr it serves the literal tracked here
955
+ if (!_isSpaceCode(chr.charCodeAt(0))) {
956
+ this.afterAt = chr === '@';
957
+ }
958
+ return false;
959
+ }
960
+ /**
961
+ * Forgets the nesting collected for the operator that just opened or ended
962
+ */
963
+ _resetInner() {
964
+ this.innerQuoted = false;
965
+ this.innerComment = 0;
966
+ this.innerLiteral = false;
967
+ this.innerEscaped = false;
968
+ }
704
969
  }
705
970
  /**
706
971
  * Maximum recursion depth for parsing nested groups.
707
972
  * RFC 5322 doesn't allow nested groups, so this is a safeguard against
708
- * malicious input that could cause stack overflow.
973
+ * malicious input that could cause stack overflow. Every level parses what is left of the
974
+ * value once more, so the limit is also the factor a crafted header multiplies the parse by.
709
975
  */
710
- const MAX_NESTED_GROUP_DEPTH = 50;
976
+ const MAX_NESTED_GROUP_DEPTH = 10;
711
977
  /**
712
978
  * Parses an address list, recursing into the groups it holds.
713
979
  *
@@ -183,7 +183,10 @@ class MailMessage {
183
183
  const values = [data.html, data.text, data.watchHtml, data.amp, data.raw, data.icalEvent];
184
184
  for (const key of ['attachments', 'alternatives']) {
185
185
  if (Array.isArray(data[key])) {
186
- values.push(...data[key]);
186
+ // one at a time, spreading a long enough list into the arguments throws
187
+ for (const entry of data[key]) {
188
+ values.push(entry);
189
+ }
187
190
  }
188
191
  }
189
192
  for (const value of values) {
@@ -64,6 +64,35 @@ const QUOTED_STRING = /^"(?:[^"\\]|\\[\s\S])*"$/;
64
64
  // else goes into angle brackets so that the header can not be read as more addresses than
65
65
  // the envelope carries
66
66
  const PLAIN_ADDRESS = /^[^\s"(),:;<>@[\\\]]+@[^\s"(),:;<>@[\\\]]+$/;
67
+ /**
68
+ * A domain literal that ends an address, see _normalizeAddress
69
+ */
70
+ const DOMAIN_LITERAL_TAIL = /@(\[[^[\]\\]*\])$/;
71
+ /**
72
+ * Removes the whitespace inside every <...> of a References value. A "<" with no ">" after
73
+ * it is left as it is. An index scan, the regex /<[^>]*>/g rescans the rest of the value
74
+ * from every such "<"
75
+ *
76
+ * @param value References header value
77
+ * @returns The value with the msg-ids closed up
78
+ */
79
+ function stripWhitespaceInAngleBrackets(value) {
80
+ let result = '';
81
+ let pos = 0;
82
+ for (;;) {
83
+ const open = value.indexOf('<', pos);
84
+ if (open < 0) {
85
+ break;
86
+ }
87
+ const close = value.indexOf('>', open + 1);
88
+ if (close < 0) {
89
+ break;
90
+ }
91
+ result += value.slice(pos, open) + value.slice(open, close + 1).replace(/\s/g, '');
92
+ pos = close + 1;
93
+ }
94
+ return result + value.slice(pos);
95
+ }
67
96
  // domainToASCII and domainToUnicode are WHATWG host parsers rather than plain IDNA
68
97
  // mappers, so they do more than map: they cut the host at '/', '\\', '?' and '#', drop C0
69
98
  // controls, and percent-decode. Handing them 'evil.example/mail.corp.example' returns the
@@ -1357,26 +1386,26 @@ class MimeNode {
1357
1386
  }
1358
1387
  return value;
1359
1388
  // space separated list of values enclosed in <>
1360
- case 'References':
1361
- value = [].concat
1362
- .apply([], [].concat(value || '').map(elm => {
1363
- elm = (elm || '')
1389
+ case 'References': {
1390
+ const ids = [];
1391
+ for (const entry of [].concat(value || '')) {
1392
+ const elm = (entry || '')
1364
1393
  .toString()
1365
1394
  .replace(/\r?\n|\r/g, ' ')
1366
1395
  .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')
1367
1396
  .trim();
1368
- return elm.replace(/<[^>]*>/g, str => str.replace(/\s/g, '')).split(/\s+/);
1369
- }))
1370
- .map(elm => {
1371
- if (elm.charAt(0) !== '<') {
1372
- elm = '<' + elm;
1373
- }
1374
- if (elm.charAt(elm.length - 1) !== '>') {
1375
- elm = elm + '>';
1397
+ for (let id of stripWhitespaceInAngleBrackets(elm).split(/\s+/)) {
1398
+ if (id.charAt(0) !== '<') {
1399
+ id = '<' + id;
1400
+ }
1401
+ if (id.charAt(id.length - 1) !== '>') {
1402
+ id = id + '>';
1403
+ }
1404
+ ids.push(id);
1376
1405
  }
1377
- return elm;
1378
- });
1379
- return value.join(' ').trim();
1406
+ }
1407
+ return ids.join(' ').trim();
1408
+ }
1380
1409
  case 'Date':
1381
1410
  if (Object.prototype.toString.call(value) === '[object Date]') {
1382
1411
  return value.toUTCString().replace(/GMT/, '+0000');
@@ -1455,7 +1484,10 @@ class MimeNode {
1455
1484
  // callers use an empty value to detect a missing address
1456
1485
  return address;
1457
1486
  }
1458
- const lastAt = address.lastIndexOf('@');
1487
+ // dtext includes '@', so a domain literal is split off at the '@' in front of it and
1488
+ // not at the last one, which delivered 'a@[x@evil.com]' to evil.com
1489
+ const literal = address.charAt(address.length - 1) === ']' ? DOMAIN_LITERAL_TAIL.exec(address) : null;
1490
+ const lastAt = literal ? address.length - literal[1].length - 1 : address.lastIndexOf('@');
1459
1491
  if (lastAt < 0) {
1460
1492
  // Bare username, there is no domain to split off
1461
1493
  return this._normalizeLocalPart(address);
@@ -1,3 +1,3 @@
1
1
  export declare const name = "nodemailer";
2
- export declare const version = "10.1.2";
2
+ export declare const version = "10.1.4";
3
3
  export declare const homepage = "https://nodemailer.com/";