Functional Weave
Code in Rust

validation.email@1.0.0

impl/typescript.ts

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.
 */
export function isEmail(value: string): boolean {
  if (typeof value !== "string") return false;
  if (value.length === 0 || value.length > MAX_TOTAL) return false;

  // 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) return false;
      at = i;
    }
  }
  if (at <= 0 || at === value.length - 1) return false;

  return isLocalPart(value.slice(0, at)) && isDomain(value.slice(at + 1));
}

function isLocalPart(local: string): boolean {
  if (local.length === 0 || local.length > MAX_LOCAL) return false;
  // A dot is a separator between atoms, so it cannot lead, trail or double up.
  if (local[0] === "." || local[local.length - 1] === ".") return false;
  for (let i = 0; i < local.length; i++) {
    const ch = local[i];
    if (ch === ".") {
      if (local[i - 1] === ".") return false;
      continue;
    }
    if (!isLocalChar(ch)) return false;
  }
  return true;
}

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) return false;

  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) return false;

  for (const label of labels) {
    if (label.length === 0 || label.length > MAX_LABEL) return false;
    if (label[0] === "-" || label[label.length - 1] === "-") return false;
    for (let i = 0; i < label.length; i++) {
      const ch = label[i];
      if (!isLetterOrDigit(ch) && ch !== "-") return false;
    }
  }

  // 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) return false;
  for (let i = 0; i < tld.length; i++) {
    if (!isLetter(tld[i])) return false;
  }
  return true;
}

/**
 * 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.
 */
export function emailDomain(value: string): string | null {
  if (!isEmail(value)) return null;
  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;
}