Skip to content

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

# email-validator — RFC 5321/5322-inspired email validation.
#
# Language: Ruby (3.2+, standard library only)
# Source:   CosmoDev polyglot showcase port of the Email Validator tool,
#           ported from src/lib/email-validator.ts (the canonical TypeScript
#           implementation).
# License:  display source — part of CosmoDev's polyglot tool pages.
#
# Practical, provider-friendly validation: errs on the side of deliverability
# while still recognising the legal-but-unusual forms (quoted local parts,
# IP-literal domains). Pure and deterministic — every malformed input becomes
# a non-valid verdict carrying explanatory reasons; nothing below raises.
#
# Self-contained: standard library only — the character classes used by the
# original are implemented as small character predicates over core Regexp.

# The structured verdict returned by {validate_email}. +valid+ is true iff
# +reasons+ is empty; +warnings+ never affect validity. +local+ / +domain+ /
# +normalized+ are nil when the address could not be split into parts.
EmailResult = Struct.new(
  :valid, :local, :domain, :normalized, :reasons, :warnings,
  keyword_init: true
) do
  def initialize(**args)
    super(
      valid: args.fetch(:valid, false),
      local: args[:local],
      domain: args[:domain],
      normalized: args[:normalized],
      reasons: args.fetch(:reasons, []),
      warnings: args.fetch(:warnings, [])
    )
  end
end

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

# Internal split result: views into the input address.
Split = Struct.new(:local, :domain, :quoted, keyword_init: true)

# ------------------------------------------------------------- predicates --

