Skip to main content

philiprehberger_email_parser/
lib.rs

1//! RFC 5322 compliant email address parsing, validation, and normalization.
2//!
3//! This crate provides an [`Email`] type for parsing, validating, and manipulating
4//! email addresses. It supports display names, quoted local parts, normalization,
5//! plus-alias removal, and role address detection.
6//!
7//! # Examples
8//!
9//! ```
10//! use philiprehberger_email_parser::Email;
11//!
12//! let email = Email::parse("user@example.com").unwrap();
13//! assert_eq!(email.local_part(), "user");
14//! assert_eq!(email.domain(), "example.com");
15//!
16//! // Quick validation
17//! assert!(Email::is_valid("user@example.com"));
18//! assert!(!Email::is_valid("invalid"));
19//! ```
20
21use std::fmt;
22use std::str::FromStr;
23
24/// Known role address local parts.
25const ROLE_ADDRESSES: &[&str] = &[
26    "admin",
27    "info",
28    "support",
29    "sales",
30    "contact",
31    "noreply",
32    "no-reply",
33    "webmaster",
34    "postmaster",
35    "hostmaster",
36    "abuse",
37    "security",
38    "billing",
39    "help",
40    "office",
41    "team",
42    "hello",
43    "press",
44    "media",
45    "jobs",
46    "careers",
47    "legal",
48    "compliance",
49    "privacy",
50    "mailer-daemon",
51    "newsletter",
52];
53
54/// Errors that can occur when parsing an email address.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub enum EmailError {
57    /// The input string is empty.
58    Empty,
59    /// No `@` sign found in the address.
60    MissingAtSign,
61    /// Multiple `@` signs found outside of a quoted local part.
62    MultipleAtSigns,
63    /// The local part (before `@`) is empty.
64    EmptyLocalPart,
65    /// The domain (after `@`) is empty.
66    EmptyDomain,
67    /// The local part exceeds 64 characters.
68    LocalPartTooLong,
69    /// The domain exceeds 255 characters.
70    DomainTooLong,
71    /// The total address exceeds 254 characters.
72    TotalTooLong,
73    /// The local part contains invalid characters or formatting.
74    InvalidLocalPart(String),
75    /// The domain contains invalid characters or formatting.
76    InvalidDomain(String),
77}
78
79impl fmt::Display for EmailError {
80    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
81        match self {
82            EmailError::Empty => write!(f, "email address is empty"),
83            EmailError::MissingAtSign => write!(f, "missing @ sign"),
84            EmailError::MultipleAtSigns => write!(f, "multiple @ signs"),
85            EmailError::EmptyLocalPart => write!(f, "empty local part"),
86            EmailError::EmptyDomain => write!(f, "empty domain"),
87            EmailError::LocalPartTooLong => write!(f, "local part exceeds 64 characters"),
88            EmailError::DomainTooLong => write!(f, "domain exceeds 255 characters"),
89            EmailError::TotalTooLong => write!(f, "total address exceeds 254 characters"),
90            EmailError::InvalidLocalPart(reason) => {
91                write!(f, "invalid local part: {}", reason)
92            }
93            EmailError::InvalidDomain(reason) => write!(f, "invalid domain: {}", reason),
94        }
95    }
96}
97
98impl std::error::Error for EmailError {}
99
100/// A parsed and validated email address.
101///
102/// Supports standard email addresses, display names, quoted local parts,
103/// and provides normalization and inspection utilities.
104///
105/// # Examples
106///
107/// ```
108/// use philiprehberger_email_parser::Email;
109///
110/// let email = Email::parse("\"John Doe\" <john@example.com>").unwrap();
111/// assert_eq!(email.display_name(), Some("John Doe"));
112/// assert_eq!(email.local_part(), "john");
113/// assert_eq!(email.domain(), "example.com");
114/// ```
115#[derive(Debug, Clone, PartialEq, Eq, Hash)]
116#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
117#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))]
118pub struct Email {
119    local_part: String,
120    domain: String,
121    display_name: Option<String>,
122}
123
124impl Email {
125    /// Parse and validate an email address string.
126    ///
127    /// Accepts the following formats:
128    /// - `user@example.com` (basic)
129    /// - `"John Doe" <user@example.com>` (quoted display name)
130    /// - `John Doe <user@example.com>` (unquoted display name)
131    /// - `"user name"@example.com` (quoted local part)
132    ///
133    /// # Errors
134    ///
135    /// Returns an [`EmailError`] if the input is not a valid email address.
136    pub fn parse(input: &str) -> Result<Email, EmailError> {
137        let input = input.trim();
138        if input.is_empty() {
139            return Err(EmailError::Empty);
140        }
141
142        let (display_name, address) = extract_display_name(input)?;
143        let address = address.trim();
144
145        if address.is_empty() {
146            return Err(EmailError::Empty);
147        }
148
149        let (local_part, domain) = split_address(address)?;
150
151        if local_part.is_empty() {
152            return Err(EmailError::EmptyLocalPart);
153        }
154        if domain.is_empty() {
155            return Err(EmailError::EmptyDomain);
156        }
157
158        if local_part.len() > 64 {
159            return Err(EmailError::LocalPartTooLong);
160        }
161        if domain.len() > 255 {
162            return Err(EmailError::DomainTooLong);
163        }
164
165        let total_len = local_part.len() + 1 + domain.len();
166        if total_len > 254 {
167            return Err(EmailError::TotalTooLong);
168        }
169
170        validate_local_part(&local_part)?;
171        validate_domain(&domain)?;
172
173        Ok(Email {
174            local_part,
175            domain,
176            display_name,
177        })
178    }
179
180    /// Quick boolean check whether a string is a valid email address.
181    ///
182    /// This is equivalent to `Email::parse(input).is_ok()`.
183    pub fn is_valid(input: &str) -> bool {
184        Email::parse(input).is_ok()
185    }
186
187    /// Returns the local part of the email address (before the `@`).
188    pub fn local_part(&self) -> &str {
189        &self.local_part
190    }
191
192    /// Returns the domain of the email address (after the `@`).
193    pub fn domain(&self) -> &str {
194        &self.domain
195    }
196
197    /// Returns the display name, if one was provided during parsing.
198    pub fn display_name(&self) -> Option<&str> {
199        self.display_name.as_deref()
200    }
201
202    /// Returns the email address in `local@domain` form (without display name).
203    pub fn as_str(&self) -> String {
204        format!("{}@{}", self.local_part, self.domain)
205    }
206
207    /// Returns a new [`Email`] with the domain lowercased.
208    ///
209    /// The local part is preserved as-is since it is case-sensitive per RFC 5321.
210    pub fn normalize(&self) -> Email {
211        Email {
212            local_part: self.local_part.clone(),
213            domain: self.domain.to_lowercase(),
214            display_name: self.display_name.clone(),
215        }
216    }
217
218    /// Returns a new [`Email`] with everything after `+` in the local part removed.
219    ///
220    /// For example, `user+tag@example.com` becomes `user@example.com`.
221    /// If there is no `+` in the local part, returns a clone.
222    pub fn without_plus_alias(&self) -> Email {
223        let local = if let Some(idx) = self.local_part.find('+') {
224            self.local_part[..idx].to_string()
225        } else {
226            self.local_part.clone()
227        };
228
229        Email {
230            local_part: local,
231            domain: self.domain.clone(),
232            display_name: self.display_name.clone(),
233        }
234    }
235
236    /// Returns `true` if the local part matches a known role address.
237    ///
238    /// Role addresses include: admin, info, support, sales, contact, noreply,
239    /// no-reply, webmaster, postmaster, hostmaster, abuse, security, billing,
240    /// help, office, team, hello, press, media, jobs, careers, legal,
241    /// compliance, privacy, mailer-daemon, newsletter.
242    pub fn is_role_address(&self) -> bool {
243        let lower = self.local_part.to_lowercase();
244        ROLE_ADDRESSES.contains(&lower.as_str())
245    }
246}
247
248impl fmt::Display for Email {
249    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
250        match &self.display_name {
251            Some(name) => write!(f, "\"{}\" <{}@{}>", name, self.local_part, self.domain),
252            None => write!(f, "{}@{}", self.local_part, self.domain),
253        }
254    }
255}
256
257impl FromStr for Email {
258    type Err = EmailError;
259
260    fn from_str(s: &str) -> Result<Self, Self::Err> {
261        Email::parse(s)
262    }
263}
264
265#[cfg(feature = "serde")]
266impl TryFrom<String> for Email {
267    type Error = EmailError;
268
269    fn try_from(s: String) -> Result<Self, Self::Error> {
270        Email::parse(&s)
271    }
272}
273
274#[cfg(feature = "serde")]
275impl From<Email> for String {
276    fn from(email: Email) -> String {
277        email.to_string()
278    }
279}
280
281/// Extract an optional display name and the raw address from the input.
282fn extract_display_name(input: &str) -> Result<(Option<String>, String), EmailError> {
283    // Check for angle bracket format: ... <addr>
284    if let Some(angle_start) = input.rfind('<') {
285        if let Some(angle_end) = input.rfind('>') {
286            if angle_end > angle_start {
287                let address = input[angle_start + 1..angle_end].trim().to_string();
288                let name_part = input[..angle_start].trim();
289
290                let display_name = if name_part.is_empty() {
291                    None
292                } else {
293                    // Strip surrounding quotes if present
294                    let name = if name_part.starts_with('"') && name_part.ends_with('"') {
295                        name_part[1..name_part.len() - 1].to_string()
296                    } else {
297                        name_part.to_string()
298                    };
299                    Some(name)
300                };
301
302                return Ok((display_name, address));
303            }
304        }
305    }
306
307    Ok((None, input.to_string()))
308}
309
310/// Split an address into local part and domain, handling quoted local parts.
311fn split_address(address: &str) -> Result<(String, String), EmailError> {
312    if let Some(after_quote) = address.strip_prefix('"') {
313        // Quoted local part: find the closing quote
314        if let Some(end_quote) = after_quote.find('"') {
315            let local = after_quote[..end_quote].to_string();
316            let rest = &after_quote[end_quote + 1..];
317            if let Some(domain_str) = rest.strip_prefix('@') {
318                let domain = domain_str.to_string();
319                return Ok((local, domain));
320            } else {
321                return Err(EmailError::MissingAtSign);
322            }
323        } else {
324            return Err(EmailError::InvalidLocalPart(
325                "unclosed quote in local part".to_string(),
326            ));
327        }
328    }
329
330    // Non-quoted: split on @
331    let at_count = address.chars().filter(|&c| c == '@').count();
332    if at_count == 0 {
333        return Err(EmailError::MissingAtSign);
334    }
335    if at_count > 1 {
336        return Err(EmailError::MultipleAtSigns);
337    }
338
339    let at_pos = address.find('@').unwrap();
340    let local = address[..at_pos].to_string();
341    let domain = address[at_pos + 1..].to_string();
342
343    Ok((local, domain))
344}
345
346/// Validate the local part of an email address.
347fn validate_local_part(local: &str) -> Result<(), EmailError> {
348    // Quoted local parts allow most characters
349    // We already extracted the content from quotes in split_address,
350    // so if we get here with a quoted-looking string that's fine.
351    // For unquoted local parts, enforce strict rules.
352
353    if local.starts_with('.') {
354        return Err(EmailError::InvalidLocalPart(
355            "cannot start with a dot".to_string(),
356        ));
357    }
358    if local.ends_with('.') {
359        return Err(EmailError::InvalidLocalPart(
360            "cannot end with a dot".to_string(),
361        ));
362    }
363    if local.contains("..") {
364        return Err(EmailError::InvalidLocalPart(
365            "consecutive dots not allowed".to_string(),
366        ));
367    }
368
369    for ch in local.chars() {
370        if !ch.is_alphanumeric()
371            && ch != '.'
372            && ch != '_'
373            && ch != '+'
374            && ch != '-'
375            && ch != ' '
376        {
377            return Err(EmailError::InvalidLocalPart(format!(
378                "invalid character '{}'",
379                ch
380            )));
381        }
382    }
383
384    Ok(())
385}
386
387/// Validate the domain part of an email address.
388fn validate_domain(domain: &str) -> Result<(), EmailError> {
389    // IP address literal: [x.x.x.x]
390    if domain.starts_with('[') && domain.ends_with(']') {
391        let ip = &domain[1..domain.len() - 1];
392        let parts: Vec<&str> = ip.split('.').collect();
393        if parts.len() == 4 {
394            for part in &parts {
395                if part.parse::<u8>().is_err() {
396                    return Err(EmailError::InvalidDomain(
397                        "invalid IP address literal".to_string(),
398                    ));
399                }
400            }
401            return Ok(());
402        }
403        return Err(EmailError::InvalidDomain(
404            "invalid IP address literal".to_string(),
405        ));
406    }
407
408    let labels: Vec<&str> = domain.split('.').collect();
409
410    if labels.len() < 2 {
411        return Err(EmailError::InvalidDomain(
412            "must have at least two labels".to_string(),
413        ));
414    }
415
416    for label in &labels {
417        if label.is_empty() {
418            return Err(EmailError::InvalidDomain("empty label".to_string()));
419        }
420        if label.len() > 63 {
421            return Err(EmailError::InvalidDomain(
422                "label exceeds 63 characters".to_string(),
423            ));
424        }
425        if label.starts_with('-') || label.ends_with('-') {
426            return Err(EmailError::InvalidDomain(
427                "label cannot start or end with a hyphen".to_string(),
428            ));
429        }
430        for ch in label.chars() {
431            if !ch.is_alphanumeric() && ch != '-' {
432                return Err(EmailError::InvalidDomain(format!(
433                    "invalid character '{}' in label",
434                    ch
435                )));
436            }
437        }
438    }
439
440    Ok(())
441}
442
443#[cfg(test)]
444mod tests {
445    use super::*;
446
447    #[test]
448    fn test_basic_valid_emails() {
449        let email = Email::parse("user@example.com").unwrap();
450        assert_eq!(email.local_part(), "user");
451        assert_eq!(email.domain(), "example.com");
452        assert_eq!(email.display_name(), None);
453    }
454
455    #[test]
456    fn test_dotted_local_part() {
457        let email = Email::parse("user.name@example.com").unwrap();
458        assert_eq!(email.local_part(), "user.name");
459    }
460
461    #[test]
462    fn test_plus_tag() {
463        let email = Email::parse("user+tag@example.com").unwrap();
464        assert_eq!(email.local_part(), "user+tag");
465    }
466
467    #[test]
468    fn test_display_name_quoted() {
469        let email = Email::parse("\"John Doe\" <user@example.com>").unwrap();
470        assert_eq!(email.display_name(), Some("John Doe"));
471        assert_eq!(email.local_part(), "user");
472        assert_eq!(email.domain(), "example.com");
473    }
474
475    #[test]
476    fn test_display_name_unquoted() {
477        let email = Email::parse("John Doe <user@example.com>").unwrap();
478        assert_eq!(email.display_name(), Some("John Doe"));
479        assert_eq!(email.local_part(), "user");
480    }
481
482    #[test]
483    fn test_empty_input() {
484        assert_eq!(Email::parse(""), Err(EmailError::Empty));
485    }
486
487    #[test]
488    fn test_just_at_sign() {
489        let result = Email::parse("@");
490        assert!(result.is_err());
491    }
492
493    #[test]
494    fn test_missing_domain() {
495        let result = Email::parse("user@");
496        assert!(result.is_err());
497    }
498
499    #[test]
500    fn test_missing_local_part() {
501        let result = Email::parse("@domain.com");
502        assert_eq!(result, Err(EmailError::EmptyLocalPart));
503    }
504
505    #[test]
506    fn test_multiple_at_signs() {
507        assert_eq!(
508            Email::parse("user@@domain.com"),
509            Err(EmailError::MultipleAtSigns)
510        );
511    }
512
513    #[test]
514    fn test_domain_starting_with_dot() {
515        let result = Email::parse("user@.com");
516        assert!(result.is_err());
517    }
518
519    #[test]
520    fn test_single_label_domain() {
521        let result = Email::parse("user@domain");
522        assert!(result.is_err());
523    }
524
525    #[test]
526    fn test_local_part_starts_with_dot() {
527        let result = Email::parse(".user@domain.com");
528        assert!(matches!(result, Err(EmailError::InvalidLocalPart(_))));
529    }
530
531    #[test]
532    fn test_consecutive_dots_in_local() {
533        let result = Email::parse("user..name@domain.com");
534        assert!(matches!(result, Err(EmailError::InvalidLocalPart(_))));
535    }
536
537    #[test]
538    fn test_local_part_too_long() {
539        let local = "a".repeat(65);
540        let addr = format!("{}@example.com", local);
541        assert_eq!(Email::parse(&addr), Err(EmailError::LocalPartTooLong));
542    }
543
544    #[test]
545    fn test_domain_too_long() {
546        let label = "a".repeat(63);
547        // Build a domain with enough labels to exceed 255 chars
548        let domain = format!("{}.{}.{}.{}.com", label, label, label, label);
549        let addr = format!("u@{}", domain);
550        assert_eq!(Email::parse(&addr), Err(EmailError::DomainTooLong));
551    }
552
553    #[test]
554    fn test_total_too_long() {
555        let local = "a".repeat(64);
556        let domain_label = "b".repeat(63);
557        let domain = format!("{}.{}.{}.com", domain_label, domain_label, domain_label);
558        let addr = format!("{}@{}", local, domain);
559        assert_eq!(Email::parse(&addr), Err(EmailError::TotalTooLong));
560    }
561
562    #[test]
563    fn test_normalize() {
564        let email = Email::parse("User@Example.COM").unwrap();
565        let normalized = email.normalize();
566        assert_eq!(normalized.local_part(), "User");
567        assert_eq!(normalized.domain(), "example.com");
568    }
569
570    #[test]
571    fn test_without_plus_alias() {
572        let email = Email::parse("user+tag@example.com").unwrap();
573        let clean = email.without_plus_alias();
574        assert_eq!(clean.local_part(), "user");
575        assert_eq!(clean.domain(), "example.com");
576    }
577
578    #[test]
579    fn test_without_plus_alias_no_plus() {
580        let email = Email::parse("user@example.com").unwrap();
581        let clean = email.without_plus_alias();
582        assert_eq!(clean.local_part(), "user");
583    }
584
585    #[test]
586    fn test_is_role_address_true() {
587        let email = Email::parse("admin@example.com").unwrap();
588        assert!(email.is_role_address());
589
590        let email = Email::parse("support@example.com").unwrap();
591        assert!(email.is_role_address());
592
593        let email = Email::parse("noreply@example.com").unwrap();
594        assert!(email.is_role_address());
595
596        let email = Email::parse("no-reply@example.com").unwrap();
597        assert!(email.is_role_address());
598    }
599
600    #[test]
601    fn test_is_role_address_false() {
602        let email = Email::parse("john@example.com").unwrap();
603        assert!(!email.is_role_address());
604    }
605
606    #[test]
607    fn test_is_role_address_case_insensitive() {
608        let email = Email::parse("Admin@example.com").unwrap();
609        assert!(email.is_role_address());
610    }
611
612    #[test]
613    fn test_is_valid_true() {
614        assert!(Email::is_valid("user@example.com"));
615        assert!(Email::is_valid("user.name@example.com"));
616    }
617
618    #[test]
619    fn test_is_valid_false() {
620        assert!(!Email::is_valid(""));
621        assert!(!Email::is_valid("not-an-email"));
622        assert!(!Email::is_valid("@"));
623        assert!(!Email::is_valid("user@"));
624    }
625
626    #[test]
627    fn test_display_without_name() {
628        let email = Email::parse("user@example.com").unwrap();
629        assert_eq!(email.to_string(), "user@example.com");
630    }
631
632    #[test]
633    fn test_display_with_name() {
634        let email = Email::parse("\"John Doe\" <user@example.com>").unwrap();
635        assert_eq!(email.to_string(), "\"John Doe\" <user@example.com>");
636    }
637
638    #[test]
639    fn test_display_roundtrip() {
640        let original = Email::parse("\"John Doe\" <user@example.com>").unwrap();
641        let displayed = original.to_string();
642        let reparsed = Email::parse(&displayed).unwrap();
643        assert_eq!(original.local_part(), reparsed.local_part());
644        assert_eq!(original.domain(), reparsed.domain());
645        assert_eq!(original.display_name(), reparsed.display_name());
646    }
647
648    #[test]
649    fn test_display_roundtrip_basic() {
650        let original = Email::parse("user@example.com").unwrap();
651        let displayed = original.to_string();
652        let reparsed = Email::parse(&displayed).unwrap();
653        assert_eq!(original, reparsed);
654    }
655
656    #[test]
657    fn test_from_str() {
658        let email: Email = "user@example.com".parse().unwrap();
659        assert_eq!(email.local_part(), "user");
660        assert_eq!(email.domain(), "example.com");
661    }
662
663    #[test]
664    fn test_from_str_invalid() {
665        let result: Result<Email, _> = "not-an-email".parse();
666        assert!(result.is_err());
667    }
668
669    #[test]
670    fn test_quoted_local_part() {
671        let email = Email::parse("\"user name\"@example.com").unwrap();
672        assert_eq!(email.local_part(), "user name");
673        assert_eq!(email.domain(), "example.com");
674    }
675
676    #[test]
677    fn test_as_str() {
678        let email = Email::parse("\"John\" <user@example.com>").unwrap();
679        assert_eq!(email.as_str(), "user@example.com");
680    }
681
682    #[test]
683    fn test_single_char_local() {
684        let email = Email::parse("a@example.com").unwrap();
685        assert_eq!(email.local_part(), "a");
686    }
687
688    #[test]
689    fn test_single_char_labels() {
690        let email = Email::parse("a@b.co").unwrap();
691        assert_eq!(email.domain(), "b.co");
692    }
693
694    #[test]
695    fn test_hyphen_in_domain() {
696        let email = Email::parse("user@my-domain.com").unwrap();
697        assert_eq!(email.domain(), "my-domain.com");
698    }
699
700    #[test]
701    fn test_domain_label_leading_hyphen() {
702        let result = Email::parse("user@-domain.com");
703        assert!(result.is_err());
704    }
705
706    #[test]
707    fn test_domain_label_trailing_hyphen() {
708        let result = Email::parse("user@domain-.com");
709        assert!(result.is_err());
710    }
711
712    #[test]
713    fn test_ip_literal_domain() {
714        let email = Email::parse("user@[192.168.1.1]").unwrap();
715        assert_eq!(email.domain(), "[192.168.1.1]");
716    }
717
718    #[test]
719    fn test_underscore_in_local() {
720        let email = Email::parse("user_name@example.com").unwrap();
721        assert_eq!(email.local_part(), "user_name");
722    }
723
724    #[test]
725    fn test_hyphen_in_local() {
726        let email = Email::parse("user-name@example.com").unwrap();
727        assert_eq!(email.local_part(), "user-name");
728    }
729
730    #[test]
731    fn test_eq_and_hash() {
732        let a = Email::parse("user@example.com").unwrap();
733        let b = Email::parse("user@example.com").unwrap();
734        assert_eq!(a, b);
735
736        use std::collections::HashSet;
737        let mut set = HashSet::new();
738        set.insert(a);
739        assert!(set.contains(&b));
740    }
741
742    #[test]
743    fn test_error_display() {
744        assert_eq!(EmailError::Empty.to_string(), "email address is empty");
745        assert_eq!(EmailError::MissingAtSign.to_string(), "missing @ sign");
746        assert_eq!(
747            EmailError::LocalPartTooLong.to_string(),
748            "local part exceeds 64 characters"
749        );
750    }
751
752    #[test]
753    fn test_angle_brackets_no_display_name() {
754        let email = Email::parse("<user@example.com>").unwrap();
755        assert_eq!(email.local_part(), "user");
756        assert_eq!(email.display_name(), None);
757    }
758}