1use crate::problem::Problem;
9use crate::problems::SLUGS;
10
11pub const MAX_EMAIL_BYTES: usize = 254;
13
14pub const MAX_LOCAL_BYTES: usize = 64;
16
17#[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#[must_use]
28pub fn is_valid(email: &str) -> bool {
29 validation_error(email).is_none()
30}
31
32#[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#[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 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}