mailparsing/datetime.rs
1use chrono::{DateTime, FixedOffset};
2
3/// Parse an RFC 2822 `date-time`, tolerating obsolete alphabetic time zones.
4///
5/// chrono's `parse_from_rfc2822` accepts the zone tokens named in RFC 5322
6/// section 4.3 (`UT`, `GMT`, the North American `EST`/`EDT`/... set) but
7/// rejects any other alphabetic zone. Real senders still emit some: Amazon SES
8/// writes its DSN dates with the token `UTC`, which chrono does not recognize.
9///
10/// When the strict parse fails we retry after replacing a trailing alphabetic
11/// zone token with a numeric offset. A token with a single widely-agreed
12/// meaning (`UTC`, and the common regional abbreviations chrono lacks such as
13/// `CET`/`CEST`) resolves to its real offset. Anything chrono neither knows nor
14/// we can resolve unambiguously falls back to `-0000`, the RFC 5322 section 4.3
15/// unknown-offset marker: the same instant as UTC, which keeps the stated
16/// wall-clock date and time while recording that the true offset is unknown.
17/// The original error is preserved when the retry does not help, so genuinely
18/// malformed input still reports the real problem rather than a zone complaint.
19pub fn parse_rfc2822_date(
20 input: &str,
21) -> Result<DateTime<FixedOffset>, chrono::format::ParseError> {
22 DateTime::parse_from_rfc2822(input).or_else(|err| {
23 if let Some(rewritten) = rewrite_unknown_alphabetic_zone(input) {
24 if let Ok(date) = DateTime::parse_from_rfc2822(&rewritten) {
25 return Ok(date);
26 }
27 }
28 Err(err)
29 })
30}
31
32/// If `input` ends with an alphabetic zone token, return a copy with that token
33/// replaced by a numeric offset; otherwise return `None`.
34///
35/// CFWS (comments and folding whitespace) is stripped with the crate's parser
36/// first, so a trailing comment such as `(Coordinated)` does not hide the zone.
37/// A recognized abbreviation resolves to its offset; any other alphabetic token
38/// becomes the `-0000` unknown offset.
39fn rewrite_unknown_alphabetic_zone(input: &str) -> Option<String> {
40 let cleaned = crate::rfc5322_parser::strip_cfws(input)?;
41 // strip_cfws joins non-empty tokens with single spaces, so the token after
42 // the final space is never empty.
43 let (head, zone) = cleaned.rsplit_once(' ')?;
44 if !zone.bytes().all(|b| b.is_ascii_alphabetic()) {
45 return None;
46 }
47 let offset = named_zone_offset(zone).unwrap_or("-0000");
48 Some(format!("{head} {offset}"))
49}
50
51/// Return the numeric UTC offset, in RFC 2822 `+HHMM` form, for an alphabetic
52/// zone abbreviation, or `None` when it is unknown or ambiguous.
53///
54/// The mapping comes from the generated `zone_offsets` table.
55fn named_zone_offset(zone: &str) -> Option<&'static str> {
56 crate::zone_offsets::ZONE_OFFSETS
57 .get(zone.to_ascii_uppercase().as_str())
58 .copied()
59}
60
61#[cfg(test)]
62mod test {
63 use super::*;
64
65 #[test]
66 fn obsolete_utc_zone() {
67 let ses = parse_rfc2822_date("Thu, 02 Jul 26 18:55:38 UTC").unwrap();
68 let canonical = parse_rfc2822_date("Thu, 02 Jul 2026 18:55:38 +0000").unwrap();
69 k9::assert_equal!(ses, canonical);
70 }
71
72 #[test]
73 fn recognized_zones_keep_their_offset() {
74 // chrono already handles these; the fallback must not intercept them.
75 let eastern = parse_rfc2822_date("Thu, 02 Jul 26 18:55:38 EST").unwrap();
76 k9::assert_equal!(eastern.to_rfc3339(), "2026-07-02T18:55:38-05:00");
77
78 let numeric = parse_rfc2822_date("Tue, 1 Jul 2003 10:52:37 +0200").unwrap();
79 k9::assert_equal!(numeric.to_rfc3339(), "2003-07-01T10:52:37+02:00");
80 }
81
82 #[test]
83 fn regional_zone_resolves_to_its_offset() {
84 // Common abbreviations chrono rejects are resolved to their real offset.
85 let cest = parse_rfc2822_date("Thu, 02 Jul 26 18:55:38 CEST").unwrap();
86 k9::assert_equal!(cest.to_rfc3339(), "2026-07-02T18:55:38+02:00");
87
88 let jst = parse_rfc2822_date("Thu, 02 Jul 26 18:55:38 JST").unwrap();
89 k9::assert_equal!(jst.to_rfc3339(), "2026-07-02T18:55:38+09:00");
90 }
91
92 #[test]
93 fn ambiguous_zone_becomes_unknown_offset() {
94 // The tz database itself renders `IST` at three offsets (Ireland
95 // +0100, Israel +0200, India +0530), so it is ambiguous in the data
96 // and we cannot resolve it. Per RFC 5322 section 4.3 it keeps the
97 // stated wall-clock time at the -0000 unknown offset, which is the same
98 // instant as UTC.
99 let ist = parse_rfc2822_date("Thu, 02 Jul 26 18:55:38 IST").unwrap();
100 let utc = parse_rfc2822_date("Thu, 02 Jul 26 18:55:38 UTC").unwrap();
101 k9::assert_equal!(ist, utc);
102 }
103
104 #[test]
105 fn trailing_zone_comment_is_ignored() {
106 let with_comment = parse_rfc2822_date("Thu, 02 Jul 26 18:55:38 UTC (Coordinated)").unwrap();
107 let canonical = parse_rfc2822_date("Thu, 02 Jul 2026 18:55:38 +0000").unwrap();
108 k9::assert_equal!(with_comment, canonical);
109 }
110
111 #[test]
112 fn garbage_still_errors() {
113 parse_rfc2822_date("not a date").unwrap_err();
114 }
115}