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 →