@limetech/lime-elements 40.13.1 → 40.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4404,6 +4404,83 @@ function formatHtmlHeader(message) {
4404
4404
  return template;
4405
4405
  }
4406
4406
 
4407
+ /** The token is an operator, a text run otherwise */
4408
+ const TOKEN_OPERATOR = 1;
4409
+ /** The next character continues the same run without whitespace in between */
4410
+ const TOKEN_NO_BREAK = 2;
4411
+ /** The text run holds characters its value leaves out or changes, so it is no plain slice */
4412
+ const TOKEN_DIRTY = 4;
4413
+ /** A backslash escapes the next character in the text run, inside a quoted string or a comment */
4414
+ const TOKEN_ESCAPES = 8;
4415
+ /** The text run opens on a character an escaping backslash in front of it has escaped */
4416
+ const TOKEN_STARTS_ESCAPED = 16;
4417
+ /**
4418
+ * The tokens of an address field, kept as offsets into it.
4419
+ *
4420
+ * Every token used to be an object of its own, an operator for every ',' and a value string
4421
+ * for every run of text, and a list of one letter names, 'a,a,a,...', took some 65 times its
4422
+ * size in tokens before any address was read. A token is now three numbers in one typed
4423
+ * array, its kind and the range of the field it covers, and its value is sliced off the field
4424
+ * when it is read. The few text runs whose value differs from their slice, as they hold an
4425
+ * escaping backslash, a line break or a control character, are rebuilt once and kept aside.
4426
+ */
4427
+ class TokenList {
4428
+ constructor(str) {
4429
+ this.str = str;
4430
+ this.slots = new Int32Array(48);
4431
+ this.length = 0;
4432
+ this.cleaned = new Map();
4433
+ }
4434
+ /**
4435
+ * Appends a token
4436
+ *
4437
+ * @param kind Kind and flags of the token
4438
+ * @param start Offset of its first character
4439
+ * @param end Offset just past its last character
4440
+ * @return Index of the token
4441
+ */
4442
+ push(kind, start, end) {
4443
+ if ((this.length + 1) * 3 > this.slots.length) {
4444
+ const grown = new Int32Array(this.slots.length * 2);
4445
+ grown.set(this.slots);
4446
+ this.slots = grown;
4447
+ }
4448
+ const at = this.length * 3;
4449
+ this.slots[at] = kind;
4450
+ this.slots[at + 1] = start;
4451
+ this.slots[at + 2] = end;
4452
+ return this.length++;
4453
+ }
4454
+ /**
4455
+ * The character of an operator token, an empty string for a text run
4456
+ */
4457
+ operatorAt(index) {
4458
+ const at = index * 3;
4459
+ return this.slots[at] & TOKEN_OPERATOR ? this.str.charAt(this.slots[at + 1]) : '';
4460
+ }
4461
+ /**
4462
+ * Whether a token is an operator
4463
+ */
4464
+ isOperator(index) {
4465
+ return (this.slots[index * 3] & TOKEN_OPERATOR) !== 0;
4466
+ }
4467
+ /**
4468
+ * Whether the next character continues the run of a token without whitespace in between
4469
+ */
4470
+ noBreak(index) {
4471
+ return (this.slots[index * 3] & TOKEN_NO_BREAK) !== 0;
4472
+ }
4473
+ /**
4474
+ * Value of a token
4475
+ */
4476
+ value(index) {
4477
+ const cleaned = this.cleaned.get(index);
4478
+ if (cleaned !== undefined) {
4479
+ return cleaned;
4480
+ }
4481
+ return this.str.slice(this.slots[index * 3 + 1], this.slots[index * 3 + 2]);
4482
+ }
4483
+ }
4407
4484
  /**
4408
4485
  * Any whitespace, the test for an angle-addr that needs recovering
4409
4486
  */
@@ -4417,6 +4494,23 @@ const QUOTED_LOCAL_ADDR = /^("(?:[^"\\]|\\[\s\S])*"@\S+)(?:\s+([\s\S]+))?$/;
4417
4494
  * One run holding a single '@' and no whitespace, the shape an addr-spec has to have
4418
4495
  */
4419
4496
  const ADDR_SPEC = /^[^@\s]+@[^@\s]+$/;
