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