1use std::fmt;
22use std::str::FromStr;
23
24const 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#[derive(Debug, Clone, PartialEq, Eq)]
56pub enum EmailError {
57 Empty,
59 MissingAtSign,
61 MultipleAtSigns,
63 EmptyLocalPart,
65 EmptyDomain,
67 LocalPartTooLong,
69 DomainTooLong,
71 TotalTooLong,
73 InvalidLocalPart(String),
75 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#[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 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 pub fn is_valid(input: &str) -> bool {
184 Email::parse(input).is_ok()
185 }
186
187 pub fn local_part(&self) -> &str {
189 &self.local_part
190 }
191
192 pub fn domain(&self) -> &str {
194 &self.domain
195 }
196
197 pub fn display_name(&self) -> Option<&str> {
199 self.display_name.as_deref()
200 }
201
202 pub fn as_str(&self) -> String {
204 format!("{}@{}", self.local_part, self.domain)
205 }
206
207 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 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 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
281fn extract_display_name(input: &str) -> Result<(Option<String>, String), EmailError> {
283 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 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
310fn split_address(address: &str) -> Result<(String, String), EmailError> {
312 if let Some(after_quote) = address.strip_prefix('"') {
313 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 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
346fn validate_local_part(local: &str) -> Result<(), EmailError> {
348 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
387fn validate_domain(domain: &str) -> Result<(), EmailError> {
389 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 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}