Skip to content

Email Validator — TypeScript 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 TypeScript implementation — the same logic the interactive tool runs, in a shareable, citable form.

// 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).

export interface EmailResult {
  valid: boolean;
  local?: string;
  domain?: string;
  normalized?: string;
  reasons: string[];
  warnings: string[];
}

const LOCAL_MAX = 64;
const DOMAIN_MAX = 253;
const TOTAL_MAX = 320;

// Characters permitted in an unquoted (atom) local part.
const LOCAL_CHARS = /^[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+$/;

/** Split an email into local + domain, honouring a quoted ("...") local part. */
function splitLocalDomain(
  email: string,
): { local: string; domain: string; quoted: boolean } | null {
  if (email.startsWith('"')) {
    // Walk the quoted string; a backslash escapes the next byte.
    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 follow the closing quote
    if (email.indexOf('@', at + 1) !== -1) return null; // stray '@' in 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 with octets 0-255 and no leading zeros. */
function isIPv4(s: string): boolean {
  const parts = s.split('.');
  if (parts.length !== 4) return false;
  return parts.every((p) => {
    if (!/^\d+$/.test(p)) return false;
    const n = Number(p);
    return n >= 0 && n <= 255 && String(n) === p;
  });
}

/** Push domain-level problems into the shared reasons/warnings arrays. */
function validateDomain(domain: string, reasons: string[], warnings: string[]): void {
  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');
  }
  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. */
export function validateEmail(raw: string): EmailResult {
  const reasons: string[] = [];
  const warnings: string[] = [];
  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) {
    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');
  }
  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. */
export function validateBatch(input: string): EmailResult[] {
  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 →