use super::funejson::Value; // 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` 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 = 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 = 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 { if !is_email(value) { return None; } let chars: Vec = value.chars().collect(); let at = chars.iter().position(|ch| *ch == '@').unwrap(); Some(chars[at + 1..].iter().collect::().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())) }