validator/validation/
email.rs

1use idna::domain_to_ascii;
2use once_cell::sync::Lazy;
3use regex::Regex;
4use std::borrow::Cow;
5
6use crate::ValidateIp;
7
8// Regex from the specs
9// https://html.spec.whatwg.org/multipage/forms.html#valid-e-mail-address
10// It will mark esoteric email addresses like quoted string as invalid
11static EMAIL_USER_RE: Lazy<Regex> =
12    Lazy::new(|| Regex::new(r"^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+\z").unwrap());
13static EMAIL_DOMAIN_RE: Lazy<Regex> = Lazy::new(|| {
14    Regex::new(
15        r"^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$"
16    ).unwrap()
17});
18// literal form, ipv4 or ipv6 address (SMTP 4.1.3)
19static EMAIL_LITERAL_RE: Lazy<Regex> =
20    Lazy::new(|| Regex::new(r"\[([a-fA-F0-9:\.]+)\]\z").unwrap());
21
22/// Checks if the domain is a valid domain and if not, check whether it's an IP
23#[must_use]
24fn validate_domain_part(domain_part: &str) -> bool {
25    if EMAIL_DOMAIN_RE.is_match(domain_part) {
26        return true;
27    }
28
29    // maybe we have an ip as a domain?
30    match EMAIL_LITERAL_RE.captures(domain_part) {
31        Some(caps) => match caps.get(1) {
32            Some(c) => c.as_str().validate_ip(),
33            None => false,
34        },
35        None => false,
36    }
37}
38
39/// Validates whether the given string is an email based on the [HTML5 spec](https://html.spec.whatwg.org/multipage/forms.html#valid-e-mail-address).
40/// [RFC 5322](https://tools.ietf.org/html/rfc5322) is not practical in most circumstances and allows email addresses
41/// that are unfamiliar to most users.
42pub trait ValidateEmail {
43    fn validate_email(&self) -> bool {
44        let val = if let Some(v) = self.as_email_string() {
45            v
46        } else {
47            return true;
48        };
49
50        if val.is_empty() || !val.contains('@') {
51            return false;
52        }
53
54        let parts: Vec<&str> = val.rsplitn(2, '@').collect();
55        let user_part = parts[1];
56        let domain_part = parts[0];
57
58        // validate the length of each part of the email, BEFORE doing the regex
59        // according to RFC5321 the max length of the local part is 64 characters
60        // and the max length of the domain part is 255 characters
61        // https://datatracker.ietf.org/doc/html/rfc5321#section-4.5.3.1.1
62        if user_part.chars().count() > 64 || domain_part.chars().count() > 255 {
63            return false;
64        }
65
66        if !EMAIL_USER_RE.is_match(user_part) {
67            return false;
68        }
69
70        if !validate_domain_part(domain_part) {
71            // Still the possibility of an [IDN](https://en.wikipedia.org/wiki/Internationalized_domain_name)
72            return match domain_to_ascii(domain_part) {
73                Ok(d) => validate_domain_part(&d),
74                Err(_) => false,
75            };
76        }
77
78        true
79    }
80
81    fn as_email_string(&self) -> Option<Cow<str>>;
82}
83
84impl<T> ValidateEmail for &T
85where
86    T: ValidateEmail,
87{
88    fn as_email_string(&self) -> Option<Cow<str>> {
89        T::as_email_string(self)
90    }
91}
92
93impl ValidateEmail for String {
94    fn as_email_string(&self) -> Option<Cow<str>> {
95        Some(Cow::from(self))
96    }
97}
98
99impl<T> ValidateEmail for Option<T>
100where
101    T: ValidateEmail,
102{
103    fn as_email_string(&self) -> Option<Cow<str>> {
104        let Some(u) = self else {
105            return None;
106        };
107
108        T::as_email_string(u)
109    }
110}
111
112impl<'a> ValidateEmail for &'a str {
113    fn as_email_string(&self) -> Option<Cow<'_, str>> {
114        Some(Cow::from(*self))
115    }
116}
117
118impl ValidateEmail for Cow<'_, str> {
119    fn as_email_string(&self) -> Option<Cow<'_, str>> {
120        Some(self.clone())
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use std::borrow::Cow;
127
128    use crate::ValidateEmail;
129
130    #[test]
131    fn test_validate_email() {
132        // Test cases taken from Django
133        // https://github.com/django/django/blob/master/tests/validators/tests.py#L48
134        let tests = vec![
135            ("email@here.com", true),
136            ("weirder-email@here.and.there.com", true),
137            (r#"!def!xyz%abc@example.com"#, true),
138            ("email@[127.0.0.1]", true),
139            ("email@[2001:dB8::1]", true),
140            ("email@[2001:dB8:0:0:0:0:0:1]", true),
141            ("email@[::fffF:127.0.0.1]", true),
142            ("example@valid-----hyphens.com", true),
143            ("example@valid-with-hyphens.com", true),
144            ("test@domain.with.idn.tld.उदाहरण.परीक्षा", true),
145            (r#""test@test"@example.com"#, false),
146            // max length for domain name labels is 63 characters per RFC 1034
147            ("a@atm.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", true),
148            ("a@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.atm", true),
149            (
150                "a@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.bbbbbbbbbb.atm",
151                true,
152            ),
153            // 64 * a
154            ("a@atm.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", false),
155            ("", false),
156            ("abc", false),
157            ("abc@", false),
158            ("abc@bar", true),
159            ("a @x.cz", false),
160            ("abc@.com", false),
161            ("something@@somewhere.com", false),
162            ("email@127.0.0.1", true),
163            ("email@[127.0.0.256]", false),
164            ("email@[2001:db8::12345]", false),
165            ("email@[2001:db8:0:0:0:0:1]", false),
166            ("email@[::ffff:127.0.0.256]", false),
167            ("example@invalid-.com", false),
168            ("example@-invalid.com", false),
169            ("example@invalid.com-", false),
170            ("example@inv-.alid-.com", false),
171            ("example@inv-.-alid.com", false),
172            (r#"test@example.com\n\n<script src="x.js">"#, false),
173            (r#""\\\011"@here.com"#, false),
174            (r#""\\\012"@here.com"#, false),
175            ("trailingdot@shouldfail.com.", false),
176            // Trailing newlines in username or domain not allowed
177            ("a@b.com\n", false),
178            ("a\n@b.com", false),
179            (r#""test@test"\n@example.com"#, false),
180            ("a@[127.0.0.1]\n", false),
181            // underscores are not allowed
182            ("John.Doe@exam_ple.com", false),
183        ];
184
185        for (input, expected) in tests {
186            // println!("{} - {}", input, expected);
187            assert_eq!(
188                input.validate_email(),
189                expected,
190                "Email `{}` was not classified correctly",
191                input
192            );
193        }
194    }
195
196    #[test]
197    fn test_validate_email_cow() {
198        let test: Cow<'static, str> = "email@here.com".into();
199        assert!(test.validate_email());
200        let test: Cow<'static, str> = String::from("email@here.com").into();
201        assert!(test.validate_email());
202        let test: Cow<'static, str> = "a@[127.0.0.1]\n".into();
203        assert!(!test.validate_email());
204        let test: Cow<'static, str> = String::from("a@[127.0.0.1]\n").into();
205        assert!(!test.validate_email());
206    }
207
208    #[test]
209    fn test_validate_email_rfc5321() {
210        // 65 character local part
211        let test = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa@mail.com";
212        assert_eq!(test.validate_email(), false);
213        // 256 character domain part
214        let test = "a@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com";
215        assert_eq!(test.validate_email(), false);
216    }
217}