Skip to content

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 →