email_validator/
lib.rs

1#![cfg_attr(not(feature = "std"), no_std)]
2
3/// Checks the syntax of an email to see if it is valid.
4pub fn validate_email(email: &str) -> bool {
5    match validate_local(email) {
6        Some(domain_start) => validate_domain(&email[domain_start..]),
7        None => false,
8    }
9}
10
11/// Checks if a character can normally appear in local portion of the email.
12///
13/// Note: Does not include: '.'
14fn is_valid_non_escaped(c: char) -> bool {
15    match c {
16        '!' | '#' | '$' | '%' | '&' | '\'' | '*' | '+' | '-' | '/' | '=' | '?' | '^' | '_'
17        | '`' | '{' | '|' | '}' | '~' => true,
18        _ => c.is_alphanumeric(),
19    }
20}
21
22/// Checks if a character is valid within a quote.
23///
24/// If this is false, the character must be escaped.
25fn is_valid_quoted(c: char) -> bool {
26    match c {
27        ' ' | '@' | ',' | '[' | ']' | '.' => true,
28        _ => is_valid_non_escaped(c),
29    }
30}
31
32/// Checks if an escaped character is valid within a quote.
33fn is_valid_quoted_escape(c: char) -> bool {
34    match c {
35        '\\' | '\"' => true,
36        _ => false,
37    }
38}
39
40/// Checks if a non-quoted escaped character is valid.
41fn is_valid_escape(c: char) -> bool {
42    match c {
43        ' ' | '@' | '\\' | '\"' | ',' | '[' | ']' => true,
44        _ => false,
45    }
46}
47
48/// The current state when validating the local portion.
49#[derive(Eq, PartialEq, Debug)]
50enum LocalState {
51    /// No characters have been validated yet.
52    Start,
53
54    /// Nothing interesting has happened.
55    Normal,
56
57    /// The previous character was a period ('.').
58    NormalPeriod,
59
60    /// The previous character was a backslash ('\'), which escapes the next character.
61    Escaped,
62
63    /// Nothing interesting, except we are in a quote.
64    QuotedNormal,
65
66    /// We are in a quote where the previous character was a backslash ('\'), which escapes the next character.
67    QuotedEscaped,
68
69    /// A quote just ended, meaning the previous character was a double quote ('"').
70    QuotedEnd,
71
72    /// The local portion has ended, meaning an at sign ('@') was found.
73    End,
74}
75
76impl LocalState {
77    /// Returns the next state if the character is valid.
78    fn transition(self, c: char) -> Option<Self> {
79        match self {
80            LocalState::Start => {
81                // // Is the character normally valid in the local portion?
82                // Periods are excluded by this function.
83                if is_valid_non_escaped(c) {
84                    return Some(LocalState::Normal);
85                }
86
87                // Is there an escaped character?
88                if c == '\\' {
89                    return Some(LocalState::Escaped);
90                }
91
92                // Did a quote begin?
93                if c == '\"' {
94                    return Some(LocalState::QuotedNormal);
95                }
96
97                // Nothing else is valid.
98                None
99            }
100            LocalState::Normal => {
101                // Is the character normally valid in the local portion?
102                if is_valid_non_escaped(c) {
103                    return Some(LocalState::Normal);
104                }
105
106                // Is the character a period?
107                if c == '.' {
108                    return Some(LocalState::NormalPeriod);
109                }
110
111                // Did the local portion end?
112                if c == '@' {
113                    return Some(LocalState::End);
114                }
115
116                // Is there an escaped character?
117                if c == '\\' {
118                    return Some(LocalState::Escaped);
119                }
120
121                // Nothing else is valid.
122                None
123            }
124            LocalState::NormalPeriod => {
125                // Is the character normally valid in the local portion?
126                if is_valid_non_escaped(c) {
127                    return Some(LocalState::Normal);
128                }
129
130                // Is there an escaped character?
131                if c == '\\' {
132                    return Some(LocalState::Escaped);
133                }
134
135                // At signs ('@') are not valid after a period.
136
137                // Nothing else is valid.
138                None
139            }
140            LocalState::Escaped => {
141                // Is the escaped character valid?
142                if is_valid_escape(c) {
143                    return Some(LocalState::Normal);
144                }
145
146                // Nothing else can be accepted.
147                None
148            }
149            LocalState::QuotedNormal => {
150                // Is this character normally valid in a quote?
151                if is_valid_quoted(c) {
152                    return Some(LocalState::QuotedNormal);
153                }
154
155                // Did the quote end?
156                if c == '\"' {
157                    return Some(LocalState::QuotedEnd);
158                }
159
160                // Is something escaped?
161                if c == '\\' {
162                    return Some(LocalState::QuotedEscaped);
163                }
164
165                // Nothing else is valid.
166                None
167            }
168            LocalState::QuotedEscaped => {
169                // Is the escaped character valid?
170                if is_valid_quoted_escape(c) {
171                    return Some(LocalState::QuotedNormal);
172                }
173
174                // Nothing else can be accepted.
175                None
176            }
177            LocalState::QuotedEnd => {
178                // Did the local portion end?
179                if c == '@' {
180                    return Some(LocalState::End);
181                }
182
183                // Nothing else is allowed to appear after a quote ends.
184                None
185            }
186
187            // Nothing (in the local portion) can appear after the local portion ends.
188            LocalState::End => None,
189        }
190    }
191}
192
193/// Validates the local portion of an email.
194fn validate_local(email: &str) -> Option<usize> {
195    let mut state = LocalState::Start;
196    for (i, c) in email.char_indices() {
197        // Check if the local portion has ended.
198        if state == LocalState::End {
199            // Make sure the local portion is not too long.
200            // Subtract one for the at sign ('@').
201            if (i - 1) > 64 {
202                return None;
203            }
204            return Some(i);
205        }
206
207        // Attempt to transition to the next state.
208        match state.transition(c) {
209            None => return None,
210            Some(new_state) => state = new_state,
211        }
212    }
213
214    // We never hit the end state, so the local portion is invalid.
215    None
216}
217
218/// The current state when validating the domain portion.
219#[derive(Eq, PartialEq, Debug)]
220enum DomainState {
221    /// No characters have been validated yet.
222    Start,
223
224    /// Nothing interesting has happened.
225    Normal,
226
227    /// A dash ('-') was the previous character.
228    Dash,
229
230    /// A period ('.') was the previous character.
231    StartDotted,
232
233    /// Nothing interesting has happened since the DNS dot was found.
234    ///
235    /// The domain is currently valid.
236    NormalDotted,
237
238    /// A dash ('-') was the previous character and the DNS dot was found.
239    DashDotted,
240}
241
242impl DomainState {
243    /// Returns the next state if the character is valid.
244    fn transition(self, c: char) -> Option<Self> {
245        match self {
246            DomainState::Start => {
247                // Is the character a letter or number?
248                if c.is_ascii_alphanumeric() {
249                    return Some(DomainState::Normal);
250                }
251
252                // Nothing else is valid.
253                None
254            }
255            DomainState::Normal => {
256                // Is the character a letter or number?
257                if c.is_ascii_alphanumeric() {
258                    return Some(DomainState::Normal);
259                }
260
261                // Is the character a dash ('-')?
262                if c == '-' {
263                    return Some(DomainState::Dash);
264                }
265
266                // Is the character a period ('.')?
267                if c == '.' {
268                    return Some(DomainState::StartDotted);
269                }
270
271                // Nothing else is valid.
272                None
273            }
274            DomainState::Dash => {
275                // Is the character a letter or number?
276                if c.is_ascii_alphanumeric() {
277                    return Some(DomainState::Normal);
278                }
279
280                // Is the character a dash ('-')?
281                if c == '-' {
282                    return Some(DomainState::Dash);
283                }
284
285                // Nothing else is valid.
286                None
287            }
288            DomainState::StartDotted => {
289                // Is the character a letter or number?
290                if c.is_ascii_alphanumeric() {
291                    return Some(DomainState::NormalDotted);
292                }
293
294                // Nothing else is valid.
295                None
296            }
297            DomainState::NormalDotted => {
298                // Is the character a letter or number?
299                if c.is_ascii_alphanumeric() {
300                    return Some(DomainState::NormalDotted);
301                }
302
303                // Is the character a dash ('-')?
304                if c == '-' {
305                    return Some(DomainState::DashDotted);
306                }
307
308                // Is the character a period ('.')?
309                if c == '.' {
310                    return Some(DomainState::StartDotted);
311                }
312
313                // Nothing else is valid.
314                None
315            }
316            DomainState::DashDotted => {
317                // Is the character a letter or number?
318                if c.is_ascii_alphanumeric() {
319                    return Some(DomainState::NormalDotted);
320                }
321
322                // Is the character a dash ('-')?
323                if c == '-' {
324                    return Some(DomainState::DashDotted);
325                }
326
327                // Nothing else is valid.
328                None
329            }
330        }
331    }
332}
333
334/// Validates the domain portion of an email.
335fn validate_domain(domain: &str) -> bool {
336    // Make sure the domain is not too long.
337    if domain.len() > 255 {
338        return false;
339    }
340
341    let mut state = DomainState::Start;
342    for c in domain.chars() {
343        // Attempt to transition to the next state.
344        match state.transition(c) {
345            None => return false,
346            Some(new_state) => state = new_state,
347        }
348    }
349
350    // The domain has been parsed and the last portion is in a good state.
351    state == DomainState::NormalDotted
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357
358    /// This email should be valid.
359    fn check(str: &str) {
360        assert!(validate_email(&str));
361    }
362
363    /// This email should be invalid.
364    fn x(str: &str) {
365        assert!(!validate_email(&str));
366    }
367
368    #[test]
369    fn normal_email() {
370        check("normalemail@example.com");
371    }
372
373    #[test]
374    fn normal_plus() {
375        check("user+mailbox@example.com");
376    }
377
378    #[test]
379    fn normal_slash_eq() {
380        check("customer/department=shipping@example.com");
381    }
382
383    #[test]
384    fn normal_dollar() {
385        check("$A12345@example.com");
386    }
387
388    #[test]
389    fn normal_exclamation_percent() {
390        check("!def!xyz%abc@example.com");
391    }
392
393    #[test]
394    fn normal_underscore() {
395        check("_somename@example.com");
396    }
397
398    #[test]
399    fn normal_apostrophe_acute_accent() {
400        check("lol`'lol'@example.com");
401    }
402
403    #[test]
404    fn normal_crazy_symbols() {
405        check("!#$%&'*+-/=?^_`{|}~@example.com");
406    }
407
408    #[test]
409    fn normal_dot() {
410        check("a.name@example.com");
411    }
412
413    #[test]
414    fn escaped_at() {
415        check("Abc\\@def@example.com");
416    }
417
418    #[test]
419    fn escaped_space() {
420        check("Fred\\ Bloggs@example.com");
421    }
422
423    #[test]
424    fn escaped_backslash() {
425        check("Joe.\\\\Blow@example.com");
426    }
427
428    #[test]
429    fn all_escaped() {
430        check("\\\\\\ \\\"\\,\\[\\]@example.com");
431    }
432
433    #[test]
434    fn quoted_at() {
435        check("\"Abc@def\"@example.com");
436    }
437
438    #[test]
439    fn quoted_space() {
440        check("\"Fred Bloggs\"@example.com");
441    }
442
443    #[test]
444    fn all_quoted() {
445        check("\"this is..quoted [te,xt]\"@example.com");
446    }
447
448    #[test]
449    fn all_escaped_quoted() {
450        check("\"\\\\\\\"\"@example.com");
451    }
452
453    #[test]
454    fn almost_too_long_local() {
455        check("thisisnotaslonglocalportionofanemailaddressthatshouldberejected1@example.com");
456    }
457
458    #[test]
459    fn subdomains() {
460        check("example@sub.domain.com");
461    }
462
463    #[test]
464    fn domain_single_dash() {
465        check("example@domain-x.com");
466    }
467
468    #[test]
469    fn domain_multi_dash() {
470        check("example@domain--x.com");
471    }
472
473    #[test]
474    fn almost_long_domain() {
475        check(
476            "example@thisisalongdomainnamethatshouldberejectedifihaveimplementedthelogiccorrectlyandwillnowrepeattisisalongdomainnamethatshouldberejectedifihaveimplementedthelogiccorrectlywowwhyoneartharethismanycharactersallowedmypooridecannotrenderinonescreenalmostdone1.com",
477        );
478    }
479
480    #[test]
481    fn almost_long_email() {
482        check(
483            "thisisnotaslonglocalportionofanemailaddressthatshouldberejected1@thisisalongdomainnamethatshouldberejectedifihaveimplementedthelogiccorrectlyandwillnowrepeattisisalongdomainnamethatshouldberejectedifihaveimplementedthelogiccorrectlywowwhyoneartharethismanycharactersallowedmypooridecannotrenderinonescreenalmostdone1.com",
484        );
485    }
486
487    // Bad
488
489    #[test]
490    fn start_dot() {
491        x(".example@example.com");
492    }
493
494    #[test]
495    fn double_dot() {
496        x("example..name@example.com");
497    }
498
499    #[test]
500    fn end_dot() {
501        x("example.@example.com");
502    }
503
504    #[test]
505    fn empty_local() {
506        x("@example.com");
507    }
508
509    #[test]
510    fn no_domain() {
511        x("myname");
512    }
513
514    #[test]
515    fn unescaped_quote() {
516        x("my\"name@example.com");
517    }
518
519    #[test]
520    fn things_after_quote() {
521        x("\"quoted\"abc@example.com");
522    }
523
524    #[test]
525    fn too_long_local() {
526        x("thisisasuperlonglocalportionofanemailaddressthatshouldberejected1@example.com");
527    }
528
529    #[test]
530    fn domain_start_dot() {
531        x("example@.domain.com");
532    }
533
534    #[test]
535    fn domain_end_dot() {
536        x("example@domain.com.");
537    }
538
539    #[test]
540    fn domain_with_double_dot() {
541        x("example@domain..com");
542    }
543
544    #[test]
545    fn domain_start_dash() {
546        x("example@-domain.com");
547    }
548
549    #[test]
550    fn domain_end_dash() {
551        x("example@domain-.com");
552    }
553
554    #[test]
555    fn tld_end_dash() {
556        x("example@domain.com-");
557    }
558
559    #[test]
560    fn domain_without_tld() {
561        x("example@domain");
562    }
563
564    #[test]
565    fn domain_with_only_tld() {
566        x("example@.com");
567    }
568
569    #[test]
570    fn domain_with_space() {
571        x("example@example .com");
572    }
573
574    #[test]
575    fn long_domain() {
576        x(
577            "example@thisisalongdomainnamethatshouldberejectedifihaveimplementedthelogiccorrectlyandwillnowrepeatthisisalongdomainnamethatshouldberejectedifihaveimplementedthelogiccorrectlywowwhyoneartharethismanycharactersallowedmypooridecannotrenderinonescreenalmostdone1.com",
578        );
579    }
580}