Email Validator — Rust 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 Rust implementation — the same logic the interactive tool runs, in a shareable, citable form.
//! # email-validator — Rust polyglot showcase port.
//!
//! Language: Rust.
//!
//! 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, panic-free.
//! Errs on the side of practical deliverability (provider-friendly) while still
//! recognising the legal-but-unusual forms (quoted local parts, IP-literal
//! domains).
//!
//! Self-contained: stdlib only — the character classes used by the original
//! are implemented as small byte predicates below, avoiding any external regex
//! dependency.
/// The structured verdict returned by [`validate_email`].
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct EmailResult {
/// True when no blocking reasons were recorded.
pub valid: bool,
/// Local part (before '@'); empty string when not parseable.
pub local: String,
/// Domain part (after '@'); empty string when not parseable.
pub domain: String,
/// `local@lowercased-domain` when both parts exist, else `None`.
pub normalized: Option<String>,
/// Blocking problems (`valid` is true iff this is empty).
pub reasons: Vec<String>,
/// Non-blocking observations (rare forms, plus-tags, …).
pub warnings: Vec<String>,
}
/// RFC-inspired length ceilings: local part, domain, total address.
const LOCAL_MAX: usize = 64;
const DOMAIN_MAX: usize = 253;
const TOTAL_MAX: usize = 320;
/// True when every byte of `s` belongs to the RFC-style "atom" character set
/// (ASCII alphanumeric plus the printable specials permitted unquoted).
/// Iterating bytes is correct here because the class is strictly ASCII.
fn is_atom_local(s: &str) -> bool {
!s.is_empty()
&& s.bytes().all(|b| {
b.is_ascii_alphanumeric()
|| matches!(
b,
b'.' | b'!' | b'#'
| b'$' | b'%' | b'&'
| b'\'' | b'*' | b'+'
| b'/' | b'=' | b'?'
| b'^' | b'_' | b'`'
| b'{' | b'|' | b'}'
| b'~' | b'-'
)
})
}
/// A valid domain label: ASCII letters, digits, and hyphens (non-empty).
fn is_valid_label(s: &str) -> bool {
!s.is_empty() && s.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')
}
/// A valid TLD: two or more ASCII letters. Byte length equals char count when
/// every byte is ASCII alphabetic, so the length check is exact.
fn is_valid_tld(s: &str) -> bool {
s.len() >= 2 && s.bytes().all(|b| b.is_ascii_alphabetic())
}
/// An all-decimal, non-empty octet string.
fn is_decimal(s: &str) -> bool {
!s.is_empty() && s.bytes().all(|b| b.is_ascii_digit())
}
/// True when `s` starts with `ipv6:` (case-insensitive).
fn is_ipv6_literal(s: &str) -> bool {
s.len() >= 5 && s.as_bytes()[..5].eq_ignore_ascii_case(b"ipv6:")
}
/// Internal split result: borrowed views into the input address.
struct Split<'a> {
local: &'a str,
domain: &'a str,
quoted: bool,
}
/// Splits an address into local + domain, honouring a quoted ("...") local
/// part. Returns `None` when the address cannot be split into exactly one '@'
/// in the right place.
fn split_local_domain(email: &str) -> Option<Split<'_>> {
let bytes = email.as_bytes();
if email.starts_with('"') {
// Walk the quoted string; a backslash escapes the next byte (so `\"`
// does not terminate the quote). We only branch on ASCII delimiters,
// so byte-indexing is safe; all slice boundaries land on ASCII chars.
let mut i = 1;
while i < email.len() {
let ch = bytes[i];
if ch == b'\\' {
i += 2;
continue;
}
if ch == b'"' {
break;
}
i += 1;
}
if i >= email.len() || bytes[i] != b'"' {
return None; // unterminated quote
}
let at = i + 1;
if at >= email.len() || bytes[at] != b'@' {
return None; // '@' must immediately follow the closing quote
}
if email[at + 1..].contains('@') {
return None; // stray '@' inside the domain
}
Some(Split {
local: &email[..at],
domain: &email[at + 1..],
quoted: true,
})
} else {
let first = email.find('@')?;
if email[first + 1..].contains('@') {
return None; // multiple '@'
}
Some(Split {
local: &email[..first],
domain: &email[first + 1..],
quoted: false,
})
}
}
/// True when `s` is a dotted-quad: four octets, each 0–255, with no leading
/// zeros. Parsing as `u32` rejects arbitrarily long digit strings via error
/// (equivalent to the reference's overflow-on-cast behaviour).
fn is_ipv4(s: &str) -> bool {
let parts: Vec<&str> = s.split('.').collect();
if parts.len() != 4 {
return false;
}
parts.iter().all(|p| {
if !is_decimal(p) {
return false;
}
match p.parse::<u32>() {
// `n.to_string() != *p` rejects leading zeros ("01" -> "1").
Ok(n) => n <= 255 && n.to_string() == *p,
Err(_) => false,
}
})
}
/// Appends domain-level problems to `reasons` / `warnings`.
fn validate_domain(domain: &str, reasons: &mut Vec<String>, warnings: &mut Vec<String>) {
if domain.is_empty() {
reasons.push("Domain is empty".to_string());
return;
}
if domain.len() > DOMAIN_MAX {
reasons.push(format!("Domain exceeds {} characters", DOMAIN_MAX));
}
// IP-literal domain: [1.2.3.4] or [IPv6:...].
if domain.starts_with('[') && domain.ends_with(']') {
let inner = &domain[1..domain.len() - 1];
if is_ipv6_literal(inner) {
warnings.push("IPv6 literal domain (uncommon; ensure your provider supports it)".to_string());
return;
}
if is_ipv4(inner) {
warnings.push("IP-literal domain (uncommon; ensure your provider supports it)".to_string());
return;
}
reasons.push("Invalid IP-literal domain".to_string());
return;
}
if domain.starts_with('[') || domain.ends_with(']') {
reasons.push("Malformed IP-literal domain (unmatched brackets)".to_string());
return;
}
if !domain.contains('.') {
reasons.push("Domain must contain at least one dot (e.g. example.com)".to_string());
return;
}
let labels: Vec<&str> = domain.split('.').collect();
for label in &labels {
if label.is_empty() {
reasons.push("Domain contains an empty label (consecutive or trailing dots)".to_string());
continue;
}
if label.len() > 63 {
reasons.push("Domain label exceeds 63 characters".to_string());
}
if !is_valid_label(label) {
reasons.push("Domain label contains invalid characters".to_string());
}
if label.starts_with('-') || label.ends_with('-') {
reasons.push("Domain label starts or ends with a hyphen".to_string());
}
}
// The TLD is the final label; require ≥2 ASCII letters so bare hostnames
// and numeric tails are rejected.
let tld = labels[labels.len() - 1];
if !is_valid_tld(tld) {
reasons.push("Top-level domain must be at least two letters".to_string());
}
}
/// Validates a single email address, returning a structured verdict.
///
/// Pure and panic-free: every malformed input becomes a non-valid result
/// carrying explanatory `reasons`.
pub fn validate_email(raw: &str) -> EmailResult {
let mut reasons: Vec<String> = Vec::new();
let mut warnings: Vec<String> = Vec::new();
let email = raw.trim();
if email.is_empty() {
return EmailResult {
valid: false,
local: String::new(),
domain: String::new(),
normalized: None,
reasons: vec!["Email is empty".to_string()],
warnings,
};
}
if email.len() > TOTAL_MAX {
reasons.push(format!("Email exceeds maximum length of {} characters", TOTAL_MAX));
}
let split = match split_local_domain(email) {
Some(s) => s,
None => {
reasons.push(
r#"Email must contain exactly one "@" separating local part and domain"#
.to_string(),
);
return EmailResult {
valid: false,
local: String::new(),
domain: String::new(),
normalized: None,
reasons,
warnings,
};
}
};
let (local, domain, quoted) = (split.local, split.domain, split.quoted);
if quoted {
// Quoted local parts are RFC-legal but almost universally rejected by
// mailbox providers — warn, and only length-check structurally.
if local.len() > LOCAL_MAX {
reasons.push(format!("Local part exceeds {} characters", LOCAL_MAX));
}
warnings.push("Quoted local part (rarely supported by providers)".to_string());
} else if local.is_empty() {
reasons.push("Local part is empty".to_string());
} else {
if local.len() > LOCAL_MAX {
reasons.push(format!("Local part exceeds {} characters", LOCAL_MAX));
}
if local.starts_with('.') || local.ends_with('.') {
reasons.push("Local part starts or ends with a dot".to_string());
}
if local.contains("..") {
reasons.push("Local part contains consecutive dots".to_string());
}
if !is_atom_local(local) {
reasons.push("Local part contains invalid characters".to_string());
}
}
// 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.contains('+') {
warnings.push("Plus-addressing (tag) detected — delivers to the base mailbox".to_string());
}
validate_domain(domain, &mut reasons, &mut warnings);
let valid = reasons.is_empty();
let normalized = if !local.is_empty() && !domain.is_empty() {
Some(format!("{}@{}", local, domain.to_lowercase()))
} else {
None
};
EmailResult {
valid,
local: local.to_string(),
domain: domain.to_string(),
normalized,
reasons,
warnings,
}
}
/// Validates many addresses — one per line. Blank or whitespace-only lines
/// are skipped. Line endings may be LF or CRLF (matching the reference's
/// `\r?\n` split).
pub fn validate_batch(input: &str) -> Vec<EmailResult> {
if input.is_empty() {
return Vec::new();
}
let mut out = Vec::new();
for line in input.split("\r\n").flat_map(|chunk| chunk.split('\n')) {
let line = line.trim();
if !line.is_empty() {
out.push(validate_email(line));
}
}
out
}
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 →