Skip to main content

sofie_values/
email.rs

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/// `Debug` masks it so it stays out of logs.
11#[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        // RFC 5321 counts octets; refused before the lowercase copy.
20        if trimmed.len() > MAXIMUM_LENGTH {
21            return Err(invalid_email("must be at most 254 bytes"));
22        }
23        // NFC, so `josé` typed composed or decomposed is one address and one account.
24        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
62/// ZWJ and ZWNJ, kept in names, are refused too: an address is matched byte for byte, so one
63/// that looks the same would be another account.
64fn 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
112/// RFC 5322 `atext` and dots; non-ASCII letters as RFC 6531 allows. Quoted local parts are
113/// refused: no mail provider hands them out.
114fn is_local_character(character: char) -> bool {
115    character.is_alphanumeric() || "!#$%&'*+-/=?^_`{|}~.".contains(character)
116}
117
118/// A host name label; non-ASCII letters are an internationalized domain before punycode.
119fn 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}