Skip to main content

sofie_values/
email.rs

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/// `Debug` masks it so it stays out of logs.
13#[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        // RFC 5321 counts octets; refused before the lowercase copy.
22        if trimmed.len() > MAXIMUM_LENGTH {
23            return Err(invalid_email("must be at most 254 bytes"));
24        }
25        // NFC, so `josé` typed composed or decomposed is one address and one account; `İ` folds
26        // to `i` rather than `i` plus a dot above, so `İlker` and `ilker` are one account too.
27        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
66/// ZWJ and ZWNJ, kept in names, are refused too: an address is matched byte for byte, so one
67/// that looks the same would be another account.
68fn 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
126/// RFC 5322 `atext` and dots; non-ASCII letters as RFC 6531 allows. Quoted local parts are
127/// refused: no mail provider hands them out.
128fn is_local_character(character: char) -> bool {
129    is_word_character(character) || "!#$%&'*+-/=?^_`{|}~.".contains(character)
130}
131
132/// Marks such as the dot lowercasing leaves on `i`, a Hindi virama or a Thai tone mark.
133fn 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
146/// A host name label; non-ASCII letters are an internationalized domain before punycode.
147fn 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}