aster_forge_validation/
email.rs

1//! Email validation and normalization helpers.
2//!
3//! The helpers implement Aster's lightweight email contract: trim and lowercase values, reject
4//! obviously malformed addresses, and keep the behavior independent from any account or identity
5//! provider model.
6
7use crate::{Result, ValidationError};
8
9/// Validates a normalized email address using Aster's lightweight email rules.
10///
11/// # Errors
12///
13/// Returns an error when the address exceeds 254 bytes, does not contain exactly one `@`, has an
14/// empty local or domain part, or has no dot in its domain.
15pub fn validate_email(email: &str) -> Result<()> {
16    if email.len() > 254 {
17        return Err(ValidationError::new("email is too long"));
18    }
19    if email.matches('@').count() != 1 {
20        return Err(ValidationError::new("invalid email format"));
21    }
22    let Some((local, domain)) = email.split_once('@') else {
23        return Err(ValidationError::new("invalid email format"));
24    };
25    if local.is_empty() || domain.is_empty() {
26        return Err(ValidationError::new("invalid email format"));
27    }
28    if !domain.contains('.') {
29        return Err(ValidationError::new("invalid email format"));
30    }
31    Ok(())
32}
33
34/// Trims and lowercases an email address, then validates it.
35///
36/// # Errors
37///
38/// Returns an error when the normalized address fails [`validate_email`].
39pub fn normalize_email(email: &str) -> Result<String> {
40    let normalized = email.trim().to_ascii_lowercase();
41    validate_email(&normalized)?;
42    Ok(normalized)
43}
44
45/// Returns the lowercased domain portion of an email address.
46///
47/// # Errors
48///
49/// Returns an error when `email` cannot be normalized as a valid address or has no `@` separator.
50pub fn email_domain(email: &str) -> Result<String> {
51    let normalized = normalize_email(email)?;
52    normalized
53        .rsplit_once('@')
54        .map(|(_, domain)| domain.to_ascii_lowercase())
55        .ok_or_else(|| ValidationError::new("invalid email format"))
56}
57
58#[cfg(test)]
59mod tests {
60    use super::{email_domain, normalize_email, validate_email};
61
62    #[test]
63    fn validate_email_requires_exactly_one_at_separator() {
64        assert!(validate_email("alice@example.com").is_ok());
65        assert!(validate_email("alice@@example.com").is_err());
66        assert!(validate_email("alice@example@com").is_err());
67        assert!(validate_email("alice.example.com").is_err());
68        assert!(validate_email("@example.com").is_err());
69        assert!(validate_email("alice@").is_err());
70    }
71
72    #[test]
73    fn email_helpers_keep_existing_normalization_contract() {
74        assert_eq!(
75            normalize_email(" Alice@Example.COM ").unwrap(),
76            "alice@example.com"
77        );
78        assert_eq!(email_domain("alice@Example.COM").unwrap(), "example.com");
79    }
80
81    #[test]
82    fn validate_email_rejects_missing_domain_dot_and_overlong_values() {
83        assert!(validate_email("alice@example").is_err());
84        assert!(validate_email("alice@.").is_ok());
85
86        let long_local = "a".repeat(245);
87        let too_long = format!("{long_local}@example.com");
88        assert!(too_long.len() > 254);
89        assert!(validate_email(&too_long).is_err());
90    }
91}