Functional Weave
Code in Rust

validation.email@1.0.0

impl/rust.rs

5,412 bytes · the Rust implementation · view raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

use super::funejson::Value;  ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one

// Every character class is hand-rolled rather than expressed as a regular
// expression. No regex crate is available here, and the only way to be sure
// three implementations agree on an edge case is for all three to make the
// same decision in the same place. The address is walked as a `Vec<char>` so
// that positions mean code points, exactly as they do in Python.

/// RFC 5321 caps the local part at 64 octets.
const MAX_LOCAL: usize = 64;
/// RFC 5321 caps a forward path at 256 octets including the angle brackets.
const MAX_TOTAL: usize = 254;
/// RFC 1035 caps a DNS label at 63 octets.
const MAX_LABEL: usize = 63;

/// The "atext" specials from RFC 5322, plus the dot handled separately below.
const LOCAL_SPECIALS: &str = "!#$%&'*+-/=?^_`{|}~";

fn is_digit(ch: char) -> bool {
    ('0'..='9').contains(&ch)
}

fn is_letter(ch: char) -> bool {
    ('a'..='z').contains(&ch) || ('A'..='Z').contains(&ch)
}

fn is_letter_or_digit(ch: char) -> bool {
    is_letter(ch) || is_digit(ch)
}

fn is_local_char(ch: char) -> bool {
    is_letter_or_digit(ch) || LOCAL_SPECIALS.contains(ch)
}

/// Is this a plausible email address?
///
/// This is a deliberate, documented subset of RFC 5322, not an implementation
/// of it. The full grammar admits comments, folded whitespace, quoted strings
/// with embedded spaces and bracketed IP literals; almost nothing downstream
/// of a signup form can handle those, and accepting them would let addresses
/// through that the mail stack then rejects.
///
/// The only true validation of an email address is sending mail to it and
/// seeing the recipient act on it. Use this to catch typos at the keyboard,
/// then confirm by email. Never use it to decide that an address is real.
pub fn is_email(value: &str) -> bool {
    let chars: Vec<char> = value.chars().collect();
    if chars.is_empty() || chars.len() > MAX_TOTAL {
        return false;
    }

    // Exactly one @: the last-@ split used by lenient parsers quietly accepts
    // "a@b@c", which no MTA will route.
    let mut at: Option<usize> = None;
    for (i, ch) in chars.iter().enumerate() {
        if *ch == '@' {
            if at.is_some() {
                return false;
            }
            at = Some(i);
        }
    }
    let at = match at {
        Some(i) => i,
        None => return false,
    };
    if at == 0 || at == chars.len() - 1 {
        return false;
    }

    is_local_part(&chars[..at]) && is_domain(&chars[at + 1..])
}

fn is_local_part(local: &[char]) -> bool {
    if local.is_empty() || local.len() > MAX_LOCAL {
        return false;
    }
    // A dot is a separator between atoms, so it cannot lead, trail or double up.
    if local[0] == '.' || local[local.len() - 1] == '.' {
        return false;
    }
    for (i, ch) in local.iter().enumerate() {
        if *ch == '.' {
            if local[i - 1] == '.' {
                return false;
            }
            continue;
        }
        if !is_local_char(*ch) {
            return false;
        }
    }
    true
}

fn is_domain(domain: &[char]) -> bool {
    // MAX_TOTAL already bounds this, but stating the domain limit separately
    // keeps the rule readable and survives any future change to the total.
    if domain.is_empty() || domain.len() > MAX_TOTAL - 2 {
        return false;
    }

    let mut labels: Vec<&[char]> = Vec::new();
    let mut start = 0usize;
    for i in 0..domain.len() {
        if domain[i] == '.' {
            labels.push(&domain[start..i]);
            start = i + 1;
        }
    }
    labels.push(&domain[start..]);

    // At least one dot. A bare "localhost" is a valid host but not an address
    // anyone outside that machine can deliver to, and a signup form is asking
    // for the latter.
    if labels.len() < 2 {
        return false;
    }

    for label in &labels {
        if label.is_empty() || label.len() > MAX_LABEL {
            return false;
        }
        if label[0] == '-' || label[label.len() - 1] == '-' {
            return false;
        }
        for ch in label.iter() {
            if !is_letter_or_digit(*ch) && *ch != '-' {
                return false;
            }
        }
    }

    // The top-level label must be two or more letters. This is what rejects
    // "user@example.123" and the bracketed-IP form, and it is the rule most
    // likely to need revisiting: it also rejects punycode-free internationalised
    // TLDs written in their native script.
    let tld = labels[labels.len() - 1];
    if tld.len() < 2 {
        return false;
    }
    tld.iter().all(|ch| is_letter(*ch))
}

/// The domain half of an address, lowercased, or `None` if the address is not
/// one this capability accepts.
///
/// Domains are case-insensitive; local parts are not, so this deliberately
/// only normalises the half where doing so is safe.
pub fn email_domain(value: &str) -> Option<String> {
    if !is_email(value) {
        return None;
    }
    let chars: Vec<char> = value.chars().collect();
    let at = chars.iter().position(|ch| *ch == '@').unwrap();
    Some(chars[at + 1..].iter().collect::<String>().to_ascii_lowercase())
}

pub fn fune_vector(args: &[Value]) -> Value {
    // A non-string argument arrives here as an empty string, which is exactly
    // the answer TypeScript and Python give for a non-string: not an address.
    Value::Bool(is_email(args[0].as_str()))
}