1#![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#[derive(Debug, Clone)]
59pub struct EmailAddress {
60 original: String,
62 local_part: String,
64 tag: Option<String>,
66 domain: String,
68 domain_unicode: Option<String>,
70 display_name: Option<String>,
72 skeleton: Option<String>,
74 freemail: bool,
76}
77
78impl EmailAddress {
79 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 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 pub fn local_part(&self) -> &str {
115 &self.local_part
116 }
117
118 pub fn tag(&self) -> Option<&str> {
124 self.tag.as_deref()
125 }
126
127 pub fn domain(&self) -> &str {
129 &self.domain
130 }
131
132 pub fn domain_unicode(&self) -> &str {
161 self.domain_unicode.as_deref().unwrap_or(&self.domain)
162 }
163
164 pub fn display_name(&self) -> Option<&str> {
166 self.display_name.as_deref()
167 }
168
169 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 pub fn original(&self) -> &str {
184 &self.original
185 }
186
187 pub fn skeleton(&self) -> Option<&str> {
191 self.skeleton.as_deref()
192 }
193
194 pub fn is_freemail(&self) -> bool {
200 self.freemail
201 }
202
203 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 #[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
280fn needs_quoting(local: &str) -> bool {
283 if local.is_empty() {
284 return true;
285 }
286 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 })
316}
317
318fn 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' => {} _ => escaped.push(ch),
330 }
331 }
332 escaped
333}
334
335fn 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' => {} _ => escaped.push(ch),
351 }
352 }
353 escaped
354}
355
356impl 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 #[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 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 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 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 #[test]
473 fn provider_aware_gmail_normalizes_by_rule() {
474 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 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 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 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 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 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 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 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 let config = Config::builder()
590 .dots_gmail_only()
591 .add_provider(ProviderRule::new(["corp.example"]).strip_dots(true))
592 .build();
593
594 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 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 #[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 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 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 #[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 #[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 #[test]
706 fn batch_parse_mixed_results() {
707 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 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 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 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 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 #[test]
771 fn domain_unicode_roundtrip() {
772 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 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 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 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 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}