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