//! SMTP-level validation utilities. //! //! Address parsing, EHLO hostname validation, and header injection detection. use regex::Regex; use std::sync::LazyLock; /// Regex for basic email address format validation. static EMAIL_RE: LazyLock = LazyLock::new(|| { Regex::new(r"^[^\s@]+@[^\s@]+\.[^\s@]+$").unwrap() }); /// Regex for valid EHLO hostname (domain name or IPv4/IPv6 literal). /// Currently unused in favor of a more permissive check, but available /// for strict validation if needed. #[allow(dead_code)] static EHLO_RE: LazyLock = LazyLock::new(|| { // Permissive: domain names, IP literals [1.2.3.4], [IPv6:...], or bare words Regex::new(r"^(?:\[(?:IPv6:)?[^\]]+\]|[a-zA-Z0-9](?:[a-zA-Z0-9\-\.]*[a-zA-Z0-9])?)$").unwrap() }); /// Validate an email address for basic SMTP format. /// /// Returns `true` if the address has a valid-looking format. /// Empty addresses (for bounce messages, MAIL FROM:<>) return `true`. pub fn is_valid_smtp_address(address: &str) -> bool { // Empty address is valid for MAIL FROM (bounce) if address.is_empty() { return true; } EMAIL_RE.is_match(address) } /// Validate an EHLO/HELO hostname. /// /// Returns `true` if the hostname looks syntactically valid. /// We are permissive because real-world SMTP clients send all kinds of values. pub fn is_valid_ehlo_hostname(hostname: &str) -> bool { if hostname.is_empty() { return false; } // Be permissive — most SMTP servers accept anything non-empty. // Only reject obviously malicious patterns. if hostname.len() > 255 { return false; } if contains_header_injection(hostname) { return false; } // Must not contain null bytes if hostname.contains('\0') { return false; } true } /// Check for SMTP header injection attempts. /// /// Returns `true` if the input contains characters that could be used /// for header injection (bare CR/LF). pub fn contains_header_injection(input: &str) -> bool { input.contains('\r') || input.contains('\n') } /// Validate the size parameter from MAIL FROM. /// /// Returns the parsed size if valid and within the max, or an error message. pub fn validate_size_param(value: &str, max_size: u64) -> Result { let size: u64 = value .parse() .map_err(|_| format!("invalid SIZE value: {value}"))?; if size > max_size { return Err(format!( "message size {size} exceeds maximum {max_size}" )); } Ok(size) } /// Extract the domain part from an email address. pub fn extract_domain(address: &str) -> Option<&str> { if address.is_empty() { return None; } address.rsplit_once('@').map(|(_, domain)| domain) } /// Normalize an email address by lowercasing the domain part. pub fn normalize_address(address: &str) -> String { if address.is_empty() { return String::new(); } match address.rsplit_once('@') { Some((local, domain)) => format!("{local}@{}", domain.to_ascii_lowercase()), None => address.to_string(), } } #[cfg(test)] mod tests { use super::*; #[test] fn test_valid_email() { assert!(is_valid_smtp_address("user@example.com")); assert!(is_valid_smtp_address("user+tag@sub.example.com")); assert!(is_valid_smtp_address("a@b.c")); } #[test] fn test_empty_address_valid() { assert!(is_valid_smtp_address("")); } #[test] fn test_invalid_email() { assert!(!is_valid_smtp_address("no-at-sign")); assert!(!is_valid_smtp_address("@no-local.com")); assert!(!is_valid_smtp_address("user@")); assert!(!is_valid_smtp_address("user@nodot")); assert!(!is_valid_smtp_address("has space@example.com")); } #[test] fn test_valid_ehlo() { assert!(is_valid_ehlo_hostname("mail.example.com")); assert!(is_valid_ehlo_hostname("localhost")); assert!(is_valid_ehlo_hostname("[127.0.0.1]")); assert!(is_valid_ehlo_hostname("[IPv6:::1]")); } #[test] fn test_invalid_ehlo() { assert!(!is_valid_ehlo_hostname("")); assert!(!is_valid_ehlo_hostname("host\r\nname")); assert!(!is_valid_ehlo_hostname(&"a".repeat(256))); } #[test] fn test_header_injection() { assert!(contains_header_injection("test\r\nBcc: evil@evil.com")); assert!(contains_header_injection("test\ninjection")); assert!(contains_header_injection("test\rinjection")); assert!(!contains_header_injection("normal text")); } #[test] fn test_size_param() { assert_eq!(validate_size_param("12345", 1_000_000), Ok(12345)); assert!(validate_size_param("99999999", 1_000).is_err()); assert!(validate_size_param("notanumber", 1_000).is_err()); } #[test] fn test_extract_domain() { assert_eq!(extract_domain("user@example.com"), Some("example.com")); assert_eq!(extract_domain(""), None); assert_eq!(extract_domain("nodomain"), None); } #[test] fn test_normalize_address() { assert_eq!( normalize_address("User@EXAMPLE.COM"), "User@example.com" ); assert_eq!(normalize_address(""), ""); } }