4497
+ /**
4498
+ * The same with a domain literal for the domain, which may hold an '@' of its own
4499
+ */
4500
+ const LITERAL_ADDR_SPEC = /^[^@\s]+@\[[^[\]\\\s]*\]$/;
4501
+ /**
4502
+ * The '@' and domain that follow a quoted local part in the same run
4503
+ */
4504
+ const QUOTED_LOCAL_DOMAIN = /^@(?:[^@\s"]+|\[[^[\]\\\s]*\])$/;
4505
+ /**
4506
+ * Whether a whitespace free run reads as an addr-spec under the strict grammar
4507
+ *
4508
+ * @param value Run to check
4509
+ * @return true when it does
4510
+ */
4511
+ function isAddrSpec(value) {
4512
+ return ADDR_SPEC.test(value) || LITERAL_ADDR_SPEC.test(value);
4513
+ }
4420
4514
  /**
4421
4515
  * The looser reading applied once the strict one finds nothing, which tolerates the
4422
4516
  * further '@' that a domain should not have but malformed headers carry anyway
@@ -4427,6 +4521,22 @@ const LOOSE_ADDR_SPEC = /^[^@\s]+@\S+$/;
4427
4521
  * on purpose: it is only run at the offset looseAddressStart picks, see there.
4428
4522
  */
4429
4523
  const LOOSE_TEXT_ADDR = /\s*\b[^@\s]+@[^\s]+\b\s*/y;
4524
+ /**
4525
+ * A domain literal that ends an address. dtext includes '@' (RFC 5322 3.4.1), so the '@'
4526
+ * in front of the literal is the one that splits the domain off, not the last one: reading
4527
+ * 'a@[x@evil.com]' at its last '@' made it an address at evil.com.
4528
+ */
4529
+ const DOMAIN_LITERAL_TAIL = /@(\[[^[\]\\]*\])$/;
4530
+ /**
4531
+ * Offset of the '@' that splits the domain off an address, or -1 when there is none
4532
+ *
4533
+ * @param address Address to split
4534
+ * @return Offset of the '@'
4535
+ */
4536
+ function domainAt(address) {
4537
+ const literal = address.charAt(address.length - 1) === ']' ? DOMAIN_LITERAL_TAIL.exec(address) : null;
4538
+ return literal ? address.length - literal[1].length - 1 : address.lastIndexOf('@');
4539
+ }
4430
4540
  /**
4431
4541
  * Restores the quoting of a local part that was read out of a quoted string.
4432
4542
  *
@@ -4440,7 +4550,7 @@ const LOOSE_TEXT_ADDR = /\s*\b[^@\s]+@[^\s]+\b\s*/y;
4440
4550
  * @return Address with the local part as a quoted-string
4441
4551
  */
4442
4552
  function quoteLocalPart(address) {
4443
- const lastAt = address.lastIndexOf('@');
4553
+ const lastAt = domainAt(address);
4444
4554
  if (lastAt < 0) {
4445
4555
  // no domain to split off, nothing can be misrouted
4446
4556
  return address;
@@ -4500,6 +4610,62 @@ function indexOfAt(text, from, to) {
4500
4610
  }
4501
4611
  return -1;
4502
4612
  }
4613
+ /**
4614
+ * A quoted-string a value opens with
4615
+ */
4616
+ const LEADING_QUOTED_STRING = /^"(?:[^"\\]|\\[\s\S])*"/;
4617
+ /**
4618
+ * Drops the whitespace on either side of the '@' at an offset.
4619
+ *
4620
+ * obs-local-part and obs-domain allow CFWS around the '@' (RFC 5322 4.4), so 'user @ good.com'
4621
+ * names user@good.com. Kept, the whitespace became part of the address as '"user "@ good.com'.
4622
+ * The scan is by index, a pattern such as /\s+@/ backtracks over a long whitespace run that
4623
+ * no '@' follows.
4624
+ *
4625
+ * @param value Value holding the '@'
4626
+ * @param at Offset of the '@', or -1
4627
+ * @return The value with the whitespace beside that '@' removed
4628
+ */
4629
+ function joinAtSpacing(value, at) {
4630
+ if (at < 0) {
4631
+ return value;
4632
+ }
4633
+ let start = at;
4634
+ while (start > 0 && isSpaceCode(value.charCodeAt(start - 1))) {
4635
+ start--;
4636
+ }
4637
+ let end = at + 1;
4638
+ while (end < value.length && isSpaceCode(value.charCodeAt(end))) {
4639
+ end++;
4640
+ }
4641
+ return start === at && end === at + 1 ? value : value.slice(0, start) + '@' + value.slice(end);
4642
+ }
4643
+ /**
4644
+ * Drops the whitespace beside the '@' that splits the domain off a value. That is the '@'
4645
+ * right after a quoted local part the value opens with, or else the only '@' it holds outside
4646
+ * any quoted string. With several it is not clear which one the whitespace belongs to, and
4647
+ * '<x@evil.com @good.com>' is left to the addr-spec recovery to read as it always has.
4648
+ *
4649
+ * @param value Value to clean up
4650
+ * @return The value with the whitespace beside that '@' removed
4651
+ */
4652
+ function collapseAtSpacing(value) {
4653
+ const quoted = LEADING_QUOTED_STRING.exec(value);
4654
+ if (quoted) {
4655
+ let at = quoted[0].length;
4656
+ while (at < value.length && isSpaceCode(value.charCodeAt(at))) {
4657
+ at++;
4658
+ }
4659
+ if (value.charAt(at) === '@' && value.indexOf('@', at + 1) < 0) {
4660
+ return joinAtSpacing(value, at);
4661
+ }
4662
+ }
4663
+ const at = value.indexOf('@');
4664
+ if (at < 0 || value.indexOf('@', at + 1) >= 0 || value.lastIndexOf('"') > at) {
4665
+ return value;
4666
+ }
4667
+ return joinAtSpacing(value, at);
4668
+ }
4503
4669
  /**
4504
4670
  * Finds the offset LOOSE_TEXT_ADDR matches at, or -1 when it does not match at all.
4505
4671
  *
@@ -4589,6 +4755,7 @@ function looseAddressStart(text) {
4589
4755
  * @return The address and the display text, with any leftovers moved to the text
4590
4756
  */
4591
4757
  function recoverAddrSpec(address, text) {
4758
+ address = collapseAtSpacing(address);
4592
4759
  if (!HAS_WHITESPACE.test(address)) {
4593
4760
  return { address, text };
4594
4761
  }
@@ -4614,7 +4781,7 @@ function recoverAddrSpec(address, text) {
4614
4781
  return { address, text };
4615
4782
  }
4616
4783
  const parts = address.split(/\s+/);
4617
- let addrIndex = parts.findIndex(part => ADDR_SPEC.test(part));
4784
+ let addrIndex = parts.findIndex(isAddrSpec);
4618
4785
  if (addrIndex < 0) {
4619
4786
  addrIndex = parts.findIndex(part => LOOSE_ADDR_SPEC.test(part));
4620
4787
  }
@@ -4639,7 +4806,16 @@ function recoverAddrSpec(address, text) {
4639
4806
  * consumer stripping the comment reads as 'user@example.comevil.com'. A comment is folding
4640
4807
  * whitespace, so it is read as a space that splits two atoms and as nothing next to an '@',
4641
4808
  * the same rule the token walk applies outside the brackets. Quoted strings and domain
4642
- * literals are copied through untouched, a '(' in there is not a comment.
4809
+ * literals are copied through untouched, a '(' in there is not a comment. An unexpected '<'
4810
+ * outside them drops everything in front of it, comments included.
4811
+ *
4812
+ * RFC 822 also let an angle-addr name the relays to go through in front of the mailbox,
4813
+ * '<@relay1,@relay2:user@example.com>', which RFC 5322 still parses as obs-route. The route
4814
+ * is not part of the mailbox, and folded into the local part it made
4815
+ * '"@relay1,@relay2:user"@example.com' a recipient that does not exist. A local part can not
4816
+ * start with '@', so contents that do, up to the first ':' outside a domain literal and in
4817
+ * front of any quoted string, are a route and are dropped, as long as a mailbox with an '@'
4818
+ * of its own follows.
4643
4819
  *
4644
4820
  * @param address Contents of the angle brackets
4645
4821
  * @return The address with the comments removed, and the text of the comments
@@ -4653,6 +4829,16 @@ function stripAddressComments(address) {
4653
4829
  // carried along rather than read back off the growing result, which would flatten it on
4654
4830
  // every comment (see lastChars in _handleAddress)
4655
4831
  let lastChar = '';
4832
+ // A comment that just closed stands for a space, written once the next character shows
4833
+ // whether one is wanted: not beside an '@', and not on top of whitespace that follows.
4834
+ // Deciding on the spot saw only the character right after the ')', so a second comment
4835
+ // in a row hid the '@' behind it and 'a(x)(y)@b' read as 'a @b'
4836
+ let pendingSpace = false;
4837
+ // first character of the result and whether a quoted string has opened since, which
4838
+ // together tell a route from a local part
4839
+ let firstChar = '';
4840
+ let quoteSeen = false;
4841
+ let routeEnd = -1;
4656
4842
  for (let i = 0, len = address.length; i < len; i++) {
4657
4843
  const chr = address.charAt(i);
4658
4844
  if (depth) {
@@ -4666,16 +4852,20 @@ function stripAddressComments(address) {
4666
4852
  else if (chr === ')' && !--depth) {
4667
4853
  comments.push(comment.trim());
4668
4854
  comment = '';
4669
- if (lastChar !== '@' && address.charAt(i + 1) !== '@') {
4670
- result += ' ';
4671
- lastChar = ' ';
4672
- }
4855
+ pendingSpace = lastChar !== '@';
4673
4856
  }
4674
4857
  else {
4675
4858
  comment += chr;
4676
4859
  }
4677
4860
  continue;
4678
4861
  }
4862
+ if (pendingSpace && chr !== '(') {
4863
+ pendingSpace = false;
4864
+ if (chr !== '@' && chr !== '<' && !isSpaceCode(address.charCodeAt(i))) {
4865
+ result += ' ';
4866
+ lastChar = ' ';
4867
+ }
4868
+ }
4679
4869
  if (closer) {
4680
4870
  if (chr === '\\' && closer === '"' && i < len - 1) {
4681
4871
  result += chr + address.charAt(++i);
@@ -4688,6 +4878,10 @@ function stripAddressComments(address) {
4688
4878
  }
4689
4879
  else if (chr === '"') {
4690
4880
  closer = '"';
4881
+ quoteSeen = true;
4882
+ }
4883
+ else if (chr === ':' && routeEnd < 0 && firstChar === '@' && !quoteSeen) {
4884
+ routeEnd = result.length + 1;
4691
4885
  }
4692
4886
  else if (chr === '[') {
4693
4887
  closer = ']';
@@ -4696,8 +4890,27 @@ function stripAddressComments(address) {
4696
4890
  depth = 1;
4697
4891
  continue;
4698
4892
  }
4893
+ else if (chr === '<') {
4894
+ // Apple Mail drops everything between an unexpected '<' and the address after it.
4895
+ // Read here rather than off the raw brackets so that a '<' inside a quoted string,
4896
+ // a comment or a domain literal stays where it is: '<"a<b"@victim.com>' cut at
4897
+ // the first '<' left the address 'b"@victim.com'
4898
+ result = '';
4899
+ lastChar = '';
4900
+ comments.length = 0;
4901
+ firstChar = '';
4902
+ quoteSeen = false;
4903
+ routeEnd = -1;
4904
+ continue;
4905
+ }
4699
4906
  result += chr;
4700
4907
  lastChar = chr;
4908
+ if (!firstChar && !isSpaceCode(address.charCodeAt(i))) {
4909
+ firstChar = chr;
4910
+ }
4911
+ }
4912
+ if (routeEnd >= 0 && result.indexOf('@', routeEnd) >= 0) {
4913
+ result = result.slice(routeEnd);
4701
4914
  }
4702
4915
  if (depth) {
4703
4916
  // an unterminated comment runs to the end of the address
@@ -4740,37 +4953,43 @@ function hasAngleAddress(text) {
4740
4953
  return false;
4741
4954
  }
4742
4955
  /**
4743
- * Converts tokens for a single address into an address object
4956
+ * Walks the tokens of one entry of an address list and sorts them into the runs of display
4957
+ * text, angle-addr and comment they belong to
4744
4958
  *
4745
- * @param tokens Tokens object
4746
- * @param depth Current recursion depth for nested group protection
4747
- * @return Address objects
4959
+ * @param tokens Tokens of the whole address list
4960
+ * @param from Index of the first token of the entry
4961
+ * @param to Index just past the last token of the entry
4962
+ * @return The collected runs, and whether the display text came out of a comment
4748
4963
  */
4749
- function _handleAddress(tokens, depth) {
4750
- let isGroup = false;
4964
+ function collectAddressParts(tokens, from, to) {
4751
4965
  let state = 'text';
4752
- let addresses = [];
4753
4966
  let data = {
4754
4967
  address: [],
4755
4968
  comment: [],
4756
- group: [],
4757
4969
  text: [],
4758
- textWasQuoted: [] // Track which text tokens came from inside quotes
4970
+ textWasQuoted: [], // Track which text tokens came from inside quotes
4971
+ // For a text run that is a quoted local part followed by the unquoted '@' and domain
4972
+ // of an addr-spec, '"user"@example.com', the length of the local part, -1 otherwise
4973
+ quotedLocalAt: []
4759
4974
  };
4975
+ // whether the last text run holds nothing but quoted text so far, which is what a quoted
4976
+ // local part is until the '@' after it joins the run
4977
+ let lastTextPureQuoted = false;
4760
4978
  let i;
4761
- let len;
4762
4979
  let insideQuotes = false; // Track if we're currently inside a quoted string
4763
4980
  // Last character of the run each state is currently accumulating. Reading it back off
4764
4981
  // the accumulator with slice(-1) makes the engine flatten the whole growing string on
4765
4982
  // every token, which is quadratic over an address built from many comment-joined atoms.
4766
4983
  // A run only ever grows by the token appended below, so the character is carried along.
4767
- const lastChars = { address: '', comment: '', group: '', text: '' };
4984
+ const lastChars = { address: '', comment: '', text: '' };
4768
4985
  // Filter out <addresses>, (comments) and regular text
4769
- for (i = 0, len = tokens.length; i < len; i++) {
4770
- let token = tokens[i];
4771
- let prevToken = i ? tokens[i - 1] : null;
4772
- if (token.type === 'operator') {
4773
- switch (token.value) {
4986
+ for (i = from; i < to; i++) {
4987
+ const value = tokens.value(i);
4988
+ // the operator in front of this token, if there is one, and whether it runs on into it
4989
+ const prevOperator = i > from ? tokens.operatorAt(i - 1) : '';
4990
+ const prevNoBreak = i > from && tokens.noBreak(i - 1);
4991
+ if (tokens.isOperator(i)) {
4992
+ switch (value) {
4774
4993
  case '<':
4775
4994
  state = 'address';
4776
4995
  insideQuotes = false;
@@ -4779,11 +4998,6 @@ function _handleAddress(tokens, depth) {
4779
4998
  state = 'comment';
4780
4999
  insideQuotes = false;
4781
5000
  break;
4782
- case ':':
4783
- state = 'group';
4784
- isGroup = true;
4785
- insideQuotes = false;
4786
- break;
4787
5001
  case '"':
4788
5002
  // Track quote state for text tokens
4789
5003
  insideQuotes = !insideQuotes;
@@ -4795,205 +5009,225 @@ function _handleAddress(tokens, depth) {
4795
5009
  break;
4796
5010
  }
4797
5011
  }
4798
- else if (token.value) {
5012
+ else {
4799
5013
  // An empty quoted string is dropped by the tokenizer, leaving no text token of its
4800
5014
  // own to record the quoting on, so the pair of quote operators right in front of
4801
5015
  // this token is all that is left of it and the run it opens carries the quoting
4802
5016
  // instead. Without this '""@example.com' reads as the bare '@example.com'. It only
4803
5017
  // ever opens a run: a run already holding unquoted text is not a quoted string
4804
- const prevPrevToken = i > 1 ? tokens[i - 2] : null;
4805
- const opensAfterEmptyQuotedString = prevToken?.type === 'operator' &&
4806
- prevToken.value === '"' &&
4807
- !!prevToken.noBreak &&
4808
- prevPrevToken?.type === 'operator' &&
4809
- prevPrevToken.value === '"';
4810
- if (state === 'address') {
4811
- // handle use case where unquoted name includes a "<"
4812
- // Apple Mail truncates everything between an unexpected < and an address
4813
- // and so will we
4814
- token.value = token.value.replace(/^[^<]*<\s*/, '');
4815
- }
5018
+ const opensAfterEmptyQuotedString = prevOperator === '"' && prevNoBreak && i > from + 1 && tokens.operatorAt(i - 2) === '"';
4816
5019
  // A comment is folding whitespace. It may sit inside an addr-spec, on either side
4817
5020
  // of the '@', but it cannot join two atoms into one: gluing across it would read
4818
5021
  // 'user@example.com(x)evil.com' as the single domain 'example.comevil.com' and
4819
5022
  // name a domain the sender never wrote.
4820
- const joins = prevToken &&
4821
- prevToken.noBreak &&
5023
+ const joins = prevNoBreak &&
4822
5024
  data[state].length &&
4823
- (prevToken.value !== ')' || lastChars[state] === '@' || token.value.charAt(0) === '@');
5025
+ (prevOperator !== ')' || lastChars[state] === '@' || value.charAt(0) === '@');
4824
5026
  if (joins) {
4825
5027
  // join values
4826
- data[state][data[state].length - 1] += token.value;
5028
+ const last = data[state].length - 1;
5029
+ if (state === 'text' && !insideQuotes) {
5030
+ if (lastTextPureQuoted && value.charAt(0) === '@') {
5031
+ data.quotedLocalAt[last] = data.text[last].length;
5032
+ }
5033
+ lastTextPureQuoted = false;
5034
+ }
5035
+ data[state][last] += value;
4827
5036
  if (state === 'text' && insideQuotes) {
4828
- data.textWasQuoted[data.textWasQuoted.length - 1] = true;
5037
+ data.textWasQuoted[last] = true;
4829
5038
  }
4830
5039
  }
4831
5040
  else {
4832
- data[state].push(token.value);
5041
+ data[state].push(value);
4833
5042
  if (state === 'text') {
4834
5043
  data.textWasQuoted.push(insideQuotes || opensAfterEmptyQuotedString);
5044
+ // an empty quoted string leaves no text of its own, so the '@' opens the run
5045
+ data.quotedLocalAt.push(opensAfterEmptyQuotedString && value.charAt(0) === '@' ? 0 : -1);
5046
+ lastTextPureQuoted = insideQuotes;
4835
5047
  }
4836
5048
  }
4837
- lastChars[state] = token.value.charAt(token.value.length - 1);
5049
+ lastChars[state] = value.charAt(value.length - 1);
4838
5050
  }
4839
5051
  }
4840
- // If there is no text but a comment, replace the two
5052
+ // If there is no text but a comment, replace the two. A comment is never part of a
5053
+ // mailbox, so text that came out of one only ever names it: reading an address out of it
5054
+ // made '(a@evil.com)' a recipient where no conformant reading has a mailbox at all
5055
+ let textFromComment = false;
4841
5056
  if (!data.text.length && data.comment.length) {
4842
5057
  data.text = data.comment;
4843
5058
  data.comment = [];
5059
+ textFromComment = true;
4844
5060
  }
4845
- if (isGroup) {
4846
- // http://tools.ietf.org/html/rfc2822#appendix-A.1.3
4847
- const text = data.text.join(' ');
4848
- // Parse group members, but flatten any nested groups (RFC 5322 doesn't allow nesting)
4849
- let groupMembers = [];
4850
- if (data.group.length) {
4851
- let parsedGroup = parseAddressList(data.group.join(','), depth + 1);
4852
- // Flatten: if any member is itself a group, extract its members into the sequence
4853
- parsedGroup.forEach(member => {
4854
- if (member.group) {
4855
- // Nested group detected - flatten it by adding its members directly
4856
- for (const nestedMember of member.group) {
4857
- groupMembers.push(nestedMember);
4858
- }
4859
- }
4860
- else {
4861
- groupMembers.push(member);
4862
- }
4863
- });
4864
- }
4865
- addresses.push({
4866
- name: decodeWords(text),
4867
- group: groupMembers
4868
- });
4869
- }
4870
- else {
4871
- // Comments come out of the angle-addr before anything asks whether one was found, so
4872
- // that brackets holding nothing but a comment read the same as empty ones
4873
- const addressComments = [];
4874
- const addressParts = [];
4875
- for (const part of data.address) {
4876
- if (part.indexOf('(') < 0) {
4877
- addressParts.push(part);
4878
- continue;
4879
- }
4880
- const stripped = stripAddressComments(part);
4881
- for (const comment of stripped.comments) {
4882
- addressComments.push(comment);
4883
- }
4884
- if (stripped.address) {
4885
- addressParts.push(stripped.address);
5061
+ return { data, textFromComment };
5062
+ }
5063
+ /**
5064
+ * Name of a group, from the tokens in front of its ':'. A comment names it when no text
5065
+ * does, as it names a mailbox.
5066
+ *
5067
+ * @param tokens Tokens of the whole address list
5068
+ * @param from Index of the first token in front of the ':'
5069
+ * @param to Index of the ':'
5070
+ * @return Decoded name of the group
5071
+ */
5072
+ function groupName(tokens, from, to) {
5073
+ // http://tools.ietf.org/html/rfc2822#appendix-A.1.3
5074
+ return decodeWords(collectAddressParts(tokens, from, to).data.text.join(' '));
5075
+ }
5076
+ /**
5077
+ * Converts tokens for a single address into an address object. An address that is nothing
5078
+ * but an encoded word holding an angle-addr is parsed again once decoded, which can give
5079
+ * more than one entry.
5080
+ *
5081
+ * @param tokens Tokens of the whole address list
5082
+ * @param from Index of the first token of this address
5083
+ * @param to Index just past the last token of this address
5084
+ * @param depth Current recursion depth of the encoded word re-parse
5085
+ * @return Address objects
5086
+ */
5087
+ function _handleAddress(tokens, from, to, depth) {
5088
+ const collected = collectAddressParts(tokens, from, to);
5089
+ const data = collected.data;
5090
+ let textFromComment = collected.textFromComment;
5091
+ let i;
5092
+ // Comments come out of the angle-addr before anything asks whether one was found, so
5093
+ // that brackets holding nothing but a comment read the same as empty ones
5094
+ const addressComments = [];
5095
+ const addressParts = [];
5096
+ for (const part of data.address) {
5097
+ const stripped = stripAddressComments(part);
5098
+ for (const comment of stripped.comments) {
5099
+ addressComments.push(comment);
5100
+ }
5101
+ if (stripped.address) {
5102
+ addressParts.push(stripped.address);
5103
+ }
5104
+ }
5105
+ data.address = addressParts;
5106
+ // If no address was found, try to detect one from regular text
5107
+ if (!data.address.length && data.text.length && !textFromComment) {
5108
+ // Whitespace beside the '@' is CFWS, so 'user @ good.com' is read as one run. Both
5109
+ // tiers read the collapsed runs, and only a run an address comes out of is replaced
5110
+ // by its collapsed form, the display text keeps its spacing
5111
+ const candidates = data.text.map(part => collapseAtSpacing(part));
5112
+ // The first addr-spec in the text is the mailbox, as it is for a conformant parser
5113
+ // reading as far as it can. Taking the last one let a comment part two of them and
5114
+ // pick the second, 'user@good.com (x) evil@evil.com' named evil.com, while the
5115
+ // same two without the comment, one run, named good.com
5116
+ for (i = 0; i < data.text.length; i++) {
5117
+ // Security fix: Do not extract email addresses from quoted strings
5118
+ // RFC 5321 allows @ inside quoted local-parts like "user@domain"@example.com
5119
+ // Extracting emails from quoted text leads to misrouting vulnerabilities
5120
+ // A run that is a quoted local part with its '@' and domain is an addr-spec of
5121
+ // its own, with the quoting put back on, and takes its turn like any other
5122
+ const local = data.quotedLocalAt[i];
5123
+ const quotedLocal = data.textWasQuoted[i] && local >= 0 && QUOTED_LOCAL_DOMAIN.test(data.text[i].slice(local))
5124
+ ? quoteLocalPart(data.text[i])
5125
+ : '';
5126
+ if (quotedLocal || (!data.textWasQuoted[i] && isAddrSpec(candidates[i]))) {
5127
+ data.address = [quotedLocal || candidates[i]];
5128
+ data.text.splice(i, 1);
5129
+ data.textWasQuoted.splice(i, 1);
5130
+ data.quotedLocalAt.splice(i, 1);
5131
+ break;
4886
5132
  }
4887
5133
  }
4888
- data.address = addressParts;
4889
- // If no address was found, try to detect one from regular text
4890
- if (!data.address.length && data.text.length) {
4891
- for (i = data.text.length - 1; i >= 0; i--) {
5134
+ // still no address
5135
+ if (!data.address.length) {
5136
+ for (i = 0; i < data.text.length; i++) {
4892
5137
  // Security fix: Do not extract email addresses from quoted strings
4893
- // RFC 5321 allows @ inside quoted local-parts like "user@domain"@example.com
4894
- // Extracting emails from quoted text leads to misrouting vulnerabilities
4895
- if (!data.textWasQuoted[i] && ADDR_SPEC.test(data.text[i])) {
4896
- data.address = data.text.splice(i, 1);
4897
- data.textWasQuoted.splice(i, 1);
4898
- break;
4899
- }
4900
- }
4901
- // still no address
4902
- if (!data.address.length) {
4903
- for (i = data.text.length - 1; i >= 0; i--) {
4904
- // Security fix: Do not extract email addresses from quoted strings
4905
- if (!data.textWasQuoted[i]) {
4906
- // handles an email address that has more than one @
4907
- const part = data.text[i];
4908
- const start = looseAddressStart(part);
4909
- if (start >= 0) {
4910
- LOOSE_TEXT_ADDR.lastIndex = start;
4911
- const match = LOOSE_TEXT_ADDR.exec(part);
4912
- if (match) {
4913
- data.address = [match[0].trim()];
4914
- // the match takes the whitespace around the address along, so
4915
- // the text on either side is joined with a single space
4916
- data.text[i] = (part.slice(0, start) +
4917
- ' ' +
4918
- part.slice(start + match[0].length)).trim();
4919
- break;
4920
- }
5138
+ if (!data.textWasQuoted[i]) {
5139
+ // handles an email address that has more than one @. The strict tier
5140
+ // found nothing, so it spliced nothing and the indexes still line up
5141
+ const part = candidates[i];
5142
+ const start = looseAddressStart(part);
5143
+ if (start >= 0) {
5144
+ LOOSE_TEXT_ADDR.lastIndex = start;
5145
+ const match = LOOSE_TEXT_ADDR.exec(part);
5146
+ if (match) {
5147
+ data.address = [match[0].trim()];
5148
+ // the match takes the whitespace around the address along, so
5149
+ // the text on either side is joined with a single space
5150
+ data.text[i] = (part.slice(0, start) + ' ' + part.slice(start + match[0].length)).trim();
5151
+ break;
4921
5152
  }
4922
- data.text[i] = part.trim();
4923
5153
  }
5154
+ data.text[i] = data.text[i].trim();
4924
5155
  }
4925
5156
  }
4926
5157
  }
4927
- // If there's still no text but a comment exists, replace the two
4928
- if (!data.text.length && data.comment.length) {
4929
- data.text = data.comment;
4930
- data.comment = [];
4931
- }
4932
- // Keep only the first address occurrence, push others to regular text
4933
- if (data.address.length > 1) {
4934
- data.text = data.text.concat(data.address.splice(1));
4935
- }
4936
- // An address is only taken from unquoted text, so anything left in the text at this
4937
- // point that still has to serve as the address carries its quoting in this flag
4938
- const addressFromQuotedText = !data.address.length && data.textWasQuoted.some(wasQuoted => wasQuoted);
4939
- // Join values with spaces
4940
- let text = data.text.join(' ');
4941
- let addressValue = data.address.join(' ');
4942
- // `^=\?[^=]+?=$` could not match a base64 word whose padding puts an '=' inside it,
4943
- // so whether a bare encoded word was decoded or left to become the address itself
4944
- // came down to whether its payload happened to need padding.
4945
- if (!addressValue && isEncodedWordsOnly(text.trim())) {
4946
- // try to extract words from text content
4947
- const decodedText = decodeWords(text);
4948
- // Security: only re-parse if decoded text contains angle-bracket addresses.
4949
- // Without this, a bare encoded email (e.g. =?utf-8?B?dGVzdEBldmlsLmNv?=)
4950
- // would be fabricated into an address from attacker-controlled input. A quoted
4951
- // encoded word is never re-parsed, addresses are not taken out of quoted strings
4952
- if (!addressFromQuotedText && hasAngleAddress(decodedText)) {
4953
- const parsedSubAddresses = parseAddressList(decodedText, depth + 1);
4954
- if (parsedSubAddresses.length) {
4955
- return parsedSubAddresses;
4956
- }
4957
- }
4958
- // No usable address found - treat decoded text as display name only
4959
- return [{ address: '', name: decodedText }];
4960
- }
4961
- if (addressFromQuotedText && text.indexOf('@') >= 0) {
4962
- // The mailbox is still sitting in the text, so it moves over here and is quoted
4963
- // before the recovery below rather than after it. Anything else the text holds
4964
- // came along with it: a comment ends the domain but leaves the atoms behind it in
4965
- // the same text, so '"user"@example.com(x)evil.com' would otherwise hand on
4966
- // 'user@example.com evil.com', a second domain riding along in a value that is no
4967
- // addr-spec at all. Putting the quotes back first is what lets the recovery tell
4968
- // the whitespace a quoted local part may carry from the wreckage trailing one
4969
- addressValue = quoteLocalPart(text);
4970
- text = '';
4971
- }
4972
- ({ address: addressValue, text } = recoverAddrSpec(addressValue, text));
4973
- // a comment names the mailbox only when nothing else does, as one outside the brackets
4974
- if (!text && addressComments.length) {
4975
- text = addressComments.join(' ');
4976
- }
4977
- const rawName = text || addressValue || '';
4978
- const address = {
4979
- address: addressValue || text || '',
4980
- name: decodeWords(rawName)
4981
- };
4982
- // The raw comparison is the check Nodemailer makes, it holds when a single source
4983
- // filled both fields. The decoded one is added because postal-mime decodes names,
4984
- // which also catches a name that decodes to the address itself. Comparing only the
4985
- // decoded name kept a name that mixes an encoded word with plain text as the address
4986
- if (address.address === rawName || address.address === address.name) {
4987
- if (address.address.match(/@/)) {
4988
- address.name = '';
4989
- }
4990
- else {
4991
- address.address = '';
5158
+ }
5159
+ // If there's still no text but a comment exists, replace the two
5160
+ if (!data.text.length && data.comment.length) {
5161
+ data.text = data.comment;
5162
+ data.comment = [];
5163
+ }
5164
+ // Keep only the first address occurrence, push others to regular text
5165
+ if (data.address.length > 1) {
5166
+ data.text = data.text.concat(data.address.splice(1));
5167
+ }
5168
+ // An address is only taken from unquoted text, so anything left in the text at this
5169
+ // point that still has to serve as the address carries its quoting in this flag
5170
+ const addressFromQuotedText = !data.address.length && data.textWasQuoted.some(wasQuoted => wasQuoted);
5171
+ // Join values with spaces
5172
+ let text = data.text.join(' ');
5173
+ let addressValue = data.address.join(' ');
5174
+ // `^=\?[^=]+?=$` could not match a base64 word whose padding puts an '=' inside it,
5175
+ // so whether a bare encoded word was decoded or left to become the address itself
5176
+ // came down to whether its payload happened to need padding.
5177
+ if (!addressValue && isEncodedWordsOnly(text.trim())) {
5178
+ // try to extract words from text content
5179
+ const decodedText = decodeWords(text);
5180
+ // Security: only re-parse if decoded text contains angle-bracket addresses.
5181
+ // Without this, a bare encoded email (e.g. =?utf-8?B?dGVzdEBldmlsLmNv?=)
5182
+ // would be fabricated into an address from attacker-controlled input. A quoted
5183
+ // encoded word is never re-parsed, addresses are not taken out of quoted strings
5184
+ // and neither is one that came out of a comment
5185
+ if (!addressFromQuotedText && !textFromComment && hasAngleAddress(decodedText)) {
5186
+ const parsedSubAddresses = parseAddressList(decodedText, depth + 1);
5187
+ if (parsedSubAddresses.length) {
5188
+ return parsedSubAddresses;
4992
5189
  }
4993
5190
  }
4994
- addresses.push(address);
5191
+ // No usable address found - treat decoded text as display name only
5192
+ return [{ address: '', name: decodedText }];
5193
+ }
5194
+ if (addressFromQuotedText && text.indexOf('@') >= 0) {
5195
+ // The mailbox is still sitting in the text, so it moves over here and is quoted
5196
+ // before the recovery below rather than after it. Anything else the text holds
5197
+ // came along with it: a comment ends the domain but leaves the atoms behind it in
5198
+ // the same text, so '"user"@example.com(x)evil.com' would otherwise hand on
5199
+ // 'user@example.com evil.com', a second domain riding along in a value that is no
5200
+ // addr-spec at all. Putting the quotes back first is what lets the recovery tell
5201
+ // the whitespace a quoted local part may carry from the wreckage trailing one
5202
+ // The quotes are gone at this point, so the '@' the domain splits off at is the
5203
+ // last one, not the single one collapseAtSpacing insists on
5204
+ addressValue = quoteLocalPart(joinAtSpacing(text, domainAt(text)));
5205
+ text = '';
5206
+ }
5207
+ ({ address: addressValue, text } = recoverAddrSpec(addressValue, text));
5208
+ // a comment names the mailbox only when nothing else does, as one outside the brackets
5209
+ if (!text && addressComments.length) {
5210
+ text = addressComments.join(' ');
5211
+ textFromComment = true;
5212
+ }
5213
+ const rawName = text || addressValue || '';
5214
+ const address = {
5215
+ address: addressValue || (textFromComment ? '' : text) || '',
5216
+ name: decodeWords(rawName)
5217
+ };
5218
+ // The raw comparison is the check Nodemailer makes, it holds when a single source
5219
+ // filled both fields. The decoded one is added because postal-mime decodes names,
5220
+ // which also catches a name that decodes to the address itself. Comparing only the
5221
+ // decoded name kept a name that mixes an encoded word with plain text as the address
5222
+ if (address.address === rawName || address.address === address.name) {
5223
+ if (address.address.match(/@/)) {
5224
+ address.name = '';
5225
+ }
5226
+ else {
5227
+ address.address = '';
5228
+ }
4995
5229
  }
4996
- return addresses;
5230
+ return [address];
4997
5231
  }
4998
5232
  /**
4999
5233
  * Tokenizer for address field strings
@@ -5006,17 +5240,24 @@ class Tokenizer {
5006
5240
  this.str = (str || '').toString();
5007
5241
  this.operatorCurrent = '';
5008
5242
  this.operatorExpecting = '';
5009
- this.node = null;
5243
+ this.node = -1;
5010
5244
  this.escaped = false;
5011
5245
  this.inDomainLiteral = false;
5012
5246
  this.afterAt = false;
5013
- this.list = [];
5247
+ this.inGroup = false;
5248
+ this.innerQuoted = false;
5249
+ this.innerComment = 0;
5250
+ this.innerLiteral = false;
5251
+ this.innerEscaped = false;
5252
+ this.list = new TokenList(this.str);
5014
5253
  this.operators = {
5015
5254
  '"': '"',
5016
5255
  '(': ')',
5017
5256
  '<': '>',
5018
5257
  ',': '',
5019
- ':': ';',
5258
+ // A group is read in the same pass as the rest of the list, so its ':' only marks
5259
+ // where the name ends and its members are tokenized like any other address
5260
+ ':': '',
5020
5261
  // Semicolons are not a legal delimiter per the RFC2822 grammar other
5021
5262
  // than for terminating a group, but they are also not valid for any
5022
5263
  // other use in this context. Given that some mail clients have
@@ -5032,27 +5273,96 @@ class Tokenizer {
5032
5273
  * @return An array of operator|text tokens
5033
5274
  */
5034
5275
  tokenize() {
5035
- let list = [];
5036
- for (let i = 0, len = this.str.length; i < len; i++) {
5037
- let chr = this.str.charAt(i);
5038
- let nextChr = i < len - 1 ? this.str.charAt(i + 1) : null;
5039
- this.checkChar(chr, nextChr);
5040
- }
5041
- this.list.forEach(node => {
5042
- node.value = (node.value || '').toString().trim();
5043
- if (node.value) {
5044
- list.push(node);
5276
+ const str = this.str;
5277
+ const len = str.length;
5278
+ for (let i = 0; i < len; i++) {
5279
+ this.checkChar(str.charAt(i), i);
5280
+ }
5281
+ this.closeText(len);
5282
+ // Compacted in place: a text run is trimmed to its value and one left empty is
5283
+ // dropped, and the value of a run that is no plain slice is rebuilt and kept aside
5284
+ const list = this.list;
5285
+ const slots = list.slots;
5286
+ let kept = 0;
5287
+ for (let i = 0, count = list.length; i < count; i++) {
5288
+ const kind = slots[i * 3];
5289
+ let start = slots[i * 3 + 1];
5290
+ let end = slots[i * 3 + 2];
5291
+ if (kind & TOKEN_DIRTY) {
5292
+ const value = this.cleanValue(start, end, kind).trim();
5293
+ if (!value) {
5294
+ continue;
5295
+ }
5296
+ list.cleaned.set(kept, value);
5045
5297
  }
5046
- });
5298
+ else if (!(kind & TOKEN_OPERATOR)) {
5299
+ while (start < end && isSpaceCode(str.charCodeAt(start))) {
5300
+ start++;
5301
+ }
5302
+ while (end > start && isSpaceCode(str.charCodeAt(end - 1))) {
5303
+ end--;
5304
+ }
5305
+ if (start === end) {
5306
+ continue;
5307
+ }
5308
+ }
5309
+ slots[kept * 3] = kind;
5310
+ slots[kept * 3 + 1] = start;
5311
+ slots[kept * 3 + 2] = end;
5312
+ kept++;
5313
+ }
5314
+ list.length = kept;
5047
5315
  return list;
5048
5316
  }
5317
+ /**
5318
+ * Ends the text run being collected, if there is one
5319
+ *
5320
+ * @param end Offset just past its last character
5321
+ */
5322
+ closeText(end) {
5323
+ if (this.node >= 0) {
5324
+ this.list.slots[this.node * 3 + 2] = end;
5325
+ this.node = -1;
5326
+ }
5327
+ }
5328
+ /**
5329
+ * Rebuilds the value of a text run that is no plain slice of the field, applying the
5330
+ * rules checkChar collects characters by: an escaping backslash is dropped, a line break
5331
+ * reads as a space and any other control character is left out
5332
+ *
5333
+ * @param start Offset of the first character of the run
5334
+ * @param end Offset just past its last character
5335
+ * @param kind Kind and flags of the run
5336
+ * @return Value of the run
5337
+ */
5338
+ cleanValue(start, end, kind) {
5339
+ const str = this.str;
5340
+ const escapes = (kind & TOKEN_ESCAPES) !== 0;
5341
+ let escaped = (kind & TOKEN_STARTS_ESCAPED) !== 0;
5342
+ let value = '';
5343
+ for (let i = start; i < end; i++) {
5344
+ const chr = str.charAt(i);
5345
+ if (escapes && !escaped && chr === '\\') {
5346
+ escaped = true;
5347
+ continue;
5348
+ }
5349
+ escaped = false;
5350
+ if (chr === '\n') {
5351
+ value += ' ';
5352
+ }
5353
+ else if (chr.charCodeAt(0) >= 0x21 || chr === ' ' || chr === '\t') {
5354
+ value += chr;
5355
+ }
5356
+ }
5357
+ return value;
5358
+ }
5049
5359
  /**
5050
5360
  * Checks if a character is an operator or text and acts accordingly
5051
5361
  *
5052
5362
  * @param chr Character from the address field
5053
- * @param nextChr The character that follows, or null at the end of the field
5363
+ * @param i Offset of the character in the field
5054
5364
  */
5055
- checkChar(chr, nextChr) {
5365
+ checkChar(chr, i) {
5056
5366
  // Track RFC 5322 domain-literals ("[" *dtext "]"). The ":" of an IPv6 address-literal
5057
5367
  // (user@[IPv6:2001:db8::1]) is dtext and must not be read as the group delimiter while
5058
5368
  // inside the brackets. That is the only operator the literal hides: letting it hide
@@ -5072,37 +5382,67 @@ class Tokenizer {
5072
5382
  this.inDomainLiteral = false;
5073
5383
  }
5074
5384
  }
5075
- if (this.escaped) ;
5076
- else if (chr === this.operatorExpecting) {
5077
- this.node = {
5078
- type: 'operator',
5079
- value: chr
5080
- };
5081
- if (nextChr && ![' ', '\t', '\r', '\n', ',', ';'].includes(nextChr)) {
5082
- this.node.noBreak = true;
5385
+ // The closer of an angle-addr is only read outside the quoted strings, comments and
5386
+ // domain literals it holds. Looking for nothing but the closer let a '>' inside a
5387
+ // quoted local part end the angle-addr, '<"a@evil.com>"@good.com>' was read as an
5388
+ // address at evil.com. The characters are still collected as they are, the angle-addr
5389
+ // is read again later
5390
+ let nested = false;
5391
+ if (this.operatorExpecting === '>') {
5392
+ nested = this.trackInner(chr);
5393
+ if (!nested && chr === ';' && this.inGroup) {
5394
+ // The ';' of a group ends an angle-addr left open inside it, as it always did
5395
+ // when a group was cut out of the field at its ';' and parsed on its own. Read
5396
+ // as text instead, an unclosed '<' swallowed the end of the group and every
5397
+ // recipient after it
5398
+ this.closeText(i);
5399
+ this.operatorExpecting = '';
5400
+ this.resetInner();
5083
5401
  }
5084
- this.list.push(this.node);
5085
- this.node = null;
5402
+ }
5403
+ else if (this.operatorExpecting === ')' && !this.escaped) {
5404
+ // Comments nest (RFC 5322 3.2.2). Ending one at the first ')' let the ')' of a
5405
+ // nested comment close the outer one and released the rest of it, an address
5406
+ // included, into the header
5407
+ if (chr === '(') {
5408
+ this.innerComment++;
5409
+ }
5410
+ else if (chr === ')' && this.innerComment) {
5411
+ this.innerComment--;
5412
+ nested = true;
5413
+ }
5414
+ }
5415
+ if (this.escaped) ;
5416
+ else if (chr === this.operatorExpecting && !nested) {
5417
+ const nextChr = this.str.charAt(i + 1);
5418
+ this.closeText(i);
5419
+ this.list.push(nextChr && ![' ', '\t', '\r', '\n', ',', ';'].includes(nextChr)
5420
+ ? TOKEN_OPERATOR | TOKEN_NO_BREAK
5421
+ : TOKEN_OPERATOR, i, i + 1);
5086
5422
  if (chr !== ')') {
5087
5423
  // a comment is folding whitespace, it does not part the "@" from the "["
5088
5424
  this.afterAt = false;
5089
5425
  }
5090
5426
  this.operatorExpecting = '';
5091
5427
  this.escaped = false;
5428
+ this.resetInner();
5092
5429
  return;
5093
5430
  }
5094
5431
  else if (!this.operatorExpecting && !(this.inDomainLiteral && chr === ':') && chr in this.operators) {
5095
- this.node = {
5096
- type: 'operator',
5097
- value: chr
5098
- };
5099
- this.list.push(this.node);
5100
- this.node = null;
5432
+ this.closeText(i);
5433
+ this.list.push(TOKEN_OPERATOR, i, i + 1);
5434
+ if (chr === ':') {
5435
+ this.inGroup = true;
5436
+ }
5437
+ else if (chr === ';') {
5438
+ this.inGroup = false;
5439
+ }
5101
5440
  if (chr !== '(') {
5102
5441
  this.afterAt = false;
5103
5442
  }
5104
5443
  this.operatorExpecting = this.operators[chr];
5105
5444
  this.escaped = false;
5445
+ this.resetInner();
5106
5446
  return;
5107
5447
  }
5108
5448
  else if ((this.operatorExpecting === '"' || this.operatorExpecting === ')') && chr === '\\') {
@@ -5111,96 +5451,209 @@ class Tokenizer {
5111
5451
  // only in quoted strings let an escaped parenthesis close the comment, which
5112
5452
  // released the rest of it, an address included, into the header
5113
5453
  this.escaped = true;
5454
+ if (this.node >= 0) {
5455
+ // the backslash is left out of the value
5456
+ this.list.slots[this.node * 3] |= TOKEN_DIRTY;
5457
+ }
5114
5458
  return;
5115
5459
  }
5116
- if (!this.node) {
5117
- this.node = {
5118
- type: 'text',
5119
- value: ''
5120
- };
5121
- this.list.push(this.node);
5460
+ if (this.node < 0) {
5461
+ this.node = this.list.push((this.operatorExpecting === '"' || this.operatorExpecting === ')' ? TOKEN_ESCAPES : 0) |
5462
+ (this.escaped ? TOKEN_STARTS_ESCAPED : 0), i, i);
5122
5463
  }
5123
5464
  if (chr === '\n') {
5124
5465
  // Convert newlines to spaces. Carriage return is ignored as \r and \n usually
5125
5466
  // go together anyway and there already is a WS for \n. Lone \r means something is fishy.
5126
5467
  chr = ' ';
5468
+ this.list.slots[this.node * 3] |= TOKEN_DIRTY;
5127
5469
  }
5128
- if (chr.charCodeAt(0) >= 0x21 || [' ', '\t'].includes(chr)) {
5129
- // skip command bytes
5130
- this.node.value += chr;
5470
+ if (chr.charCodeAt(0) >= 0x21 || chr === ' ' || chr === '\t') {
5471
+ // the character is part of the value, which is read off the field later
5131
5472
  // text inside a quoted string or a comment is not part of an addr-spec. Tracked
5132
5473
  // as it goes rather than read back off the value (see lastChars in _handleAddress)
5133
5474
  if (!this.operatorExpecting && chr !== ' ' && chr !== '\t') {
5134
5475
  this.afterAt = chr === '@';
5135
5476
  }
5136
5477
  }
5478
+ else {
5479
+ // skip command bytes
5480
+ this.list.slots[this.node * 3] |= TOKEN_DIRTY;
5481
+ }
5137
5482
  this.escaped = false;
5138
5483
  }
5484
+ /**
5485
+ * Tracks the quoted strings, comments and domain literals inside an angle-addr, which its
5486
+ * closer must not be read from. A backslash escapes the next character inside a quoted
5487
+ * string and a comment. A domain literal opens right after an "@" and ends at the "]".
5488
+ * dtext holds the list separators as well, and inside the brackets they never split the
5489
+ * list anyway.
5490
+ *
5491
+ * @param chr Character from the address field
5492
+ * @return true while the character sits inside one of them, the one that closes it included
5493
+ */
5494
+ trackInner(chr) {
5495
+ if (this.innerEscaped) {
5496
+ this.innerEscaped = false;
5497
+ return true;
5498
+ }
5499
+ if (this.innerQuoted) {
5500
+ if (chr === '\\') {
5501
+ this.innerEscaped = true;
5502
+ }
5503
+ else if (chr === '"') {
5504
+ this.innerQuoted = false;
5505
+ }
5506
+ return true;
5507
+ }
5508
+ if (this.innerComment) {
5509
+ if (chr === '\\') {
5510
+ this.innerEscaped = true;
5511
+ }
5512
+ else if (chr === '(') {
5513
+ this.innerComment++;
5514
+ }
5515
+ else if (chr === ')') {
5516
+ this.innerComment--;
5517
+ }
5518
+ return true;
5519
+ }
5520
+ if (this.innerLiteral) {
5521
+ if (chr === ']') {
5522
+ this.innerLiteral = false;
5523
+ }
5524
+ return true;
5525
+ }
5526
+ if (chr === '"') {
5527
+ this.innerQuoted = true;
5528
+ return true;
5529
+ }
5530
+ if (chr === '(') {
5531
+ this.innerComment = 1;
5532
+ return true;
5533
+ }
5534
+ if (chr === '[' && this.afterAt) {
5535
+ this.innerLiteral = true;
5536
+ return true;
5537
+ }
5538
+ // afterAt is otherwise only kept outside the operators and is cleared when one opens
5539
+ // or ends, so inside an angle-addr it serves the literal tracked here
5540
+ if (!isSpaceCode(chr.charCodeAt(0))) {
5541
+ this.afterAt = chr === '@';
5542
+ }
5543
+ return false;
5544
+ }
5545
+ /**
5546
+ * Forgets the nesting collected for the operator that just opened or ended
5547
+ */
5548
+ resetInner() {
5549
+ this.innerQuoted = false;
5550
+ this.innerComment = 0;
5551
+ this.innerLiteral = false;
5552
+ this.innerEscaped = false;
5553
+ }
5139
5554
  }
5140
5555
  /**
5141
- * Maximum recursion depth for parsing nested groups.
5142
- * RFC 5322 doesn't allow nested groups, so this is a safeguard against
5143
- * malicious input that could cause stack overflow.
5556
+ * Maximum depth of the re-parse of an address that is nothing but an encoded word holding
5557
+ * an angle-addr. Groups no longer recurse, but a decoded word could hold another one, so this
5558
+ * still bounds that.
5144
5559
  */
5145
- const MAX_NESTED_GROUP_DEPTH = 50;
5560
+ const MAX_NESTED_GROUP_DEPTH = 10;
5146
5561
  /**
5147
- * Parses an address list, recursing into groups. The depth is threaded through the
5148
- * calls rather than taken from an option, so a caller supplied options object can not
5149
- * seed it and lift the recursion limit.
5562
+ * Folds the fragments an unquoted display name holding a comma is split into back together.
5563
+ *
5564
+ * "Joe Foo, PhD <joe@example.com>" is split on the comma into
5565
+ * [{name:"Joe Foo", address:""}, {name:"PhD", address:"joe@example.com"}]. A name-only entry
5566
+ * followed by an entry with both name and address is recombined. Walked back to front so
5567
+ * that a run of fragments folds into one entry in a single pass. Splicing each fragment out
5568
+ * of the list instead would be quadratic.
5569
+ *
5570
+ * @param list Parsed entries, in order
5571
+ * @return The entries with the fragments folded in
5572
+ */
5573
+ function mergeNameFragments(list) {
5574
+ const merged = [];
5575
+ for (let i = list.length - 1; i >= 0; i--) {
5576
+ const current = list[i];
5577
+ const next = merged.length ? merged[merged.length - 1] : null;
5578
+ if (next && current.address === '' && current.name && !current.group && next.address && next.name) {
5579
+ next.name = current.name + ', ' + next.name;
5580
+ }
5581
+ else {
5582
+ merged.push(current);
5583
+ }
5584
+ }
5585
+ return merged.reverse();
5586
+ }
5587
+ /**
5588
+ * Parses an address list, groups included, in a single pass over its tokens.
5589
+ *
5590
+ * A group used to be cut out of the field at the ';' that ends it and parsed again as an
5591
+ * address list of its own, recursively, so every level of nesting parsed what was left of
5592
+ * the field once more. RFC 5322 does not nest groups at all, and the nested ones were
5593
+ * flattened anyway. The tokens are now walked once: a ':' opens a group, its members are
5594
+ * read as they come, a ';' closes it. A ':' inside an open group stands for a group nested
5595
+ * in it, whose name is dropped and whose members belong to the outer one, and the first ';'
5596
+ * ends the group, nested or not, as before.
5150
5597
  *
5151
5598
  * @param str Address field
5152
- * @param depth Current recursion depth for nested group protection
5599
+ * @param depth Current depth of the encoded word re-parse
5153
5600
  * @return An array of address objects
5154
5601
  */
5155
5602
  function parseAddressList(str, depth) {
5156
- // Prevent stack overflow from deeply nested groups (DoS protection)
5603
+ // Prevent stack overflow from encoded words decoding to more of them (DoS protection)
5157
5604
  if (depth > MAX_NESTED_GROUP_DEPTH) {
5158
5605
  return [];
5159
5606
  }
5160
- let tokenizer = new Tokenizer(str);
5161
- let tokens = tokenizer.tokenize();
5162
- let addresses = [];
5163
- let address = [];
5607
+ const tokens = new Tokenizer(str).tokenize();
5164
5608
  const parsedAddresses = [];
5165
- tokens.forEach(token => {
5166
- if (token.type === 'operator' && (token.value === ',' || token.value === ';')) {
5167
- if (address.length) {
5168
- addresses.push(address);
5169
- }
5170
- address = [];
5609
+ let group = null;
5610
+ let members = [];
5611
+ // Every address is handed over as the range of the token list it covers, as soon as the
5612
+ // separator after it is seen. Copying the tokens of each address into an array of its own,
5613
+ // and collecting those before handling any, held a second structure as large as the token
5614
+ // list for the whole parse, and an array that receives a single push is allocated room for
5615
+ // seventeen: a list of one letter names took some 80 times its size in that alone
5616
+ let from = 0;
5617
+ for (let i = 0, len = tokens.length; i <= len; i++) {
5618
+ const operator = i < len ? tokens.operatorAt(i) : '';
5619
+ if (i < len && operator !== ',' && operator !== ';' && operator !== ':') {
5620
+ continue;
5171
5621
  }
5172
- else {
5173
- address.push(token);
5622
+ if (operator === ':') {
5623
+ if (!group) {
5624
+ group = { name: groupName(tokens, from, i), group: [] };
5625
+ members = [];
5626
+ }
5627
+ from = i + 1;
5628
+ continue;
5174
5629
  }
5175
- });
5176
- if (address.length) {
5177
- addresses.push(address);
5178
- }
5179
- // Appended in place. Rebuilding the list with concat() for every address copies
5180
- // everything parsed so far and is quadratic in the number of addresses.
5181
- for (const tokens of addresses) {
5182
- for (const address of _handleAddress(tokens, depth)) {
5183
- parsedAddresses.push(address);
5184
- }
5185
- }
5186
- // Merge fragments produced when unquoted display names contain commas.
5187
- // "Joe Foo, PhD <joe@example.com>" is split on the comma into
5188
- // [{name:"Joe Foo", address:""}, {name:"PhD", address:"joe@example.com"}].
5189
- // Recombine: a name-only entry followed by an entry with both name and address.
5190
- // Walked back to front so that a run of fragments folds into one entry in a single
5191
- // pass. Splicing each fragment out of the list instead would be quadratic.
5192
- const mergedAddresses = [];
5193
- for (let i = parsedAddresses.length - 1; i >= 0; i--) {
5194
- const current = parsedAddresses[i];
5195
- const next = mergedAddresses.length ? mergedAddresses[mergedAddresses.length - 1] : null;
5196
- if (next && current.address === '' && current.name && !current.group && next.address && next.name) {
5197
- next.name = current.name + ', ' + next.name;
5630
+ if (i > from) {
5631
+ // Appended in place. Rebuilding the list with concat() for every address copies
5632
+ // everything parsed so far and is quadratic in the number of addresses.
5633
+ for (const address of _handleAddress(tokens, from, i, depth)) {
5634
+ if (!group) {
5635
+ parsedAddresses.push(address);
5636
+ }
5637
+ else if (address.group) {
5638
+ // an encoded word inside a group decoded to a group of its own, which is
5639
+ // flattened into this one as a nested group is
5640
+ for (const member of address.group) {
5641
+ members.push(member);
5642
+ }
5643
+ }
5644
+ else {
5645
+ members.push(address);
5646
+ }
5647
+ }
5198
5648
  }
5199
- else {
5200
- mergedAddresses.push(current);
5649
+ from = i + 1;
5650
+ if (group && (operator === ';' || i === len)) {
5651
+ group.group = mergeNameFragments(members);
5652
+ parsedAddresses.push(group);
5653
+ group = null;
5201
5654
  }
5202
5655
  }
5203
- return mergedAddresses.reverse();
5656
+ return mergeNameFragments(parsedAddresses);
5204
5657
  }
5205
5658
  /**
5206
5659
  * Parses structured e-mail addresses from an address field