@mavogel/awscdk-rootmail 0.1.24 → 0.1.25
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/.jsii +3 -3
- package/lib/rootmail.js +1 -1
- package/lib/ses-receive.js +1 -1
- package/node_modules/@zone-eu/mailsplit/README.md +1 -1
- package/node_modules/@zone-eu/mailsplit/lib/headers.js +10 -4
- package/node_modules/@zone-eu/mailsplit/lib/message-splitter.js +5 -4
- package/node_modules/@zone-eu/mailsplit/lib/mime-node.d.ts +4 -1
- package/node_modules/@zone-eu/mailsplit/lib/mime-node.js +23 -2
- package/node_modules/@zone-eu/mailsplit/lib/types.d.ts +3 -1
- package/node_modules/@zone-eu/mailsplit/package.json +4 -4
- package/node_modules/libbase64/CHANGELOG.md +20 -0
- package/node_modules/libbase64/README.md +40 -13
- package/node_modules/libbase64/dist/cjs/libbase64.d.ts +80 -0
- package/node_modules/libbase64/dist/cjs/libbase64.js +310 -0
- package/node_modules/libbase64/dist/cjs/package.json +3 -0
- package/node_modules/libbase64/dist/esm/libbase64.d.ts +80 -0
- package/node_modules/libbase64/{lib → dist/esm}/libbase64.js +74 -117
- package/node_modules/libbase64/dist/esm/package.json +3 -0
- package/node_modules/libbase64/package.json +59 -13
- package/node_modules/libmime/CHANGELOG.md +31 -0
- package/node_modules/libmime/README.md +55 -26
- package/node_modules/libmime/SECURITY.md +152 -0
- package/node_modules/libmime/dist/cjs/charset.d.ts +43 -0
- package/node_modules/libmime/dist/cjs/charset.js +135 -0
- package/node_modules/libmime/dist/cjs/charsets.d.ts +2 -0
- package/node_modules/libmime/dist/cjs/charsets.js +213 -0
- package/node_modules/libmime/dist/cjs/get-charset-name.d.ts +1 -0
- package/node_modules/libmime/dist/cjs/get-charset-name.js +224 -0
- package/node_modules/libmime/dist/cjs/libmime.d.ts +218 -0
- package/node_modules/libmime/dist/cjs/libmime.js +1013 -0
- package/node_modules/libmime/dist/cjs/mimetypes.d.ts +7 -0
- package/node_modules/libmime/dist/cjs/mimetypes.js +2072 -0
- package/node_modules/libmime/dist/cjs/package.json +3 -0
- package/node_modules/libmime/dist/cjs/types.d.ts +59 -0
- package/node_modules/libmime/dist/cjs/types.js +4 -0
- package/node_modules/libmime/dist/esm/charset.d.ts +43 -0
- package/node_modules/libmime/dist/esm/charset.js +125 -0
- package/node_modules/libmime/dist/esm/charsets.d.ts +2 -0
- package/node_modules/libmime/{lib → dist/esm}/charsets.js +3 -5
- package/node_modules/libmime/dist/esm/get-charset-name.d.ts +1 -0
- package/node_modules/libmime/{lib → dist/esm}/get-charset-name.js +1 -8
- package/node_modules/libmime/dist/esm/libmime.d.ts +219 -0
- package/node_modules/libmime/{lib → dist/esm}/libmime.js +256 -329
- package/node_modules/libmime/dist/esm/mimetypes.d.ts +7 -0
- package/node_modules/libmime/dist/esm/mimetypes.js +2068 -0
- package/node_modules/libmime/dist/esm/package.json +3 -0
- package/node_modules/libmime/dist/esm/types.d.ts +59 -0
- package/node_modules/libmime/dist/esm/types.js +3 -0
- package/node_modules/libmime/package.json +58 -15
- package/node_modules/libqp/CHANGELOG.md +18 -0
- package/node_modules/libqp/README.md +50 -19
- package/node_modules/libqp/dist/cjs/libqp.d.ts +74 -0
- package/node_modules/libqp/dist/cjs/libqp.js +378 -0
- package/node_modules/libqp/dist/cjs/package.json +3 -0
- package/node_modules/libqp/dist/esm/libqp.d.ts +74 -0
- package/node_modules/libqp/dist/esm/libqp.js +369 -0
- package/node_modules/libqp/dist/esm/package.json +3 -0
- package/node_modules/libqp/lib/libqp.js +4 -383
- package/node_modules/libqp/lib/package.json +3 -0
- package/node_modules/libqp/package.json +62 -6
- package/node_modules/mailparser/.release-please-manifest.json +1 -1
- package/node_modules/mailparser/CHANGELOG.md +42 -0
- package/node_modules/mailparser/package.json +5 -5
- package/node_modules/nodemailer/CHANGELOG.md +44 -0
- package/node_modules/nodemailer/dist/cjs/addressparser/index.js +695 -267
- package/node_modules/nodemailer/dist/cjs/mailer/mail-message.js +4 -1
- package/node_modules/nodemailer/dist/cjs/mime-funcs/index.js +8 -2
- package/node_modules/nodemailer/dist/cjs/mime-node/index.js +48 -16
- package/node_modules/nodemailer/dist/cjs/package-info.d.ts +1 -1
- package/node_modules/nodemailer/dist/cjs/package-info.js +1 -1
- package/node_modules/nodemailer/dist/cjs/shared/url.js +22 -5
- package/node_modules/nodemailer/dist/esm/addressparser/index.js +695 -267
- package/node_modules/nodemailer/dist/esm/mailer/mail-message.js +4 -1
- package/node_modules/nodemailer/dist/esm/mime-funcs/index.js +8 -2
- package/node_modules/nodemailer/dist/esm/mime-node/index.js +48 -16
- package/node_modules/nodemailer/dist/esm/package-info.d.ts +1 -1
- package/node_modules/nodemailer/dist/esm/package-info.js +1 -1
- package/node_modules/nodemailer/dist/esm/shared/url.js +22 -5
- package/node_modules/nodemailer/package.json +1 -1
- package/package.json +2 -2
- package/node_modules/libbase64/.eslintrc +0 -11
- package/node_modules/libbase64/.github/FUNDING.yml +0 -4
- package/node_modules/libbase64/.github/VULNERABILITY_REPORT.yml +0 -75
- package/node_modules/libbase64/.github/workflows/release.yaml +0 -39
- package/node_modules/libbase64/.github/workflows/test.yml +0 -21
- package/node_modules/libbase64/.ncurc.js +0 -13
- package/node_modules/libbase64/.prettierrc.js +0 -8
- package/node_modules/libmime/lib/charset.js +0 -131
- package/node_modules/libmime/lib/mimetypes.js +0 -2049
- package/node_modules/libqp/.eslintrc +0 -15
- package/node_modules/libqp/.github/FUNDING.yml +0 -4
- package/node_modules/libqp/.github/VULNERABILITY_REPORT.yml +0 -75
- package/node_modules/libqp/.github/workflows/release.yaml +0 -39
- package/node_modules/libqp/.github/workflows/test.yml +0 -21
- package/node_modules/libqp/.prettierrc.js +0 -8
- package/node_modules/libqp/test/libqp-test.js +0 -366
|
@@ -1,5 +1,99 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
/* eslint-disable no-bitwise -- the kind of a token is a set of bit flags packed into one slot of a typed array */
|
|
4
|
+
/** The token is an operator, a text run otherwise */
|
|
5
|
+
const TOKEN_OPERATOR = 1;
|
|
6
|
+
/** The next character continues the same run without whitespace in between */
|
|
7
|
+
const TOKEN_NO_BREAK = 2;
|
|
8
|
+
/** The text run holds characters its value leaves out or changes, so it is no plain slice */
|
|
9
|
+
const TOKEN_DIRTY = 4;
|
|
10
|
+
/** A backslash escapes the next character in the text run, inside a quoted string or a comment */
|
|
11
|
+
const TOKEN_ESCAPES = 8;
|
|
12
|
+
/** The text run opens on a character an escaping backslash in front of it has escaped */
|
|
13
|
+
const TOKEN_STARTS_ESCAPED = 16;
|
|
14
|
+
/**
|
|
15
|
+
* The tokens of an address field, kept as offsets into it.
|
|
16
|
+
*
|
|
17
|
+
* Every token used to be an object of its own, an operator for every ',' and a value string
|
|
18
|
+
* for every run of text, and a list of one letter names, 'a,a,a,...', took some 65 times its
|
|
19
|
+
* size in tokens before any address was read. A token is now three numbers in one typed
|
|
20
|
+
* array, its kind and the range of the field it covers, and its value is sliced off the field
|
|
21
|
+
* when it is read. The few text runs whose value differs from their slice, as they hold an
|
|
22
|
+
* escaping backslash, a line break or a control character, are rebuilt once and kept aside.
|
|
23
|
+
*/
|
|
24
|
+
class TokenList {
|
|
25
|
+
constructor(str) {
|
|
26
|
+
this.str = str;
|
|
27
|
+
this.slots = new Int32Array(48);
|
|
28
|
+
this.length = 0;
|
|
29
|
+
this.cleaned = new Map();
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Appends a token
|
|
33
|
+
*
|
|
34
|
+
* @param kind Kind and flags of the token
|
|
35
|
+
* @param start Offset of its first character
|
|
36
|
+
* @param end Offset just past its last character
|
|
37
|
+
* @return Index of the token
|
|
38
|
+
*/
|
|
39
|
+
push(kind, start, end) {
|
|
40
|
+
if ((this.length + 1) * 3 > this.slots.length) {
|
|
41
|
+
const grown = new Int32Array(this.slots.length * 2);
|
|
42
|
+
grown.set(this.slots);
|
|
43
|
+
this.slots = grown;
|
|
44
|
+
}
|
|
45
|
+
const at = this.length * 3;
|
|
46
|
+
this.slots[at] = kind;
|
|
47
|
+
this.slots[at + 1] = start;
|
|
48
|
+
this.slots[at + 2] = end;
|
|
49
|
+
return this.length++;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The character of an operator token, an empty string for a text run
|
|
53
|
+
*/
|
|
54
|
+
operatorAt(index) {
|
|
55
|
+
const at = index * 3;
|
|
56
|
+
return this.slots[at] & TOKEN_OPERATOR ? this.str.charAt(this.slots[at + 1]) : '';
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Whether a token is an operator
|
|
60
|
+
*/
|
|
61
|
+
isOperator(index) {
|
|
62
|
+
return (this.slots[index * 3] & TOKEN_OPERATOR) !== 0;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Whether the next character continues the run of a token without whitespace in between
|
|
66
|
+
*/
|
|
67
|
+
noBreak(index) {
|
|
68
|
+
return (this.slots[index * 3] & TOKEN_NO_BREAK) !== 0;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Value of a token
|
|
72
|
+
*/
|
|
73
|
+
value(index) {
|
|
74
|
+
const cleaned = this.cleaned.get(index);
|
|
75
|
+
if (cleaned !== undefined) {
|
|
76
|
+
return cleaned;
|
|
77
|
+
}
|
|
78
|
+
return this.str.slice(this.slots[index * 3 + 1], this.slots[index * 3 + 2]);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* A domain literal that ends an address. dtext includes '@' (RFC 5322 3.4.1), so the '@'
|
|
83
|
+
* in front of the literal is the one that splits the domain off, not the last one: reading
|
|
84
|
+
* 'a@[x@evil.com]' at its last '@' made it an address at evil.com.
|
|
85
|
+
*/
|
|
86
|
+
const DOMAIN_LITERAL_TAIL = /@(\[[^[\]\\]*\])$/;
|
|
87
|
+
/**
|
|
88
|
+
* Offset of the '@' that splits the domain off an address, or -1 when there is none
|
|
89
|
+
*
|
|
90
|
+
* @param address Address to split
|
|
91
|
+
* @return Offset of the '@'
|
|
92
|
+
*/
|
|
93
|
+
function _domainAt(address) {
|
|
94
|
+
const literal = address.charAt(address.length - 1) === ']' ? DOMAIN_LITERAL_TAIL.exec(address) : null;
|
|
95
|
+
return literal ? address.length - literal[1].length - 1 : address.lastIndexOf('@');
|
|
96
|
+
}
|
|
3
97
|
/**
|
|
4
98
|
* Restores the quoting of a local part that was read out of a quoted string.
|
|
5
99
|
*
|
|
@@ -18,7 +112,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
18
112
|
* @return Address with the local part as a quoted-string
|
|
19
113
|
*/
|
|
20
114
|
function _quoteLocalPart(address) {
|
|
21
|
-
const lastAt = address
|
|
115
|
+
const lastAt = _domainAt(address);
|
|
22
116
|
if (lastAt < 0) {
|
|
23
117
|
// no domain to split off, nothing can be misrouted
|
|
24
118
|
return address;
|
|
@@ -44,6 +138,23 @@ const QUOTED_LOCAL_ADDR = /^("(?:[^"\\]|\\[\s\S])*"@\S+)(?:\s+([\s\S]+))?$/;
|
|
|
44
138
|
* One run holding a single '@' and no whitespace, the shape an addr-spec has to have.
|
|
45
139
|
*/
|
|
46
140
|
const ADDR_SPEC = /^[^@\s]+@[^@\s]+$/;
|
|
141
|
+
/**
|
|
142
|
+
* The same with a domain literal for the domain, which may hold an '@' of its own.
|
|
143
|
+
*/
|
|
144
|
+
const LITERAL_ADDR_SPEC = /^[^@\s]+@\[[^[\]\\\s]*\]$/;
|
|
145
|
+
/**
|
|
146
|
+
* The '@' and domain that follow a quoted local part in the same run.
|
|
147
|
+
*/
|
|
148
|
+
const QUOTED_LOCAL_DOMAIN = /^@(?:[^@\s"]+|\[[^[\]\\\s]*\])$/;
|
|
149
|
+
/**
|
|
150
|
+
* Whether a whitespace free run reads as an addr-spec under the strict grammar
|
|
151
|
+
*
|
|
152
|
+
* @param value Run to check
|
|
153
|
+
* @return true when it does
|
|
154
|
+
*/
|
|
155
|
+
function _isAddrSpec(value) {
|
|
156
|
+
return ADDR_SPEC.test(value) || LITERAL_ADDR_SPEC.test(value);
|
|
157
|
+
}
|
|
47
158
|
/**
|
|
48
159
|
* The looser reading applied once the strict one finds nothing, which tolerates the
|
|
49
160
|
* further '@' that a domain should not have but malformed headers carry anyway.
|
|
@@ -110,6 +221,62 @@ function _indexOfAt(text, from, to) {
|
|
|
110
221
|
}
|
|
111
222
|
return -1;
|
|
112
223
|
}
|
|
224
|
+
/**
|
|
225
|
+
* A quoted-string a value opens with.
|
|
226
|
+
*/
|
|
227
|
+
const LEADING_QUOTED_STRING = /^"(?:[^"\\]|\\[\s\S])*"/;
|
|
228
|
+
/**
|
|
229
|
+
* Drops the whitespace on either side of the '@' at an offset.
|
|
230
|
+
*
|
|
231
|
+
* obs-local-part and obs-domain allow CFWS around the '@' (RFC 5322 4.4), so 'user @ good.com'
|
|
232
|
+
* names user@good.com. Kept, the whitespace went into the envelope as '"user "@ good.com'.
|
|
233
|
+
* The scan is by index, a pattern such as /\s+@/ backtracks over a long whitespace run that
|
|
234
|
+
* no '@' follows.
|
|
235
|
+
*
|
|
236
|
+
* @param value Value holding the '@'
|
|
237
|
+
* @param at Offset of the '@', or -1
|
|
238
|
+
* @return The value with the whitespace beside that '@' removed
|
|
239
|
+
*/
|
|
240
|
+
function _joinAtSpacing(value, at) {
|
|
241
|
+
if (at < 0) {
|
|
242
|
+
return value;
|
|
243
|
+
}
|
|
244
|
+
let start = at;
|
|
245
|
+
while (start > 0 && _isSpaceCode(value.charCodeAt(start - 1))) {
|
|
246
|
+
start--;
|
|
247
|
+
}
|
|
248
|
+
let end = at + 1;
|
|
249
|
+
while (end < value.length && _isSpaceCode(value.charCodeAt(end))) {
|
|
250
|
+
end++;
|
|
251
|
+
}
|
|
252
|
+
return start === at && end === at + 1 ? value : value.slice(0, start) + '@' + value.slice(end);
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Drops the whitespace beside the '@' that splits the domain off a value. That is the '@'
|
|
256
|
+
* right after a quoted local part the value opens with, or else the only '@' it holds outside
|
|
257
|
+
* any quoted string. With several it is not clear which one the whitespace belongs to, and
|
|
258
|
+
* '<x@evil.com @good.com>' is left to the addr-spec recovery to read as it always has.
|
|
259
|
+
*
|
|
260
|
+
* @param value Value to clean up
|
|
261
|
+
* @return The value with the whitespace beside that '@' removed
|
|
262
|
+
*/
|
|
263
|
+
function _collapseAtSpacing(value) {
|
|
264
|
+
const quoted = LEADING_QUOTED_STRING.exec(value);
|
|
265
|
+
if (quoted) {
|
|
266
|
+
let at = quoted[0].length;
|
|
267
|
+
while (at < value.length && _isSpaceCode(value.charCodeAt(at))) {
|
|
268
|
+
at++;
|
|
269
|
+
}
|
|
270
|
+
if (value.charAt(at) === '@' && value.indexOf('@', at + 1) < 0) {
|
|
271
|
+
return _joinAtSpacing(value, at);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
const at = value.indexOf('@');
|
|
275
|
+
if (at < 0 || value.indexOf('@', at + 1) >= 0 || value.lastIndexOf('"') > at) {
|
|
276
|
+
return value;
|
|
277
|
+
}
|
|
278
|
+
return _joinAtSpacing(value, at);
|
|
279
|
+
}
|
|
113
280
|
/**
|
|
114
281
|
* Finds the offset LOOSE_TEXT_ADDR matches at, or -1 when it does not match at all.
|
|
115
282
|
*
|
|
@@ -199,6 +366,7 @@ function _looseAddressStart(text) {
|
|
|
199
366
|
* @param data Collected address parts, mutated in place
|
|
200
367
|
*/
|
|
201
368
|
function _recoverAddrSpec(data) {
|
|
369
|
+
data.address = _collapseAtSpacing(data.address);
|
|
202
370
|
if (!HAS_WHITESPACE.test(data.address)) {
|
|
203
371
|
return;
|
|
204
372
|
}
|
|
@@ -225,7 +393,7 @@ function _recoverAddrSpec(data) {
|
|
|
225
393
|
return;
|
|
226
394
|
}
|
|
227
395
|
const parts = data.address.split(/\s+/);
|
|
228
|
-
let addrIndex = parts.findIndex(
|
|
396
|
+
let addrIndex = parts.findIndex(_isAddrSpec);
|
|
229
397
|
if (addrIndex < 0) {
|
|
230
398
|
addrIndex = parts.findIndex(part => LOOSE_ADDR_SPEC.test(part));
|
|
231
399
|
}
|
|
@@ -252,6 +420,15 @@ function _recoverAddrSpec(data) {
|
|
|
252
420
|
* A comment is folding whitespace, so it is read as a space that splits two atoms and as
|
|
253
421
|
* nothing next to an '@', the same rule the token walk applies outside the brackets. Quoted
|
|
254
422
|
* strings and domain literals are copied through untouched, a '(' in there is not a comment.
|
|
423
|
+
* An unexpected '<' outside them drops everything in front of it, comments included.
|
|
424
|
+
*
|
|
425
|
+
* RFC 822 also let an angle-addr name the relays to go through in front of the mailbox,
|
|
426
|
+
* '<@relay1,@relay2:user@example.com>', which RFC 5322 still parses as obs-route. The route
|
|
427
|
+
* is not part of the mailbox, and folded into the local part it made
|
|
428
|
+
* '"@relay1,@relay2:user"@example.com' a recipient that does not exist. A local part can not
|
|
429
|
+
* start with '@', so contents that do, up to the first ':' outside a domain literal and in
|
|
430
|
+
* front of any quoted string, are a route and are dropped, as long as a mailbox with an '@'
|
|
431
|
+
* of its own follows.
|
|
255
432
|
*
|
|
256
433
|
* @param address Contents of the angle brackets
|
|
257
434
|
* @return The address with the comments removed, and the text of the comments
|
|
@@ -265,6 +442,16 @@ function _stripAddressComments(address) {
|
|
|
265
442
|
// carried along rather than read back off the growing result, which would flatten it on
|
|
266
443
|
// every comment (see lastChars in _handleAddress)
|
|
267
444
|
let lastChar = '';
|
|
445
|
+
// A comment that just closed stands for a space, written once the next character shows
|
|
446
|
+
// whether one is wanted: not beside an '@', and not on top of whitespace that follows.
|
|
447
|
+
// Deciding on the spot saw only the character right after the ')', so a second comment
|
|
448
|
+
// in a row hid the '@' behind it and 'a(x)(y)@b' read as 'a @b'
|
|
449
|
+
let pendingSpace = false;
|
|
450
|
+
// first character of the result and whether a quoted string has opened since, which
|
|
451
|
+
// together tell a route from a local part
|
|
452
|
+
let firstChar = '';
|
|
453
|
+
let quoteSeen = false;
|
|
454
|
+
let routeEnd = -1;
|
|
268
455
|
for (let i = 0, len = address.length; i < len; i++) {
|
|
269
456
|
const chr = address.charAt(i);
|
|
270
457
|
if (depth) {
|
|
@@ -278,16 +465,20 @@ function _stripAddressComments(address) {
|
|
|
278
465
|
else if (chr === ')' && !--depth) {
|
|
279
466
|
comments.push(comment.trim());
|
|
280
467
|
comment = '';
|
|
281
|
-
|
|
282
|
-
result += ' ';
|
|
283
|
-
lastChar = ' ';
|
|
284
|
-
}
|
|
468
|
+
pendingSpace = lastChar !== '@';
|
|
285
469
|
}
|
|
286
470
|
else {
|
|
287
471
|
comment += chr;
|
|
288
472
|
}
|
|
289
473
|
continue;
|
|
290
474
|
}
|
|
475
|
+
if (pendingSpace && chr !== '(') {
|
|
476
|
+
pendingSpace = false;
|
|
477
|
+
if (chr !== '@' && chr !== '<' && !_isSpaceCode(address.charCodeAt(i))) {
|
|
478
|
+
result += ' ';
|
|
479
|
+
lastChar = ' ';
|
|
480
|
+
}
|
|
481
|
+
}
|
|
291
482
|
if (closer) {
|
|
292
483
|
if (chr === '\\' && closer === '"' && i < len - 1) {
|
|
293
484
|
result += chr + address.charAt(++i);
|
|
@@ -300,6 +491,10 @@ function _stripAddressComments(address) {
|
|
|
300
491
|
}
|
|
301
492
|
else if (chr === '"') {
|
|
302
493
|
closer = '"';
|
|
494
|
+
quoteSeen = true;
|
|
495
|
+
}
|
|
496
|
+
else if (chr === ':' && routeEnd < 0 && firstChar === '@' && !quoteSeen) {
|
|
497
|
+
routeEnd = result.length + 1;
|
|
303
498
|
}
|
|
304
499
|
else if (chr === '[') {
|
|
305
500
|
closer = ']';
|
|
@@ -308,8 +503,27 @@ function _stripAddressComments(address) {
|
|
|
308
503
|
depth = 1;
|
|
309
504
|
continue;
|
|
310
505
|
}
|
|
506
|
+
else if (chr === '<') {
|
|
507
|
+
// Apple Mail drops everything between an unexpected '<' and the address after it.
|
|
508
|
+
// Read here rather than off the raw brackets so that a '<' inside a quoted string,
|
|
509
|
+
// a comment or a domain literal stays where it is: '<"a<b"@victim.com>' cut at
|
|
510
|
+
// the first '<' left the address 'b"@victim.com'
|
|
511
|
+
result = '';
|
|
512
|
+
lastChar = '';
|
|
513
|
+
comments.length = 0;
|
|
514
|
+
firstChar = '';
|
|
515
|
+
quoteSeen = false;
|
|
516
|
+
routeEnd = -1;
|
|
517
|
+
continue;
|
|
518
|
+
}
|
|
311
519
|
result += chr;
|
|
312
520
|
lastChar = chr;
|
|
521
|
+
if (!firstChar && !_isSpaceCode(address.charCodeAt(i))) {
|
|
522
|
+
firstChar = chr;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
if (routeEnd >= 0 && result.indexOf('@', routeEnd) >= 0) {
|
|
526
|
+
result = result.slice(routeEnd);
|
|
313
527
|
}
|
|
314
528
|
if (depth) {
|
|
315
529
|
// an unterminated comment runs to the end of the address
|
|
@@ -318,36 +532,41 @@ function _stripAddressComments(address) {
|
|
|
318
532
|
return { address: result.trim(), comments: comments.filter(text => text) };
|
|
319
533
|
}
|
|
320
534
|
/**
|
|
321
|
-
*
|
|
535
|
+
* Walks the tokens of one entry of an address list and sorts them into the runs of display
|
|
536
|
+
* text, angle-addr and comment they belong to
|
|
322
537
|
*
|
|
323
|
-
* @param tokens Tokens
|
|
324
|
-
* @param
|
|
325
|
-
* @
|
|
538
|
+
* @param tokens Tokens of the whole address list
|
|
539
|
+
* @param from Index of the first token of the entry
|
|
540
|
+
* @param to Index just past the last token of the entry
|
|
541
|
+
* @return The collected runs, and whether the display text came out of a comment
|
|
326
542
|
*/
|
|
327
|
-
function
|
|
328
|
-
let isGroup = false;
|
|
543
|
+
function _collectAddressParts(tokens, from, to) {
|
|
329
544
|
let state = 'text';
|
|
330
|
-
const addresses = [];
|
|
331
545
|
const data = {
|
|
332
546
|
address: [],
|
|
333
547
|
comment: [],
|
|
334
|
-
group: [],
|
|
335
548
|
text: [],
|
|
336
|
-
textWasQuoted: []
|
|
549
|
+
textWasQuoted: [],
|
|
550
|
+
quotedLocalAt: []
|
|
337
551
|
};
|
|
338
552
|
let insideQuotes = false;
|
|
553
|
+
// whether the last text run holds nothing but quoted text so far, which is what a quoted
|
|
554
|
+
// local part is until the '@' after it joins the run
|
|
555
|
+
let lastTextPureQuoted = false;
|
|
339
556
|
// Last character of the run each state is currently accumulating. Reading it back off
|
|
340
557
|
// the accumulator with slice(-1) makes the engine flatten the whole growing string on
|
|
341
558
|
// every token, which is quadratic over an address built from many comment-joined atoms
|
|
342
559
|
// (GHSA-prgh-xp8r-p3m5). A run only ever grows by the token appended below, so the
|
|
343
560
|
// character is carried along instead of re-read.
|
|
344
|
-
const lastChars = { address: '', comment: '',
|
|
561
|
+
const lastChars = { address: '', comment: '', text: '' };
|
|
345
562
|
// Filter out <addresses>, (comments) and regular text
|
|
346
|
-
for (let i =
|
|
347
|
-
const
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
563
|
+
for (let i = from; i < to; i++) {
|
|
564
|
+
const value = tokens.value(i);
|
|
565
|
+
// the operator in front of this token, if there is one, and whether it runs on into it
|
|
566
|
+
const prevOperator = i > from ? tokens.operatorAt(i - 1) : '';
|
|
567
|
+
const prevNoBreak = i > from && tokens.noBreak(i - 1);
|
|
568
|
+
if (tokens.isOperator(i)) {
|
|
569
|
+
switch (value) {
|
|
351
570
|
case '<':
|
|
352
571
|
state = 'address';
|
|
353
572
|
insideQuotes = false;
|
|
@@ -356,11 +575,6 @@ function _handleAddress(tokens, depth) {
|
|
|
356
575
|
state = 'comment';
|
|
357
576
|
insideQuotes = false;
|
|
358
577
|
break;
|
|
359
|
-
case ':':
|
|
360
|
-
state = 'group';
|
|
361
|
-
isGroup = true;
|
|
362
|
-
insideQuotes = false;
|
|
363
|
-
break;
|
|
364
578
|
case '"':
|
|
365
579
|
insideQuotes = !insideQuotes;
|
|
366
580
|
state = 'text';
|
|
@@ -371,7 +585,7 @@ function _handleAddress(tokens, depth) {
|
|
|
371
585
|
break;
|
|
372
586
|
}
|
|
373
587
|
}
|
|
374
|
-
else
|
|
588
|
+
else {
|
|
375
589
|
// An empty quoted string is dropped by the tokenizer, leaving no text token of its
|
|
376
590
|
// own for textWasQuoted to be recorded on, so the pair of quote operators right in
|
|
377
591
|
// front of this token is all that is left of it and the run it opens carries the
|
|
@@ -379,181 +593,200 @@ function _handleAddress(tokens, depth) {
|
|
|
379
593
|
// the quotes never go back on, and the value is no longer an addr-spec a trailing
|
|
380
594
|
// comment can be peeled off of. It only ever opens a run: a run that already holds
|
|
381
595
|
// material collected outside the quotes is not a quoted string, whatever follows it
|
|
382
|
-
const
|
|
383
|
-
const opensAfterEmptyQuotedString = prevToken?.type === 'operator' &&
|
|
384
|
-
prevToken.value === '"' &&
|
|
385
|
-
!!prevToken.noBreak &&
|
|
386
|
-
prevPrevToken?.type === 'operator' &&
|
|
387
|
-
prevPrevToken.value === '"';
|
|
388
|
-
if (state === 'address') {
|
|
389
|
-
// Handle unquoted name that includes a "<".
|
|
390
|
-
// Apple Mail truncates everything between an unexpected < and an address.
|
|
391
|
-
token.value = token.value.replace(/^[^<]*<\s*/, '');
|
|
392
|
-
}
|
|
596
|
+
const opensAfterEmptyQuotedString = prevOperator === '"' && prevNoBreak && i > from + 1 && tokens.operatorAt(i - 2) === '"';
|
|
393
597
|
// A comment is folding whitespace. It may sit inside an addr-spec, on either side
|
|
394
598
|
// of the '@', but it cannot join two atoms into one: gluing across it would read
|
|
395
599
|
// 'user@example.com(x)evil.com' as the single domain 'example.comevil.com' and
|
|
396
600
|
// deliver to a domain the sender never named.
|
|
397
601
|
const parts = data[state];
|
|
398
|
-
const joins =
|
|
399
|
-
prevToken.noBreak &&
|
|
400
|
-
parts.length &&
|
|
401
|
-
(prevToken.value !== ')' || lastChars[state] === '@' || token.value.charAt(0) === '@');
|
|
602
|
+
const joins = prevNoBreak && parts.length && (prevOperator !== ')' || lastChars[state] === '@' || value.charAt(0) === '@');
|
|
402
603
|
if (joins) {
|
|
403
|
-
data[state]
|
|
404
|
-
if (
|
|
405
|
-
|
|
604
|
+
const last = data[state].length - 1;
|
|
605
|
+
if (state === 'text' && !insideQuotes) {
|
|
606
|
+
if (lastTextPureQuoted && value.charAt(0) === '@') {
|
|
607
|
+
data.quotedLocalAt[last] = data.text[last].length;
|
|
608
|
+
}
|
|
609
|
+
lastTextPureQuoted = false;
|
|
406
610
|
}
|
|
611
|
+
data[state][last] += value;
|
|
612
|
+
lastChars[state] = value.charAt(value.length - 1);
|
|
407
613
|
if (state === 'text' && insideQuotes) {
|
|
408
|
-
data.textWasQuoted[
|
|
614
|
+
data.textWasQuoted[last] = true;
|
|
409
615
|
}
|
|
410
616
|
}
|
|
411
617
|
else {
|
|
412
|
-
data[state].push(
|
|
413
|
-
lastChars[state] =
|
|
618
|
+
data[state].push(value);
|
|
619
|
+
lastChars[state] = value.charAt(value.length - 1);
|
|
414
620
|
if (state === 'text') {
|
|
415
621
|
data.textWasQuoted.push(insideQuotes || opensAfterEmptyQuotedString);
|
|
622
|
+
// an empty quoted string leaves no text of its own, so the '@' opens the run
|
|
623
|
+
data.quotedLocalAt.push(opensAfterEmptyQuotedString && value.charAt(0) === '@' ? 0 : -1);
|
|
624
|
+
lastTextPureQuoted = insideQuotes;
|
|
416
625
|
}
|
|
417
626
|
}
|
|
418
627
|
}
|
|
419
628
|
}
|
|
420
|
-
// If there is no text but a comment, replace the two
|
|
629
|
+
// If there is no text but a comment, replace the two. A comment is never part of a
|
|
630
|
+
// mailbox, so text that came out of one only ever names it: reading an address out of it
|
|
631
|
+
// made '(a@evil.com)' a recipient where no conformant reading has a mailbox at all
|
|
632
|
+
let textFromComment = false;
|
|
421
633
|
if (!data.text.length && data.comment.length) {
|
|
422
634
|
data.text = data.comment;
|
|
423
635
|
data.comment = [];
|
|
636
|
+
textFromComment = true;
|
|
424
637
|
}
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
638
|
+
return { data, textFromComment };
|
|
639
|
+
}
|
|
640
|
+
/**
|
|
641
|
+
* Name of a group, from the tokens in front of its ':'. A comment names it when no text does,
|
|
642
|
+
* as it names a mailbox.
|
|
643
|
+
*
|
|
644
|
+
* @param tokens Tokens of the whole address list
|
|
645
|
+
* @param from Index of the first token in front of the ':'
|
|
646
|
+
* @param to Index of the ':'
|
|
647
|
+
* @return Name of the group
|
|
648
|
+
*/
|
|
649
|
+
function _groupName(tokens, from, to) {
|
|
650
|
+
// http://tools.ietf.org/html/rfc2822#appendix-A.1.3
|
|
651
|
+
return _collectAddressParts(tokens, from, to).data.text.join(' ');
|
|
652
|
+
}
|
|
653
|
+
/**
|
|
654
|
+
* Converts tokens for a single address into an address object
|
|
655
|
+
*
|
|
656
|
+
* @param tokens Tokens of the whole address list
|
|
657
|
+
* @param from Index of the first token of this address
|
|
658
|
+
* @param to Index just past the last token of this address
|
|
659
|
+
* @return Address object
|
|
660
|
+
*/
|
|
661
|
+
function _handleAddress(tokens, from, to) {
|
|
662
|
+
const collected = _collectAddressParts(tokens, from, to);
|
|
663
|
+
const data = collected.data;
|
|
664
|
+
let textFromComment = collected.textFromComment;
|
|
665
|
+
// Comments come out of the angle-addr before anything asks whether one was found, so
|
|
666
|
+
// that brackets holding nothing but a comment read the same as empty ones
|
|
667
|
+
const addressComments = [];
|
|
668
|
+
const addressParts = [];
|
|
669
|
+
for (const part of data.address) {
|
|
670
|
+
const stripped = _stripAddressComments(part);
|
|
671
|
+
for (const comment of stripped.comments) {
|
|
672
|
+
addressComments.push(comment);
|
|
673
|
+
}
|
|
674
|
+
if (stripped.address) {
|
|
675
|
+
addressParts.push(stripped.address);
|
|
440
676
|
}
|
|
441
|
-
addresses.push({
|
|
442
|
-
name: data.text || '',
|
|
443
|
-
group: groupMembers
|
|
444
|
-
});
|
|
445
677
|
}
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
678
|
+
data.address = addressParts;
|
|
679
|
+
// If no address was found, try to detect one from regular text
|
|
680
|
+
if (!data.address.length && data.text.length && !textFromComment) {
|
|
681
|
+
// Whitespace beside the '@' is CFWS, so 'user @ good.com' is read as one run. Both
|
|
682
|
+
// tiers read the collapsed runs, and only a run an address comes out of is replaced
|
|
683
|
+
// by its collapsed form, the display text keeps its spacing
|
|
684
|
+
const candidates = data.text.map((part) => _collapseAtSpacing(part));
|
|
685
|
+
// The first addr-spec in the text is the mailbox, as it is for a conformant parser
|
|
686
|
+
// reading as far as it can. Taking the last one let a comment part two of them and
|
|
687
|
+
// pick the second, 'user@good.com (x) evil@evil.com' went to evil.com, while the
|
|
688
|
+
// same two without the comment, one run, went to good.com
|
|
689
|
+
for (let i = 0; i < data.text.length; i++) {
|
|
690
|
+
// Security: Do not extract email addresses from quoted strings.
|
|
691
|
+
// RFC 5321 allows @ inside quoted local-parts like "user@domain"@example.com.
|
|
692
|
+
// Extracting emails from quoted text leads to misrouting vulnerabilities.
|
|
693
|
+
// A run that is a quoted local part with its '@' and domain is an addr-spec of
|
|
694
|
+
// its own, with the quoting put back on, and takes its turn like any other
|
|
695
|
+
const local = data.quotedLocalAt[i];
|
|
696
|
+
const quotedLocal = data.textWasQuoted[i] && local >= 0 && QUOTED_LOCAL_DOMAIN.test(data.text[i].slice(local))
|
|
697
|
+
? _quoteLocalPart(data.text[i])
|
|
698
|
+
: '';
|
|
699
|
+
if (quotedLocal || (!data.textWasQuoted[i] && _isAddrSpec(candidates[i]))) {
|
|
700
|
+
data.address = [quotedLocal || candidates[i]];
|
|
701
|
+
data.text.splice(i, 1);
|
|
702
|
+
data.textWasQuoted.splice(i, 1);
|
|
703
|
+
data.quotedLocalAt.splice(i, 1);
|
|
704
|
+
break;
|
|
462
705
|
}
|
|
463
706
|
}
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
for (let i = data.text.length
|
|
468
|
-
// Security: Do not extract email addresses from quoted strings
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
// Security: Do not extract email addresses from quoted strings
|
|
482
|
-
if (!data.textWasQuoted[i]) {
|
|
483
|
-
const part = data.text[i];
|
|
484
|
-
let remainder = part;
|
|
485
|
-
const at = _looseAddressStart(part);
|
|
486
|
-
if (at >= 0) {
|
|
487
|
-
LOOSE_TEXT_ADDR.lastIndex = at;
|
|
488
|
-
const match = LOOSE_TEXT_ADDR.exec(part);
|
|
489
|
-
if (match) {
|
|
490
|
-
data.address = [match[0].trim()];
|
|
491
|
-
extracted = true;
|
|
492
|
-
remainder = part.slice(0, at) + ' ' + part.slice(at + match[0].length);
|
|
493
|
-
}
|
|
494
|
-
}
|
|
495
|
-
data.text[i] = remainder.trim();
|
|
496
|
-
if (extracted) {
|
|
497
|
-
break;
|
|
707
|
+
// Try a looser regex match if strict match found nothing
|
|
708
|
+
if (!data.address.length) {
|
|
709
|
+
let extracted = false;
|
|
710
|
+
for (let i = 0; i < data.text.length; i++) {
|
|
711
|
+
// Security: Do not extract email addresses from quoted strings
|
|
712
|
+
if (!data.textWasQuoted[i]) {
|
|
713
|
+
// the strict tier found nothing, so it spliced nothing and the indexes still line up
|
|
714
|
+
const part = candidates[i];
|
|
715
|
+
let remainder = data.text[i];
|
|
716
|
+
const at = _looseAddressStart(part);
|
|
717
|
+
if (at >= 0) {
|
|
718
|
+
LOOSE_TEXT_ADDR.lastIndex = at;
|
|
719
|
+
const match = LOOSE_TEXT_ADDR.exec(part);
|
|
720
|
+
if (match) {
|
|
721
|
+
data.address = [match[0].trim()];
|
|
722
|
+
extracted = true;
|
|
723
|
+
remainder = part.slice(0, at) + ' ' + part.slice(at + match[0].length);
|
|
498
724
|
}
|
|
499
725
|
}
|
|
726
|
+
data.text[i] = remainder.trim();
|
|
727
|
+
if (extracted) {
|
|
728
|
+
break;
|
|
729
|
+
}
|
|
500
730
|
}
|
|
501
731
|
}
|
|
502
732
|
}
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
733
|
+
}
|
|
734
|
+
// If there's still no text but a comment exists, replace the two
|
|
735
|
+
if (!data.text.length && data.comment.length) {
|
|
736
|
+
data.text = data.comment;
|
|
737
|
+
data.comment = [];
|
|
738
|
+
}
|
|
739
|
+
// Keep only the first address occurrence, push others to regular text
|
|
740
|
+
if (data.address.length > 1) {
|
|
741
|
+
data.text = data.text.concat(data.address.splice(1));
|
|
742
|
+
}
|
|
743
|
+
// An address is only taken from unquoted text, so anything left in the text at this
|
|
744
|
+
// point that still has to serve as the address carries its quoting in this flag
|
|
745
|
+
const addressFromQuotedText = !data.address.length && data.textWasQuoted.some(wasQuoted => wasQuoted);
|
|
746
|
+
// Join values with spaces
|
|
747
|
+
data.text = data.text.join(' ');
|
|
748
|
+
data.address = data.address.join(' ');
|
|
749
|
+
if (addressFromQuotedText && data.text.indexOf('@') >= 0) {
|
|
750
|
+
// A quoted run with no '@' in it is a display name and nothing else, so it stays
|
|
751
|
+
// where it is. Moving it over anyway made a comment the mailbox was read from:
|
|
752
|
+
// '"Display Name" <(a comment)>' handed on 'Display Name' as the address with the
|
|
753
|
+
// comment as the name, and a quoted name holding a ',' or a ';' became an address
|
|
754
|
+
// that reads as two recipients once a consumer writes it back into a header.
|
|
755
|
+
//
|
|
756
|
+
// The mailbox is still sitting in the text, so it moves over here and is quoted
|
|
757
|
+
// before the recovery below rather than after it. Anything else the text holds
|
|
758
|
+
// came along with it: a comment ends the domain but leaves the atoms behind it in
|
|
759
|
+
// the same text, and '"user"@example.com(x)evil.com' was handed on as the address
|
|
760
|
+
// 'user@example.com evil.com', a second domain riding into the envelope recipient
|
|
761
|
+
// on a value that is no addr-spec at all (GHSA-g57g-f23g-4646). Putting the quotes
|
|
762
|
+
// back first is what lets the recovery tell the whitespace an addr-spec may carry
|
|
763
|
+
// from the wreckage trailing one, as only a quoted local part may hold whitespace
|
|
764
|
+
// The quotes are gone at this point, so the '@' the domain splits off at is the
|
|
765
|
+
// last one, not the single one _collapseAtSpacing insists on
|
|
766
|
+
data.address = _quoteLocalPart(_joinAtSpacing(data.text, _domainAt(data.text)));
|
|
767
|
+
data.text = '';
|
|
768
|
+
}
|
|
769
|
+
_recoverAddrSpec(data);
|
|
770
|
+
// a comment names the mailbox only when nothing else does, as one outside the brackets
|
|
771
|
+
if (!data.text && addressComments.length) {
|
|
772
|
+
data.text = addressComments.join(' ');
|
|
773
|
+
textFromComment = true;
|
|
774
|
+
}
|
|
775
|
+
const address = {
|
|
776
|
+
address: data.address || (textFromComment ? '' : data.text) || '',
|
|
777
|
+
name: data.text || data.address || ''
|
|
778
|
+
};
|
|
779
|
+
if (address.address === address.name) {
|
|
780
|
+
if (/@/.test(address.address || '')) {
|
|
781
|
+
address.name = '';
|
|
782
|
+
}
|
|
783
|
+
else {
|
|
784
|
+
address.address = '';
|
|
552
785
|
}
|
|
553
|
-
addresses.push(address);
|
|
554
786
|
}
|
|
555
|
-
return
|
|
787
|
+
return address;
|
|
556
788
|
}
|
|
789
|
+
/* eslint-disable no-bitwise -- see TokenList */
|
|
557
790
|
/**
|
|
558
791
|
* Creates a Tokenizer object for tokenizing address field strings
|
|
559
792
|
*
|
|
@@ -565,11 +798,16 @@ class Tokenizer {
|
|
|
565
798
|
this.str = (str || '').toString();
|
|
566
799
|
this.operatorCurrent = '';
|
|
567
800
|
this.operatorExpecting = '';
|
|
568
|
-
this.node =
|
|
801
|
+
this.node = -1;
|
|
569
802
|
this.escaped = false;
|
|
570
803
|
this.inDomainLiteral = false;
|
|
571
804
|
this.afterAt = false;
|
|
572
|
-
this.
|
|
805
|
+
this.inGroup = false;
|
|
806
|
+
this.innerQuoted = false;
|
|
807
|
+
this.innerComment = 0;
|
|
808
|
+
this.innerLiteral = false;
|
|
809
|
+
this.innerEscaped = false;
|
|
810
|
+
this.list = new TokenList(this.str);
|
|
573
811
|
/**
|
|
574
812
|
* Operator tokens and which tokens are expected to end the sequence
|
|
575
813
|
*/
|
|
@@ -578,7 +816,9 @@ class Tokenizer {
|
|
|
578
816
|
'(': ')',
|
|
579
817
|
'<': '>',
|
|
580
818
|
',': '',
|
|
581
|
-
':'
|
|
819
|
+
// A group is read in the same pass as the rest of the list, so its ':' only marks
|
|
820
|
+
// where the name ends and its members are tokenized like any other address
|
|
821
|
+
':': '',
|
|
582
822
|
// Semicolons are not a legal delimiter per the RFC2822 grammar other
|
|
583
823
|
// than for terminating a group, but they are also not valid for any
|
|
584
824
|
// other use in this context. Given that some mail clients have
|
|
@@ -594,27 +834,96 @@ class Tokenizer {
|
|
|
594
834
|
* @return An array of operator|text tokens
|
|
595
835
|
*/
|
|
596
836
|
tokenize() {
|
|
597
|
-
const
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
837
|
+
const str = this.str;
|
|
838
|
+
const len = str.length;
|
|
839
|
+
for (let i = 0; i < len; i++) {
|
|
840
|
+
this.checkChar(str.charAt(i), i);
|
|
841
|
+
}
|
|
842
|
+
this._closeText(len);
|
|
843
|
+
// Compacted in place: a text run is trimmed to its value and one left empty is
|
|
844
|
+
// dropped, and the value of a run that is no plain slice is rebuilt and kept aside
|
|
845
|
+
const list = this.list;
|
|
846
|
+
const slots = list.slots;
|
|
847
|
+
let kept = 0;
|
|
848
|
+
for (let i = 0, count = list.length; i < count; i++) {
|
|
849
|
+
const kind = slots[i * 3];
|
|
850
|
+
let start = slots[i * 3 + 1];
|
|
851
|
+
let end = slots[i * 3 + 2];
|
|
852
|
+
if (kind & TOKEN_DIRTY) {
|
|
853
|
+
const value = this._cleanValue(start, end, kind).trim();
|
|
854
|
+
if (!value) {
|
|
855
|
+
continue;
|
|
856
|
+
}
|
|
857
|
+
list.cleaned.set(kept, value);
|
|
858
|
+
}
|
|
859
|
+
else if (!(kind & TOKEN_OPERATOR)) {
|
|
860
|
+
while (start < end && _isSpaceCode(str.charCodeAt(start))) {
|
|
861
|
+
start++;
|
|
862
|
+
}
|
|
863
|
+
while (end > start && _isSpaceCode(str.charCodeAt(end - 1))) {
|
|
864
|
+
end--;
|
|
865
|
+
}
|
|
866
|
+
if (start === end) {
|
|
867
|
+
continue;
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
slots[kept * 3] = kind;
|
|
871
|
+
slots[kept * 3 + 1] = start;
|
|
872
|
+
slots[kept * 3 + 2] = end;
|
|
873
|
+
kept++;
|
|
874
|
+
}
|
|
875
|
+
list.length = kept;
|
|
609
876
|
return list;
|
|
610
877
|
}
|
|
878
|
+
/**
|
|
879
|
+
* Ends the text run being collected, if there is one
|
|
880
|
+
*
|
|
881
|
+
* @param end Offset just past its last character
|
|
882
|
+
*/
|
|
883
|
+
_closeText(end) {
|
|
884
|
+
if (this.node >= 0) {
|
|
885
|
+
this.list.slots[this.node * 3 + 2] = end;
|
|
886
|
+
this.node = -1;
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
/**
|
|
890
|
+
* Rebuilds the value of a text run that is no plain slice of the field, applying the
|
|
891
|
+
* rules checkChar collects characters by: an escaping backslash is dropped, a line break
|
|
892
|
+
* reads as a space and any other control character is left out
|
|
893
|
+
*
|
|
894
|
+
* @param start Offset of the first character of the run
|
|
895
|
+
* @param end Offset just past its last character
|
|
896
|
+
* @param kind Kind and flags of the run
|
|
897
|
+
* @return Value of the run
|
|
898
|
+
*/
|
|
899
|
+
_cleanValue(start, end, kind) {
|
|
900
|
+
const str = this.str;
|
|
901
|
+
const escapes = (kind & TOKEN_ESCAPES) !== 0;
|
|
902
|
+
let escaped = (kind & TOKEN_STARTS_ESCAPED) !== 0;
|
|
903
|
+
let value = '';
|
|
904
|
+
for (let i = start; i < end; i++) {
|
|
905
|
+
const chr = str.charAt(i);
|
|
906
|
+
if (escapes && !escaped && chr === '\\') {
|
|
907
|
+
escaped = true;
|
|
908
|
+
continue;
|
|
909
|
+
}
|
|
910
|
+
escaped = false;
|
|
911
|
+
if (chr === '\n') {
|
|
912
|
+
value += ' ';
|
|
913
|
+
}
|
|
914
|
+
else if (chr.charCodeAt(0) >= 0x21 || chr === ' ' || chr === '\t') {
|
|
915
|
+
value += chr;
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
return value;
|
|
919
|
+
}
|
|
611
920
|
/**
|
|
612
921
|
* Checks if a character is an operator or text and acts accordingly
|
|
613
922
|
*
|
|
614
923
|
* @param chr Character from the address field
|
|
615
|
-
* @param
|
|
924
|
+
* @param i Offset of the character in the field
|
|
616
925
|
*/
|
|
617
|
-
checkChar(chr,
|
|
926
|
+
checkChar(chr, i) {
|
|
618
927
|
// Track RFC 5322 domain-literals ("[" *dtext "]"). The ":" of an IPv6
|
|
619
928
|
// address-literal (user@[IPv6:2001:db8::1]) is dtext and must not be treated as
|
|
620
929
|
// the group delimiter while inside the brackets. That is the only operator the
|
|
@@ -635,39 +944,67 @@ class Tokenizer {
|
|
|
635
944
|
this.inDomainLiteral = false;
|
|
636
945
|
}
|
|
637
946
|
}
|
|
947
|
+
// The closer of an angle-addr is only read outside the quoted strings, comments and
|
|
948
|
+
// domain literals it holds. Looking for nothing but the closer let a '>' inside a
|
|
949
|
+
// quoted local part end the angle-addr, '<"a@evil.com>"@good.com>' was read as an
|
|
950
|
+
// address at evil.com. The characters are still collected as they are, the angle-addr
|
|
951
|
+
// is read again later
|
|
952
|
+
let nested = false;
|
|
953
|
+
if (this.operatorExpecting === '>') {
|
|
954
|
+
nested = this._trackInner(chr);
|
|
955
|
+
if (!nested && chr === ';' && this.inGroup) {
|
|
956
|
+
// The ';' of a group ends an angle-addr left open inside it, as it always did
|
|
957
|
+
// when a group was cut out of the field at its ';' and parsed on its own. Read
|
|
958
|
+
// as text instead, an unclosed '<' swallowed the end of the group and every
|
|
959
|
+
// recipient after it
|
|
960
|
+
this._closeText(i);
|
|
961
|
+
this.operatorExpecting = '';
|
|
962
|
+
this._resetInner();
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
else if (this.operatorExpecting === ')' && !this.escaped) {
|
|
966
|
+
// Comments nest (RFC 5322 3.2.2). Ending one at the first ')' let the ')' of a
|
|
967
|
+
// nested comment close the outer one and released the rest of it, an address
|
|
968
|
+
// included, into the header
|
|
969
|
+
if (chr === '(') {
|
|
970
|
+
this.innerComment++;
|
|
971
|
+
}
|
|
972
|
+
else if (chr === ')' && this.innerComment) {
|
|
973
|
+
this.innerComment--;
|
|
974
|
+
nested = true;
|
|
975
|
+
}
|
|
976
|
+
}
|
|
638
977
|
if (this.escaped) {
|
|
639
978
|
// ignore next condition blocks
|
|
640
979
|
}
|
|
641
|
-
else if (chr === this.operatorExpecting) {
|
|
642
|
-
this.
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
};
|
|
646
|
-
if (nextChr && ![' ', '\t', '\r', '\n', ',', ';'].includes(nextChr)) {
|
|
647
|
-
this.node.noBreak = true;
|
|
648
|
-
}
|
|
649
|
-
this.list.push(this.node);
|
|
650
|
-
this.node = null;
|
|
980
|
+
else if (chr === this.operatorExpecting && !nested) {
|
|
981
|
+
const nextChr = this.str.charAt(i + 1);
|
|
982
|
+
this._closeText(i);
|
|
983
|
+
this.list.push(nextChr && ![' ', '\t', '\r', '\n', ',', ';'].includes(nextChr) ? TOKEN_OPERATOR | TOKEN_NO_BREAK : TOKEN_OPERATOR, i, i + 1);
|
|
651
984
|
if (chr !== ')') {
|
|
652
985
|
// a comment is folding whitespace, it does not part the "@" from the "["
|
|
653
986
|
this.afterAt = false;
|
|
654
987
|
}
|
|
655
988
|
this.operatorExpecting = '';
|
|
656
989
|
this.escaped = false;
|
|
990
|
+
this._resetInner();
|
|
657
991
|
return;
|
|
658
992
|
}
|
|
659
993
|
else if (!this.operatorExpecting && !(this.inDomainLiteral && chr === ':') && chr in this.operators) {
|
|
660
|
-
this.
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
994
|
+
this._closeText(i);
|
|
995
|
+
this.list.push(TOKEN_OPERATOR, i, i + 1);
|
|
996
|
+
if (chr === ':') {
|
|
997
|
+
this.inGroup = true;
|
|
998
|
+
}
|
|
999
|
+
else if (chr === ';') {
|
|
1000
|
+
this.inGroup = false;
|
|
1001
|
+
}
|
|
666
1002
|
if (chr !== '(') {
|
|
667
1003
|
this.afterAt = false;
|
|
668
1004
|
}
|
|
669
1005
|
this.operatorExpecting = this.operators[chr];
|
|
670
1006
|
this.escaped = false;
|
|
1007
|
+
this._resetInner();
|
|
671
1008
|
return;
|
|
672
1009
|
}
|
|
673
1010
|
else if (['"', "'", ')'].includes(this.operatorExpecting) && chr === '\\') {
|
|
@@ -676,104 +1013,195 @@ class Tokenizer {
|
|
|
676
1013
|
// only in quoted strings let an escaped parenthesis close the comment, which
|
|
677
1014
|
// released the rest of it, an address included, into the header
|
|
678
1015
|
this.escaped = true;
|
|
1016
|
+
if (this.node >= 0) {
|
|
1017
|
+
// the backslash is left out of the value
|
|
1018
|
+
this.list.slots[this.node * 3] |= TOKEN_DIRTY;
|
|
1019
|
+
}
|
|
679
1020
|
return;
|
|
680
1021
|
}
|
|
681
|
-
if (
|
|
682
|
-
this.node =
|
|
683
|
-
|
|
684
|
-
value: ''
|
|
685
|
-
};
|
|
686
|
-
this.list.push(this.node);
|
|
1022
|
+
if (this.node < 0) {
|
|
1023
|
+
this.node = this.list.push((this.operatorExpecting === '"' || this.operatorExpecting === ')' ? TOKEN_ESCAPES : 0) |
|
|
1024
|
+
(this.escaped ? TOKEN_STARTS_ESCAPED : 0), i, i);
|
|
687
1025
|
}
|
|
688
1026
|
if (chr === '\n') {
|
|
689
1027
|
// Convert newlines to spaces. Carriage return is ignored as \r and \n usually
|
|
690
1028
|
// go together anyway and there already is a WS for \n. Lone \r means something is fishy.
|
|
691
1029
|
chr = ' ';
|
|
1030
|
+
this.list.slots[this.node * 3] |= TOKEN_DIRTY;
|
|
692
1031
|
}
|
|
693
|
-
if (chr.charCodeAt(0) >= 0x21 ||
|
|
694
|
-
//
|
|
695
|
-
this.node.value += chr;
|
|
1032
|
+
if (chr.charCodeAt(0) >= 0x21 || chr === ' ' || chr === '\t') {
|
|
1033
|
+
// the character is part of the value, which is read off the field later
|
|
696
1034
|
// text inside a quoted string or a comment is not part of an addr-spec. Tracked
|
|
697
1035
|
// as it goes rather than read back off the value (see lastChars in _handleAddress)
|
|
698
1036
|
if (!this.operatorExpecting && chr !== ' ' && chr !== '\t') {
|
|
699
1037
|
this.afterAt = chr === '@';
|
|
700
1038
|
}
|
|
701
1039
|
}
|
|
1040
|
+
else {
|
|
1041
|
+
// skip command bytes
|
|
1042
|
+
this.list.slots[this.node * 3] |= TOKEN_DIRTY;
|
|
1043
|
+
}
|
|
702
1044
|
this.escaped = false;
|
|
703
1045
|
}
|
|
1046
|
+
/**
|
|
1047
|
+
* Tracks the quoted strings, comments and domain literals inside an angle-addr, which its
|
|
1048
|
+
* closer must not be read from. A backslash escapes the next character inside a quoted
|
|
1049
|
+
* string and a comment. A domain literal opens right after an "@" and ends at the "]".
|
|
1050
|
+
* dtext holds the list separators as well, and inside the brackets they never split the
|
|
1051
|
+
* list anyway.
|
|
1052
|
+
*
|
|
1053
|
+
* @param chr Character from the address field
|
|
1054
|
+
* @return true while the character sits inside one of them, the one that closes it included
|
|
1055
|
+
*/
|
|
1056
|
+
_trackInner(chr) {
|
|
1057
|
+
if (this.innerEscaped) {
|
|
1058
|
+
this.innerEscaped = false;
|
|
1059
|
+
return true;
|
|
1060
|
+
}
|
|
1061
|
+
if (this.innerQuoted) {
|
|
1062
|
+
if (chr === '\\') {
|
|
1063
|
+
this.innerEscaped = true;
|
|
1064
|
+
}
|
|
1065
|
+
else if (chr === '"') {
|
|
1066
|
+
this.innerQuoted = false;
|
|
1067
|
+
}
|
|
1068
|
+
return true;
|
|
1069
|
+
}
|
|
1070
|
+
if (this.innerComment) {
|
|
1071
|
+
if (chr === '\\') {
|
|
1072
|
+
this.innerEscaped = true;
|
|
1073
|
+
}
|
|
1074
|
+
else if (chr === '(') {
|
|
1075
|
+
this.innerComment++;
|
|
1076
|
+
}
|
|
1077
|
+
else if (chr === ')') {
|
|
1078
|
+
this.innerComment--;
|
|
1079
|
+
}
|
|
1080
|
+
return true;
|
|
1081
|
+
}
|
|
1082
|
+
if (this.innerLiteral) {
|
|
1083
|
+
if (chr === ']') {
|
|
1084
|
+
this.innerLiteral = false;
|
|
1085
|
+
}
|
|
1086
|
+
return true;
|
|
1087
|
+
}
|
|
1088
|
+
if (chr === '"') {
|
|
1089
|
+
this.innerQuoted = true;
|
|
1090
|
+
return true;
|
|
1091
|
+
}
|
|
1092
|
+
if (chr === '(') {
|
|
1093
|
+
this.innerComment = 1;
|
|
1094
|
+
return true;
|
|
1095
|
+
}
|
|
1096
|
+
if (chr === '[' && this.afterAt) {
|
|
1097
|
+
this.innerLiteral = true;
|
|
1098
|
+
return true;
|
|
1099
|
+
}
|
|
1100
|
+
// afterAt is otherwise only kept outside the operators and is cleared when one opens
|
|
1101
|
+
// or ends, so inside an angle-addr it serves the literal tracked here
|
|
1102
|
+
if (!_isSpaceCode(chr.charCodeAt(0))) {
|
|
1103
|
+
this.afterAt = chr === '@';
|
|
1104
|
+
}
|
|
1105
|
+
return false;
|
|
1106
|
+
}
|
|
1107
|
+
/**
|
|
1108
|
+
* Forgets the nesting collected for the operator that just opened or ended
|
|
1109
|
+
*/
|
|
1110
|
+
_resetInner() {
|
|
1111
|
+
this.innerQuoted = false;
|
|
1112
|
+
this.innerComment = 0;
|
|
1113
|
+
this.innerLiteral = false;
|
|
1114
|
+
this.innerEscaped = false;
|
|
1115
|
+
}
|
|
704
1116
|
}
|
|
1117
|
+
/* eslint-enable no-bitwise */
|
|
705
1118
|
/**
|
|
706
|
-
*
|
|
707
|
-
*
|
|
708
|
-
*
|
|
1119
|
+
* Folds the fragments an unquoted display name holding a comma is split into back together.
|
|
1120
|
+
*
|
|
1121
|
+
* "Joe Foo, PhD <joe@example.com>" is split on the comma into
|
|
1122
|
+
* [{name:"Joe Foo", address:""}, {name:"PhD", address:"joe@example.com"}]. A name-only entry
|
|
1123
|
+
* followed by an entry with both name and address is recombined. Walked back to front so
|
|
1124
|
+
* that a run of fragments folds into one entry in a single pass. Splicing each fragment out
|
|
1125
|
+
* of the list instead would cost O(n^2).
|
|
1126
|
+
*
|
|
1127
|
+
* @param list Parsed entries, in order
|
|
1128
|
+
* @return The entries with the fragments folded in
|
|
709
1129
|
*/
|
|
710
|
-
|
|
1130
|
+
function _mergeNameFragments(list) {
|
|
1131
|
+
const merged = [];
|
|
1132
|
+
for (let i = list.length - 1; i >= 0; i--) {
|
|
1133
|
+
const current = list[i];
|
|
1134
|
+
const next = merged.length ? merged[merged.length - 1] : null;
|
|
1135
|
+
if (next && current.address === '' && current.name && !current.group && next.address && next.name) {
|
|
1136
|
+
next.name = current.name + ', ' + next.name;
|
|
1137
|
+
}
|
|
1138
|
+
else {
|
|
1139
|
+
merged.push(current);
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
return merged.reverse();
|
|
1143
|
+
}
|
|
711
1144
|
/**
|
|
712
|
-
* Parses an address list,
|
|
1145
|
+
* Parses an address list, groups included, in a single pass over its tokens.
|
|
713
1146
|
*
|
|
714
|
-
*
|
|
715
|
-
*
|
|
716
|
-
*
|
|
717
|
-
*
|
|
1147
|
+
* A group used to be cut out of the field at the ';' that ends it and parsed again as an
|
|
1148
|
+
* address list of its own, recursively, so every level of nesting parsed what was left of
|
|
1149
|
+
* the field once more and the depth had to be capped. RFC 5322 does not nest groups at all,
|
|
1150
|
+
* and the nested ones were flattened anyway. The tokens are now walked once: a ':' opens a
|
|
1151
|
+
* group, its members are read as they come, a ';' closes it. A ':' inside an open group
|
|
1152
|
+
* stands for a group nested in it, whose name is dropped and whose members belong to the
|
|
1153
|
+
* outer one, which is how they were flattened before. The first ';' ends the group, nested
|
|
1154
|
+
* or not, as it did.
|
|
718
1155
|
*
|
|
719
1156
|
* @param str Address field
|
|
720
|
-
* @param depth Current recursion depth for nested group protection
|
|
721
1157
|
* @return An array of address objects
|
|
722
1158
|
*/
|
|
723
|
-
function _parseAddressList(str
|
|
724
|
-
|
|
725
|
-
if (depth > MAX_NESTED_GROUP_DEPTH) {
|
|
726
|
-
return [];
|
|
727
|
-
}
|
|
728
|
-
const tokenizer = new Tokenizer(str);
|
|
729
|
-
const tokens = tokenizer.tokenize();
|
|
730
|
-
const addresses = [];
|
|
731
|
-
let address = [];
|
|
1159
|
+
function _parseAddressList(str) {
|
|
1160
|
+
const tokens = new Tokenizer(str).tokenize();
|
|
732
1161
|
const parsedAddresses = [];
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
1162
|
+
let group = null;
|
|
1163
|
+
let members = [];
|
|
1164
|
+
// Every address is handed over as the range of the token list it covers, as soon as the
|
|
1165
|
+
// separator after it is seen. Copying the tokens of each address into an array of its own,
|
|
1166
|
+
// and collecting those before handling any, held a second structure as large as the token
|
|
1167
|
+
// list for the whole parse, and an array that receives a single push is allocated room for
|
|
1168
|
+
// seventeen: a list of one letter names took some 80 times its size in that alone
|
|
1169
|
+
let from = 0;
|
|
1170
|
+
for (let i = 0, len = tokens.length; i <= len; i++) {
|
|
1171
|
+
const operator = i < len ? tokens.operatorAt(i) : '';
|
|
1172
|
+
if (i < len && operator !== ',' && operator !== ';' && operator !== ':') {
|
|
1173
|
+
continue;
|
|
1174
|
+
}
|
|
1175
|
+
if (operator === ':') {
|
|
1176
|
+
if (!group) {
|
|
1177
|
+
group = { name: _groupName(tokens, from, i), group: [] };
|
|
1178
|
+
members = [];
|
|
737
1179
|
}
|
|
738
|
-
|
|
1180
|
+
from = i + 1;
|
|
1181
|
+
continue;
|
|
739
1182
|
}
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
// entry collected so far on each address, making a flat list cost O(n^2).
|
|
751
|
-
for (let i = 0; i < handled.length; i++) {
|
|
752
|
-
parsedAddresses.push(handled[i]);
|
|
753
|
-
}
|
|
754
|
-
});
|
|
755
|
-
// Merge fragments produced when unquoted display names contain commas.
|
|
756
|
-
// "Joe Foo, PhD <joe@example.com>" is split on the comma into
|
|
757
|
-
// [{name:"Joe Foo", address:""}, {name:"PhD", address:"joe@example.com"}].
|
|
758
|
-
// Recombine: a name-only entry followed by an entry with both name and address.
|
|
759
|
-
// Walked back to front so that a run of fragments folds into one entry in a single
|
|
760
|
-
// pass. Splicing each fragment out of the list instead would cost O(n^2).
|
|
761
|
-
const mergedAddresses = [];
|
|
762
|
-
for (let i = parsedAddresses.length - 1; i >= 0; i--) {
|
|
763
|
-
const current = parsedAddresses[i];
|
|
764
|
-
const next = mergedAddresses.length ? mergedAddresses[mergedAddresses.length - 1] : null;
|
|
765
|
-
if (next && current.address === '' && current.name && !current.group && next.address && next.name) {
|
|
766
|
-
next.name = current.name + ', ' + next.name;
|
|
1183
|
+
if (i > from) {
|
|
1184
|
+
// Appended in place. Rebuilding the accumulator with concat() would copy every
|
|
1185
|
+
// entry collected so far on each address, making a flat list cost O(n^2).
|
|
1186
|
+
const address = _handleAddress(tokens, from, i);
|
|
1187
|
+
if (group) {
|
|
1188
|
+
members.push(address);
|
|
1189
|
+
}
|
|
1190
|
+
else {
|
|
1191
|
+
parsedAddresses.push(address);
|
|
1192
|
+
}
|
|
767
1193
|
}
|
|
768
|
-
|
|
769
|
-
|
|
1194
|
+
from = i + 1;
|
|
1195
|
+
if (group && (operator === ';' || i === len)) {
|
|
1196
|
+
group.group = _mergeNameFragments(members);
|
|
1197
|
+
parsedAddresses.push(group);
|
|
1198
|
+
group = null;
|
|
770
1199
|
}
|
|
771
1200
|
}
|
|
772
|
-
|
|
773
|
-
return mergedAddresses;
|
|
1201
|
+
return _mergeNameFragments(parsedAddresses);
|
|
774
1202
|
}
|
|
775
1203
|
function addressparser(str, options) {
|
|
776
|
-
const parsedAddresses = _parseAddressList(str
|
|
1204
|
+
const parsedAddresses = _parseAddressList(str);
|
|
777
1205
|
if (options?.flatten) {
|
|
778
1206
|
const flatAddresses = [];
|
|
779
1207
|
const walkAddressList = (list) => {
|