mirror of
https://github.com/dawidd6/action-send-mail.git
synced 2026-10-19 22:14:07 +07:00
Co-authored-by: dawidd6 <9713907+dawidd6@users.noreply.github.com>
1223 lines
51 KiB
JavaScript
1223 lines
51 KiB
JavaScript
"use strict";
|
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
/* eslint-disable no-bitwise -- the kind of a token is a set of bit flags packed into one slot of a typed array */
|
|
/** The token is an operator, a text run otherwise */
|
|
const TOKEN_OPERATOR = 1;
|
|
/** The next character continues the same run without whitespace in between */
|
|
const TOKEN_NO_BREAK = 2;
|
|
/** The text run holds characters its value leaves out or changes, so it is no plain slice */
|
|
const TOKEN_DIRTY = 4;
|
|
/** A backslash escapes the next character in the text run, inside a quoted string or a comment */
|
|
const TOKEN_ESCAPES = 8;
|
|
/** The text run opens on a character an escaping backslash in front of it has escaped */
|
|
const TOKEN_STARTS_ESCAPED = 16;
|
|
/**
|
|
* The tokens of an address field, kept as offsets into it.
|
|
*
|
|
* Every token used to be an object of its own, an operator for every ',' and a value string
|
|
* for every run of text, and a list of one letter names, 'a,a,a,...', took some 65 times its
|
|
* size in tokens before any address was read. A token is now three numbers in one typed
|
|
* array, its kind and the range of the field it covers, and its value is sliced off the field
|
|
* when it is read. The few text runs whose value differs from their slice, as they hold an
|
|
* escaping backslash, a line break or a control character, are rebuilt once and kept aside.
|
|
*/
|
|
class TokenList {
|
|
constructor(str) {
|
|
this.str = str;
|
|
this.slots = new Int32Array(48);
|
|
this.length = 0;
|
|
this.cleaned = new Map();
|
|
}
|
|
/**
|
|
* Appends a token
|
|
*
|
|
* @param kind Kind and flags of the token
|
|
* @param start Offset of its first character
|
|
* @param end Offset just past its last character
|
|
* @return Index of the token
|
|
*/
|
|
push(kind, start, end) {
|
|
if ((this.length + 1) * 3 > this.slots.length) {
|
|
const grown = new Int32Array(this.slots.length * 2);
|
|
grown.set(this.slots);
|
|
this.slots = grown;
|
|
}
|
|
const at = this.length * 3;
|
|
this.slots[at] = kind;
|
|
this.slots[at + 1] = start;
|
|
this.slots[at + 2] = end;
|
|
return this.length++;
|
|
}
|
|
/**
|
|
* The character of an operator token, an empty string for a text run
|
|
*/
|
|
operatorAt(index) {
|
|
const at = index * 3;
|
|
return this.slots[at] & TOKEN_OPERATOR ? this.str.charAt(this.slots[at + 1]) : '';
|
|
}
|
|
/**
|
|
* Whether a token is an operator
|
|
*/
|
|
isOperator(index) {
|
|
return (this.slots[index * 3] & TOKEN_OPERATOR) !== 0;
|
|
}
|
|
/**
|
|
* Whether the next character continues the run of a token without whitespace in between
|
|
*/
|
|
noBreak(index) {
|
|
return (this.slots[index * 3] & TOKEN_NO_BREAK) !== 0;
|
|
}
|
|
/**
|
|
* Value of a token
|
|
*/
|
|
value(index) {
|
|
const cleaned = this.cleaned.get(index);
|
|
if (cleaned !== undefined) {
|
|
return cleaned;
|
|
}
|
|
return this.str.slice(this.slots[index * 3 + 1], this.slots[index * 3 + 2]);
|
|
}
|
|
}
|
|
/**
|
|
* A domain literal that ends an address. dtext includes '@' (RFC 5322 3.4.1), so the '@'
|
|
* in front of the literal is the one that splits the domain off, not the last one: reading
|
|
* 'a@[x@evil.com]' at its last '@' made it an address at evil.com.
|
|
*/
|
|
const DOMAIN_LITERAL_TAIL = /@(\[[^[\]\\]*\])$/;
|
|
/**
|
|
* Offset of the '@' that splits the domain off an address, or -1 when there is none
|
|
*
|
|
* @param address Address to split
|
|
* @return Offset of the '@'
|
|
*/
|
|
function _domainAt(address) {
|
|
const literal = address.charAt(address.length - 1) === ']' ? DOMAIN_LITERAL_TAIL.exec(address) : null;
|
|
return literal ? address.length - literal[1].length - 1 : address.lastIndexOf('@');
|
|
}
|
|
/**
|
|
* Restores the quoting of a local part that was read out of a quoted string.
|
|
*
|
|
* RFC 5321 allows '@' inside a quoted local part, so handing '"user@evil.com"@good.com'
|
|
* on as the bare 'user@evil.com@good.com' leaves it to the consumer which '@' splits the
|
|
* domain off. Getting that wrong is a misrouting vector, so the quotes go back on. The
|
|
* same holds for the other specials: a ',' or a ';' that loses its quotes reads as a
|
|
* recipient separator once the consumer puts the address back into a header.
|
|
*
|
|
* This module has no dependencies so that it can ship on its own, which is why the two
|
|
* grammar tests below are spelled out here instead of shared with src/mime-node. Keeping
|
|
* only what is ambiguous quoted is deliberate, mime-node applies the stricter RFC 5321
|
|
* dot-atom rule on top of this when it emits an address.
|
|
*
|
|
* @param address Address with an unquoted local part
|
|
* @return Address with the local part as a quoted-string
|
|
*/
|
|
function _quoteLocalPart(address) {
|
|
const lastAt = _domainAt(address);
|
|
if (lastAt < 0) {
|
|
// no domain to split off, nothing can be misrouted
|
|
return address;
|
|
}
|
|
const user = address.substr(0, lastAt);
|
|
if (/^[^\s"(),:;<>@[\\\]]+$/.test(user) || /^"(?:[^"\\]|\\[\s\S])*"$/.test(user)) {
|
|
// a local part that carries no special reads the same with or without the quotes,
|
|
// and one that is already a complete quoted-string needs nothing either
|
|
return address;
|
|
}
|
|
return '"' + user.replace(/["\\]/g, '\\$&') + '"@' + address.substr(lastAt + 1);
|
|
}
|
|
/**
|
|
* Reached for every parsed address, so it is built once rather than per call.
|
|
*/
|
|
const HAS_WHITESPACE = /\s/;
|
|
/**
|
|
* An addr-spec that carries its whitespace legally, inside a quoted local part. The
|
|
* optional tail is the malformed shape: a real mailbox with wreckage trailing it.
|
|
*/
|
|
const QUOTED_LOCAL_ADDR = /^("(?:[^"\\]|\\[\s\S])*"@\S+)(?:\s+([\s\S]+))?$/;
|
|
/**
|
|
* One run holding a single '@' and no whitespace, the shape an addr-spec has to have.
|
|
*/
|
|
const ADDR_SPEC = /^[^@\s]+@[^@\s]+$/;
|
|
/**
|
|
* The same with a domain literal for the domain, which may hold an '@' of its own.
|
|
*/
|
|
const LITERAL_ADDR_SPEC = /^[^@\s]+@\[[^[\]\\\s]*\]$/;
|
|
/**
|
|
* The '@' and domain that follow a quoted local part in the same run.
|
|
*/
|
|
const QUOTED_LOCAL_DOMAIN = /^@(?:[^@\s"]+|\[[^[\]\\\s]*\])$/;
|
|
/**
|
|
* Whether a whitespace free run reads as an addr-spec under the strict grammar
|
|
*
|
|
* @param value Run to check
|
|
* @return true when it does
|
|
*/
|
|
function _isAddrSpec(value) {
|
|
return ADDR_SPEC.test(value) || LITERAL_ADDR_SPEC.test(value);
|
|
}
|
|
/**
|
|
* The looser reading applied once the strict one finds nothing, which tolerates the
|
|
* further '@' that a domain should not have but malformed headers carry anyway.
|
|
*/
|
|
const LOOSE_ADDR_SPEC = /^[^@\s]+@\S+$/;
|
|
/**
|
|
* An addr-spec sitting inside free text, together with the whitespace around it. Sticky
|
|
* on purpose: it is run at the one offset _looseAddressStart picks rather than being let
|
|
* loose to search, see there.
|
|
*/
|
|
const LOOSE_TEXT_ADDR = /\s*\b[^@\s]+@[^\s]+\b\s*/y;
|
|
/**
|
|
* The characters JS `\s` matches, which the scan below has to agree with to land on the
|
|
* same match the pattern would.
|
|
*/
|
|
function _isSpaceCode(code) {
|
|
return (code === 0x20 ||
|
|
(code >= 0x09 && code <= 0x0d) ||
|
|
code === 0xa0 ||
|
|
code === 0x1680 ||
|
|
(code >= 0x2000 && code <= 0x200a) ||
|
|
code === 0x2028 ||
|
|
code === 0x2029 ||
|
|
code === 0x202f ||
|
|
code === 0x205f ||
|
|
code === 0x3000 ||
|
|
code === 0xfeff);
|
|
}
|
|
/**
|
|
* The characters JS `\w` matches without the unicode flag, the set the `\b` in
|
|
* LOOSE_TEXT_ADDR is read against. charCodeAt off either end of the string gives NaN,
|
|
* which compares false throughout, so out of range reads as the non-word the pattern
|
|
* treats them as.
|
|
*/
|
|
function _isWordCode(code) {
|
|
return (code >= 0x30 && code <= 0x39) || (code >= 0x41 && code <= 0x5a) || (code >= 0x61 && code <= 0x7a) || code === 0x5f;
|
|
}
|
|
/**
|
|
* Whether `\b` holds at an offset
|
|
*/
|
|
function _isBoundary(text, at) {
|
|
return _isWordCode(text.charCodeAt(at - 1)) !== _isWordCode(text.charCodeAt(at));
|
|
}
|
|
/**
|
|
* Offset of the first '@' in `text` between `from` and `to`, or -1 when the range holds none.
|
|
*
|
|
* indexOf would scan on to the end of the value, and the value here is a whole header. The
|
|
* walk below steps one whitespace delimited run at a time and only ever uses a '@' that sits
|
|
* inside the run it is on, so an unbounded probe rescans everything behind that run once per
|
|
* run and grows with the square of the header: 400KB of free text carrying no '@' took a
|
|
* quarter of a second. GHSA-v53p-9fqp-m79j took the pattern search out of this walk and left
|
|
* the probe unbounded behind it.
|
|
*
|
|
* @param text Text to look in
|
|
* @param from Offset to start at
|
|
* @param to Offset to stop before
|
|
* @return Offset of the '@', or -1
|
|
*/
|
|
function _indexOfAt(text, from, to) {
|
|
for (let i = from; i < to; i++) {
|
|
if (text.charCodeAt(i) === 0x40) {
|
|
return i;
|
|
}
|
|
}
|
|
return -1;
|
|
}
|
|
/**
|
|
* A quoted-string a value opens with.
|
|
*/
|
|
const LEADING_QUOTED_STRING = /^"(?:[^"\\]|\\[\s\S])*"/;
|
|
/**
|
|
* Drops the whitespace on either side of the '@' at an offset.
|
|
*
|
|
* obs-local-part and obs-domain allow CFWS around the '@' (RFC 5322 4.4), so 'user @ good.com'
|
|
* names user@good.com. Kept, the whitespace went into the envelope as '"user "@ good.com'.
|
|
* The scan is by index, a pattern such as /\s+@/ backtracks over a long whitespace run that
|
|
* no '@' follows.
|
|
*
|
|
* @param value Value holding the '@'
|
|
* @param at Offset of the '@', or -1
|
|
* @return The value with the whitespace beside that '@' removed
|
|
*/
|
|
function _joinAtSpacing(value, at) {
|
|
if (at < 0) {
|
|
return value;
|
|
}
|
|
let start = at;
|
|
while (start > 0 && _isSpaceCode(value.charCodeAt(start - 1))) {
|
|
start--;
|
|
}
|
|
let end = at + 1;
|
|
while (end < value.length && _isSpaceCode(value.charCodeAt(end))) {
|
|
end++;
|
|
}
|
|
return start === at && end === at + 1 ? value : value.slice(0, start) + '@' + value.slice(end);
|
|
}
|
|
/**
|
|
* Drops the whitespace beside the '@' that splits the domain off a value. That is the '@'
|
|
* right after a quoted local part the value opens with, or else the only '@' it holds outside
|
|
* any quoted string. With several it is not clear which one the whitespace belongs to, and
|
|
* '<x@evil.com @good.com>' is left to the addr-spec recovery to read as it always has.
|
|
*
|
|
* @param value Value to clean up
|
|
* @return The value with the whitespace beside that '@' removed
|
|
*/
|
|
function _collapseAtSpacing(value) {
|
|
const quoted = LEADING_QUOTED_STRING.exec(value);
|
|
if (quoted) {
|
|
let at = quoted[0].length;
|
|
while (at < value.length && _isSpaceCode(value.charCodeAt(at))) {
|
|
at++;
|
|
}
|
|
if (value.charAt(at) === '@' && value.indexOf('@', at + 1) < 0) {
|
|
return _joinAtSpacing(value, at);
|
|
}
|
|
}
|
|
const at = value.indexOf('@');
|
|
if (at < 0 || value.indexOf('@', at + 1) >= 0 || value.lastIndexOf('"') > at) {
|
|
return value;
|
|
}
|
|
return _joinAtSpacing(value, at);
|
|
}
|
|
/**
|
|
* Finds the offset LOOSE_TEXT_ADDR matches at, or -1 when it does not match at all.
|
|
*
|
|
* Letting the pattern search for itself is quadratic: '[^@\s]+' is retried from every
|
|
* offset and rescans the run to the next '@' each time, so 140KB of header holding no
|
|
* usable '@' blocks the event loop for about ten seconds (GHSA-v53p-9fqp-m79j). The search is also unnecessary.
|
|
* '[^@\s]+' crosses neither whitespace nor a '@', so a match can only begin at the head of
|
|
* a whitespace delimited run or just past a '@' inside one, and '[^\s]+\b' gives characters
|
|
* back until it lands on a boundary, so the only end it can take in that run is the last
|
|
* boundary in it. Both are found in one pass, and the pattern is then run at that single
|
|
* offset.
|
|
*
|
|
* @param text Free text to look in
|
|
* @return Offset to match at, or -1
|
|
*/
|
|
function _looseAddressStart(text) {
|
|
const len = text.length;
|
|
let pos = 0;
|
|
while (pos < len) {
|
|
while (pos < len && _isSpaceCode(text.charCodeAt(pos))) {
|
|
pos++;
|
|
}
|
|
if (pos >= len) {
|
|
break;
|
|
}
|
|
const runStart = pos;
|
|
let runEnd = pos;
|
|
while (runEnd < len && !_isSpaceCode(text.charCodeAt(runEnd))) {
|
|
runEnd++;
|
|
}
|
|
let at = _indexOfAt(text, runStart, runEnd);
|
|
if (at >= 0) {
|
|
let lastBoundary = -1;
|
|
for (let k = runEnd; k > runStart; k--) {
|
|
if (_isBoundary(text, k)) {
|
|
lastBoundary = k;
|
|
break;
|
|
}
|
|
}
|
|
let atomStart = runStart;
|
|
while (lastBoundary >= 0 && at >= 0) {
|
|
// '[^@\s]+' has to cover a character before the '@' and '[^\s]+' one after it,
|
|
// and the boundary that ends the match has to sit past both
|
|
if (at > atomStart && runEnd > at + 1 && lastBoundary > at + 1) {
|
|
for (let start = atomStart; start < at; start++) {
|
|
if (_isBoundary(text, start)) {
|
|
if (start > runStart) {
|
|
return start;
|
|
}
|
|
// the leading '\s*' is greedy, so a match that begins at the run
|
|
// takes the whitespace in front of it along
|
|
let padded = runStart;
|
|
while (padded > 0 && _isSpaceCode(text.charCodeAt(padded - 1))) {
|
|
padded--;
|
|
}
|
|
return padded;
|
|
}
|
|
}
|
|
}
|
|
atomStart = at + 1;
|
|
at = _indexOfAt(text, atomStart, runEnd);
|
|
}
|
|
}
|
|
pos = runEnd;
|
|
}
|
|
return -1;
|
|
}
|
|
/**
|
|
* Recovers the addr-spec from an angle-addr that came back holding unquoted whitespace.
|
|
*
|
|
* A malformed header can put more than a mailbox between the angle brackets, most often
|
|
* because the generator wrote the recipient twice: '<user@example.com user@example.com>'
|
|
* or '<example.com user@example.com>'. Whitespace is not addr-spec, so the whole run can
|
|
* never be a mailbox anyone could deliver to, and passing it on as the address loses the
|
|
* recipient that is sitting right there in the header.
|
|
*
|
|
* The run that still reads as an addr-spec is kept and whatever is left over becomes
|
|
* display text rather than being dropped. Candidates are read strictly first and then
|
|
* under the looser grammar, the same two tiers the unquoted-text branch below applies to
|
|
* the same problem, so that '<a@b@c.com junk>' and a bare 'a@b@c.com junk' agree on the
|
|
* recipient. When several runs qualify the first wins, which is what that branch's looser
|
|
* tier does within a token.
|
|
*
|
|
* A quoted local part is left alone: RFC 5321 allows whitespace inside it, so
|
|
* '<"user name"@example.com>' is well formed and means exactly what it says.
|
|
*
|
|
* @param data Collected address parts, mutated in place
|
|
*/
|
|
function _recoverAddrSpec(data) {
|
|
data.address = _collapseAtSpacing(data.address);
|
|
if (!HAS_WHITESPACE.test(data.address)) {
|
|
return;
|
|
}
|
|
let address;
|
|
let rest;
|
|
const quoted = data.address.match(QUOTED_LOCAL_ADDR);
|
|
if (quoted) {
|
|
if (!quoted[2]) {
|
|
// the whitespace sits inside the quoted local part, this is a well formed mailbox
|
|
return;
|
|
}
|
|
// a real mailbox with wreckage trailing it, so peel the addr-spec off whole rather
|
|
// than splitting into the quotes
|
|
address = quoted[1];
|
|
rest = [quoted[2]];
|
|
}
|
|
else {
|
|
if (data.address.indexOf('"') >= 0) {
|
|
// Splitting on whitespace loses track of where the quoted string starts and ends,
|
|
// and this module does not take addresses out of quoted strings: the run picked out
|
|
// of '<junk "user@evil.com b"@good.com>' would be an address from the domain the
|
|
// quotes were hiding. Every well formed shape was already handled above, so what is
|
|
// left is wreckage either way and the original is the honest answer
|
|
return;
|
|
}
|
|
const parts = data.address.split(/\s+/);
|
|
let addrIndex = parts.findIndex(_isAddrSpec);
|
|
if (addrIndex < 0) {
|
|
addrIndex = parts.findIndex(part => LOOSE_ADDR_SPEC.test(part));
|
|
}
|
|
if (addrIndex < 0) {
|
|
// nothing in there reads as an address, there is no better answer than the original
|
|
return;
|
|
}
|
|
address = parts.splice(addrIndex, 1)[0];
|
|
rest = parts;
|
|
}
|
|
data.address = address;
|
|
data.text = [data.text]
|
|
.concat(rest)
|
|
.filter(part => part)
|
|
.join(' ');
|
|
}
|
|
/**
|
|
* Takes the RFC 5322 comments out of the contents of an angle-addr.
|
|
*
|
|
* The tokenizer tracks a single open operator, so once '<' is open a '(' is plain text and
|
|
* a comment inside the brackets reached the address verbatim: 'Name <user@example.com(x)evil.com>'
|
|
* put 'user@example.com(x)evil.com' into the envelope, a value that is no mailbox and that a
|
|
* receiver stripping the comment reads as 'user@example.comevil.com' (GHSA-g73g-hqqh-jr95).
|
|
* A comment is folding whitespace, so it is read as a space that splits two atoms and as
|
|
* nothing next to an '@', the same rule the token walk applies outside the brackets. Quoted
|
|
* strings and domain literals are copied through untouched, a '(' in there is not a comment.
|
|
* An unexpected '<' outside them drops everything in front of it, comments included.
|
|
*
|
|
* RFC 822 also let an angle-addr name the relays to go through in front of the mailbox,
|
|
* '<@relay1,@relay2:user@example.com>', which RFC 5322 still parses as obs-route. The route
|
|
* is not part of the mailbox, and folded into the local part it made
|
|
* '"@relay1,@relay2:user"@example.com' a recipient that does not exist. A local part can not
|
|
* start with '@', so contents that do, up to the first ':' outside a domain literal and in
|
|
* front of any quoted string, are a route and are dropped, as long as a mailbox with an '@'
|
|
* of its own follows.
|
|
*
|
|
* @param address Contents of the angle brackets
|
|
* @return The address with the comments removed, and the text of the comments
|
|
*/
|
|
function _stripAddressComments(address) {
|
|
const comments = [];
|
|
let result = '';
|
|
let comment = '';
|
|
let depth = 0;
|
|
let closer = '';
|
|
// carried along rather than read back off the growing result, which would flatten it on
|
|
// every comment (see lastChars in _handleAddress)
|
|
let lastChar = '';
|
|
// A comment that just closed stands for a space, written once the next character shows
|
|
// whether one is wanted: not beside an '@', and not on top of whitespace that follows.
|
|
// Deciding on the spot saw only the character right after the ')', so a second comment
|
|
// in a row hid the '@' behind it and 'a(x)(y)@b' read as 'a @b'
|
|
let pendingSpace = false;
|
|
// first character of the result and whether a quoted string has opened since, which
|
|
// together tell a route from a local part
|
|
let firstChar = '';
|
|
let quoteSeen = false;
|
|
let routeEnd = -1;
|
|
for (let i = 0, len = address.length; i < len; i++) {
|
|
const chr = address.charAt(i);
|
|
if (depth) {
|
|
if (chr === '\\' && i < len - 1) {
|
|
comment += address.charAt(++i);
|
|
}
|
|
else if (chr === '(') {
|
|
depth++;
|
|
comment += chr;
|
|
}
|
|
else if (chr === ')' && !--depth) {
|
|
comments.push(comment.trim());
|
|
comment = '';
|
|
pendingSpace = lastChar !== '@';
|
|
}
|
|
else {
|
|
comment += chr;
|
|
}
|
|
continue;
|
|
}
|
|
if (pendingSpace && chr !== '(') {
|
|
pendingSpace = false;
|
|
if (chr !== '@' && chr !== '<' && !_isSpaceCode(address.charCodeAt(i))) {
|
|
result += ' ';
|
|
lastChar = ' ';
|
|
}
|
|
}
|
|
if (closer) {
|
|
if (chr === '\\' && closer === '"' && i < len - 1) {
|
|
result += chr + address.charAt(++i);
|
|
lastChar = address.charAt(i);
|
|
continue;
|
|
}
|
|
if (chr === closer) {
|
|
closer = '';
|
|
}
|
|
}
|
|
else if (chr === '"') {
|
|
closer = '"';
|
|
quoteSeen = true;
|
|
}
|
|
else if (chr === ':' && routeEnd < 0 && firstChar === '@' && !quoteSeen) {
|
|
routeEnd = result.length + 1;
|
|
}
|
|
else if (chr === '[') {
|
|
closer = ']';
|
|
}
|
|
else if (chr === '(') {
|
|
depth = 1;
|
|
continue;
|
|
}
|
|
else if (chr === '<') {
|
|
// Apple Mail drops everything between an unexpected '<' and the address after it.
|
|
// Read here rather than off the raw brackets so that a '<' inside a quoted string,
|
|
// a comment or a domain literal stays where it is: '<"a<b"@victim.com>' cut at
|
|
// the first '<' left the address 'b"@victim.com'
|
|
result = '';
|
|
lastChar = '';
|
|
comments.length = 0;
|
|
firstChar = '';
|
|
quoteSeen = false;
|
|
routeEnd = -1;
|
|
continue;
|
|
}
|
|
result += chr;
|
|
lastChar = chr;
|
|
if (!firstChar && !_isSpaceCode(address.charCodeAt(i))) {
|
|
firstChar = chr;
|
|
}
|
|
}
|
|
if (routeEnd >= 0 && result.indexOf('@', routeEnd) >= 0) {
|
|
result = result.slice(routeEnd);
|
|
}
|
|
if (depth) {
|
|
// an unterminated comment runs to the end of the address
|
|
comments.push(comment.trim());
|
|
}
|
|
return { address: result.trim(), comments: comments.filter(text => text) };
|
|
}
|
|
/**
|
|
* Walks the tokens of one entry of an address list and sorts them into the runs of display
|
|
* text, angle-addr and comment they belong to
|
|
*
|
|
* @param tokens Tokens of the whole address list
|
|
* @param from Index of the first token of the entry
|
|
* @param to Index just past the last token of the entry
|
|
* @return The collected runs, and whether the display text came out of a comment
|
|
*/
|
|
function _collectAddressParts(tokens, from, to) {
|
|
let state = 'text';
|
|
const data = {
|
|
address: [],
|
|
comment: [],
|
|
text: [],
|
|
textWasQuoted: [],
|
|
quotedLocalAt: []
|
|
};
|
|
let insideQuotes = false;
|
|
// whether the last text run holds nothing but quoted text so far, which is what a quoted
|
|
// local part is until the '@' after it joins the run
|
|
let lastTextPureQuoted = false;
|
|
// Last character of the run each state is currently accumulating. Reading it back off
|
|
// the accumulator with slice(-1) makes the engine flatten the whole growing string on
|
|
// every token, which is quadratic over an address built from many comment-joined atoms
|
|
// (GHSA-prgh-xp8r-p3m5). A run only ever grows by the token appended below, so the
|
|
// character is carried along instead of re-read.
|
|
const lastChars = { address: '', comment: '', text: '' };
|
|
// Filter out <addresses>, (comments) and regular text
|
|
for (let i = from; i < to; i++) {
|
|
const value = tokens.value(i);
|
|
// the operator in front of this token, if there is one, and whether it runs on into it
|
|
const prevOperator = i > from ? tokens.operatorAt(i - 1) : '';
|
|
const prevNoBreak = i > from && tokens.noBreak(i - 1);
|
|
if (tokens.isOperator(i)) {
|
|
switch (value) {
|
|
case '<':
|
|
state = 'address';
|
|
insideQuotes = false;
|
|
break;
|
|
case '(':
|
|
state = 'comment';
|
|
insideQuotes = false;
|
|
break;
|
|
case '"':
|
|
insideQuotes = !insideQuotes;
|
|
state = 'text';
|
|
break;
|
|
default:
|
|
state = 'text';
|
|
insideQuotes = false;
|
|
break;
|
|
}
|
|
}
|
|
else {
|
|
// An empty quoted string is dropped by the tokenizer, leaving no text token of its
|
|
// own for textWasQuoted to be recorded on, so the pair of quote operators right in
|
|
// front of this token is all that is left of it and the run it opens carries the
|
|
// quoting instead. Without this '""@example.com' reads as the bare '@example.com',
|
|
// the quotes never go back on, and the value is no longer an addr-spec a trailing
|
|
// comment can be peeled off of. It only ever opens a run: a run that already holds
|
|
// material collected outside the quotes is not a quoted string, whatever follows it
|
|
const opensAfterEmptyQuotedString = prevOperator === '"' && prevNoBreak && i > from + 1 && tokens.operatorAt(i - 2) === '"';
|
|
// A comment is folding whitespace. It may sit inside an addr-spec, on either side
|
|
// of the '@', but it cannot join two atoms into one: gluing across it would read
|
|
// 'user@example.com(x)evil.com' as the single domain 'example.comevil.com' and
|
|
// deliver to a domain the sender never named.
|
|
const parts = data[state];
|
|
const joins = prevNoBreak && parts.length && (prevOperator !== ')' || lastChars[state] === '@' || value.charAt(0) === '@');
|
|
if (joins) {
|
|
const last = data[state].length - 1;
|
|
if (state === 'text' && !insideQuotes) {
|
|
if (lastTextPureQuoted && value.charAt(0) === '@') {
|
|
data.quotedLocalAt[last] = data.text[last].length;
|
|
}
|
|
lastTextPureQuoted = false;
|
|
}
|
|
data[state][last] += value;
|
|
lastChars[state] = value.charAt(value.length - 1);
|
|
if (state === 'text' && insideQuotes) {
|
|
data.textWasQuoted[last] = true;
|
|
}
|
|
}
|
|
else {
|
|
data[state].push(value);
|
|
lastChars[state] = value.charAt(value.length - 1);
|
|
if (state === 'text') {
|
|
data.textWasQuoted.push(insideQuotes || opensAfterEmptyQuotedString);
|
|
// an empty quoted string leaves no text of its own, so the '@' opens the run
|
|
data.quotedLocalAt.push(opensAfterEmptyQuotedString && value.charAt(0) === '@' ? 0 : -1);
|
|
lastTextPureQuoted = insideQuotes;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// If there is no text but a comment, replace the two. A comment is never part of a
|
|
// mailbox, so text that came out of one only ever names it: reading an address out of it
|
|
// made '(a@evil.com)' a recipient where no conformant reading has a mailbox at all
|
|
let textFromComment = false;
|
|
if (!data.text.length && data.comment.length) {
|
|
data.text = data.comment;
|
|
data.comment = [];
|
|
textFromComment = true;
|
|
}
|
|
return { data, textFromComment };
|
|
}
|
|
/**
|
|
* Name of a group, from the tokens in front of its ':'. A comment names it when no text does,
|
|
* as it names a mailbox.
|
|
*
|
|
* @param tokens Tokens of the whole address list
|
|
* @param from Index of the first token in front of the ':'
|
|
* @param to Index of the ':'
|
|
* @return Name of the group
|
|
*/
|
|
function _groupName(tokens, from, to) {
|
|
// http://tools.ietf.org/html/rfc2822#appendix-A.1.3
|
|
return _collectAddressParts(tokens, from, to).data.text.join(' ');
|
|
}
|
|
/**
|
|
* Converts tokens for a single address into an address object
|
|
*
|
|
* @param tokens Tokens of the whole address list
|
|
* @param from Index of the first token of this address
|
|
* @param to Index just past the last token of this address
|
|
* @return Address object
|
|
*/
|
|
function _handleAddress(tokens, from, to) {
|
|
const collected = _collectAddressParts(tokens, from, to);
|
|
const data = collected.data;
|
|
let textFromComment = collected.textFromComment;
|
|
// Comments come out of the angle-addr before anything asks whether one was found, so
|
|
// that brackets holding nothing but a comment read the same as empty ones
|
|
const addressComments = [];
|
|
const addressParts = [];
|
|
for (const part of data.address) {
|
|
const stripped = _stripAddressComments(part);
|
|
for (const comment of stripped.comments) {
|
|
addressComments.push(comment);
|
|
}
|
|
if (stripped.address) {
|
|
addressParts.push(stripped.address);
|
|
}
|
|
}
|
|
data.address = addressParts;
|
|
// If no address was found, try to detect one from regular text
|
|
if (!data.address.length && data.text.length && !textFromComment) {
|
|
// Whitespace beside the '@' is CFWS, so 'user @ good.com' is read as one run. Both
|
|
// tiers read the collapsed runs, and only a run an address comes out of is replaced
|
|
// by its collapsed form, the display text keeps its spacing
|
|
const candidates = data.text.map((part) => _collapseAtSpacing(part));
|
|
// The first addr-spec in the text is the mailbox, as it is for a conformant parser
|
|
// reading as far as it can. Taking the last one let a comment part two of them and
|
|
// pick the second, 'user@good.com (x) evil@evil.com' went to evil.com, while the
|
|
// same two without the comment, one run, went to good.com
|
|
for (let i = 0; i < data.text.length; i++) {
|
|
// Security: Do not extract email addresses from quoted strings.
|
|
// RFC 5321 allows @ inside quoted local-parts like "user@domain"@example.com.
|
|
// Extracting emails from quoted text leads to misrouting vulnerabilities.
|
|
// A run that is a quoted local part with its '@' and domain is an addr-spec of
|
|
// its own, with the quoting put back on, and takes its turn like any other
|
|
const local = data.quotedLocalAt[i];
|
|
const quotedLocal = data.textWasQuoted[i] && local >= 0 && QUOTED_LOCAL_DOMAIN.test(data.text[i].slice(local))
|
|
? _quoteLocalPart(data.text[i])
|
|
: '';
|
|
if (quotedLocal || (!data.textWasQuoted[i] && _isAddrSpec(candidates[i]))) {
|
|
data.address = [quotedLocal || candidates[i]];
|
|
data.text.splice(i, 1);
|
|
data.textWasQuoted.splice(i, 1);
|
|
data.quotedLocalAt.splice(i, 1);
|
|
break;
|
|
}
|
|
}
|
|
// Try a looser regex match if strict match found nothing
|
|
if (!data.address.length) {
|
|
let extracted = false;
|
|
for (let i = 0; i < data.text.length; i++) {
|
|
// Security: Do not extract email addresses from quoted strings
|
|
if (!data.textWasQuoted[i]) {
|
|
// the strict tier found nothing, so it spliced nothing and the indexes still line up
|
|
const part = candidates[i];
|
|
let remainder = data.text[i];
|
|
const at = _looseAddressStart(part);
|
|
if (at >= 0) {
|
|
LOOSE_TEXT_ADDR.lastIndex = at;
|
|
const match = LOOSE_TEXT_ADDR.exec(part);
|
|
if (match) {
|
|
data.address = [match[0].trim()];
|
|
extracted = true;
|
|
remainder = part.slice(0, at) + ' ' + part.slice(at + match[0].length);
|
|
}
|
|
}
|
|
data.text[i] = remainder.trim();
|
|
if (extracted) {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// If there's still no text but a comment exists, replace the two
|
|
if (!data.text.length && data.comment.length) {
|
|
data.text = data.comment;
|
|
data.comment = [];
|
|
}
|
|
// Keep only the first address occurrence, push others to regular text
|
|
if (data.address.length > 1) {
|
|
data.text = data.text.concat(data.address.splice(1));
|
|
}
|
|
// An address is only taken from unquoted text, so anything left in the text at this
|
|
// point that still has to serve as the address carries its quoting in this flag
|
|
const addressFromQuotedText = !data.address.length && data.textWasQuoted.some(wasQuoted => wasQuoted);
|
|
// Join values with spaces
|
|
data.text = data.text.join(' ');
|
|
data.address = data.address.join(' ');
|
|
if (addressFromQuotedText && data.text.indexOf('@') >= 0) {
|
|
// A quoted run with no '@' in it is a display name and nothing else, so it stays
|
|
// where it is. Moving it over anyway made a comment the mailbox was read from:
|
|
// '"Display Name" <(a comment)>' handed on 'Display Name' as the address with the
|
|
// comment as the name, and a quoted name holding a ',' or a ';' became an address
|
|
// that reads as two recipients once a consumer writes it back into a header.
|
|
//
|
|
// The mailbox is still sitting in the text, so it moves over here and is quoted
|
|
// before the recovery below rather than after it. Anything else the text holds
|
|
// came along with it: a comment ends the domain but leaves the atoms behind it in
|
|
// the same text, and '"user"@example.com(x)evil.com' was handed on as the address
|
|
// 'user@example.com evil.com', a second domain riding into the envelope recipient
|
|
// on a value that is no addr-spec at all (GHSA-g57g-f23g-4646). Putting the quotes
|
|
// back first is what lets the recovery tell the whitespace an addr-spec may carry
|
|
// from the wreckage trailing one, as only a quoted local part may hold whitespace
|
|
// The quotes are gone at this point, so the '@' the domain splits off at is the
|
|
// last one, not the single one _collapseAtSpacing insists on
|
|
data.address = _quoteLocalPart(_joinAtSpacing(data.text, _domainAt(data.text)));
|
|
data.text = '';
|
|
}
|
|
_recoverAddrSpec(data);
|
|
// a comment names the mailbox only when nothing else does, as one outside the brackets
|
|
if (!data.text && addressComments.length) {
|
|
data.text = addressComments.join(' ');
|
|
textFromComment = true;
|
|
}
|
|
const address = {
|
|
address: data.address || (textFromComment ? '' : data.text) || '',
|
|
name: data.text || data.address || ''
|
|
};
|
|
if (address.address === address.name) {
|
|
if (/@/.test(address.address || '')) {
|
|
address.name = '';
|
|
}
|
|
else {
|
|
address.address = '';
|
|
}
|
|
}
|
|
return address;
|
|
}
|
|
/* eslint-disable no-bitwise -- see TokenList */
|
|
/**
|
|
* Creates a Tokenizer object for tokenizing address field strings
|
|
*
|
|
* @constructor
|
|
* @param str Address field string
|
|
*/
|
|
class Tokenizer {
|
|
constructor(str) {
|
|
this.str = (str || '').toString();
|
|
this.operatorCurrent = '';
|
|
this.operatorExpecting = '';
|
|
this.node = -1;
|
|
this.escaped = false;
|
|
this.inDomainLiteral = false;
|
|
this.afterAt = false;
|
|
this.inGroup = false;
|
|
this.innerQuoted = false;
|
|
this.innerComment = 0;
|
|
this.innerLiteral = false;
|
|
this.innerEscaped = false;
|
|
this.list = new TokenList(this.str);
|
|
/**
|
|
* Operator tokens and which tokens are expected to end the sequence
|
|
*/
|
|
this.operators = {
|
|
'"': '"',
|
|
'(': ')',
|
|
'<': '>',
|
|
',': '',
|
|
// A group is read in the same pass as the rest of the list, so its ':' only marks
|
|
// where the name ends and its members are tokenized like any other address
|
|
':': '',
|
|
// Semicolons are not a legal delimiter per the RFC2822 grammar other
|
|
// than for terminating a group, but they are also not valid for any
|
|
// other use in this context. Given that some mail clients have
|
|
// historically allowed the semicolon as a delimiter equivalent to the
|
|
// comma in their UI, it makes sense to treat them the same as a comma
|
|
// when used outside of a group.
|
|
';': ''
|
|
};
|
|
}
|
|
/**
|
|
* Tokenizes the original input string
|
|
*
|
|
* @return An array of operator|text tokens
|
|
*/
|
|
tokenize() {
|
|
const str = this.str;
|
|
const len = str.length;
|
|
for (let i = 0; i < len; i++) {
|
|
this.checkChar(str.charAt(i), i);
|
|
}
|
|
this._closeText(len);
|
|
// Compacted in place: a text run is trimmed to its value and one left empty is
|
|
// dropped, and the value of a run that is no plain slice is rebuilt and kept aside
|
|
const list = this.list;
|
|
const slots = list.slots;
|
|
let kept = 0;
|
|
for (let i = 0, count = list.length; i < count; i++) {
|
|
const kind = slots[i * 3];
|
|
let start = slots[i * 3 + 1];
|
|
let end = slots[i * 3 + 2];
|
|
if (kind & TOKEN_DIRTY) {
|
|
const value = this._cleanValue(start, end, kind).trim();
|
|
if (!value) {
|
|
continue;
|
|
}
|
|
list.cleaned.set(kept, value);
|
|
}
|
|
else if (!(kind & TOKEN_OPERATOR)) {
|
|
while (start < end && _isSpaceCode(str.charCodeAt(start))) {
|
|
start++;
|
|
}
|
|
while (end > start && _isSpaceCode(str.charCodeAt(end - 1))) {
|
|
end--;
|
|
}
|
|
if (start === end) {
|
|
continue;
|
|
}
|
|
}
|
|
slots[kept * 3] = kind;
|
|
slots[kept * 3 + 1] = start;
|
|
slots[kept * 3 + 2] = end;
|
|
kept++;
|
|
}
|
|
list.length = kept;
|
|
return list;
|
|
}
|
|
/**
|
|
* Ends the text run being collected, if there is one
|
|
*
|
|
* @param end Offset just past its last character
|
|
*/
|
|
_closeText(end) {
|
|
if (this.node >= 0) {
|
|
this.list.slots[this.node * 3 + 2] = end;
|
|
this.node = -1;
|
|
}
|
|
}
|
|
/**
|
|
* Rebuilds the value of a text run that is no plain slice of the field, applying the
|
|
* rules checkChar collects characters by: an escaping backslash is dropped, a line break
|
|
* reads as a space and any other control character is left out
|
|
*
|
|
* @param start Offset of the first character of the run
|
|
* @param end Offset just past its last character
|
|
* @param kind Kind and flags of the run
|
|
* @return Value of the run
|
|
*/
|
|
_cleanValue(start, end, kind) {
|
|
const str = this.str;
|
|
const escapes = (kind & TOKEN_ESCAPES) !== 0;
|
|
let escaped = (kind & TOKEN_STARTS_ESCAPED) !== 0;
|
|
let value = '';
|
|
for (let i = start; i < end; i++) {
|
|
const chr = str.charAt(i);
|
|
if (escapes && !escaped && chr === '\\') {
|
|
escaped = true;
|
|
continue;
|
|
}
|
|
escaped = false;
|
|
if (chr === '\n') {
|
|
value += ' ';
|
|
}
|
|
else if (chr.charCodeAt(0) >= 0x21 || chr === ' ' || chr === '\t') {
|
|
value += chr;
|
|
}
|
|
}
|
|
return value;
|
|
}
|
|
/**
|
|
* Checks if a character is an operator or text and acts accordingly
|
|
*
|
|
* @param chr Character from the address field
|
|
* @param i Offset of the character in the field
|
|
*/
|
|
checkChar(chr, i) {
|
|
// Track RFC 5322 domain-literals ("[" *dtext "]"). The ":" of an IPv6
|
|
// address-literal (user@[IPv6:2001:db8::1]) is dtext and must not be treated as
|
|
// the group delimiter while inside the brackets. That is the only operator the
|
|
// literal hides: letting it hide the others as well meant a "[" could turn the
|
|
// comment, quoted string or angle-addr after it into text and pick a different
|
|
// mailbox, '[ ( ] <a@victim.com> ) <b@evil.com>' yielded a@victim.com where
|
|
// RFC 5322 reads b@evil.com. A "[" also only opens a literal right after the "@"
|
|
// of an addr-spec, so the group delimiter of a display name holding one stays
|
|
// intact. Quoted strings and comments are handled separately via
|
|
// operatorExpecting, so only enter this state when no operator is open. The list
|
|
// separators "," and ";" always end the literal (and split the address list) so
|
|
// that an unclosed "[" cannot swallow later recipients.
|
|
if (!this.escaped && !this.operatorExpecting) {
|
|
if (!this.inDomainLiteral && chr === '[' && this.afterAt) {
|
|
this.inDomainLiteral = true;
|
|
}
|
|
else if (this.inDomainLiteral && (chr === ']' || chr === ',' || chr === ';')) {
|
|
this.inDomainLiteral = false;
|
|
}
|
|
}
|
|
// The closer of an angle-addr is only read outside the quoted strings, comments and
|
|
// domain literals it holds. Looking for nothing but the closer let a '>' inside a
|
|
// quoted local part end the angle-addr, '<"a@evil.com>"@good.com>' was read as an
|
|
// address at evil.com. The characters are still collected as they are, the angle-addr
|
|
// is read again later
|
|
let nested = false;
|
|
if (this.operatorExpecting === '>') {
|
|
nested = this._trackInner(chr);
|
|
if (!nested && chr === ';' && this.inGroup) {
|
|
// The ';' of a group ends an angle-addr left open inside it, as it always did
|
|
// when a group was cut out of the field at its ';' and parsed on its own. Read
|
|
// as text instead, an unclosed '<' swallowed the end of the group and every
|
|
// recipient after it
|
|
this._closeText(i);
|
|
this.operatorExpecting = '';
|
|
this._resetInner();
|
|
}
|
|
}
|
|
else if (this.operatorExpecting === ')' && !this.escaped) {
|
|
// Comments nest (RFC 5322 3.2.2). Ending one at the first ')' let the ')' of a
|
|
// nested comment close the outer one and released the rest of it, an address
|
|
// included, into the header
|
|
if (chr === '(') {
|
|
this.innerComment++;
|
|
}
|
|
else if (chr === ')' && this.innerComment) {
|
|
this.innerComment--;
|
|
nested = true;
|
|
}
|
|
}
|
|
if (this.escaped) {
|
|
// ignore next condition blocks
|
|
}
|
|
else if (chr === this.operatorExpecting && !nested) {
|
|
const nextChr = this.str.charAt(i + 1);
|
|
this._closeText(i);
|
|
this.list.push(nextChr && ![' ', '\t', '\r', '\n', ',', ';'].includes(nextChr) ? TOKEN_OPERATOR | TOKEN_NO_BREAK : TOKEN_OPERATOR, i, i + 1);
|
|
if (chr !== ')') {
|
|
// a comment is folding whitespace, it does not part the "@" from the "["
|
|
this.afterAt = false;
|
|
}
|
|
this.operatorExpecting = '';
|
|
this.escaped = false;
|
|
this._resetInner();
|
|
return;
|
|
}
|
|
else if (!this.operatorExpecting && !(this.inDomainLiteral && chr === ':') && chr in this.operators) {
|
|
this._closeText(i);
|
|
this.list.push(TOKEN_OPERATOR, i, i + 1);
|
|
if (chr === ':') {
|
|
this.inGroup = true;
|
|
}
|
|
else if (chr === ';') {
|
|
this.inGroup = false;
|
|
}
|
|
if (chr !== '(') {
|
|
this.afterAt = false;
|
|
}
|
|
this.operatorExpecting = this.operators[chr];
|
|
this.escaped = false;
|
|
this._resetInner();
|
|
return;
|
|
}
|
|
else if (['"', "'", ')'].includes(this.operatorExpecting) && chr === '\\') {
|
|
// A backslash escapes the next character inside a quoted string and inside a
|
|
// comment alike (RFC 5322 3.2.2, ccontent includes quoted-pair). Honouring it
|
|
// only in quoted strings let an escaped parenthesis close the comment, which
|
|
// released the rest of it, an address included, into the header
|
|
this.escaped = true;
|
|
if (this.node >= 0) {
|
|
// the backslash is left out of the value
|
|
this.list.slots[this.node * 3] |= TOKEN_DIRTY;
|
|
}
|
|
return;
|
|
}
|
|
if (this.node < 0) {
|
|
this.node = this.list.push((this.operatorExpecting === '"' || this.operatorExpecting === ')' ? TOKEN_ESCAPES : 0) |
|
|
(this.escaped ? TOKEN_STARTS_ESCAPED : 0), i, i);
|
|
}
|
|
if (chr === '\n') {
|
|
// Convert newlines to spaces. Carriage return is ignored as \r and \n usually
|
|
// go together anyway and there already is a WS for \n. Lone \r means something is fishy.
|
|
chr = ' ';
|
|
this.list.slots[this.node * 3] |= TOKEN_DIRTY;
|
|
}
|
|
if (chr.charCodeAt(0) >= 0x21 || chr === ' ' || chr === '\t') {
|
|
// the character is part of the value, which is read off the field later
|
|
// text inside a quoted string or a comment is not part of an addr-spec. Tracked
|
|
// as it goes rather than read back off the value (see lastChars in _handleAddress)
|
|
if (!this.operatorExpecting && chr !== ' ' && chr !== '\t') {
|
|
this.afterAt = chr === '@';
|
|
}
|
|
}
|
|
else {
|
|
// skip command bytes
|
|
this.list.slots[this.node * 3] |= TOKEN_DIRTY;
|
|
}
|
|
this.escaped = false;
|
|
}
|
|
/**
|
|
* Tracks the quoted strings, comments and domain literals inside an angle-addr, which its
|
|
* closer must not be read from. A backslash escapes the next character inside a quoted
|
|
* string and a comment. A domain literal opens right after an "@" and ends at the "]".
|
|
* dtext holds the list separators as well, and inside the brackets they never split the
|
|
* list anyway.
|
|
*
|
|
* @param chr Character from the address field
|
|
* @return true while the character sits inside one of them, the one that closes it included
|
|
*/
|
|
_trackInner(chr) {
|
|
if (this.innerEscaped) {
|
|
this.innerEscaped = false;
|
|
return true;
|
|
}
|
|
if (this.innerQuoted) {
|
|
if (chr === '\\') {
|
|
this.innerEscaped = true;
|
|
}
|
|
else if (chr === '"') {
|
|
this.innerQuoted = false;
|
|
}
|
|
return true;
|
|
}
|
|
if (this.innerComment) {
|
|
if (chr === '\\') {
|
|
this.innerEscaped = true;
|
|
}
|
|
else if (chr === '(') {
|
|
this.innerComment++;
|
|
}
|
|
else if (chr === ')') {
|
|
this.innerComment--;
|
|
}
|
|
return true;
|
|
}
|
|
if (this.innerLiteral) {
|
|
if (chr === ']') {
|
|
this.innerLiteral = false;
|
|
}
|
|
return true;
|
|
}
|
|
if (chr === '"') {
|
|
this.innerQuoted = true;
|
|
return true;
|
|
}
|
|
if (chr === '(') {
|
|
this.innerComment = 1;
|
|
return true;
|
|
}
|
|
if (chr === '[' && this.afterAt) {
|
|
this.innerLiteral = true;
|
|
return true;
|
|
}
|
|
// afterAt is otherwise only kept outside the operators and is cleared when one opens
|
|
// or ends, so inside an angle-addr it serves the literal tracked here
|
|
if (!_isSpaceCode(chr.charCodeAt(0))) {
|
|
this.afterAt = chr === '@';
|
|
}
|
|
return false;
|
|
}
|
|
/**
|
|
* Forgets the nesting collected for the operator that just opened or ended
|
|
*/
|
|
_resetInner() {
|
|
this.innerQuoted = false;
|
|
this.innerComment = 0;
|
|
this.innerLiteral = false;
|
|
this.innerEscaped = false;
|
|
}
|
|
}
|
|
/* eslint-enable no-bitwise */
|
|
/**
|
|
* Folds the fragments an unquoted display name holding a comma is split into back together.
|
|
*
|
|
* "Joe Foo, PhD <joe@example.com>" is split on the comma into
|
|
* [{name:"Joe Foo", address:""}, {name:"PhD", address:"joe@example.com"}]. A name-only entry
|
|
* followed by an entry with both name and address is recombined. Walked back to front so
|
|
* that a run of fragments folds into one entry in a single pass. Splicing each fragment out
|
|
* of the list instead would cost O(n^2).
|
|
*
|
|
* @param list Parsed entries, in order
|
|
* @return The entries with the fragments folded in
|
|
*/
|
|
function _mergeNameFragments(list) {
|
|
const merged = [];
|
|
for (let i = list.length - 1; i >= 0; i--) {
|
|
const current = list[i];
|
|
const next = merged.length ? merged[merged.length - 1] : null;
|
|
if (next && current.address === '' && current.name && !current.group && next.address && next.name) {
|
|
next.name = current.name + ', ' + next.name;
|
|
}
|
|
else {
|
|
merged.push(current);
|
|
}
|
|
}
|
|
return merged.reverse();
|
|
}
|
|
/**
|
|
* Parses an address list, groups included, in a single pass over its tokens.
|
|
*
|
|
* A group used to be cut out of the field at the ';' that ends it and parsed again as an
|
|
* address list of its own, recursively, so every level of nesting parsed what was left of
|
|
* the field once more and the depth had to be capped. RFC 5322 does not nest groups at all,
|
|
* and the nested ones were flattened anyway. The tokens are now walked once: a ':' opens a
|
|
* group, its members are read as they come, a ';' closes it. A ':' inside an open group
|
|
* stands for a group nested in it, whose name is dropped and whose members belong to the
|
|
* outer one, which is how they were flattened before. The first ';' ends the group, nested
|
|
* or not, as it did.
|
|
*
|
|
* @param str Address field
|
|
* @return An array of address objects
|
|
*/
|
|
function _parseAddressList(str) {
|
|
const tokens = new Tokenizer(str).tokenize();
|
|
const parsedAddresses = [];
|
|
let group = null;
|
|
let members = [];
|
|
// Every address is handed over as the range of the token list it covers, as soon as the
|
|
// separator after it is seen. Copying the tokens of each address into an array of its own,
|
|
// and collecting those before handling any, held a second structure as large as the token
|
|
// list for the whole parse, and an array that receives a single push is allocated room for
|
|
// seventeen: a list of one letter names took some 80 times its size in that alone
|
|
let from = 0;
|
|
for (let i = 0, len = tokens.length; i <= len; i++) {
|
|
const operator = i < len ? tokens.operatorAt(i) : '';
|
|
if (i < len && operator !== ',' && operator !== ';' && operator !== ':') {
|
|
continue;
|
|
}
|
|
if (operator === ':') {
|
|
if (!group) {
|
|
group = { name: _groupName(tokens, from, i), group: [] };
|
|
members = [];
|
|
}
|
|
from = i + 1;
|
|
continue;
|
|
}
|
|
if (i > from) {
|
|
// Appended in place. Rebuilding the accumulator with concat() would copy every
|
|
// entry collected so far on each address, making a flat list cost O(n^2).
|
|
const address = _handleAddress(tokens, from, i);
|
|
if (group) {
|
|
members.push(address);
|
|
}
|
|
else {
|
|
parsedAddresses.push(address);
|
|
}
|
|
}
|
|
from = i + 1;
|
|
if (group && (operator === ';' || i === len)) {
|
|
group.group = _mergeNameFragments(members);
|
|
parsedAddresses.push(group);
|
|
group = null;
|
|
}
|
|
}
|
|
return _mergeNameFragments(parsedAddresses);
|
|
}
|
|
function addressparser(str, options) {
|
|
const parsedAddresses = _parseAddressList(str);
|
|
if (options?.flatten) {
|
|
const flatAddresses = [];
|
|
const walkAddressList = (list) => {
|
|
list.forEach(entry => {
|
|
if (entry.group) {
|
|
return walkAddressList(entry.group);
|
|
}
|
|
flatAddresses.push(entry);
|
|
});
|
|
};
|
|
walkAddressList(parsedAddresses);
|
|
return flatAddresses;
|
|
}
|
|
return parsedAddresses;
|
|
}
|
|
exports.default = addressparser;
|
|
module.exports = exports.default;
|
|
Object.defineProperty(module.exports, 'default', { value: exports.default, enumerable: false, writable: true, configurable: true });
|