UNPKG

parse-email-address

Version:

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

279 lines (278 loc) 10.3 kB
import { AddressListTokenType, tokenizeAddressList, } from './address-list-tokens.js'; import { normalizeEmailAddress } from './parse-email-address.js'; /** * The longest header value that {@link parseEmailAddressList} will read. RFC 5322 sets no limit on * the length of a folded address list header, so this exists only to bound the work done on hostile * input; it is far above any legitimate header. */ export const maxAddressListLength = 65_536; /** The RFC 5321 section 4.5.3.1.1 `local-part` limit. */ const maxLocalPartLength = 64; /** The RFC 5321 section 4.5.3.1.3 `Path` limit, less the angle brackets it counts. */ const maxAddressLength = 254; /** * Parse an [RFC 5322](https://datatracker.ietf.org/doc/html/rfc5322#section-3.4) address list * header value (`To`, `Cc`, `Bcc`, `From`, `Reply-To`, and friends) into its mailboxes. * * Unlike `parseEmailAddress`, which implements the strict RFC 5321 envelope grammar, this accepts * everything a message header may carry: display names, quoted strings, nested comments, folding * whitespace, groups, domain literals, obsolete source routes, and multiple addresses. Malformed * entries are dropped rather than throwing, so a single broken address never costs you the rest of * the header. * * A display name or a group name is never reported as an address. `billing@example.com Jane * <attacker@example.org>` reads as one address, `attacker@example.org`, because only the angle-addr * names a real recipient, and `billing@example.com: victim@example.org;` reads as one address in a * group. Code that decides where mail goes must rely on this rather than on searching the header * for text that looks like an address. * * @example * * ```ts * import {parseEmailAddressList} from 'parse-email-address'; * * const result = parseEmailAddressList('Jane Doe <jane@example.org>, john@example.org'); * // result is two entries, the first with `displayName: 'Jane Doe'` * * parseEmailAddressList('undisclosed-recipients:;'); // returns `[]` * ``` * * @throws Nothing, this will never throw an error. */ export function parseEmailAddressList(headerValue) { if (!headerValue || headerValue.length > maxAddressListLength) { return []; } return parseAddressListTokens(tokenizeAddressList(headerValue)); } /** * Parse a header value that should hold exactly one mailbox, such as `From` or `Sender`. Unlike * `parseEmailAddress` this accepts a display name, and unlike {@link parseEmailAddressList} it * refuses to guess when the value holds more than one address. * * @example * * ```ts * import {parseHeaderEmailAddress} from 'parse-email-address'; * * parseHeaderEmailAddress('Jane Doe <jane@example.org>'); // returns the parsed mailbox * parseHeaderEmailAddress('jane@example.org, john@example.org'); // returns `undefined` * ``` * * @returns `undefined` unless the given value holds exactly one address. * @throws Nothing, this will never throw an error. */ export function parseHeaderEmailAddress(headerValue) { const addresses = parseEmailAddressList(headerValue); return addresses.length === 1 ? addresses[0] : undefined; } function parseAddressListTokens(tokens) { const addresses = []; let index = 0; let groupName = undefined; while (index < tokens.length) { const phraseStart = index; while (tokens[index]?.type === AddressListTokenType.Word) { index++; } const words = tokens.slice(phraseStart, index); const next = tokens[index]; if (!next) { break; } else if (next.raw === '@') { const parsed = parseAddrSpec({ tokens, words, atIndex: index, groupName, }); /** * A display name or a group name may itself look like an address, as in * `billing@example.com Jane <attacker@example.org>`. Anything that precedes an * angle-addr or a group's `:` is a name rather than a mailbox, however many words sit * in between, so it must never be reported as a recipient. */ const followedBy = tokens[findEntryTerminator(tokens, parsed.nextIndex)]?.raw; if (parsed.address && followedBy !== '<' && followedBy !== ':') { addresses.push(parsed.address); } index = parsed.nextIndex; } else if (next.raw === '<') { const parsed = parseAngleAddr({ tokens, startIndex: index, displayName: joinPhrase(words), groupName, }); if (parsed.address) { addresses.push(parsed.address); } index = parsed.nextIndex; } else if (next.raw === ':') { groupName = joinPhrase(words); index++; } else if (next.raw === ';') { /** * A group ends here. Mailers that separate a plain address list with semicolons instead * of commas land here too, which is why this only ends the group rather than the list. */ groupName = undefined; index++; } else { /** A comma, a stray `>`, or a phrase with no address attached to it. */ index++; } } return addresses; } /** * Reads an `addr-spec` whose `@` sits at `atIndex`. Every word before the `@` must belong to the * `local-part`, with nothing separating one from the next: `Jane Doe @example.org` names no * mailbox, so it must not be read as one called `Doe`. A second `@` after the domain is likewise * refused rather than resolved in the sender's favor, since `victim@bank.test@attacker.test` is a * mailbox at `attacker.test` in RFC 5321's reading and at `bank.test` in a first-`@`-wins reading. */ function parseAddrSpec({ tokens, words, atIndex, groupName, }) { const domainToken = tokens[atIndex + 1]; const domain = domainToken?.type === AddressListTokenType.Word || domainToken?.type === AddressListTokenType.DomainLiteral ? domainToken.raw : undefined; const nextIndex = domain == undefined ? atIndex + 1 : atIndex + 2; if (domain == undefined || !words.length || !words.every((word, wordIndex) => wordIndex === 0 || !word.isSpaced) || tokens[nextIndex]?.raw === '@') { return { address: undefined, nextIndex, }; } const user = words.map((word) => word.raw).join(''); const full = `${user}@${domain}`; return { address: { user, domain, full, displayName: undefined, groupName, normalized: normalizeAddressWithinLimits({ user, domain, }), }, nextIndex, }; } /** * RFC 5321 caps a `local-part` at 64 octets and a `Path` at 256, so anything longer has no valid * normalized form. Checking that here also keeps a hostile header away from the RFC 5321 parser, * whose cost grows faster than linearly in address length. * * The root label of a fully qualified domain may be written as a trailing dot, which RFC 5321's * `Domain` has no room for, so it is dropped before normalizing rather than costing the address its * comparable form. */ function normalizeAddressWithinLimits({ user, domain, }) { const full = `${user}@${domain.replace(/\.$/, '')}`; return user.length > maxLocalPartLength || full.length > maxAddressLength ? undefined : normalizeEmailAddress(full); } function parseAngleAddr({ tokens, startIndex, displayName, groupName, }) { const contentStart = skipObsoleteRoute(tokens, startIndex + 1); let index = contentStart; while (tokens[index]?.type === AddressListTokenType.Word) { index++; } const parsed = tokens[index]?.raw === '@' ? parseAddrSpec({ tokens, words: tokens.slice(contentStart, index), atIndex: index, groupName, }) : { address: undefined, nextIndex: index, }; return { address: parsed.address && { ...parsed.address, displayName, }, nextIndex: findAngleAddrEnd(tokens, parsed.nextIndex), }; } /** * Skips the `obs-route` of an obsolete source route, as in `<@relay.example.org:jane@example.org>`. * RFC 5321 says such a route must be accepted and then ignored. * * @returns The index the route ended at, or the given index when there is no route. */ function skipObsoleteRoute(tokens, startIndex) { if (tokens[startIndex]?.raw !== '@') { return startIndex; } let index = startIndex; while (index < tokens.length) { const token = tokens[index]; if (token?.raw === ':') { return index + 1; } else if (token?.type === AddressListTokenType.Special && token.raw !== '@' && token.raw !== ',') { return startIndex; } index++; } return startIndex; } /** * Finds the end of an angle-addr. A missing `>` stops at the next list separator or angle-addr so * that an unterminated entry does not swallow the addresses after it. */ function findAngleAddrEnd(tokens, startIndex) { let index = startIndex; while (index < tokens.length) { const raw = tokens[index]?.raw; if (raw === '>') { return index + 1; } else if (raw === ',' || raw === ';' || raw === '<') { return index; } index++; } return index; } const entryTerminators = [ ',', ';', '<', ':', ]; /** * @returns The index of the token that ends the entry starting at the given index, which is the end * of the token list when nothing does. */ function findEntryTerminator(tokens, startIndex) { let index = startIndex; while (index < tokens.length && !entryTerminators.includes(tokens[index]?.raw ?? '')) { index++; } return index; } function joinPhrase(words) { return (words .map((word) => word.text) .join(' ') .trim() || undefined); }