Skip to main content

email_validator_rfc5322/
lib.rs

1//! RFC 5322 compliant email address validator
2//!
3//! This crate provides email validation according to RFC 5322 Section 3.4.1
4//! with support for:
5//! - Standard email addresses (`user@example.com`)
6//! - Quoted local parts (`"john doe"@example.com`)
7//! - IP literal domains (`user@[192.168.1.1]`, `user@[IPv6:2001:db8::1]`)
8//! - All RFC 5322 special characters in local parts
9//!
10//! # Examples
11//!
12//! ```
13//! use email_validator_rfc5322::{validate_email, is_valid_email};
14//!
15//! // Simple validation
16//! assert!(is_valid_email("user@example.com"));
17//! assert!(!is_valid_email("invalid"));
18//!
19//! // With error details
20//! match validate_email("user@example.com") {
21//!     Ok(()) => println!("Valid email"),
22//!     Err(e) => println!("Invalid: {:?}", e),
23//! }
24//! ```
25//!
26//! # RFC Compliance
27//!
28//! This implementation follows:
29//! - RFC 5322: Internet Message Format (email syntax)
30//! - RFC 5321: SMTP (length limits: 64 local, 255 domain, 254 total)
31//! - RFC 1035: DNS (label length limit: 63)
32
33use regex::Regex;
34use std::sync::LazyLock;
35
36/// Validation error with detailed information about what failed
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum EmailValidationError {
39    /// Email string is empty
40    Empty,
41    /// No @ symbol found
42    NoAtSymbol,
43    /// Multiple @ symbols found outside quoted string
44    MultipleAtSymbols,
45    /// Local part (before @) is empty
46    LocalPartEmpty,
47    /// Local part exceeds 64 characters (RFC 5321)
48    LocalPartTooLong,
49    /// Domain part (after @) is empty
50    DomainEmpty,
51    /// Domain exceeds 255 characters (RFC 5321)
52    DomainTooLong,
53    /// Invalid character in local part
54    InvalidLocalPartCharacter,
55    /// Invalid character in domain
56    InvalidDomainCharacter,
57    /// Consecutive dots (..) in address
58    ConsecutiveDots,
59    /// Address starts with a dot
60    LeadingDot,
61    /// Address part ends with a dot
62    TrailingDot,
63    /// Malformed quoted string in local part
64    InvalidQuotedString,
65    /// Unmatched quote in local part
66    UnbalancedQuotes,
67    /// Invalid IP address in domain literal
68    InvalidIPLiteral,
69    /// Domain label exceeds 63 characters (RFC 1035)
70    DomainLabelTooLong,
71    /// Total email length exceeds 254 characters (RFC 5321)
72    TotalLengthExceeded,
73}
74
75impl std::fmt::Display for EmailValidationError {
76    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77        match self {
78            Self::Empty => write!(f, "email address is empty"),
79            Self::NoAtSymbol => write!(f, "missing @ symbol"),
80            Self::MultipleAtSymbols => write!(f, "multiple @ symbols"),
81            Self::LocalPartEmpty => write!(f, "local part is empty"),
82            Self::LocalPartTooLong => write!(f, "local part exceeds 64 characters"),
83            Self::DomainEmpty => write!(f, "domain is empty"),
84            Self::DomainTooLong => write!(f, "domain exceeds 255 characters"),
85            Self::InvalidLocalPartCharacter => write!(f, "invalid character in local part"),
86            Self::InvalidDomainCharacter => write!(f, "invalid character in domain"),
87            Self::ConsecutiveDots => write!(f, "consecutive dots not allowed"),
88            Self::LeadingDot => write!(f, "leading dot not allowed"),
89            Self::TrailingDot => write!(f, "trailing dot not allowed"),
90            Self::InvalidQuotedString => write!(f, "invalid quoted string"),
91            Self::UnbalancedQuotes => write!(f, "unbalanced quotes"),
92            Self::InvalidIPLiteral => write!(f, "invalid IP literal"),
93            Self::DomainLabelTooLong => write!(f, "domain label exceeds 63 characters"),
94            Self::TotalLengthExceeded => write!(f, "email exceeds 254 characters"),
95        }
96    }
97}
98
99impl std::error::Error for EmailValidationError {}
100
101/// Result type for email validation
102pub type ValidationResult = Result<(), EmailValidationError>;
103
104/// Validates an email address according to RFC 5322
105///
106/// Returns `Ok(())` if valid, or an error describing what failed.
107///
108/// # Examples
109///
110/// ```
111/// use email_validator_rfc5322::validate_email;
112///
113/// assert!(validate_email("user@example.com").is_ok());
114/// assert!(validate_email("\"quoted\"@example.com").is_ok());
115/// assert!(validate_email("user@[192.168.1.1]").is_ok());
116///
117/// // Invalid examples
118/// assert!(validate_email("").is_err());
119/// assert!(validate_email("no-at-symbol").is_err());
120/// assert!(validate_email("user@").is_err());
121/// ```
122pub fn validate_email(email: &str) -> ValidationResult {
123    if email.is_empty() {
124        return Err(EmailValidationError::Empty);
125    }
126    if email.len() > 254 {
127        return Err(EmailValidationError::TotalLengthExceeded);
128    }
129
130    let (local_part, domain) = split_email(email)?;
131    validate_local_part(local_part)?;
132    validate_domain(domain)?;
133
134    Ok(())
135}
136
137/// Returns `true` if the email address is valid according to RFC 5322
138///
139/// This is a convenience wrapper around [`validate_email`] for simple boolean checks.
140///
141/// # Examples
142///
143/// ```
144/// use email_validator_rfc5322::is_valid_email;
145///
146/// if is_valid_email("user@example.com") {
147///     println!("Email is valid!");
148/// }
149/// ```
150#[inline]
151pub fn is_valid_email(email: &str) -> bool {
152    validate_email(email).is_ok()
153}
154
155fn split_email(email: &str) -> Result<(&str, &str), EmailValidationError> {
156    let bytes = email.as_bytes();
157    let mut in_quotes = false;
158    let mut escape_next = false;
159    let mut at_pos = None;
160
161    for (i, &byte) in bytes.iter().enumerate() {
162        if escape_next {
163            escape_next = false;
164            continue;
165        }
166        match byte {
167            b'\\' if in_quotes => escape_next = true,
168            b'"' => in_quotes = !in_quotes,
169            b'@' if !in_quotes => {
170                if at_pos.is_some() {
171                    return Err(EmailValidationError::MultipleAtSymbols);
172                }
173                at_pos = Some(i);
174            }
175            _ => {}
176        }
177    }
178
179    if in_quotes {
180        return Err(EmailValidationError::UnbalancedQuotes);
181    }
182
183    match at_pos {
184        None => Err(EmailValidationError::NoAtSymbol),
185        Some(pos) => {
186            let local = &email[..pos];
187            let domain = &email[pos + 1..];
188            if local.is_empty() {
189                return Err(EmailValidationError::LocalPartEmpty);
190            }
191            if domain.is_empty() {
192                return Err(EmailValidationError::DomainEmpty);
193            }
194            Ok((local, domain))
195        }
196    }
197}
198
199fn validate_local_part(local: &str) -> ValidationResult {
200    if local.len() > 64 {
201        return Err(EmailValidationError::LocalPartTooLong);
202    }
203
204    if local.starts_with('"') && local.ends_with('"') && local.len() >= 2 {
205        validate_quoted_string(local)
206    } else if local.contains('"') {
207        Err(EmailValidationError::InvalidQuotedString)
208    } else {
209        validate_dot_atom(local, true)
210    }
211}
212
213fn validate_quoted_string(s: &str) -> ValidationResult {
214    let inner = &s[1..s.len() - 1];
215    let bytes = inner.as_bytes();
216    let mut i = 0;
217
218    while i < bytes.len() {
219        let byte = bytes[i];
220        if byte == b'\\' {
221            if i + 1 >= bytes.len() {
222                return Err(EmailValidationError::InvalidQuotedString);
223            }
224            let next = bytes[i + 1];
225            if !is_vchar(next) && !is_wsp(next) {
226                return Err(EmailValidationError::InvalidQuotedString);
227            }
228            i += 2;
229        } else if is_qtext(byte) || is_wsp(byte) {
230            i += 1;
231        } else {
232            return Err(EmailValidationError::InvalidQuotedString);
233        }
234    }
235    Ok(())
236}
237
238fn validate_dot_atom(s: &str, is_local: bool) -> ValidationResult {
239    if s.is_empty() {
240        return Err(if is_local {
241            EmailValidationError::LocalPartEmpty
242        } else {
243            EmailValidationError::DomainEmpty
244        });
245    }
246    if s.starts_with('.') {
247        return Err(EmailValidationError::LeadingDot);
248    }
249    if s.ends_with('.') {
250        return Err(EmailValidationError::TrailingDot);
251    }
252    if s.contains("..") {
253        return Err(EmailValidationError::ConsecutiveDots);
254    }
255
256    for byte in s.bytes() {
257        if byte == b'.' {
258            continue;
259        }
260        if is_local {
261            if !is_atext(byte) {
262                return Err(EmailValidationError::InvalidLocalPartCharacter);
263            }
264        } else if !is_domain_char(byte) {
265            return Err(EmailValidationError::InvalidDomainCharacter);
266        }
267    }
268    Ok(())
269}
270
271fn validate_domain(domain: &str) -> ValidationResult {
272    if domain.len() > 255 {
273        return Err(EmailValidationError::DomainTooLong);
274    }
275
276    if domain.starts_with('[') && domain.ends_with(']') {
277        validate_domain_literal(domain)
278    } else {
279        validate_domain_name(domain)
280    }
281}
282
283fn validate_domain_literal(domain: &str) -> ValidationResult {
284    let inner = &domain[1..domain.len() - 1];
285    if let Some(ipv6) = inner.strip_prefix("IPv6:") {
286        validate_ipv6(ipv6)
287    } else {
288        validate_ipv4(inner)
289    }
290}
291
292fn validate_ipv4(ip: &str) -> ValidationResult {
293    static IPV4_RE: LazyLock<Regex> = LazyLock::new(|| {
294        Regex::new(
295            r"^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])$",
296        )
297        .unwrap()
298    });
299
300    if IPV4_RE.is_match(ip) {
301        Ok(())
302    } else {
303        Err(EmailValidationError::InvalidIPLiteral)
304    }
305}
306
307fn validate_ipv6(ip: &str) -> ValidationResult {
308    if ip.is_empty() {
309        return Err(EmailValidationError::InvalidIPLiteral);
310    }
311    if ip == "::" {
312        return Ok(());
313    }
314
315    let double_colon_count = ip.matches("::").count();
316    if double_colon_count > 1 {
317        return Err(EmailValidationError::InvalidIPLiteral);
318    }
319
320    let has_double_colon = double_colon_count == 1;
321    let parts: Vec<&str> = ip.split(':').collect();
322
323    if has_double_colon {
324        if parts.len() > 8 {
325            return Err(EmailValidationError::InvalidIPLiteral);
326        }
327    } else if parts.len() != 8 {
328        return Err(EmailValidationError::InvalidIPLiteral);
329    }
330
331    for part in &parts {
332        if part.is_empty() {
333            continue;
334        }
335        if part.len() > 4 {
336            return Err(EmailValidationError::InvalidIPLiteral);
337        }
338        if !part.chars().all(|c| c.is_ascii_hexdigit()) {
339            return Err(EmailValidationError::InvalidIPLiteral);
340        }
341    }
342    Ok(())
343}
344
345fn validate_domain_name(domain: &str) -> ValidationResult {
346    validate_dot_atom(domain, false)?;
347
348    for label in domain.split('.') {
349        if label.len() > 63 {
350            return Err(EmailValidationError::DomainLabelTooLong);
351        }
352        if label.starts_with('-') || label.ends_with('-') {
353            return Err(EmailValidationError::InvalidDomainCharacter);
354        }
355    }
356    Ok(())
357}
358
359#[inline]
360const fn is_vchar(byte: u8) -> bool {
361    byte >= 0x21 && byte <= 0x7E
362}
363
364#[inline]
365const fn is_wsp(byte: u8) -> bool {
366    byte == b' ' || byte == b'\t'
367}
368
369#[inline]
370const fn is_qtext(byte: u8) -> bool {
371    byte == 33 || (byte >= 35 && byte <= 91) || (byte >= 93 && byte <= 126)
372}
373
374#[inline]
375fn is_atext(byte: u8) -> bool {
376    byte.is_ascii_alphanumeric()
377        || matches!(
378            byte,
379            b'!' | b'#'
380                | b'$'
381                | b'%'
382                | b'&'
383                | b'\''
384                | b'*'
385                | b'+'
386                | b'-'
387                | b'/'
388                | b'='
389                | b'?'
390                | b'^'
391                | b'_'
392                | b'`'
393                | b'{'
394                | b'|'
395                | b'}'
396                | b'~'
397        )
398}
399
400#[inline]
401fn is_domain_char(byte: u8) -> bool {
402    byte.is_ascii_alphanumeric() || byte == b'-'
403}
404
405#[cfg(test)]
406mod tests {
407    use super::*;
408
409    #[test]
410    fn valid_simple_emails() {
411        assert!(validate_email("test@example.com").is_ok());
412        assert!(validate_email("user.name@domain.org").is_ok());
413        assert!(validate_email("user+tag@example.com").is_ok());
414        assert!(validate_email("a@b.co").is_ok());
415    }
416
417    #[test]
418    fn valid_special_chars() {
419        let specials = [
420            "user!def@example.com",
421            "user#comment@example.com",
422            "user$money@example.com",
423            "user%encoded@example.com",
424            "user&and@example.com",
425            "user'quote@example.com",
426            "user*star@example.com",
427            "user/slash@example.com",
428            "user=equals@example.com",
429            "user?query@example.com",
430            "user^caret@example.com",
431            "user_underscore@example.com",
432            "user`backtick@example.com",
433            "user{brace@example.com",
434            "user|pipe@example.com",
435            "user}brace@example.com",
436            "user~tilde@example.com",
437        ];
438        for email in specials {
439            assert!(validate_email(email).is_ok(), "Failed: {}", email);
440        }
441    }
442
443    #[test]
444    fn valid_quoted_strings() {
445        assert!(validate_email("\"john doe\"@example.com").is_ok());
446        assert!(validate_email("\"john@doe\"@example.com").is_ok());
447        assert!(validate_email("\"john\\\"doe\"@example.com").is_ok());
448        assert!(validate_email("\"\"@example.com").is_ok());
449        assert!(validate_email("\"user@domain\"@example.com").is_ok());
450    }
451
452    #[test]
453    fn valid_ip_literals() {
454        assert!(validate_email("user@[192.168.1.1]").is_ok());
455        assert!(validate_email("user@[127.0.0.1]").is_ok());
456        assert!(validate_email("user@[0.0.0.0]").is_ok());
457        assert!(validate_email("user@[255.255.255.255]").is_ok());
458    }
459
460    #[test]
461    fn valid_ipv6_literals() {
462        assert!(validate_email("user@[IPv6:2001:db8::1]").is_ok());
463        assert!(validate_email("user@[IPv6:2001:db8:85a3:0000:0000:8a2e:0370:7334]").is_ok());
464        assert!(validate_email("user@[IPv6:::]").is_ok()); // :: address = [IPv6:::]
465        assert!(validate_email("user@[IPv6:::1]").is_ok()); // ::1 loopback
466        assert!(validate_email("user@[IPv6:fe80::1]").is_ok());
467    }
468
469    #[test]
470    fn invalid_empty() {
471        assert_eq!(validate_email(""), Err(EmailValidationError::Empty));
472    }
473
474    #[test]
475    fn invalid_no_at() {
476        assert_eq!(
477            validate_email("userexample.com"),
478            Err(EmailValidationError::NoAtSymbol)
479        );
480    }
481
482    #[test]
483    fn invalid_multiple_at() {
484        assert_eq!(
485            validate_email("user@@example.com"),
486            Err(EmailValidationError::MultipleAtSymbols)
487        );
488        assert_eq!(
489            validate_email("user@name@example.com"),
490            Err(EmailValidationError::MultipleAtSymbols)
491        );
492    }
493
494    #[test]
495    fn invalid_empty_parts() {
496        assert_eq!(
497            validate_email("@example.com"),
498            Err(EmailValidationError::LocalPartEmpty)
499        );
500        assert_eq!(
501            validate_email("user@"),
502            Err(EmailValidationError::DomainEmpty)
503        );
504    }
505
506    #[test]
507    fn invalid_dots() {
508        assert_eq!(
509            validate_email(".user@example.com"),
510            Err(EmailValidationError::LeadingDot)
511        );
512        assert_eq!(
513            validate_email("user.@example.com"),
514            Err(EmailValidationError::TrailingDot)
515        );
516        assert_eq!(
517            validate_email("user..name@example.com"),
518            Err(EmailValidationError::ConsecutiveDots)
519        );
520    }
521
522    #[test]
523    fn invalid_characters() {
524        assert_eq!(
525            validate_email("user name@example.com"),
526            Err(EmailValidationError::InvalidLocalPartCharacter)
527        );
528        assert_eq!(
529            validate_email("user\t@example.com"),
530            Err(EmailValidationError::InvalidLocalPartCharacter)
531        );
532    }
533
534    #[test]
535    fn local_part_length_limit() {
536        let long_local = "a".repeat(65);
537        assert_eq!(
538            validate_email(&format!("{}@example.com", long_local)),
539            Err(EmailValidationError::LocalPartTooLong)
540        );
541
542        let max_local = "a".repeat(64);
543        assert!(validate_email(&format!("{}@example.com", max_local)).is_ok());
544    }
545
546    #[test]
547    fn total_length_limit() {
548        let long_email = format!("user@{}.com", "a".repeat(250));
549        assert_eq!(
550            validate_email(&long_email),
551            Err(EmailValidationError::TotalLengthExceeded)
552        );
553    }
554
555    #[test]
556    fn domain_length_limit() {
557        // Note: A 256-char domain + "@" + local = 258+ chars, exceeding 254 total limit.
558        // Total length is checked first, so DomainTooLong only triggers for domain-only
559        // validation or if total limit is relaxed. We test that the check exists.
560        // 200 + 1 + 50 + 1 + 3 + 1 = 256 chars
561        let domain_256 = format!("{}.{}.co", "a".repeat(200), "b".repeat(52));
562        assert!(domain_256.len() > 255, "domain len: {}", domain_256.len());
563        // This hits TotalLengthExceeded first (258 > 254)
564        assert_eq!(
565            validate_email(&format!("u@{}", domain_256)),
566            Err(EmailValidationError::TotalLengthExceeded)
567        );
568    }
569
570    #[test]
571    fn domain_label_limits() {
572        let long_label = "a".repeat(64);
573        assert_eq!(
574            validate_email(&format!("user@{}.com", long_label)),
575            Err(EmailValidationError::DomainLabelTooLong)
576        );
577
578        let max_label = "a".repeat(63);
579        assert!(validate_email(&format!("user@{}.com", max_label)).is_ok());
580    }
581
582    #[test]
583    fn domain_hyphen_rules() {
584        assert_eq!(
585            validate_email("user@-example.com"),
586            Err(EmailValidationError::InvalidDomainCharacter)
587        );
588        assert_eq!(
589            validate_email("user@example-.com"),
590            Err(EmailValidationError::InvalidDomainCharacter)
591        );
592        assert!(validate_email("user@ex-ample.com").is_ok());
593        assert!(validate_email("user@ex--ample.com").is_ok());
594    }
595
596    #[test]
597    fn invalid_quoted_strings() {
598        assert_eq!(
599            validate_email("\"user@example.com"),
600            Err(EmailValidationError::UnbalancedQuotes)
601        );
602        assert_eq!(
603            validate_email("user\"quoted\"@example.com"),
604            Err(EmailValidationError::InvalidQuotedString)
605        );
606    }
607
608    #[test]
609    fn invalid_ip_literals() {
610        assert_eq!(
611            validate_email("user@[999.999.999.999]"),
612            Err(EmailValidationError::InvalidIPLiteral)
613        );
614        assert_eq!(
615            validate_email("user@[192.168.1]"),
616            Err(EmailValidationError::InvalidIPLiteral)
617        );
618        assert_eq!(
619            validate_email("user@[not.an.ip.addr]"),
620            Err(EmailValidationError::InvalidIPLiteral)
621        );
622    }
623
624    #[test]
625    fn invalid_ipv6_literals() {
626        assert_eq!(
627            validate_email("user@[IPv6:1:2:3:4:5:6:7:8:9]"),
628            Err(EmailValidationError::InvalidIPLiteral)
629        );
630        assert_eq!(
631            validate_email("user@[IPv6:2001::db8::1]"),
632            Err(EmailValidationError::InvalidIPLiteral)
633        );
634        assert_eq!(
635            validate_email("user@[IPv6:GHIJ::]"),
636            Err(EmailValidationError::InvalidIPLiteral)
637        );
638    }
639
640    #[test]
641    fn is_valid_email_helper() {
642        assert!(is_valid_email("user@example.com"));
643        assert!(!is_valid_email("invalid"));
644    }
645
646    #[test]
647    fn error_display() {
648        assert_eq!(
649            EmailValidationError::Empty.to_string(),
650            "email address is empty"
651        );
652        assert_eq!(
653            EmailValidationError::NoAtSymbol.to_string(),
654            "missing @ symbol"
655        );
656    }
657}