4,598 bytes · the TypeScript implementation · view raw
// Every character class is hand-rolled rather than expressed as a regular// expression. The Rust sibling has no regex crate available, and the only way// to be sure three implementations agree on an edge case is for all three to// make the same decision in the same place./** RFC 5321 caps the local part at 64 octets. */const MAX_LOCAL = 64;
/** RFC 5321 caps a forward path at 256 octets including the angle brackets. */const MAX_TOTAL = 254;
/** RFC 1035 caps a DNS label at 63 octets. */const MAX_LABEL = 63;
/** The "atext" specials from RFC 5322, plus the dot handled separately below. */const LOCAL_SPECIALS = "!#$%&'*+-/=?^_`{|}~";
function isDigit(ch: string): boolean {
return ch >= "0" && ch <= "9";
}
function isLetter(ch: string): boolean {
return (ch >= "a" && ch <= "z") || (ch >= "A" && ch <= "Z");
}
function isLetterOrDigit(ch: string): boolean {
return isLetter(ch) || isDigit(ch);
}
function isLocalChar(ch: string): boolean {
return isLetterOrDigit(ch) || LOCAL_SPECIALS.indexOf(ch) >= 0;
}
/** * Is this a plausible email address? * * This is a deliberate, documented subset of RFC 5322, not an implementation * of it. The full grammar admits comments, folded whitespace, quoted strings * with embedded spaces and bracketed IP literals; almost nothing downstream * of a signup form can handle those, and accepting them would let addresses * through that the mail stack then rejects. * * The only true validation of an email address is sending mail to it and * seeing the recipient act on it. Use this to catch typos at the keyboard, * then confirm by email. Never use it to decide that an address is real. */exportfunction isEmail(value: string): boolean {
if (typeof value !== "string") returnfalse;
if (value.length === 0 || value.length > MAX_TOTAL) returnfalse;
// Exactly one @: the last-@ split used by lenient parsers quietly accepts// "a@b@c", which no MTA will route.let at = -1;
for (let i = 0; i < value.length; i++) {
if (value[i] === "@") {
if (at !== -1) returnfalse;
at = i;
}
}
if (at <= 0 || at === value.length - 1) returnfalse;
return isLocalPart(value.slice(0, at)) && isDomain(value.slice(at + 1));
}
function isLocalPart(local: string): boolean {
if (local.length === 0 || local.length > MAX_LOCAL) returnfalse;
// A dot is a separator between atoms, so it cannot lead, trail or double up.if (local[0] === "." || local[local.length - 1] === ".") returnfalse;
for (let i = 0; i < local.length; i++) {
const ch = local[i];
if (ch === ".") {
if (local[i - 1] === ".") returnfalse;
continue;
}
if (!isLocalChar(ch)) returnfalse;
}
returntrue;
}
function isDomain(domain: string): boolean {
// MAX_TOTAL already bounds this, but stating the domain limit separately// keeps the rule readable and survives any future change to the total.if (domain.length === 0 || domain.length > MAX_TOTAL - 2) returnfalse;
const labels = domain.split(".");
// At least one dot. A bare "localhost" is a valid host but not an address// anyone outside that machine can deliver to, and a signup form is asking// for the latter.if (labels.length < 2) returnfalse;
for (const label of labels) {
if (label.length === 0 || label.length > MAX_LABEL) returnfalse;
if (label[0] === "-" || label[label.length - 1] === "-") returnfalse;
for (let i = 0; i < label.length; i++) {
const ch = label[i];
if (!isLetterOrDigit(ch) && ch !== "-") returnfalse;
}
}
// The top-level label must be two or more letters. This is what rejects// "user@example.123" and the bracketed-IP form, and it is the rule most// likely to need revisiting: it also rejects punycode-free internationalised// TLDs written in their native script.const tld = labels[labels.length - 1];
if (tld.length < 2) returnfalse;
for (let i = 0; i < tld.length; i++) {
if (!isLetter(tld[i])) returnfalse;
}
returntrue;
}
/** * The domain half of an address, lowercased, or null if the address is not * one this capability accepts. * * Domains are case-insensitive; local parts are not, so this deliberately * only normalises the half where doing so is safe. */exportfunction emailDomain(value: string): string | null {
if (!isEmail(value)) returnnull;
const domain = value.slice(value.indexOf("@") + 1);
let out = "";
for (let i = 0; i < domain.length; i++) {
const ch = domain[i];
out += ch >= "A" && ch <= "Z" ? String.fromCharCode(ch.charCodeAt(0) + 32) : ch;
}
return out;
}