Skip to main content

structured_email_address/
lib.rs

1//! # structured-email-address
2//!
3//! RFC 5321/5322/6531 conformant email address parser, validator, and normalizer.
4//!
5//! Unlike existing Rust crates that stop at RFC validation, this crate provides:
6//! - **Subaddress extraction**: `user+tag@domain` → separate `user`, `tag`, `domain`
7//! - **Provider-aware normalization**: Gmail dot-stripping, configurable case folding
8//! - **PSL domain validation**: verify domain against the Public Suffix List
9//! - **Anti-homoglyph protection**: detect Cyrillic/Latin lookalikes via Unicode skeleton
10//! - **Configurable strictness**: Strict (5321), Standard (5322), Lax (obs-* allowed)
11//! - **Zero-copy parsing**: internal spans into the input string
12//!
13//! # Quick Start
14//!
15//! ```
16//! use structured_email_address::{EmailAddress, Config};
17//!
18//! // Simple: parse with defaults
19//! let email: EmailAddress = "user+tag@example.com".parse().unwrap();
20//! assert_eq!(email.local_part(), "user+tag");
21//! assert_eq!(email.tag(), Some("tag"));
22//! assert_eq!(email.domain(), "example.com");
23//!
24//! // Configured: Gmail normalization pipeline
25//! let config = Config::builder()
26//!     .strip_subaddress()
27//!     .dots_gmail_only()
28//!     .lowercase_all()
29//!     .build();
30//!
31//! let email = EmailAddress::parse_with("A.L.I.C.E+promo@Gmail.COM", &config).unwrap();
32//! assert_eq!(email.canonical(), "alice@gmail.com");
33//! assert_eq!(email.tag(), Some("promo"));
34//! ```
35
36#![cfg_attr(
37    not(test),
38    deny(clippy::unwrap_used, clippy::expect_used, clippy::panic)
39)]
40
41mod config;
42mod error;
43mod normalize;
44mod parser;
45mod provider;
46mod validate;
47
48pub use config::{
49    CasePolicy, Config, ConfigBuilder, DomainCheck, DotPolicy, Strictness, SubaddressPolicy,
50};
51pub use error::{Error, ErrorKind};
52pub use normalize::confusable_skeleton;
53pub use provider::{ProviderRegistry, ProviderRule};
54
55/// A parsed, validated, and normalized email address.
56///
57/// Immutable after construction. All accessors return borrowed data.
58#[derive(Debug, Clone)]
59pub struct EmailAddress {
60    /// Original input, exactly as supplied to the parser.
61    original: String,
62    /// Canonical local part (after normalization).
63    local_part: String,
64    /// Extracted subaddress tag, if any.
65    tag: Option<String>,
66    /// Canonical domain (IDNA-encoded, lowercased).
67    domain: String,
68    /// Unicode form of the domain (only when domain has punycode labels).
69    domain_unicode: Option<String>,
70    /// Display name, if parsed from `name-addr` format.
71    display_name: Option<String>,
72    /// Confusable skeleton, if config enabled it.
73    skeleton: Option<String>,
74    /// Whether the domain is a known freemail provider (from the registry).
75    freemail: bool,
76}
77
78impl EmailAddress {
79    /// Parse and validate with the given configuration.
80    pub fn parse_with(input: &str, config: &Config) -> Result<Self, Error> {
81        let parsed = parser::parse(
82            input,
83            config.strictness,
84            config.allow_display_name,
85            config.allow_domain_literal,
86        )?;
87
88        let normalized = normalize::normalize(&parsed, config)?;
89        validate::validate(&parsed, &normalized, config)?;
90
91        // Freemail status comes from the provider registry (built-ins + any
92        // custom rules), independent of provider-aware normalization.
93        let freemail = config
94            .providers
95            .lookup(&normalized.domain)
96            .is_some_and(|p| p.is_freemail());
97
98        Ok(Self {
99            original: parsed.input.to_string(),
100            local_part: normalized.local_part,
101            tag: normalized.tag,
102            domain: normalized.domain,
103            domain_unicode: normalized.domain_unicode,
104            display_name: normalized.display_name,
105            skeleton: normalized.skeleton,
106            freemail,
107        })
108    }
109
110    /// The canonical local part (after normalization).
111    ///
112    /// If subaddress stripping is enabled, this excludes the `+tag`.
113    /// If dot stripping is enabled, dots are removed.
114    pub fn local_part(&self) -> &str {
115        &self.local_part
116    }
117
118    /// The extracted subaddress tag, if present.
119    ///
120    /// For `user+promo@example.com`, returns `Some("promo")`.
121    /// Always extracted regardless of [`SubaddressPolicy`] — the policy only
122    /// affects whether it appears in [`canonical()`](Self::canonical).
123    pub fn tag(&self) -> Option<&str> {
124        self.tag.as_deref()
125    }
126
127    /// The canonical domain (IDNA-encoded, lowercased).
128    pub fn domain(&self) -> &str {
129        &self.domain
130    }
131
132    /// The canonical domain in Unicode form.
133    ///
134    /// For internationalized domains (`münchen.de` → `xn--mnchen-3ya.de`),
135    /// returns the Unicode form of the canonical domain. For ASCII-only
136    /// domains, returns the same value as [`domain()`](Self::domain).
137    ///
138    /// # Security
139    ///
140    /// The Unicode form is intended for **display only**. It may reintroduce
141    /// [IDN homograph attacks](https://en.wikipedia.org/wiki/IDN_homograph_attack)
142    /// where visually similar characters from different scripts produce
143    /// different domain names (e.g. Cyrillic `а` vs Latin `a`).
144    ///
145    /// For security-sensitive comparisons (allow-lists, deduplication, access
146    /// control), always use [`domain()`](Self::domain) which returns the
147    /// ACE/Punycode form. If you must compare Unicode domains, apply your own
148    /// confusable-detection logic (see [`confusable_skeleton()`]).
149    ///
150    /// ```
151    /// use structured_email_address::EmailAddress;
152    ///
153    /// let email: EmailAddress = "user@münchen.de".parse().unwrap();
154    /// assert_eq!(email.domain(), "xn--mnchen-3ya.de");
155    /// assert_eq!(email.domain_unicode(), "münchen.de");
156    ///
157    /// let ascii: EmailAddress = "user@example.com".parse().unwrap();
158    /// assert_eq!(ascii.domain_unicode(), "example.com");
159    /// ```
160    pub fn domain_unicode(&self) -> &str {
161        self.domain_unicode.as_deref().unwrap_or(&self.domain)
162    }
163
164    /// The display name, if parsed from `"Name" <addr>` or `Name <addr>` format.
165    pub fn display_name(&self) -> Option<&str> {
166        self.display_name.as_deref()
167    }
168
169    /// The full canonical address: `local_part@domain`.
170    ///
171    /// If the local part contains characters that require quoting (spaces,
172    /// special chars), it is wrapped in quotes for RFC compliance.
173    pub fn canonical(&self) -> String {
174        if needs_quoting(&self.local_part) {
175            let escaped = escape_local_part(&self.local_part);
176            format!("\"{}\"@{}", escaped, self.domain)
177        } else {
178            format!("{}@{}", self.local_part, self.domain)
179        }
180    }
181
182    /// The original input, exactly as supplied to the parser (not trimmed).
183    pub fn original(&self) -> &str {
184        &self.original
185    }
186
187    /// The confusable skeleton of the local part (if config enabled it).
188    ///
189    /// Two addresses with the same skeleton + domain are visually confusable.
190    pub fn skeleton(&self) -> Option<&str> {
191        self.skeleton.as_deref()
192    }
193
194    /// Check if the domain is a known freemail provider.
195    ///
196    /// Determined from the [`ProviderRegistry`] in the [`Config`] used to parse
197    /// (built-in providers plus any registered via
198    /// [`ConfigBuilder::add_provider`]).
199    pub fn is_freemail(&self) -> bool {
200        self.freemail
201    }
202
203    /// Parse a batch of email addresses with the given configuration.
204    ///
205    /// Returns one `Result` per input, in the same order. The config is
206    /// shared across all inputs, amortizing setup cost.
207    ///
208    /// # Example
209    ///
210    /// ```
211    /// use structured_email_address::{EmailAddress, Config};
212    ///
213    /// let config = Config::default();
214    /// let results = EmailAddress::parse_batch(
215    ///     &["alice@example.com", "invalid", "bob@example.org"],
216    ///     &config,
217    /// );
218    /// assert!(results[0].is_ok());
219    /// assert!(results[1].is_err());
220    /// assert!(results[2].is_ok());
221    /// ```
222    pub fn parse_batch(inputs: &[&str], config: &Config) -> Vec<Result<Self, Error>> {
223        inputs
224            .iter()
225            .map(|input| Self::parse_with(input, config))
226            .collect()
227    }
228
229    /// Parse a batch of email addresses in parallel using rayon.
230    ///
231    /// Same semantics as [`parse_batch`](Self::parse_batch), but distributes
232    /// work across rayon's thread pool. Useful for bulk import/validation of
233    /// large lists (10K+ addresses).
234    ///
235    /// Requires the `rayon` feature.
236    ///
237    /// # Example
238    ///
239    /// ```
240    /// use structured_email_address::{EmailAddress, Config};
241    ///
242    /// let config = Config::default();
243    /// let results = EmailAddress::parse_batch_par(
244    ///     &["alice@example.com", "bob@example.org"],
245    ///     &config,
246    /// );
247    /// assert!(results.iter().all(|r| r.is_ok()));
248    /// ```
249    #[cfg(feature = "rayon")]
250    pub fn parse_batch_par(inputs: &[&str], config: &Config) -> Vec<Result<Self, Error>> {
251        use rayon::prelude::*;
252
253        inputs
254            .par_iter()
255            .map(|input| Self::parse_with(input, config))
256            .collect()
257    }
258}
259
260impl std::fmt::Display for EmailAddress {
261    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
262        let local = if needs_quoting(&self.local_part) {
263            format!("\"{}\"", escape_local_part(&self.local_part))
264        } else {
265            self.local_part.clone()
266        };
267        match &self.display_name {
268            Some(name) => write!(
269                f,
270                "\"{}\" <{}@{}>",
271                escape_display_name(name),
272                local,
273                self.domain
274            ),
275            None => write!(f, "{}@{}", local, self.domain),
276        }
277    }
278}
279
280/// Check if a local-part needs quoting for RFC 5321/5322 serialization.
281/// Returns true if the local part contains characters outside of atext.
282fn needs_quoting(local: &str) -> bool {
283    if local.is_empty() {
284        return true;
285    }
286    // Dots are only safe in valid dot-atom form (no leading/trailing/consecutive dots).
287    if local.starts_with('.') || local.ends_with('.') || local.contains("..") {
288        return true;
289    }
290    local.chars().any(|ch| {
291        !ch.is_ascii_alphanumeric()
292            && !matches!(
293                ch,
294                '!' | '#'
295                    | '$'
296                    | '%'
297                    | '&'
298                    | '\''
299                    | '*'
300                    | '+'
301                    | '-'
302                    | '/'
303                    | '='
304                    | '?'
305                    | '^'
306                    | '_'
307                    | '`'
308                    | '{'
309                    | '|'
310                    | '}'
311                    | '~'
312                    | '.'
313            )
314            && (ch as u32) < 0x80 // non-ASCII doesn't need quoting per RFC 6531
315    })
316}
317
318/// Escape a local-part for use inside quotes: backslash-escape `"` and `\`,
319/// strip CR/LF to prevent header injection (FWS is collapsed during normalization).
320fn escape_local_part(local: &str) -> String {
321    let mut escaped = String::with_capacity(local.len());
322    for ch in local.chars() {
323        match ch {
324            '"' | '\\' => {
325                escaped.push('\\');
326                escaped.push(ch);
327            }
328            '\r' | '\n' => {} // strip CRLF to prevent header injection
329            _ => escaped.push(ch),
330        }
331    }
332    escaped
333}
334
335/// Backslash-escapes `"` and `\`, and strips bare CR/LF to prevent
336/// header injection in serialized output.
337fn escape_display_name(name: &str) -> String {
338    let mut escaped = String::with_capacity(name.len());
339    for ch in name.chars() {
340        match ch {
341            '"' => {
342                escaped.push('\\');
343                escaped.push('"');
344            }
345            '\\' => {
346                escaped.push('\\');
347                escaped.push('\\');
348            }
349            '\r' | '\n' => {} // strip CRLF
350            _ => escaped.push(ch),
351        }
352    }
353    escaped
354}
355
356/// Equality is based on canonical form (`local_part` + `domain`) only.
357/// Display name, tag, and skeleton are intentionally excluded —
358/// `"John" <user@example.com>` equals `"Jane" <user@example.com>`
359/// because they route to the same mailbox.
360impl PartialEq for EmailAddress {
361    fn eq(&self, other: &Self) -> bool {
362        self.local_part == other.local_part && self.domain == other.domain
363    }
364}
365
366impl Eq for EmailAddress {}
367
368impl std::hash::Hash for EmailAddress {
369    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
370        self.local_part.hash(state);
371        self.domain.hash(state);
372    }
373}
374
375impl std::str::FromStr for EmailAddress {
376    type Err = Error;
377
378    fn from_str(s: &str) -> Result<Self, Self::Err> {
379        Self::parse_with(s, &Config::default())
380    }
381}
382
383#[cfg(feature = "serde")]
384impl serde::Serialize for EmailAddress {
385    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
386        self.canonical().serialize(serializer)
387    }
388}
389
390#[cfg(feature = "serde")]
391impl<'de> serde::Deserialize<'de> for EmailAddress {
392    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
393        let s = String::deserialize(deserializer)?;
394        s.parse().map_err(serde::de::Error::custom)
395    }
396}
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401
402    // ── FromStr (default config) ──
403
404    #[test]
405    fn parse_simple() {
406        let email: EmailAddress = "user@example.com".parse().unwrap_or_else(|e| panic!("{e}"));
407        assert_eq!(email.local_part(), "user");
408        assert_eq!(email.domain(), "example.com");
409        assert_eq!(email.tag(), None);
410        assert_eq!(email.canonical(), "user@example.com");
411    }
412
413    #[test]
414    fn parse_with_tag() {
415        let email: EmailAddress = "user+newsletter@example.com"
416            .parse()
417            .unwrap_or_else(|e| panic!("{e}"));
418        assert_eq!(email.local_part(), "user+newsletter");
419        assert_eq!(email.tag(), Some("newsletter"));
420    }
421
422    #[test]
423    fn display_format() {
424        let email: EmailAddress = "user@example.com".parse().unwrap_or_else(|e| panic!("{e}"));
425        assert_eq!(format!("{email}"), "user@example.com");
426    }
427
428    #[test]
429    fn display_name_escaping() {
430        let config = Config::builder().allow_display_name().build();
431        // Display name with quotes should be escaped
432        let email = EmailAddress::parse_with("John \"Johnny\" Doe <user@example.com>", &config)
433            .unwrap_or_else(|e| panic!("{e}"));
434        let formatted = format!("{email}");
435        assert!(
436            formatted.contains("\\\"Johnny\\\""),
437            "Expected escaped quotes in: {formatted}"
438        );
439    }
440
441    #[test]
442    fn equality_by_canonical() {
443        let a: EmailAddress = "user@example.com".parse().unwrap_or_else(|e| panic!("{e}"));
444        let b: EmailAddress = "user@Example.COM".parse().unwrap_or_else(|e| panic!("{e}"));
445        // Default config: domain-only lowercase, so local parts same case → equal
446        assert_eq!(a, b);
447    }
448
449    #[test]
450    fn freemail_detection() {
451        let email: EmailAddress = "user@gmail.com".parse().unwrap_or_else(|e| panic!("{e}"));
452        assert!(email.is_freemail());
453
454        let email: EmailAddress = "user@company.com".parse().unwrap_or_else(|e| panic!("{e}"));
455        assert!(!email.is_freemail());
456    }
457
458    #[test]
459    fn freemail_via_custom_provider() {
460        // A registered custom provider marked freemail is reported by is_freemail().
461        use crate::ProviderRule;
462        let config = Config::builder()
463            .add_provider(ProviderRule::new(["freebie.example"]).freemail(true))
464            .build();
465        let email = EmailAddress::parse_with("user@freebie.example", &config)
466            .unwrap_or_else(|e| panic!("{e}"));
467        assert!(email.is_freemail());
468    }
469
470    // ── Provider-aware normalization (#5) ──
471
472    #[test]
473    fn provider_aware_gmail_normalizes_by_rule() {
474        // provider_aware applies Gmail's rule (strip dots, fold case, '+' tag)
475        // without setting any global dot/case policy. strip_subaddress drops the
476        // extracted tag from the canonical form.
477        let config = Config::builder()
478            .provider_aware()
479            .strip_subaddress()
480            .build();
481        let email = EmailAddress::parse_with("A.Li.Ce+promo@Gmail.com", &config)
482            .unwrap_or_else(|e| panic!("{e}"));
483        assert_eq!(email.local_part(), "alice");
484        assert_eq!(email.tag(), Some("promo"));
485        assert_eq!(email.domain(), "gmail.com");
486    }
487
488    #[test]
489    fn provider_aware_gmail_preserves_tag_by_default() {
490        // Default subaddress policy keeps the tag in the canonical local part;
491        // dots are still stripped and case folded by the Gmail rule.
492        let config = Config::builder().provider_aware().build();
493        let email = EmailAddress::parse_with("A.Li.Ce+promo@Gmail.com", &config)
494            .unwrap_or_else(|e| panic!("{e}"));
495        assert_eq!(email.local_part(), "alice+promo");
496        assert_eq!(email.tag(), Some("promo"));
497    }
498
499    #[test]
500    fn provider_aware_leaves_non_provider_domains_to_global_policy() {
501        // A non-provider domain is untouched by provider rules: dots preserved,
502        // local-part case preserved (global defaults).
503        let config = Config::builder().provider_aware().build();
504        let email = EmailAddress::parse_with("A.L.I.C.E@example.com", &config)
505            .unwrap_or_else(|e| panic!("{e}"));
506        assert_eq!(email.local_part(), "A.L.I.C.E");
507        assert_eq!(email.domain(), "example.com");
508    }
509
510    #[test]
511    fn provider_aware_quoted_local_preserves_case() {
512        // A quoted local-part is literal: the provider rule's case folding
513        // (Gmail) must NOT apply inside it, just as dots and the subaddress
514        // separator don't. Without a global lowercase policy, case is preserved.
515        let config = Config::builder().provider_aware().build();
516        let email = EmailAddress::parse_with("\"A.B\"@gmail.com", &config)
517            .unwrap_or_else(|e| panic!("{e}"));
518        assert_eq!(email.local_part(), "A.B");
519
520        // A global lowercase policy is not provider-specific, so it still folds
521        // a quoted local-part — even when a provider rule matches (the rule's
522        // own folding is skipped for quoted, but the global policy still applies).
523        let config = Config::builder().provider_aware().lowercase_all().build();
524        let email = EmailAddress::parse_with("\"A.B\"@gmail.com", &config)
525            .unwrap_or_else(|e| panic!("{e}"));
526        assert_eq!(email.local_part(), "a.b");
527    }
528
529    #[test]
530    fn provider_aware_off_does_not_strip_gmail_dots() {
531        // Without provider_aware and without dots_gmail_only, gmail dots stay.
532        let email: EmailAddress = "a.b.c@gmail.com".parse().unwrap_or_else(|e| panic!("{e}"));
533        assert_eq!(email.local_part(), "a.b.c");
534    }
535
536    #[test]
537    fn custom_provider_aware_rule_applies() {
538        use crate::ProviderRule;
539        // Custom provider: strips dots, '-' separator.
540        let config = Config::builder()
541            .provider_aware()
542            .strip_subaddress()
543            .add_provider(
544                ProviderRule::new(["corp.example"])
545                    .strip_dots(true)
546                    .lowercase_local(true)
547                    .subaddress_separator(Some('-')),
548            )
549            .build();
550        let email = EmailAddress::parse_with("John.Doe-tag@corp.example", &config)
551            .unwrap_or_else(|e| panic!("{e}"));
552        assert_eq!(email.local_part(), "johndoe");
553        assert_eq!(email.tag(), Some("tag"));
554    }
555
556    #[test]
557    fn idn_provider_rule_consistent_across_normalization_and_freemail() {
558        use crate::ProviderRule;
559        // A provider rule registered with the Unicode domain must apply to the
560        // IDNA-encoded address for BOTH provider-aware normalization and
561        // is_freemail() — the canonical domain is used at both call sites.
562        let config = Config::builder()
563            .provider_aware()
564            .add_provider(
565                ProviderRule::new(["münchen.de"])
566                    .strip_dots(true)
567                    .lowercase_local(true)
568                    .freemail(true),
569            )
570            .build();
571        let email =
572            EmailAddress::parse_with("A.B@münchen.de", &config).unwrap_or_else(|e| panic!("{e}"));
573        assert_eq!(email.domain(), "xn--mnchen-3ya.de");
574        assert_eq!(
575            email.local_part(),
576            "ab",
577            "provider rule strips dots + folds case"
578        );
579        assert!(email.is_freemail(), "same rule drives is_freemail");
580    }
581
582    #[test]
583    fn dots_gmail_only_ignores_custom_providers() {
584        use crate::ProviderRule;
585        // GmailOnly is a legacy mode tied to the built-in dot-stripping providers
586        // (Gmail/Googlemail). Custom providers affect normalization ONLY under
587        // provider_aware(); a custom strip_dots rule must NOT leak into GmailOnly
588        // when provider_aware is off.
589        let config = Config::builder()
590            .dots_gmail_only()
591            .add_provider(ProviderRule::new(["corp.example"]).strip_dots(true))
592            .build();
593
594        // The custom provider's strip_dots is ignored: dots are preserved.
595        let email =
596            EmailAddress::parse_with("a.b@corp.example", &config).unwrap_or_else(|e| panic!("{e}"));
597        assert_eq!(email.local_part(), "a.b");
598
599        // Built-in Gmail still strips dots under GmailOnly.
600        let email =
601            EmailAddress::parse_with("a.b.c@gmail.com", &config).unwrap_or_else(|e| panic!("{e}"));
602        assert_eq!(email.local_part(), "abc");
603    }
604
605    // ── Configured parsing ──
606
607    #[test]
608    fn full_normalization_pipeline() {
609        let config = Config::builder()
610            .strip_subaddress()
611            .dots_gmail_only()
612            .lowercase_all()
613            .check_confusables()
614            .build();
615
616        let email = EmailAddress::parse_with("A.L.I.C.E+promo@Gmail.COM", &config)
617            .unwrap_or_else(|e| panic!("{e}"));
618        assert_eq!(email.canonical(), "alice@gmail.com");
619        assert_eq!(email.tag(), Some("promo"));
620        assert!(email.skeleton().is_some());
621    }
622
623    #[test]
624    fn display_name_parsing() {
625        let config = Config::builder().allow_display_name().build();
626
627        let email = EmailAddress::parse_with("John Doe <user@example.com>", &config)
628            .unwrap_or_else(|e| panic!("{e}"));
629        assert_eq!(email.display_name(), Some("John Doe"));
630        assert_eq!(email.local_part(), "user");
631        assert_eq!(email.domain(), "example.com");
632    }
633
634    #[test]
635    fn leading_comment_full_pipeline() {
636        // #40: a leading RFC 5322 comment before the local-part must parse
637        // end-to-end, with the comment stripped from the canonical address.
638        let config = Config::builder()
639            .strictness(Strictness::Lax)
640            .allow_display_name()
641            .allow_domain_literal()
642            .allow_single_label_domain()
643            .lowercase_all()
644            .build();
645
646        for input in [
647            "(comment)jane.smith@example.com",
648            "jane(comment).smith@example.com",
649            "jane.smith(comment)@example.com",
650            "jane.smith@example.com",
651        ] {
652            let email = EmailAddress::parse_with(input, &config)
653                .unwrap_or_else(|e| panic!("'{input}' must parse: {e}"));
654            assert_eq!(email.canonical(), "jane.smith@example.com");
655        }
656    }
657
658    #[test]
659    fn rejects_newline_in_address() {
660        // Header-injection hardening: a trailing newline must not be silently
661        // accepted (it previously survived an over-eager input.trim()).
662        let config = Config::default();
663        assert!("user@example.com\n".parse::<EmailAddress>().is_err());
664        assert!(EmailAddress::parse_with("user@example.com\r\n", &config).is_err());
665    }
666
667    // ── Serde ──
668
669    #[cfg(feature = "serde")]
670    #[test]
671    fn serde_roundtrip() {
672        let email: EmailAddress = "user@example.com".parse().unwrap_or_else(|e| panic!("{e}"));
673        let json = serde_json::to_string(&email).unwrap_or_else(|e| panic!("{e}"));
674        assert_eq!(json, "\"user@example.com\"");
675
676        let back: EmailAddress = serde_json::from_str(&json).unwrap_or_else(|e| panic!("{e}"));
677        assert_eq!(email, back);
678    }
679
680    // ── Validation errors ──
681
682    #[test]
683    fn rejects_empty() {
684        let result: Result<EmailAddress, _> = "".parse();
685        assert!(result.is_err());
686    }
687
688    #[test]
689    fn rejects_no_domain_dot() {
690        let result: Result<EmailAddress, _> = "user@localhost".parse();
691        assert!(result.is_err());
692        assert!(matches!(result.unwrap_err().kind(), ErrorKind::DomainNoDot));
693    }
694
695    #[test]
696    fn allows_single_label_when_configured() {
697        let config = Config::builder().allow_single_label_domain().build();
698        let email =
699            EmailAddress::parse_with("user@localhost", &config).unwrap_or_else(|e| panic!("{e}"));
700        assert_eq!(email.domain(), "localhost");
701    }
702
703    // ── Batch parsing ──
704
705    #[test]
706    fn batch_parse_mixed_results() {
707        // Verifies that parse_batch returns Ok for valid and Err for invalid
708        // inputs, preserving input order.
709        let config = Config::default();
710        let results = EmailAddress::parse_batch(
711            &["alice@example.com", "invalid", "bob@example.org"],
712            &config,
713        );
714        assert_eq!(results.len(), 3);
715        assert!(results[0].is_ok());
716        assert!(results[1].is_err());
717        assert!(results[2].is_ok());
718        assert_eq!(results[0].as_ref().map(|e| e.domain()), Ok("example.com"));
719        assert_eq!(results[2].as_ref().map(|e| e.domain()), Ok("example.org"));
720    }
721
722    #[test]
723    fn batch_parse_empty_input() {
724        // Empty slice returns empty vec.
725        let config = Config::default();
726        let results = EmailAddress::parse_batch(&[], &config);
727        assert!(results.is_empty());
728    }
729
730    #[test]
731    fn batch_parse_all_valid() {
732        // Batch of valid addresses all succeed.
733        let config = Config::default();
734        let inputs = &["a@b.com", "x@y.org", "test+tag@example.com"];
735        let results = EmailAddress::parse_batch(inputs, &config);
736        assert!(results.iter().all(|r| r.is_ok()));
737    }
738
739    #[test]
740    fn batch_parse_all_invalid() {
741        // Batch of invalid addresses all fail.
742        let config = Config::default();
743        let results = EmailAddress::parse_batch(&["", "noatsign", "@missing-local.com"], &config);
744        assert!(results.iter().all(|r| r.is_err()));
745    }
746
747    #[test]
748    fn batch_parse_with_config() {
749        // Batch parsing respects config (e.g., subaddress stripping).
750        let config = Config::builder()
751            .strip_subaddress()
752            .dots_gmail_only()
753            .lowercase_all()
754            .build();
755        let results =
756            EmailAddress::parse_batch(&["A.L.I.C.E+promo@Gmail.COM", "BOB@example.com"], &config);
757        assert_eq!(results.len(), 2);
758        assert_eq!(
759            results[0].as_ref().map(|e| e.canonical()),
760            Ok("alice@gmail.com".to_string())
761        );
762        assert_eq!(
763            results[1].as_ref().map(|e| e.canonical()),
764            Ok("bob@example.com".to_string())
765        );
766    }
767
768    // ── domain_unicode() accessor ──
769
770    #[test]
771    fn domain_unicode_roundtrip() {
772        // IDN domain: input Unicode → domain() punycode → domain_unicode() back to Unicode.
773        let email: EmailAddress = "user@münchen.de".parse().unwrap_or_else(|e| panic!("{e}"));
774        assert_eq!(email.domain(), "xn--mnchen-3ya.de");
775        assert_eq!(email.domain_unicode(), "münchen.de");
776    }
777
778    #[test]
779    fn domain_unicode_ascii_fallback() {
780        // ASCII domain: domain_unicode() returns same as domain().
781        let email: EmailAddress = "user@example.com".parse().unwrap_or_else(|e| panic!("{e}"));
782        assert_eq!(email.domain_unicode(), "example.com");
783        assert_eq!(email.domain_unicode(), email.domain());
784    }
785
786    #[test]
787    fn domain_unicode_mixed_labels() {
788        // Domain with one IDN label and one ASCII label.
789        let email: EmailAddress = "user@über.example.com"
790            .parse()
791            .unwrap_or_else(|e| panic!("{e}"));
792        assert_eq!(email.domain(), "xn--ber-goa.example.com");
793        assert_eq!(email.domain_unicode(), "über.example.com");
794    }
795
796    #[test]
797    fn domain_unicode_japanese() {
798        // Japanese domain roundtrip.
799        let email: EmailAddress = "user@例え.jp".parse().unwrap_or_else(|e| panic!("{e}"));
800        assert!(email.domain().contains("xn--"));
801        assert_eq!(email.domain_unicode(), "例え.jp");
802    }
803
804    #[cfg(feature = "rayon")]
805    #[test]
806    fn batch_par_matches_sequential() {
807        // Parallel variant produces identical results to sequential.
808        let config = Config::builder().strip_subaddress().lowercase_all().build();
809        let inputs = &[
810            "alice@example.com",
811            "invalid",
812            "BOB+tag@Example.ORG",
813            "",
814            "user@test.com",
815        ];
816        let seq = EmailAddress::parse_batch(inputs, &config);
817        let par = EmailAddress::parse_batch_par(inputs, &config);
818        assert_eq!(seq.len(), par.len());
819        for (i, (s, p)) in seq.iter().zip(par.iter()).enumerate() {
820            match (s, p) {
821                (Ok(a), Ok(b)) => assert_eq!(a, b, "result {i} diverges"),
822                (Err(a), Err(b)) => assert_eq!(a, b, "error {i} diverges: {a} vs {b}"),
823                _ => panic!("result {i}: one Ok, one Err"),
824            }
825        }
826    }
827}