Skip to main content

cratefield_core/
email.rs

1//! Email normalisation and validation shared by every module that stores an
2//! address (issue #10: normalisation belongs in core, never module-to-module).
3//!
4//! [`normalize`] is trim, Unicode NFC, then lowercase; [`validate`] is a
5//! deliberately conservative RFC-ish check with a 254-byte cap — it rejects
6//! exotic-but-legal addresses rather than trying to accept every valid one.
7
8use crate::problem::Problem;
9use crate::problems::SLUGS;
10
11/// The maximum accepted address length in bytes (RFC 5321 "forward-path").
12pub const MAX_EMAIL_BYTES: usize = 254;
13
14/// The maximum local-part length in bytes (RFC 5321).
15pub const MAX_LOCAL_BYTES: usize = 64;
16
17/// Trim, Unicode NFC normalise, then lowercase. Idempotent.
18#[must_use]
19pub fn normalize(email: &str) -> String {
20    use unicode_normalization::UnicodeNormalization;
21    email.trim().nfc().flat_map(char::to_lowercase).collect()
22}
23
24/// A conservative validator: non-empty, one `@`, sane lengths, an ASCII
25/// local part from the unreserved set, and a dot-separated alphanumeric
26/// domain with no empty or hyphen-edge labels.
27#[must_use]
28pub fn is_valid(email: &str) -> bool {
29    validation_error(email).is_none()
30}
31
32/// The reason an address is rejected, for problem `detail`s.
33#[must_use]
34pub fn validation_error(email: &str) -> Option<&'static str> {
35    if email.len() > MAX_EMAIL_BYTES {
36        return Some("email is longer than 254 bytes");
37    }
38    let Some((local, domain)) = email.split_once('@') else {
39        return Some("email must contain exactly one @");
40    };
41    if email.matches('@').count() != 1 {
42        return Some("email must contain exactly one @");
43    }
44    if local.is_empty() {
45        return Some("email local part is empty");
46    }
47    if local.len() > MAX_LOCAL_BYTES {
48        return Some("email local part is longer than 64 bytes");
49    }
50    if !local
51        .bytes()
52        .all(|b| b.is_ascii_alphanumeric() || b"!#$%&'*+-/=?^_`{|}~.".contains(&b))
53    {
54        return Some("email local part contains unsupported characters");
55    }
56    if local.starts_with('.') || local.ends_with('.') || local.contains("..") {
57        return Some("email local part has a misplaced dot");
58    }
59    if domain.is_empty() {
60        return Some("email domain is empty");
61    }
62    if !domain
63        .bytes()
64        .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'.')
65    {
66        return Some("email domain contains unsupported characters");
67    }
68    if !domain.contains('.') {
69        return Some("email domain must contain at least one dot");
70    }
71    for label in domain.split('.') {
72        if label.is_empty() {
73            return Some("email domain has an empty label");
74        }
75        if label.starts_with('-') || label.ends_with('-') {
76            return Some("email domain label starts or ends with a hyphen");
77        }
78    }
79    None
80}
81
82/// A `400 validation-failed` problem for a rejected address.
83#[must_use]
84pub fn invalid_email_problem(reason: &str) -> Problem {
85    Problem::new(&SLUGS.validation_failed).with_detail(format!("email: {reason}"))
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91
92    #[test]
93    fn normalize_trims_folds_and_nfcs() {
94        assert_eq!(normalize("  Nick@Example.COM "), "nick@example.com");
95        // "é" as e + combining accent normalises to the single code point.
96        assert_eq!(normalize("cafe\u{301}@example.com"), "café@example.com");
97        assert_eq!(normalize(&normalize(" A@B.CO ")), "a@b.co");
98    }
99
100    #[test]
101    fn accepts_plain_addresses() {
102        for good in [
103            "nick@example.com",
104            "first.last+tag@sub.example.co",
105            "a@b.cd",
106            "o'brien@example.com",
107        ] {
108            assert_eq!(validation_error(good), None, "{good} must be valid");
109        }
110    }
111
112    #[test]
113    fn rejects_the_obvious() {
114        for bad in [
115            "",
116            "no-at-sign",
117            "two@ats@here",
118            "@nodomain.com",
119            "nolocal@",
120            "no@dot",
121            "no@empty..label",
122            ".lead.dot@example.com",
123            "trail.dot.@example.com",
124            "two..dots@example.com",
125            "sp ace@example.com",
126            "ünïcode@example.com",
127        ] {
128            assert!(validation_error(bad).is_some(), "{bad:?} must be rejected");
129        }
130    }
131
132    #[test]
133    fn rejects_over_long_addresses() {
134        let local = "a".repeat(65);
135        assert!(validation_error(&format!("{local}@example.com")).is_some());
136        let long = format!("{}@{}", "a".repeat(64), "b".repeat(240));
137        assert!(validation_error(&long).is_some());
138    }
139}