1use std::fmt;
2
3use sofie_core::{Error, Result};
4use unicode_normalization::{UnicodeNormalization, is_nfc};
5
6const MAXIMUM_LENGTH: usize = 254;
7const MAXIMUM_LOCAL_LENGTH: usize = 64;
8const MAXIMUM_LABEL_LENGTH: usize = 63;
9
10#[derive(Clone, PartialEq, Eq, Hash)]
12pub struct Email {
13 address: String,
14}
15
16impl Email {
17 pub fn parse(input: &str) -> Result<Self> {
18 let trimmed = input.trim();
19 if trimmed.len() > MAXIMUM_LENGTH {
21 return Err(invalid_email("must be at most 254 bytes"));
22 }
23 Self::from_normalized(trimmed.to_lowercase().nfc().collect())
25 }
26
27 fn from_stored(text: String) -> Result<Self> {
28 let normalized = text.trim().len() == text.len()
29 && text.chars().flat_map(char::to_lowercase).eq(text.chars())
30 && is_nfc(&text);
31 if normalized {
32 return Self::from_normalized(text);
33 }
34 Self::parse(&text)
35 }
36
37 fn from_normalized(address: String) -> Result<Self> {
38 if address.len() > MAXIMUM_LENGTH {
39 return Err(invalid_email("must be at most 254 bytes"));
40 }
41 if address.chars().any(char::is_whitespace) {
42 return Err(invalid_email("must not contain whitespace"));
43 }
44 if address.chars().any(is_invisible) {
45 return Err(invalid_email(
46 "must not contain control or invisible formatting characters",
47 ));
48 }
49 let (local, domain) = address
50 .split_once('@')
51 .ok_or_else(|| invalid_email("must contain an @"))?;
52 validate_parts(local, domain)?;
53 Ok(Self { address })
54 }
55
56 #[must_use]
57 pub fn as_str(&self) -> &str {
58 &self.address
59 }
60}
61
62fn is_invisible(character: char) -> bool {
65 sofie_core::misleads_display(character) || matches!(character, '\u{200C}' | '\u{200D}')
66}
67
68fn validate_parts(local: &str, domain: &str) -> Result<()> {
69 if domain.contains('@') {
70 return Err(invalid_email("must contain a single @"));
71 }
72 if local.is_empty() {
73 return Err(invalid_email("must have a part before the @"));
74 }
75 if local.len() > MAXIMUM_LOCAL_LENGTH {
76 return Err(invalid_email("must have at most 64 bytes before the @"));
77 }
78 if !local.chars().all(is_local_character) {
79 return Err(invalid_email(
80 "must use only letters, digits and !#$%&'*+-/=?^_`{|}~ before the @",
81 ));
82 }
83 if local.split('.').any(str::is_empty) {
84 return Err(invalid_email(
85 "must not start or end the part before the @ with a dot, or repeat a dot in it",
86 ));
87 }
88 if !domain.contains('.') {
89 return Err(invalid_email("must have a domain with a dot after the @"));
90 }
91 if domain.split('.').any(str::is_empty) {
92 return Err(invalid_email(
93 "must not start or end the domain with a dot, or repeat a dot in it",
94 ));
95 }
96 if domain
97 .split('.')
98 .any(|label| label.len() > MAXIMUM_LABEL_LENGTH)
99 {
100 return Err(invalid_email(
101 "must have at most 63 bytes between the dots of the domain",
102 ));
103 }
104 if !domain.split('.').all(is_domain_label) {
105 return Err(invalid_email(
106 "must use only letters, digits and inner hyphens between the dots of the domain",
107 ));
108 }
109 Ok(())
110}
111
112fn is_local_character(character: char) -> bool {
115 character.is_alphanumeric() || "!#$%&'*+-/=?^_`{|}~.".contains(character)
116}
117
118fn is_domain_label(label: &str) -> bool {
120 !label.starts_with('-')
121 && !label.ends_with('-')
122 && label
123 .chars()
124 .all(|character| character.is_alphanumeric() || character == '-')
125}
126
127fn invalid_email(message: &str) -> Error {
128 Error::invalid("email", message)
129}
130
131impl fmt::Display for Email {
132 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
133 formatter.write_str(&self.address)
134 }
135}
136
137impl fmt::Debug for Email {
138 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
139 let (local, domain) = self
140 .address
141 .split_once('@')
142 .expect("a parsed email contains an @");
143 let first = local.chars().next().unwrap_or_default();
144 write!(formatter, "{first}***@{domain}")
145 }
146}
147
148sofie_core::text_value!(Email, address, Email::from_stored);
149
150#[cfg(test)]
151mod tests {
152 use sofie_core::{Cast, RowReader, SqlValue};
153
154 use super::*;
155 use crate::testing::{assert_refused, round_trip};
156
157 #[test]
158 fn accepts_and_normalizes_valid_addresses() {
159 let samples = [
160 ("jane@example.com", "jane@example.com"),
161 (" Jane.Doe@Example.COM \n", "jane.doe@example.com"),
162 ("a+tag@mail.example.org", "a+tag@mail.example.org"),
163 (
164 "o'brien_{x}@sub-domain.example.com",
165 "o'brien_{x}@sub-domain.example.com",
166 ),
167 ("Jürgen@München.de", "jürgen@münchen.de"),
168 ("jose\u{301}@example.com", "jos\u{E9}@example.com"),
169 ];
170 for (input, normalized) in samples {
171 assert_eq!(Email::parse(input).unwrap().as_str(), normalized);
172 }
173 }
174
175 #[test]
176 fn stored_text_is_checked_and_normalized_like_input() {
177 use sofie_core::value::Value;
178 let stored = |text: &str| Email::try_from_inner(text.to_owned()).map(|email| email.address);
179
180 assert_eq!(
181 stored("jane@example.com").ok().as_deref(),
182 Some("jane@example.com")
183 );
184 assert_eq!(
185 stored(" Jane@Example.COM").ok().as_deref(),
186 Some("jane@example.com")
187 );
188 assert_eq!(
189 stored("jose\u{301}@example.com").ok().as_deref(),
190 Some("jos\u{E9}@example.com")
191 );
192 assert!(stored("jane").is_err());
193 }
194
195 #[test]
196 fn rejects_invalid_addresses() {
197 let too_long = format!("jane@{}com", "a.".repeat(125));
198 let too_many_bytes = format!("jane@{}com", "é.".repeat(83));
199 let long_local = format!("{}@example.com", "a".repeat(65));
200 let samples = [
201 "",
202 "jane.example.com",
203 "jane@doe@example.com",
204 "@example.com",
205 "jane@",
206 "jane@localhost",
207 "jane doe@example.com",
208 "jane@exa\tmple.com",
209 too_long.as_str(),
210 too_many_bytes.as_str(),
211 "jane\u{202E}moc.elpmaxe@example.com",
212 "jane\u{0}@example.com",
213 "ja\u{00AD}ne@example.com",
214 "ja\u{200D}ne@example.com",
215 "ja\u{200C}ne@example.com",
216 "jane@exam\u{E0067}ple.com",
217 long_local.as_str(),
218 ".jane@example.com",
219 "jane.@example.com",
220 "ja..ne@example.com",
221 "jane@.example.com",
222 "jane@example..com",
223 "jane@example.com.",
224 "jane@exa<mple>.com",
225 "a@b.com,c.d",
226 "a@b.c;d",
227 "x@-evil-.com",
228 "x@evil-.com",
229 "jane@exa_mple.com",
230 "ja<ne>@example.com",
231 "\"jane\"@example.com",
232 "ja,ne@example.com",
233 "ja\\ne@example.com",
234 ];
235 assert_refused(Email::parse, &samples);
236 let longest_local = format!("{}@example.com", "a".repeat(64));
237 assert!(Email::parse(&longest_local).is_ok());
238 }
239
240 #[test]
241 fn refuses_a_domain_label_over_63_bytes() {
242 let longest = format!("jane@{}.com", "a".repeat(63));
243 let too_long = format!("jane@{}.com", "a".repeat(64));
244
245 assert!(Email::parse(&longest).is_ok());
246 assert!(matches!(Email::parse(&too_long), Err(Error::Invalid(_))));
247 }
248
249 #[test]
250 fn debug_masks_the_local_part() {
251 let email = Email::parse("jane@example.com").unwrap();
252
253 assert_eq!(format!("{email:?}"), "j***@example.com");
254 assert_eq!(email.to_string(), "jane@example.com");
255 }
256
257 #[test]
258 fn round_trips_through_one_text_column() {
259 let email = Email::parse("jane@example.com").unwrap();
260 assert_eq!(
261 round_trip(&email),
262 vec![SqlValue::Text("jane@example.com".into())]
263 );
264 }
265
266 #[test]
267 fn an_invalid_stored_address_is_an_internal_error() {
268 let values = [SqlValue::Text("jane".into())];
269 let mut row = RowReader::new(&values);
270
271 assert!(matches!(
272 Email::decode(&mut row),
273 Err(Error::Internal { .. })
274 ));
275 }
276
277 #[test]
278 fn round_trips_through_json_as_a_string() {
279 let email = Email::parse("jane@example.com").unwrap();
280 let json = serde_json::to_string(&email).unwrap();
281
282 assert_eq!(json, "\"jane@example.com\"");
283 assert_eq!(serde_json::from_str::<Email>(&json).unwrap(), email);
284 }
285
286 #[test]
287 fn json_deserialization_validates_and_normalizes() {
288 let email: Email = serde_json::from_str("\"Jane@Example.com\"").unwrap();
289
290 assert_eq!(email.as_str(), "jane@example.com");
291 assert!(serde_json::from_str::<Email>("\"jane\"").is_err());
292 }
293}