# The printable specials permitted unquoted in an RFC-style "atom" local part.
ATOM_SPECIALS = %w[. ! # $ % & ' * + / = ? ^ _ ` { | } ~ -].to_h { |c| [c, true] }.freeze

# True when every character of +s+ belongs to the RFC-style "atom" character
# set (ASCII alphanumeric plus the printable specials permitted unquoted).
def atom_local?(s)
  !s.empty? && s.each_char.all? do |c|
    c =~ /[A-Za-z0-9]/ || ATOM_SPECIALS[c]
  end
end

# A valid domain label: ASCII letters, digits, and hyphens (non-empty).
def valid_label?(s)
  !s.empty? && s.each_char.all? { |c| c =~ /[A-Za-z0-9-]/ }
end

# A valid TLD: two or more ASCII letters. Length equals char count when every
# character is ASCII alphabetic, so the length check is exact.
def valid_tld?(s)
  s.length >= 2 && s.each_char.all? { |c| c =~ /[A-Za-z]/ }
end

# An all-decimal, non-empty octet string.
def decimal?(s)
  !s.empty? && s.each_char.all? { |c| c =~ /\d/ }
end

# True when +s+ starts with "ipv6:" (case-insensitive).
def ipv6_literal?(s)
  s.length >= 5 && s[0, 5].casecmp("ipv6:").zero?
end

# True when +s+ is a dotted-quad: four octets, each 0-255, with no leading
# zeros. The 3-digit length cap rejects arbitrarily long digit strings before
# they can overflow the value parse (equivalent to the reference's
# overflow-on-cast behaviour).
def ipv4?(s)
  parts = s.split(".", -1)
  return false unless parts.length == 4

  parts.all? do |p|
    next false if p.empty? || p.length > 3 || !decimal?(p)
    value = p.to_i
    value <= 255 && !(p.length > 1 && p.start_with?("0"))
  end
end

# ------------------------------------------------------------------ split --

# Splits an address into local + domain, honouring a quoted ("...") local
# part. Returns nil when the address cannot be split into exactly one '@' in
# the right place.
def split_local_domain(email)
  if email.start_with?('"')
    # Walk the quoted string; a backslash escapes the next character (so `\"`
    # does not terminate the quote).
    i = 1
    n = email.length
    while i < n
      case email[i]
      when "\\" then i += 2
      when '"' then break
      else i += 1
      end
    end
    return nil if i >= n || email[i] != '"'    # unterminated quote
    at = i + 1
    return nil if at >= n || email[at] != "@"  # '@' must follow the closing quote
    return nil if email.index("@", at + 1)     # stray '@' inside the domain
    Split.new(local: email[0...at], domain: email[(at + 1)..], quoted: true)
  else
    first = email.index("@")
    return nil if first.nil?
    return nil if email.index("@", first + 1)  # multiple '@'
    Split.new(local: email[0...first], domain: email[(first + 1)..], quoted: false)
  end
end

# ---------------------------------------------------------------- domain --

# Appends domain-level problems to +reasons+ / +warnings+ in place.
def validate_domain(domain, reasons, warnings)
  if domain.empty?
    reasons << "Domain is empty"
    return
  end
  reasons << "Domain exceeds #{DOMAIN_MAX} characters" if domain.length > DOMAIN_MAX

  # IP-literal domain: [1.2.3.4] or [IPv6:...].
  if domain.start_with?("[") && domain.end_with?("]")
    inner = domain[1..-2]
    if ipv6_literal?(inner)
      warnings << "IPv6 literal domain (uncommon; ensure your provider supports it)"
      return
    end
    if ipv4?(inner)
      warnings << "IP-literal domain (uncommon; ensure your provider supports it)"
      return
    end
    reasons << "Invalid IP-literal domain"
    return
  end
  if domain.start_with?("[") || domain.end_with?("]")
    reasons << "Malformed IP-literal domain (unmatched brackets)"
    return
  end

  unless domain.include?(".")
    reasons << "Domain must contain at least one dot (e.g. example.com)"
    return
  end

  labels = domain.split(".", -1)
  labels.each do |label|
    if label.empty?
      reasons << "Domain contains an empty label (consecutive or trailing dots)"
      next
    end
    reasons << "Domain label exceeds 63 characters" if label.length > 63
    reasons << "Domain label contains invalid characters" unless valid_label?(label)
    if label.start_with?("-") || label.end_with?("-")
      reasons << "Domain label starts or ends with a hyphen"
    end
  end
  # The TLD is the final label; require >=2 ASCII letters so bare hostnames
  # and numeric tails are rejected.
  reasons << "Top-level domain must be at least two letters" unless valid_tld?(labels.last)
end

# ----------------------------------------------------------------- email --

# Validates a single email address; returns a structured verdict, never
# raises.
def validate_email(raw)
  reasons = []
  warnings = []
  email = raw.strip

  if email.empty?
    return EmailResult.new(valid: false, reasons: ["Email is empty"], warnings: warnings)
  end

  reasons << "Email exceeds maximum length of #{TOTAL_MAX} characters" if email.length > TOTAL_MAX

  split = split_local_domain(email)
  if split.nil?
    reasons << 'Email must contain exactly one "@" separating local part and domain'
    return EmailResult.new(valid: false, reasons: reasons, warnings: warnings)
  end
  local = split.local
  domain = split.domain

  if split.quoted
    # Quoted local parts are RFC-legal but almost universally rejected by
    # mailbox providers — warn, and only length-check structurally.
    reasons << "Local part exceeds #{LOCAL_MAX} characters" if local.length > LOCAL_MAX
    warnings << "Quoted local part (rarely supported by providers)"
  elsif local.empty?
    reasons << "Local part is empty"
  else
    reasons << "Local part exceeds #{LOCAL_MAX} characters" if local.length > LOCAL_MAX
    if local.start_with?(".") || local.end_with?(".")
      reasons << "Local part starts or ends with a dot"
    end
    reasons << "Local part contains consecutive dots" if local.include?("..")
    reasons << "Local part contains invalid characters" unless atom_local?(local)
  end
  # Plus-addressing (`user+tag@`) is valid and delivers to the base mailbox,
  # but callers filtering on exact address may want to know.
  warnings << "Plus-addressing (tag) detected — delivers to the base mailbox" if !split.quoted && local.include?("+")

  validate_domain(domain, reasons, warnings)

  normalized = !local.empty? && !domain.empty? ? "#{local}@#{domain.downcase}" : nil
  EmailResult.new(
    valid: reasons.empty?,
    local: local,
    domain: domain,
    normalized: normalized,
    reasons: reasons,
    warnings: warnings
  )
end

# Validates many emails (one per line); blank/whitespace-only lines are
# skipped. Line endings may be LF or CRLF (matching the reference's `\r?\n`
# split).
def validate_batch(text)
  return [] if text.empty?

  text.split(/\r?\n/, -1).map(&:strip).reject(&:empty?).map { |line| validate_email(line) }
end

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 →