parse-email-address
Version:
Parse/validate email addresses with RFC-5321, and message header address lists with RFC-5322.
140 lines (139 loc) • 6.04 kB
JavaScript
/* eslint-disable @typescript-eslint/no-non-null-assertion */
/**
* This is largely copied from
* https://github.com/gene-hightower/smtp-address-parser/blob/75e0f93837cc302c122cfd3f9a6d9a49f9dd56a3/lib/index.ts
* which has the following license:
*
* MIT License
*
* Copyright (c) 2021 Gene Hightower
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
* It has been modified to have proper imports.
*/
import * as rawNearley from './nearley.js';
import { asciiSafeLowerCase } from './ascii-safe-lower-case.js';
import myGrammar from './grammar.js';
const { Grammar, Parser } = rawNearley;
/**
* `Grammar.fromCompiled` reads `ParserStart` off the compiled grammar as it builds, so each start
* rule needs its own build.
*/
myGrammar.ParserStart = 'IPv6_addr';
const ipv6Grammar = Grammar.fromCompiled(myGrammar);
myGrammar.ParserStart = 'Mailbox';
const grammar = Grammar.fromCompiled(myGrammar);
const reservedIpv6Tag = '[IPv6:';
/**
* An `IPv6:` literal also matches the grammar's `General_address_literal` rule, which accepts any
* tag followed by printable ASCII, so the grammar alone would accept `[IPv6:not-hex-at-all]`. The
* tag is reserved, so its content has to satisfy `IPv6_addr` itself.
*/
function isValidIpv6Address(ipv6Address) {
const parser = new Parser(ipv6Grammar);
try {
parser.feed(ipv6Address);
}
catch {
return false;
}
return !!parser.results?.length;
}
// <https://tools.ietf.org/html/rfc5321#section-4.1.2>
export function parse(address) {
// An insane length, to protect the parsing code from huge input. SMTP line limit, minus command size.
const insane_length = 1000 - 'MAIL FROM:<>\r\n'.length;
if (address.length > insane_length) {
throw new Error('address too long');
}
const parser = new Parser(grammar);
parser.feed(address);
/**
* The RFC grammar reaches some addresses by more than one path (a well formed IPv6 literal
* matches both `IPv6_address_literal` and `General_address_literal`). Those paths all produce
* the same output, so only differing output means the address is genuinely ambiguous.
*/
const distinctResults = new Set(parser.results.map((result) => JSON.stringify(result)));
if (distinctResults.size !== 1) {
throw new Error('address parsing failed: ambiguous grammar');
}
// Domain checks
const at_idx = address.lastIndexOf('@'); // must be found, since parse was successful
const domain = address.slice(Math.max(0, at_idx + 1));
if (asciiSafeLowerCase(domain).startsWith(asciiSafeLowerCase(reservedIpv6Tag))) {
if (!isValidIpv6Address(domain.slice(reservedIpv6Tag.length, -1))) {
throw new Error('invalid IPv6 address literal');
}
}
else if (domain[0] !== '[') {
// Not an address literal
if (domain.length > 253) {
throw new Error('domain too long');
}
const labels = domain.split('.');
if (labels.length < 2) {
throw new Error('domain not fully qualified');
}
else if (labels[labels.length - 1].length < 2) {
throw new Error('top level domain label too short');
}
labels.sort(function (a, b) {
return b.length - a.length;
});
if (labels[0].length > 63) {
throw new Error('domain label too long');
}
}
return parser.results[0];
}
/** Strip +something, strip '.'s, and map to lower case. */
export function normalize_dot_string(dot_string) {
const noTag = (function () {
const plus_loc = dot_string.indexOf('+');
if (plus_loc === -1) {
return dot_string;
}
return dot_string.slice(0, Math.max(0, plus_loc));
})();
const noDot = noTag.replace(/\./g, '');
return asciiSafeLowerCase(noDot);
}
/** The G style address normalization. */
export function normalize(address) {
const a = parse(address);
const domain = a.domainPart.AddressLiteral ?? asciiSafeLowerCase(a.domainPart.DomainName);
const local = a.localPart.QuotedString ?? normalize_dot_string(a.localPart.DotString);
return `${local}@${domain}`;
}
export function canonicalize_quoted_string(quoted_string) {
const unquoted = quoted_string.slice(1).slice(0, Math.max(0, quoted_string.length - 2));
const unescaped = unquoted.replace(/(?:\\(.))/g, '$1');
const reEscaped = unescaped.replace(/(?:(["\\]))/g, String.raw `\$1`);
return `"${reEscaped}"`; // re-quote
}
/** Apply a canonicalization consistent with standards to support comparison as a string. */
export function canonicalize(address) {
const a = parse(address);
const domain = a.domainPart.AddressLiteral ?? asciiSafeLowerCase(a.domainPart.DomainName);
const local = a.localPart.QuotedString
? canonicalize_quoted_string(a.localPart.QuotedString)
: a.localPart.DotString;
return `${local}@${domain}`;
}