Skip to main content

sofie_values/
email.rs

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/// `Debug` masks it so it stays out of logs.
14#[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        // RFC 5321 counts octets; refused before the lowercase copy.
23        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        // NFC, so `josé` typed composed or decomposed is one address and one account; `İ` folds
31        // to `i` rather than `i` plus a dot above, so `İlker` and `ilker` are one account too. It
32        // is composed first, so `I` plus a dot above folds the same.
33        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    /// Also reads an address stored before marks that draw nothing were refused.
39    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
88/// ZWJ and ZWNJ, kept in names, are refused too: an address is matched byte for byte, so one
89/// that looks the same would be another account.
90fn 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
181/// RFC 5322 `atext` and dots; non-ASCII letters as RFC 6531 allows. Quoted local parts are
182/// refused: no mail provider hands them out.
183fn is_local_character(character: char) -> bool {
184    is_word_character(character) || character == '.' || LOCAL_SYMBOLS.contains(character)
185}
186
187/// Marks such as the dot lowercasing leaves on `i`, a Hindi virama or a Thai tone mark.
188fn 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
201/// A host name label; non-ASCII letters are an internationalized domain before punycode.
202fn 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
210/// Beside letters and digits, what RFC 5322 lets the part before the @ hold.
211const 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}