UNPKG

parse-email-address

Version:

Parse/validate email addresses with RFC-5321, and message header address lists with RFC-5322.

225 lines (224 loc) 7.79 kB
// cspell:word atext /** * The kinds of tokens that an [RFC 5322](https://datatracker.ietf.org/doc/html/rfc5322#section-3.4) * address list is built from. Whitespace and comments are not tokens: the tokenizer drops them and * records their presence in {@link AddressListToken.isSpaced}. */ export var AddressListTokenType; (function (AddressListTokenType) { /** An `atom` run or a `quoted-string`. */ AddressListTokenType["Word"] = "word"; /** A bracketed `domain-literal`, such as `[192.0.2.10]`. */ AddressListTokenType["DomainLiteral"] = "domain-literal"; /** One of the `specials` characters that give an address list its structure. */ AddressListTokenType["Special"] = "special"; })(AddressListTokenType || (AddressListTokenType = {})); /** * The `specials` characters that can appear in an address list without quoting. `[` and `]` are * handled as domain literals instead, and `(`, `)`, and `"` are consumed by the tokenizer. */ const specialCharacters = [ '<', '>', '@', ',', ';', ':', ]; /** * Where tokenizing resumes after an unterminated comment, quoted string, or domain literal: the * start of the next entry, or of the angle-addr that a mangled display name was wrapping. */ const recoveryCharacters = [ ',', ';', '<', ]; /** `atext` from RFC 5322, plus `.` so that a whole `dot-atom` becomes one token. */ const asciiAtomCharacterRegExp = /[\w!#$%&'*+\-./=?^`{|}~]/; /** The first character that [RFC 6531](https://datatracker.ietf.org/doc/html/rfc6531) adds. */ const firstNonAsciiCharacter = String.fromCodePoint(0x80); /** * Non-ASCII characters that RFC 6531 does _not_ add to `atext`: the C1 controls, the format * characters (a byte order mark or a zero width space), and every flavor of Unicode space. Letting * an invisible character into an address would silently join two of them into one. */ const nonAtomCharacterRegExp = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}\p{Zs}]/u; function isAtomCharacter(character) { if (asciiAtomCharacterRegExp.test(character)) { return true; } else if (character < firstNonAsciiCharacter) { return false; } return !nonAtomCharacterRegExp.test(character); } const doubleQuote = '"'; const backslash = '\\'; /** * Splits an RFC 5322 address list header value into tokens, discarding whitespace and comments. * * This is a single linear pass. Unlike the ambiguous RFC 5322 ABNF, it cannot fan out into a forest * of candidate parses, so header length alone bounds its cost. */ export function tokenizeAddressList(headerValue) { const tokens = []; let index = 0; let isSpaced = false; while (index < headerValue.length) { const character = headerValue.charAt(index); if (character === '(') { index = scanToCommentEnd(headerValue, index).end; isSpaced = true; } else if (character === doubleQuote) { const scan = scanToClosingCharacter({ headerValue, startIndex: index, closingCharacter: doubleQuote, }); const raw = headerValue.slice(index, scan.end); if (scan.isTerminated) { tokens.push({ type: AddressListTokenType.Word, raw, text: unquote(raw), isSpaced, }); } index = scan.end; isSpaced = !scan.isTerminated; } else if (character === '[') { const scan = scanToClosingCharacter({ headerValue, startIndex: index, closingCharacter: ']', }); const raw = headerValue.slice(index, scan.end); if (scan.isTerminated) { tokens.push({ type: AddressListTokenType.DomainLiteral, raw, text: raw, isSpaced, }); } index = scan.end; isSpaced = !scan.isTerminated; } else if (specialCharacters.includes(character)) { tokens.push({ type: AddressListTokenType.Special, raw: character, text: character, isSpaced, }); index++; isSpaced = false; } else if (isAtomCharacter(character)) { const end = findAtomEnd(headerValue, index); const raw = headerValue.slice(index, end); tokens.push({ type: AddressListTokenType.Word, raw, text: raw, isSpaced, }); index = end; isSpaced = false; } else { /** * Whitespace, including the CRLF of a folded header, plus any character that cannot * appear in an address at all. Either way it separates the tokens around it. */ index++; isSpaced = true; } } return tokens; } /** Comments nest, so this tracks depth instead of stopping at the first `)`. */ function scanToCommentEnd(headerValue, startIndex) { let index = startIndex + 1; let depth = 1; let recoveryIndex = -1; while (index < headerValue.length) { const character = headerValue.charAt(index); if (character === backslash) { index += 2; continue; } else if (character === '(') { depth++; } else if (character === ')') { depth--; if (!depth) { return { end: index + 1, isTerminated: true, }; } } else if (recoveryIndex < 0 && recoveryCharacters.includes(character)) { recoveryIndex = index; } index++; } return unterminatedScan(headerValue, recoveryIndex); } function scanToClosingCharacter({ headerValue, startIndex, closingCharacter, }) { let index = startIndex + 1; let recoveryIndex = -1; while (index < headerValue.length) { const character = headerValue.charAt(index); if (character === backslash) { index += 2; continue; } else if (character === closingCharacter) { return { end: index + 1, isTerminated: true, }; } else if (recoveryIndex < 0 && recoveryCharacters.includes(character)) { recoveryIndex = index; } index++; } return unterminatedScan(headerValue, recoveryIndex); } /** * An unterminated comment, quoted string, or domain literal resumes at its recovery point so that * one unbalanced delimiter in a display name cannot silently delete every address after it. The * recovery point was found during the same forward scan, so recovery stays linear. */ function unterminatedScan(headerValue, recoveryIndex) { return { end: recoveryIndex < 0 ? headerValue.length : recoveryIndex, isTerminated: false, }; } function findAtomEnd(headerValue, startIndex) { let index = startIndex; while (index < headerValue.length && isAtomCharacter(headerValue.charAt(index))) { index++; } return index; } /** * Reads the value of a `quoted-string` token, which is only ever built from a terminated one. * Quotes and quoted pairs are removed, and folding whitespace is collapsed so that no display name * can carry a newline into whatever consumes it. */ function unquote(raw) { return raw .slice(1, -1) .replaceAll(/\\([\S\s])/g, '$1') .replaceAll(/\s+/g, ' ') .trim(); }