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