1#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct ParsedEmail {
22 pub message_id: Option<String>,
24 pub in_reply_to: Option<String>,
26 pub references: Option<String>,
28 pub subject: Option<String>,
30 pub from: Address,
32 pub to: Vec<Address>,
34 pub cc: Vec<Address>,
36 pub bcc: Vec<Address>,
38 pub reply_to: Vec<Address>,
40 pub date: Option<DateTime>,
42 pub body_text: Option<String>,
44 pub body_html: Option<String>,
46 pub attachments: Vec<ParsedAttachment>,
48 pub raw_headers: String,
50 pub size: u64,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct Address {
60 pub name: Option<String>,
62 pub email: String,
64}
65
66const SPECIALS: &[char] = &[
68 '(', ')', '<', '>', '[', ']', ':', ';', '@', '\\', ',', '.', '"',
69];
70
71impl std::fmt::Display for Address {
72 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80 match &self.name {
81 Some(name) if !name.is_empty() => {
82 if !name.is_ascii() {
83 let encoded = crate::builder::encode_rfc2047_if_needed(name);
86 write!(f, "{encoded} <{}>", self.email)
87 } else if name.chars().any(|c| SPECIALS.contains(&c)) {
88 let escaped = name.replace('\\', "\\\\").replace('"', "\\\"");
89 write!(f, "\"{escaped}\" <{}>", self.email)
90 } else {
91 write!(f, "{name} <{}>", self.email)
92 }
93 }
94 _ => write!(f, "{}", self.email),
95 }
96 }
97}
98
99impl std::str::FromStr for Address {
100 type Err = crate::error::Error;
101
102 fn from_str(s: &str) -> Result<Self, Self::Err> {
107 let s = s.trim();
108 if s.is_empty() {
109 return Err(crate::error::Error::InvalidAddress(
110 "empty address string".into(),
111 ));
112 }
113
114 if let Some(angle_start) = s.rfind('<') {
116 if let Some(angle_end) = s.rfind('>') {
117 if angle_end > angle_start {
118 let email = s[angle_start + 1..angle_end].trim().to_string();
119 let name_part = s[..angle_start].trim();
120 let name = if name_part.is_empty() {
121 None
122 } else {
123 let stripped = strip_outer_quotes(name_part);
128 let name = stripped.trim().to_string();
129 if name.is_empty() {
130 None
131 } else {
132 let unescaped = unescape_quoted_string(&name);
135 let decoded = crate::parser::decode_encoded_words(&unescaped);
136 Some(decoded)
137 }
138 };
139 if email.is_empty() {
140 return Err(crate::error::Error::InvalidAddress(
141 "empty email in angle brackets".into(),
142 ));
143 }
144 return Ok(Self { name, email });
145 }
146 }
147 }
148
149 if s.contains('@') {
151 return Ok(Self {
152 name: None,
153 email: s.to_string(),
154 });
155 }
156
157 Err(crate::error::Error::InvalidAddress(format!(
158 "cannot parse address: {s}"
159 )))
160 }
161}
162
163fn strip_outer_quotes(input: &str) -> &str {
173 if input.len() >= 2 && input.starts_with('"') && input.ends_with('"') {
174 &input[1..input.len() - 1]
175 } else {
176 input
177 }
178}
179
180fn unescape_quoted_string(input: &str) -> String {
184 let mut result = String::with_capacity(input.len());
185 let mut chars = input.chars();
186 while let Some(c) = chars.next() {
187 if c == '\\' {
188 if let Some(next) = chars.next() {
189 result.push(next);
190 } else {
191 result.push(c);
192 }
193 } else {
194 result.push(c);
195 }
196 }
197 result
198}
199
200#[derive(Debug, Clone, PartialEq, Eq)]
209pub struct ParsedAttachment {
210 pub filename: Option<String>,
212 pub content_type: String,
214 pub content_id: Option<String>,
216 pub is_inline: bool,
218 pub size: Option<u64>,
220 pub section: Option<String>,
222}
223
224const DOW_NAMES: [&str; 7] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
226
227const MONTH_NAMES: [&str; 12] = [
229 "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
230];
231
232#[derive(Debug, Clone)]
237pub struct DateTime {
238 pub year: u16,
240 pub month: u8,
242 pub day: u8,
244 pub hour: u8,
246 pub minute: u8,
248 pub second: u8,
250 pub tz_offset_minutes: i16,
252}
253
254impl DateTime {
255 pub fn to_unix_timestamp(&self) -> i64 {
263 let days = Self::civil_to_days(
264 i32::from(self.year),
265 u32::from(self.month),
266 u32::from(self.day),
267 );
268 let secs = days * 86400
269 + i64::from(self.hour) * 3600
270 + i64::from(self.minute) * 60
271 + i64::from(self.second);
272 secs - i64::from(self.tz_offset_minutes) * 60
274 }
275
276 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
282 pub fn from_unix_timestamp(timestamp: i64, tz_offset_minutes: i16) -> Self {
283 let local_secs = timestamp + i64::from(tz_offset_minutes) * 60;
285 let days = local_secs.div_euclid(86400);
286 let time_secs = local_secs.rem_euclid(86400) as u64;
287
288 let (year, month, day) = Self::civil_from_days(days);
289
290 Self {
291 year: year as u16,
292 month: month as u8,
293 day: day as u8,
294 hour: (time_secs / 3600) as u8,
295 minute: ((time_secs % 3600) / 60) as u8,
296 second: (time_secs % 60) as u8,
297 tz_offset_minutes,
298 }
299 }
300
301 #[allow(
306 clippy::cast_possible_truncation,
307 clippy::cast_sign_loss,
308 clippy::cast_possible_wrap
309 )]
310 pub fn now() -> Self {
311 use std::time::{SystemTime, UNIX_EPOCH};
312
313 let secs = SystemTime::now()
314 .duration_since(UNIX_EPOCH)
315 .unwrap_or_default()
316 .as_secs();
317
318 Self::from_unix_timestamp(secs as i64, 0)
319 }
320
321 #[allow(clippy::cast_sign_loss)]
328 pub fn weekday(&self) -> u8 {
329 let days = Self::civil_to_days(
330 i32::from(self.year),
331 u32::from(self.month),
332 u32::from(self.day),
333 );
334 (((days % 7) + 4 + 7) % 7) as u8
336 }
337
338 pub fn to_rfc5322_string(&self) -> String {
346 let dow = self.weekday();
347 let dow_name = DOW_NAMES[dow as usize];
348 let month_idx = self.month.clamp(1, 12).saturating_sub(1) as usize;
351 let month_name = MONTH_NAMES[month_idx];
352 let (sign, tz_h, tz_m) = self.tz_parts();
353 format!(
354 "{dow_name}, {:02} {month_name} {:04} {:02}:{:02}:{:02} {sign}{tz_h:02}{tz_m:02}",
355 self.day, self.year, self.hour, self.minute, self.second,
356 )
357 }
358
359 pub fn to_iso8601_string(&self) -> String {
370 let (sign, tz_h, tz_m) = self.tz_parts();
371 format!(
372 "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}{sign}{tz_h:02}:{tz_m:02}",
373 self.year, self.month, self.day, self.hour, self.minute, self.second,
374 )
375 }
376
377 pub fn parse_rfc5322(input: &str) -> Option<Self> {
390 crate::parser::parse_rfc5322_date(input)
391 }
392
393 fn tz_parts(&self) -> (char, u16, u16) {
398 let sign = if self.tz_offset_minutes >= 0 {
399 '+'
400 } else {
401 '-'
402 };
403 let abs = self.tz_offset_minutes.unsigned_abs();
404 (sign, abs / 60, abs % 60)
405 }
406
407 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
411 fn civil_from_days(z: i64) -> (i32, u32, u32) {
412 let z = z + 719_468;
413 let era = (if z >= 0 { z } else { z - 146_096 }) / 146_097;
414 let doe = (z - era * 146_097) as u32;
415 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
416 let y = i64::from(yoe) + era * 400;
417 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
418 let mp = (5 * doy + 2) / 153;
419 let d = doy - (153 * mp + 2) / 5 + 1;
420 let m = if mp < 10 { mp + 3 } else { mp - 9 };
421 let y = if m <= 2 { y + 1 } else { y };
422 (y as i32, m, d)
423 }
424
425 #[allow(clippy::cast_sign_loss, clippy::cast_possible_wrap)]
429 fn civil_to_days(year: i32, month: u32, day: u32) -> i64 {
430 let y = if month <= 2 {
431 i64::from(year) - 1
432 } else {
433 i64::from(year)
434 };
435 let m = if month <= 2 { month + 9 } else { month - 3 };
436 let era = (if y >= 0 { y } else { y - 399 }) / 400;
437 let yoe = (y - era * 400) as u64;
438 let doy = (153 * u64::from(m) + 2) / 5 + u64::from(day) - 1;
439 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
440 era * 146_097 + doe as i64 - 719_468
441 }
442}
443
444impl PartialEq for DateTime {
445 fn eq(&self, other: &Self) -> bool {
451 self.to_unix_timestamp() == other.to_unix_timestamp()
452 }
453}
454
455impl Eq for DateTime {}
456
457impl std::hash::Hash for DateTime {
458 fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
464 self.to_unix_timestamp().hash(state);
465 }
466}
467
468impl PartialOrd for DateTime {
469 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
470 Some(self.cmp(other))
471 }
472}
473
474impl Ord for DateTime {
475 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
477 self.to_unix_timestamp().cmp(&other.to_unix_timestamp())
478 }
479}
480
481impl std::fmt::Display for DateTime {
482 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
483 let (sign, tz_h, tz_m) = self.tz_parts();
484 write!(
485 f,
486 "{:04}-{:02}-{:02} {:02}:{:02}:{:02} {sign}{tz_h:02}{tz_m:02}",
487 self.year, self.month, self.day, self.hour, self.minute, self.second,
488 )
489 }
490}
491
492impl std::str::FromStr for DateTime {
493 type Err = crate::error::Error;
494
495 fn from_str(s: &str) -> Result<Self, Self::Err> {
506 Self::parse_rfc5322(s).ok_or_else(|| {
507 crate::error::Error::InvalidDate(format!("cannot parse RFC 5322 date: {s}"))
508 })
509 }
510}
511
512#[derive(Debug, Clone, PartialEq, Eq)]
520pub struct OutgoingEmail {
521 pub from: Address,
523 pub to: Vec<Address>,
525 pub cc: Vec<Address>,
527 pub bcc: Vec<Address>,
530 pub reply_to: Option<Address>,
532 pub subject: String,
534 pub body_text: Option<String>,
536 pub body_html: Option<String>,
538 pub in_reply_to: Option<String>,
540 pub references: Option<String>,
542 pub attachments: Vec<OutgoingAttachment>,
544}
545
546#[derive(Debug, Clone, PartialEq, Eq)]
552pub struct OutgoingAttachment {
553 pub filename: String,
555 pub content_type: String,
557 pub data: Vec<u8>,
559}
560
561#[derive(Debug, Clone, PartialEq, Eq)]
567pub struct BuiltMessage {
568 pub raw: Vec<u8>,
570 pub envelope_recipients: Vec<String>,
572 pub message_id: String,
574}
575
576#[cfg(test)]
577#[allow(clippy::unwrap_used)]
578mod tests {
579 use super::*;
580
581 #[test]
582 fn address_display_bare_email() {
583 let addr = Address {
584 name: None,
585 email: "user@example.com".into(),
586 };
587 assert_eq!(addr.to_string(), "user@example.com");
588 }
589
590 #[test]
591 fn address_display_with_name() {
592 let addr = Address {
593 name: Some("John Doe".into()),
594 email: "john@example.com".into(),
595 };
596 assert_eq!(addr.to_string(), "John Doe <john@example.com>");
597 }
598
599 #[test]
600 fn address_display_name_with_specials_quoted() {
601 let addr = Address {
602 name: Some("Doe, John".into()),
603 email: "john@example.com".into(),
604 };
605 assert_eq!(addr.to_string(), "\"Doe, John\" <john@example.com>");
606 }
607
608 #[test]
609 fn address_display_name_with_quotes_escaped() {
610 let addr = Address {
611 name: Some("John \"Doc\" Doe".into()),
612 email: "john@example.com".into(),
613 };
614 assert_eq!(
615 addr.to_string(),
616 "\"John \\\"Doc\\\" Doe\" <john@example.com>"
617 );
618 }
619
620 #[test]
621 fn address_from_str_bare_email() {
622 let addr: Address = "user@example.com".parse().unwrap();
623 assert_eq!(addr.email, "user@example.com");
624 assert!(addr.name.is_none());
625 }
626
627 #[test]
628 fn address_from_str_with_name() {
629 let addr: Address = "John Doe <john@example.com>".parse().unwrap();
630 assert_eq!(addr.email, "john@example.com");
631 assert_eq!(addr.name.as_deref(), Some("John Doe"));
632 }
633
634 #[test]
635 fn address_from_str_quoted_name() {
636 let addr: Address = "\"Doe, John\" <john@example.com>".parse().unwrap();
637 assert_eq!(addr.email, "john@example.com");
638 assert_eq!(addr.name.as_deref(), Some("Doe, John"));
639 }
640
641 #[test]
642 fn address_from_str_escaped_quotes_in_name() {
643 let addr: Address = "\"John \\\"Doc\\\" Doe\" <john@example.com>"
644 .parse()
645 .unwrap();
646 assert_eq!(addr.email, "john@example.com");
647 assert_eq!(addr.name.as_deref(), Some("John \"Doc\" Doe"));
648 }
649
650 #[test]
651 fn address_from_str_empty_rejected() {
652 let result: Result<Address, _> = "".parse();
653 assert!(result.is_err());
654 }
655
656 #[test]
657 fn address_from_str_no_at_rejected() {
658 let result: Result<Address, _> = "not-an-email".parse();
659 assert!(result.is_err());
660 }
661
662 #[test]
663 fn address_round_trip_display_from_str() {
664 let original = Address {
665 name: Some("Doe, John".into()),
666 email: "john@example.com".into(),
667 };
668 let displayed = original.to_string();
669 let parsed: Address = displayed.parse().unwrap();
670 assert_eq!(original, parsed);
671 }
672
673 #[test]
674 fn datetime_now_returns_plausible_date() {
675 let now = DateTime::now();
676 assert!(now.year >= 2025, "DateTime::now() year is {}", now.year);
678 assert!((1..=12).contains(&now.month));
679 assert!((1..=31).contains(&now.day));
680 assert!(now.hour <= 23);
681 assert!(now.minute <= 59);
682 assert!(now.second <= 60);
683 assert_eq!(now.tz_offset_minutes, 0, "now() should return UTC");
684 }
685
686 #[test]
687 fn datetime_weekday_known_dates() {
688 let dt = DateTime {
690 year: 2025,
691 month: 2,
692 day: 13,
693 hour: 0,
694 minute: 0,
695 second: 0,
696 tz_offset_minutes: 0,
697 };
698 assert_eq!(dt.weekday(), 4, "2025-02-13 should be Thursday (4)");
699
700 let epoch = DateTime::from_unix_timestamp(0, 0);
702 assert_eq!(epoch.weekday(), 4, "1970-01-01 should be Thursday (4)");
703
704 let sunday = DateTime {
706 year: 2025,
707 month: 3,
708 day: 16,
709 hour: 0,
710 minute: 0,
711 second: 0,
712 tz_offset_minutes: 0,
713 };
714 assert_eq!(sunday.weekday(), 0, "2025-03-16 should be Sunday (0)");
715 }
716
717 #[test]
718 fn datetime_eq_consistent_with_ord() {
719 let utc = DateTime {
722 year: 2025,
723 month: 1,
724 day: 15,
725 hour: 12,
726 minute: 0,
727 second: 0,
728 tz_offset_minutes: 0,
729 };
730 let plus_five = DateTime {
731 year: 2025,
732 month: 1,
733 day: 15,
734 hour: 17,
735 minute: 0,
736 second: 0,
737 tz_offset_minutes: 300, };
739
740 assert_eq!(utc.to_unix_timestamp(), plus_five.to_unix_timestamp());
742
743 assert_eq!(
745 utc.cmp(&plus_five),
746 std::cmp::Ordering::Equal,
747 "cmp should consider same-UTC-instant values equal"
748 );
749
750 assert_eq!(
752 utc, plus_five,
753 "PartialEq must agree with Ord: same UTC instant should be =="
754 );
755 }
756
757 #[test]
758 fn datetime_hash_consistent_with_eq() {
759 use std::collections::hash_map::DefaultHasher;
760 use std::hash::{Hash, Hasher};
761
762 fn hash_of(dt: &DateTime) -> u64 {
763 let mut hasher = DefaultHasher::new();
764 dt.hash(&mut hasher);
765 hasher.finish()
766 }
767
768 let utc = DateTime {
771 year: 2025,
772 month: 1,
773 day: 15,
774 hour: 12,
775 minute: 0,
776 second: 0,
777 tz_offset_minutes: 0,
778 };
779 let plus_five = DateTime {
780 year: 2025,
781 month: 1,
782 day: 15,
783 hour: 17,
784 minute: 0,
785 second: 0,
786 tz_offset_minutes: 300,
787 };
788
789 assert_eq!(utc, plus_five);
791
792 assert_eq!(
794 hash_of(&utc),
795 hash_of(&plus_five),
796 "Hash must be consistent with Eq: same UTC instant must hash the same"
797 );
798 }
799
800 #[test]
801 fn datetime_to_rfc5322_string_utc() {
802 let dt = DateTime {
803 year: 2025,
804 month: 2,
805 day: 13,
806 hour: 15,
807 minute: 47,
808 second: 33,
809 tz_offset_minutes: 0,
810 };
811 assert_eq!(dt.to_rfc5322_string(), "Thu, 13 Feb 2025 15:47:33 +0000");
813 }
814
815 #[test]
816 fn datetime_to_rfc5322_string_positive_offset() {
817 let dt = DateTime {
818 year: 2025,
819 month: 2,
820 day: 13,
821 hour: 21,
822 minute: 17,
823 second: 33,
824 tz_offset_minutes: 330, };
826 assert_eq!(dt.to_rfc5322_string(), "Thu, 13 Feb 2025 21:17:33 +0530");
827 }
828
829 #[test]
830 fn datetime_to_rfc5322_string_negative_offset() {
831 let dt = DateTime {
832 year: 2025,
833 month: 3,
834 day: 16,
835 hour: 9,
836 minute: 0,
837 second: 0,
838 tz_offset_minutes: -480, };
840 assert_eq!(dt.to_rfc5322_string(), "Sun, 16 Mar 2025 09:00:00 -0800");
842 }
843
844 #[test]
845 fn datetime_parse_rfc5322_basic() {
846 let dt = DateTime::parse_rfc5322("Thu, 13 Feb 2025 15:47:33 +0000").unwrap();
847 assert_eq!(dt.year, 2025);
848 assert_eq!(dt.month, 2);
849 assert_eq!(dt.day, 13);
850 assert_eq!(dt.hour, 15);
851 assert_eq!(dt.minute, 47);
852 assert_eq!(dt.second, 33);
853 assert_eq!(dt.tz_offset_minutes, 0);
854 }
855
856 #[test]
857 fn datetime_parse_rfc5322_with_offset() {
858 let dt = DateTime::parse_rfc5322("Fri, 14 Feb 2025 09:15:00 -0800").unwrap();
859 assert_eq!(dt.year, 2025);
860 assert_eq!(dt.tz_offset_minutes, -480);
861 }
862
863 #[test]
864 fn datetime_parse_rfc5322_round_trip() {
865 let original = DateTime {
866 year: 2025,
867 month: 12,
868 day: 25,
869 hour: 0,
870 minute: 0,
871 second: 0,
872 tz_offset_minutes: 330,
873 };
874 let s = original.to_rfc5322_string();
875 let parsed = DateTime::parse_rfc5322(&s).unwrap();
876 assert_eq!(original, parsed);
877 }
878
879 #[test]
880 fn datetime_parse_rfc5322_invalid() {
881 assert!(DateTime::parse_rfc5322("not a date").is_none());
882 assert!(DateTime::parse_rfc5322("").is_none());
883 }
884
885 #[test]
886 fn datetime_to_iso8601_string_utc() {
887 let dt = DateTime {
888 year: 2025,
889 month: 2,
890 day: 13,
891 hour: 15,
892 minute: 47,
893 second: 33,
894 tz_offset_minutes: 0,
895 };
896 assert_eq!(dt.to_iso8601_string(), "2025-02-13T15:47:33+00:00");
897 }
898
899 #[test]
900 fn datetime_to_iso8601_string_positive_offset() {
901 let dt = DateTime {
902 year: 2025,
903 month: 2,
904 day: 13,
905 hour: 21,
906 minute: 17,
907 second: 33,
908 tz_offset_minutes: 330, };
910 assert_eq!(dt.to_iso8601_string(), "2025-02-13T21:17:33+05:30");
911 }
912
913 #[test]
914 fn datetime_to_iso8601_string_negative_offset() {
915 let dt = DateTime {
916 year: 2025,
917 month: 3,
918 day: 16,
919 hour: 9,
920 minute: 0,
921 second: 0,
922 tz_offset_minutes: -480, };
924 assert_eq!(dt.to_iso8601_string(), "2025-03-16T09:00:00-08:00");
925 }
926
927 #[test]
931 fn address_display_non_ascii_is_rfc2047_encoded() {
932 let addr = Address {
933 name: Some("José García".into()),
934 email: "jose@example.com".into(),
935 };
936 let displayed = addr.to_string();
937
938 assert!(
940 displayed.is_ascii(),
941 "Display output must be pure ASCII, got: {displayed}"
942 );
943 assert!(
945 displayed.contains("=?UTF-8?B?"),
946 "Non-ASCII name must be RFC 2047 encoded, got: {displayed}"
947 );
948 assert!(
950 displayed.contains("<jose@example.com>"),
951 "Email must appear in angle brackets, got: {displayed}"
952 );
953 }
954
955 #[test]
958 fn address_display_ascii_name_unchanged() {
959 let addr = Address {
960 name: Some("John Doe".into()),
961 email: "john@example.com".into(),
962 };
963 let displayed = addr.to_string();
964 assert_eq!(displayed, "John Doe <john@example.com>");
965 assert!(
967 !displayed.contains("=?"),
968 "ASCII name should not be RFC 2047 encoded, got: {displayed}"
969 );
970 }
971
972 #[test]
975 fn address_display_non_ascii_round_trip() {
976 let original = Address {
977 name: Some("José García".into()),
978 email: "jose@example.com".into(),
979 };
980 let displayed = original.to_string();
981 let parsed: Address = displayed.parse().unwrap();
982 assert_eq!(
983 original, parsed,
984 "Round-trip failed: displayed as '{displayed}', parsed name = {:?}",
985 parsed.name
986 );
987 }
988
989 #[test]
993 fn datetime_invalid_month_no_panic() {
994 let dt_zero = DateTime {
996 year: 2025,
997 month: 0,
998 day: 15,
999 hour: 12,
1000 minute: 0,
1001 second: 0,
1002 tz_offset_minutes: 0,
1003 };
1004 let _ = dt_zero.to_rfc5322_string();
1006 let _ = dt_zero.weekday();
1007
1008 let dt_thirteen = DateTime {
1010 year: 2025,
1011 month: 13,
1012 day: 15,
1013 hour: 12,
1014 minute: 0,
1015 second: 0,
1016 tz_offset_minutes: 0,
1017 };
1018 let _ = dt_thirteen.to_rfc5322_string();
1020 let _ = dt_thirteen.weekday();
1021
1022 let dt_max = DateTime {
1024 year: 2025,
1025 month: 255,
1026 day: 15,
1027 hour: 12,
1028 minute: 0,
1029 second: 0,
1030 tz_offset_minutes: 0,
1031 };
1032 let _ = dt_max.to_rfc5322_string();
1034 let _ = dt_max.weekday();
1035 }
1036
1037 #[test]
1038 fn datetime_from_str_basic() {
1039 let dt: DateTime = "Thu, 13 Feb 2025 15:47:33 +0000".parse().unwrap();
1040 assert_eq!(dt.year, 2025);
1041 assert_eq!(dt.month, 2);
1042 assert_eq!(dt.day, 13);
1043 assert_eq!(dt.hour, 15);
1044 assert_eq!(dt.minute, 47);
1045 assert_eq!(dt.second, 33);
1046 assert_eq!(dt.tz_offset_minutes, 0);
1047 }
1048
1049 #[test]
1050 fn datetime_from_str_with_offset() {
1051 let dt: DateTime = "Fri, 14 Feb 2025 09:15:00 -0800".parse().unwrap();
1052 assert_eq!(dt.year, 2025);
1053 assert_eq!(dt.tz_offset_minutes, -480);
1054 }
1055
1056 #[test]
1057 fn datetime_from_str_invalid() {
1058 let result: Result<DateTime, _> = "not a date".parse();
1059 assert!(result.is_err());
1060 }
1061
1062 #[test]
1063 fn datetime_from_str_empty() {
1064 let result: Result<DateTime, _> = "".parse();
1065 assert!(result.is_err());
1066 }
1067
1068 #[test]
1069 fn datetime_from_str_round_trip() {
1070 let original = DateTime {
1071 year: 2025,
1072 month: 7,
1073 day: 4,
1074 hour: 12,
1075 minute: 30,
1076 second: 0,
1077 tz_offset_minutes: -300,
1078 };
1079 let s = original.to_rfc5322_string();
1080 let parsed: DateTime = s.parse().unwrap();
1081 assert_eq!(original, parsed);
1082 }
1083
1084 #[test]
1085 fn datetime_display_utc() {
1086 let dt = DateTime {
1087 year: 2025,
1088 month: 2,
1089 day: 13,
1090 hour: 15,
1091 minute: 47,
1092 second: 33,
1093 tz_offset_minutes: 0,
1094 };
1095 assert_eq!(dt.to_string(), "2025-02-13 15:47:33 +0000");
1096 }
1097
1098 #[test]
1099 fn datetime_display_positive_offset() {
1100 let dt = DateTime {
1101 year: 2025,
1102 month: 2,
1103 day: 13,
1104 hour: 21,
1105 minute: 17,
1106 second: 33,
1107 tz_offset_minutes: 330, };
1109 assert_eq!(dt.to_string(), "2025-02-13 21:17:33 +0530");
1110 }
1111
1112 #[test]
1113 fn datetime_display_negative_offset() {
1114 let dt = DateTime {
1115 year: 2025,
1116 month: 3,
1117 day: 16,
1118 hour: 9,
1119 minute: 0,
1120 second: 0,
1121 tz_offset_minutes: -480, };
1123 assert_eq!(dt.to_string(), "2025-03-16 09:00:00 -0800");
1124 }
1125
1126 #[test]
1127 fn datetime_display_non_half_hour_offset() {
1128 let dt = DateTime {
1130 year: 2025,
1131 month: 6,
1132 day: 1,
1133 hour: 12,
1134 minute: 0,
1135 second: 0,
1136 tz_offset_minutes: 345, };
1138 assert_eq!(dt.to_string(), "2025-06-01 12:00:00 +0545");
1139 }
1140
1141 #[test]
1142 fn datetime_display_extreme_offset() {
1143 let dt = DateTime {
1145 year: 2025,
1146 month: 1,
1147 day: 1,
1148 hour: 0,
1149 minute: 0,
1150 second: 0,
1151 tz_offset_minutes: 720, };
1153 assert_eq!(dt.to_string(), "2025-01-01 00:00:00 +1200");
1154
1155 let dt_neg = DateTime {
1157 year: 2025,
1158 month: 1,
1159 day: 1,
1160 hour: 0,
1161 minute: 0,
1162 second: 0,
1163 tz_offset_minutes: -720, };
1165 assert_eq!(dt_neg.to_string(), "2025-01-01 00:00:00 -1200");
1166 }
1167
1168 #[test]
1171 fn address_from_str_empty_angle_brackets_rejected() {
1172 let result: Result<Address, _> = "<>".parse();
1173 let err = result.unwrap_err();
1174 let msg = err.to_string();
1175 assert!(
1176 msg.contains("empty email in angle brackets"),
1177 "expected 'empty email in angle brackets' error, got: {msg}"
1178 );
1179 }
1180
1181 #[test]
1184 fn address_from_str_angle_brackets_no_name() {
1185 let addr: Address = "<user@example.com>".parse().unwrap();
1186 assert_eq!(addr.email, "user@example.com");
1187 assert!(
1188 addr.name.is_none(),
1189 "expected name to be None for bare angle-bracket address, got: {:?}",
1190 addr.name
1191 );
1192 }
1193
1194 #[test]
1198 fn address_from_str_quoted_empty_name_is_none() {
1199 let addr: Address = "\"\" <user@example.com>".parse().unwrap();
1200 assert_eq!(addr.email, "user@example.com");
1201 assert!(
1202 addr.name.is_none(),
1203 "expected name to be None for quoted empty name, got: {:?}",
1204 addr.name
1205 );
1206 }
1207
1208 #[test]
1212 fn address_from_str_whitespace_only_name_is_none() {
1213 let addr: Address = " <user@example.com>".parse().unwrap();
1214 assert_eq!(addr.email, "user@example.com");
1215 assert!(
1216 addr.name.is_none(),
1217 "expected name to be None for whitespace-only prefix, got: {:?}",
1218 addr.name
1219 );
1220 }
1221
1222 #[test]
1226 fn address_from_str_unclosed_angle_bracket() {
1227 let result: Result<Address, _> = "<user@example.com".parse();
1230 if let Ok(addr) = result {
1233 assert!(addr.name.is_none());
1235 assert!(addr.email.contains("user@example.com"));
1236 }
1237 }
1239
1240 #[test]
1245 fn address_display_name_with_special_chars() {
1246 let addr = Address {
1247 name: Some("O'Brien \\test".into()),
1248 email: "obrien@example.com".into(),
1249 };
1250 let displayed = addr.to_string();
1251 assert!(
1253 displayed.contains("\\\\"),
1254 "backslash should be escaped in quoted name, got: {displayed}"
1255 );
1256 assert!(
1257 displayed.starts_with('"'),
1258 "name with specials should be quoted, got: {displayed}"
1259 );
1260 assert!(
1261 displayed.contains("<obrien@example.com>"),
1262 "email must appear in angle brackets, got: {displayed}"
1263 );
1264 }
1265
1266 #[test]
1270 fn unescape_trailing_backslash() {
1271 let result = unescape_quoted_string("hello\\");
1272 assert_eq!(
1273 result, "hello\\",
1274 "trailing backslash with no following char should be preserved"
1275 );
1276 }
1277}