Skip to main content

codoseo_web/auth/
email.rs

1//! Email addresses: a light validity check, and the canonical form used to find an account.
2//!
3//! Review focus 5: one person using variants (`Ana@Gmail.com`, `a.na+seo@gmail.com`) to get
4//! extra free accounts. The canonical form lowercases the address, drops a `+tag` on every
5//! domain, and for Gmail also removes dots and maps `googlemail.com` to `gmail.com`.
6
7/// Trims and checks that `raw` looks like `local@domain.tld`. Returns the trimmed address.
8pub fn parse(raw: &str) -> Option<&str> {
9    let email = raw.trim();
10    if email.len() > 254 || email.chars().any(|c| c.is_whitespace() || c.is_control()) {
11        return None;
12    }
13    let (local, domain) = email.rsplit_once('@')?;
14    let domain_ok = domain.contains('.')
15        && !domain.starts_with('.')
16        && !domain.ends_with('.')
17        && !domain.contains("..")
18        && domain
19            .chars()
20            .all(|c| c.is_alphanumeric() || c == '.' || c == '-');
21    (!local.is_empty() && !local.contains('@') && domain_ok).then_some(email)
22}
23
24/// The account lookup key for an address already accepted by [`parse`].
25pub fn canonical(email: &str) -> String {
26    let lower = email.trim().to_lowercase();
27    let Some((local, domain)) = lower.rsplit_once('@') else {
28        return lower;
29    };
30    let local = local.split('+').next().unwrap_or(local);
31    let (local, domain) = match domain {
32        "gmail.com" | "googlemail.com" => (local.replace('.', ""), "gmail.com"),
33        _ => (local.to_owned(), domain),
34    };
35    format!("{local}@{domain}")
36}
37
38#[cfg(test)]
39mod tests {
40    use super::*;
41
42    #[test]
43    fn gmail_variants_share_one_canonical_form() {
44        let a = canonical("Ana@Gmail.com");
45        assert_eq!(a, "ana@gmail.com");
46        assert_eq!(canonical("a.na+seo@gmail.com"), a);
47        assert_eq!(canonical("A.N.A@googlemail.com"), a);
48    }
49
50    #[test]
51    fn other_domains_keep_dots_but_drop_tags() {
52        assert_eq!(
53            canonical("First.Last+x@Example.com"),
54            "first.last@example.com"
55        );
56        assert_ne!(canonical("a.na@example.com"), canonical("ana@example.com"));
57    }
58
59    #[test]
60    fn parse_accepts_and_rejects() {
61        assert_eq!(parse("  ana@example.com "), Some("ana@example.com"));
62        for bad in [
63            "",
64            "ana",
65            "ana@",
66            "@example.com",
67            "ana@example",
68            "a b@example.com",
69            "ana@ex..com",
70        ] {
71            assert_eq!(parse(bad), None, "{bad:?}");
72        }
73    }
74}