Skip to content

Email Validator — JavaScript source

Validate email addresses one at a time or in bulk. Checks syntax, length limits, local-part and domain rules, plus-addressing, and IP-literal domains - all in your browser.

This is the JavaScript implementation — the same logic the interactive tool runs, in a shareable, citable form.

/**
 * email-validator - JavaScript polyglot showcase port.
 *
 * Language: JavaScript (ES module).
 *
 * CosmoDev polyglot showcase: the pure logic of the email-validator tool,
 * ported from src/lib/email-validator.ts. This file is display source - part
 * of CosmoDev's polyglot tool pages, where each tool's logic is shown side by
 * side in several languages.
 *
 * RFC 5321/5322-inspired email validation. Pure, deterministic, never throws.
 * Errs on the side of practical deliverability (provider-friendly) while still
 * recognising the legal-but-unusual forms (quoted local parts, IP-literal
 * domains).
 */

// RFC-inspired length ceilings: local part, domain, and total address.
const LOCAL_MAX = 64;
const DOMAIN_MAX = 253;
const TOTAL_MAX = 320;

// Characters permitted in an unquoted (atom) local part. This is the
// printable-ASCII subset from RFC 5322 - alphanumerics plus a fixed set of
// specials. Anything else forces the local part to be quoted.
const LOCAL_CHARS = /^[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+$/;

/**
 * Split an email into local + domain, honouring a quoted ("...") local part.
 * Returns { local, domain, quoted } or null when the address cannot be split
 * into exactly one '@' in the right place.
 */
function splitLocalDomain(email) {
  if (email.startsWith('"')) {
    // Walk the quoted string; a backslash escapes the next byte (so `\"`
    // does not terminate the quote).
    let i = 1;
    while (i < email.length) {
      const ch = email[i];
      if (ch === '\\') { i += 2; continue; }
      if (ch === '"') break;
      i += 1;
    }
    if (email[i] !== '"') return null; // unterminated quote
    const at = i + 1;
    if (email[at] !== '@') return null; // '@' must immediately follow the closing quote
    if (email.indexOf('@', at + 1) !== -1) return null; // stray '@' inside the domain
    return { local: email.slice(0, at), domain: email.slice(at + 1), quoted: true };
  }

  const first = email.indexOf('@');
  if (first === -1) return null;
  if (email.indexOf('@', first + 1) !== -1) return null; // multiple '@'
  return { local: email.slice(0, first), domain: email.slice(first + 1), quoted: false };
}

/** True when `s` is a dotted-quad: four octets, each 0-255, with no leading zeros. */
function isIPv4(s) {
  const parts = s.split('.');
  if (parts.length !== 4) return false;
  return parts.every((p) => {
    if (!/^\d+$/.test(p)) return false;
    const n = Number(p);
    // `String(n) === p` rejects leading zeros ("01") and numeric overflow forms.
    return n >= 0 && n <= 255 && String(n) === p;
  });
}

/** Push domain-level problems into the shared reasons/warnings arrays. */
function validateDomain(domain, reasons, warnings) {
  if (!domain) {
    reasons.push('Domain is empty');
    return;
  }
  if (domain.length > DOMAIN_MAX) {
    reasons.push(`Domain exceeds ${DOMAIN_MAX} characters`);
  }

  // IP-literal domain: [1.2.3.4] or [IPv6:...].
  if (domain.startsWith('[') && domain.endsWith(']')) {
    const inner = domain.slice(1, -1);
    if (/^ipv6:/i.test(inner)) {
      warnings.push('IPv6 literal domain (uncommon; ensure your provider supports it)');
      return;
    }
    if (isIPv4(inner)) {
      warnings.push('IP-literal domain (uncommon; ensure your provider supports it)');
      return;
    }
    reasons.push('Invalid IP-literal domain');
    return;
  }
  if (domain.startsWith('[') || domain.endsWith(']')) {
    reasons.push('Malformed IP-literal domain (unmatched brackets)');
    return;
  }

  if (!domain.includes('.')) {
    reasons.push('Domain must contain at least one dot (e.g. example.com)');
    return;
  }

  const labels = domain.split('.');
  for (const label of labels) {
    if (!label) {
      reasons.push('Domain contains an empty label (consecutive or trailing dots)');
      continue;
    }
    if (label.length > 63) reasons.push('Domain label exceeds 63 characters');
    if (!/^[A-Za-z0-9-]+$/.test(label)) reasons.push('Domain label contains invalid characters');
    if (label.startsWith('-') || label.endsWith('-')) reasons.push('Domain label starts or ends with a hyphen');
  }
  // The TLD is the final label; require at least two ASCII letters so that
  // bare/host-name domains and numeric tails are rejected.
  const tld = labels[labels.length - 1];
  if (!/^[A-Za-z]{2,}$/.test(tld)) reasons.push('Top-level domain must be at least two letters');
}

/**
 * Validate a single email address; returns a structured verdict, never throws.
 * `valid` is true iff `reasons` is empty. `warnings` never affect validity.
 */
export function validateEmail(raw) {
  const reasons = [];
  const warnings = [];
  const email = raw.trim();

  if (!email) {
    return { valid: false, reasons: ['Email is empty'], warnings };
  }

  if (email.length > TOTAL_MAX) {
    reasons.push(`Email exceeds maximum length of ${TOTAL_MAX} characters`);
  }

  const split = splitLocalDomain(email);
  if (!split) {
    reasons.push('Email must contain exactly one "@" separating local part and domain');
    return { valid: false, reasons, warnings };
  }
  const { local, domain, quoted } = split;

  if (quoted) {
    // Quoted local parts are RFC-legal but almost universally rejected by
    // mailbox providers - flag as a warning, only length-check structurally.
    if (local.length > LOCAL_MAX) reasons.push(`Local part exceeds ${LOCAL_MAX} characters`);
    warnings.push('Quoted local part (rarely supported by providers)');
  } else if (!local) {
    reasons.push('Local part is empty');
  } else {
    if (local.length > LOCAL_MAX) reasons.push(`Local part exceeds ${LOCAL_MAX} characters`);
    if (local.startsWith('.') || local.endsWith('.')) reasons.push('Local part starts or ends with a dot');
    if (local.includes('..')) reasons.push('Local part contains consecutive dots');
    if (!LOCAL_CHARS.test(local)) reasons.push('Local part contains invalid characters');
  }
  // Plus-addressing (`user+tag@`) is valid and delivers to the base mailbox,
  // but callers filtering on exact address may want to know.
  if (!quoted && local.includes('+')) {
    warnings.push('Plus-addressing (tag) detected - delivers to the base mailbox');
  }

  validateDomain(domain, reasons, warnings);

  const valid = reasons.length === 0;
  return {
    valid,
    local,
    domain,
    normalized: local && domain ? `${local}@${domain.toLowerCase()}` : undefined,
    reasons,
    warnings,
  };
}

/**
 * Validate many emails (one per line). Blank / whitespace-only lines are
 * skipped. Line endings may be LF or CRLF.
 */
export function validateBatch(input) {
  if (!input) return [];
  return input
    .split(/\r?\n/)
    .map((line) => line.trim())
    .filter((line) => line.length > 0)
    .map(validateEmail);
}

Also available in 13 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →