mailparsing/
datetime.rs

1use chrono::{DateTime, Datelike, FixedOffset, Offset, TimeZone, Timelike};
2
3const SHORT_WEEKDAYS: [&str; 7] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
4const SHORT_MONTHS: [&str; 12] = [
5    "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
6];
7
8/// Format `date` as an RFC 2822 `date-time`.
9///
10/// A year with more than four digits keeps all of them, and a negative year is
11/// clamped to `0000`. This keeps a corrupt timestamp printable, where chrono's
12/// `DateTime::to_rfc2822` would panic instead.
13pub fn format_rfc2822_date<Tz: TimeZone>(date: DateTime<Tz>) -> String {
14    // Applying the offset can push a near-limit instant out of NaiveDateTime's
15    // range. Fall back to the UTC wall clock at a +0000 offset to keep the
16    // printed fields and the printed offset describing the same instant, rather
17    // than panic on such a value.
18    let zero = FixedOffset::east_opt(0).expect("zero is a valid offset");
19    let raw_offset = date.offset().fix();
20    let (local, offset) = match date.naive_utc().checked_add_offset(raw_offset) {
21        Some(local) => (local, raw_offset),
22        None => (date.naive_utc(), zero),
23    };
24
25    let weekday = SHORT_WEEKDAYS[local.weekday().num_days_from_sunday() as usize];
26    let month = SHORT_MONTHS[local.month0() as usize];
27    let year = local.year().max(0);
28    let day = local.day();
29    let hour = local.hour();
30    let minute = local.minute();
31    // A leap second is stored as second 59 with a nanosecond past one second.
32    // Adding it here renders the 60 that RFC 2822 expects.
33    let second = local.second() + local.nanosecond() / 1_000_000_000;
34
35    let offset_seconds = offset.local_minus_utc();
36    let (sign, offset_seconds) = if offset_seconds < 0 {
37        ('-', -offset_seconds)
38    } else {
39        ('+', offset_seconds)
40    };
41    let offset_hours = offset_seconds / 3600;
42    let offset_minutes = (offset_seconds % 3600) / 60;
43
44    format!(
45        "{weekday}, {day} {month} {year:04} \
46         {hour:02}:{minute:02}:{second:02} \
47         {sign}{offset_hours:02}{offset_minutes:02}"
48    )
49}
50
51/// Parse an RFC 2822 `date-time`, tolerating obsolete alphabetic time zones.
52///
53/// chrono's `parse_from_rfc2822` accepts the zone tokens named in RFC 5322
54/// section 4.3 (`UT`, `GMT`, the North American `EST`/`EDT`/... set) but
55/// rejects any other alphabetic zone. Real senders still emit some: Amazon SES
56/// writes its DSN dates with the token `UTC`, which chrono does not recognize.
57///
58/// When the strict parse fails we retry after replacing a trailing alphabetic
59/// zone token with a numeric offset. A token with one widely-agreed meaning
60/// (`UTC`, and the common regional abbreviations chrono lacks such as
61/// `CET`/`CEST`) resolves to its real offset. Anything chrono neither knows nor
62/// we can resolve unambiguously falls back to `-0000`, the RFC 5322 section 4.3
63/// unknown-offset marker: the same instant as UTC, which keeps the stated
64/// wall-clock date and time while recording that the true offset is unknown.
65/// The original error is preserved when the retry does not help. This keeps
66/// malformed input reporting the real problem rather than a zone complaint.
67// This function is the sanctioned caller of chrono's strict parser. The
68// disallowed_methods lint blocks calling it anywhere else.
69#[allow(clippy::disallowed_methods)]
70pub fn parse_rfc2822_date(
71    input: &str,
72) -> Result<DateTime<FixedOffset>, chrono::format::ParseError> {
73    DateTime::parse_from_rfc2822(input).or_else(|err| {
74        if let Some(rewritten) = rewrite_unknown_alphabetic_zone(input) {
75            if let Ok(date) = DateTime::parse_from_rfc2822(&rewritten) {
76                return Ok(date);
77            }
78        }
79        Err(err)
80    })
81}
82
83/// If `input` ends with an alphabetic zone token, return a copy with that token
84/// replaced by a numeric offset; otherwise return `None`.
85///
86/// CFWS (comments and folding whitespace) is stripped with the crate's parser
87/// first, so a trailing comment such as `(Coordinated)` does not hide the zone.
88/// A recognized abbreviation resolves to its offset; any other alphabetic token
89/// becomes the `-0000` unknown offset.
90fn rewrite_unknown_alphabetic_zone(input: &str) -> Option<String> {
91    let cleaned = crate::rfc5322_parser::strip_cfws(input)?;
92    // strip_cfws joins non-empty tokens with single spaces, so the token after
93    // the final space is never empty.
94    let (head, zone) = cleaned.rsplit_once(' ')?;
95    if !zone.bytes().all(|b| b.is_ascii_alphabetic()) {
96        return None;
97    }
98    let offset = named_zone_offset(zone).unwrap_or("-0000");
99    Some(format!("{head} {offset}"))
100}
101
102/// Return the numeric UTC offset, in RFC 2822 `+HHMM` form, for an alphabetic
103/// zone abbreviation, or `None` when it is unknown or ambiguous.
104///
105/// The mapping comes from the generated `zone_offsets` table.
106fn named_zone_offset(zone: &str) -> Option<&'static str> {
107    crate::zone_offsets::ZONE_OFFSETS
108        .get(zone.to_ascii_uppercase().as_str())
109        .copied()
110}
111
112#[cfg(test)]
113mod test {
114    use super::*;
115
116    #[test]
117    fn obsolete_utc_zone() {
118        let ses = parse_rfc2822_date("Thu, 02 Jul 26 18:55:38 UTC").unwrap();
119        let canonical = parse_rfc2822_date("Thu, 02 Jul 2026 18:55:38 +0000").unwrap();
120        k9::assert_equal!(ses, canonical);
121    }
122
123    #[test]
124    fn recognized_zones_keep_their_offset() {
125        // chrono already handles these; the fallback must not intercept them.
126        let eastern = parse_rfc2822_date("Thu, 02 Jul 26 18:55:38 EST").unwrap();
127        k9::assert_equal!(eastern.to_rfc3339(), "2026-07-02T18:55:38-05:00");
128
129        let numeric = parse_rfc2822_date("Tue, 1 Jul 2003 10:52:37 +0200").unwrap();
130        k9::assert_equal!(numeric.to_rfc3339(), "2003-07-01T10:52:37+02:00");
131    }
132
133    #[test]
134    fn regional_zone_resolves_to_its_offset() {
135        // Common abbreviations chrono rejects are resolved to their real offset.
136        let cest = parse_rfc2822_date("Thu, 02 Jul 26 18:55:38 CEST").unwrap();
137        k9::assert_equal!(cest.to_rfc3339(), "2026-07-02T18:55:38+02:00");
138
139        let jst = parse_rfc2822_date("Thu, 02 Jul 26 18:55:38 JST").unwrap();
140        k9::assert_equal!(jst.to_rfc3339(), "2026-07-02T18:55:38+09:00");
141    }
142
143    #[test]
144    fn ambiguous_zone_becomes_unknown_offset() {
145        // The tz database itself renders `IST` at three offsets (Ireland
146        // +0100, Israel +0200, India +0530), so it is ambiguous in the data
147        // and we cannot resolve it. Per RFC 5322 section 4.3 it keeps the
148        // stated wall-clock time at the -0000 unknown offset, which is the same
149        // instant as UTC.
150        let ist = parse_rfc2822_date("Thu, 02 Jul 26 18:55:38 IST").unwrap();
151        let utc = parse_rfc2822_date("Thu, 02 Jul 26 18:55:38 UTC").unwrap();
152        k9::assert_equal!(ist, utc);
153    }
154
155    #[test]
156    fn trailing_zone_comment_is_ignored() {
157        let with_comment = parse_rfc2822_date("Thu, 02 Jul 26 18:55:38 UTC (Coordinated)").unwrap();
158        let canonical = parse_rfc2822_date("Thu, 02 Jul 2026 18:55:38 +0000").unwrap();
159        k9::assert_equal!(with_comment, canonical);
160    }
161
162    #[test]
163    fn garbage_still_errors() {
164        parse_rfc2822_date("not a date").unwrap_err();
165    }
166
167    #[test]
168    fn format_representable_date() {
169        let date = parse_rfc2822_date("Tue, 1 Jul 2003 10:52:37 +0200").unwrap();
170        k9::assert_equal!(format_rfc2822_date(date), "Tue, 1 Jul 2003 10:52:37 +0200");
171    }
172
173    #[test]
174    fn format_matches_chrono_for_representable_dates() {
175        // Our hand-rolled formatter must agree with chrono byte-for-byte over
176        // the range chrono can render, across the day/year padding and offset
177        // boundaries. Outside that range chrono panics, which is the whole
178        // reason we format the field ourselves.
179        let utc = FixedOffset::east_opt(0).unwrap();
180        let east = FixedOffset::east_opt(2 * 3600).unwrap();
181        let west = FixedOffset::west_opt(5 * 3600).unwrap();
182        let samples = [
183            east.with_ymd_and_hms(2003, 7, 1, 10, 52, 37).unwrap(),
184            utc.with_ymd_and_hms(2026, 7, 2, 18, 55, 38).unwrap(),
185            west.with_ymd_and_hms(2026, 7, 2, 18, 55, 38).unwrap(),
186            utc.with_ymd_and_hms(9999, 12, 31, 23, 59, 59).unwrap(),
187            utc.with_ymd_and_hms(1, 1, 1, 0, 0, 0).unwrap(),
188        ];
189        for date in samples {
190            // The comparison against chrono's own output is the point of the test.
191            #[allow(clippy::disallowed_methods)]
192            let expected = date.to_rfc2822();
193            k9::assert_equal!(format_rfc2822_date(date), expected);
194        }
195    }
196
197    #[test]
198    fn format_far_future_year_keeps_all_digits() {
199        // A year past 9999 has no four-digit RFC 2822 form. chrono panics on it;
200        // ours renders every digit rather than fail.
201        let far_future = chrono::Utc.with_ymd_and_hms(60123, 1, 1, 0, 0, 0).unwrap();
202        k9::assert_equal!(
203            format_rfc2822_date(far_future),
204            "Fri, 1 Jan 60123 00:00:00 +0000"
205        );
206    }
207
208    #[test]
209    fn format_negative_year_clamps_to_zero() {
210        let ancient = chrono::Utc.with_ymd_and_hms(-44, 3, 15, 12, 0, 0).unwrap();
211        k9::assert_equal!(
212            format_rfc2822_date(ancient),
213            "Thu, 15 Mar 0000 12:00:00 +0000"
214        );
215    }
216
217    #[test]
218    fn format_offset_overflow_falls_back_to_utc() {
219        // An instant at the top of NaiveDateTime's range cannot have a positive
220        // offset applied without overflowing. The result must stay internally
221        // consistent, keeping the same UTC wall clock and +0000 offset as the
222        // plain UTC value rather than pairing the UTC fields with the
223        // un-applied offset.
224        let max_utc = DateTime::<chrono::Utc>::MAX_UTC;
225        let shifted = max_utc.with_timezone(&FixedOffset::east_opt(5 * 3600).unwrap());
226        k9::assert_equal!(format_rfc2822_date(shifted), format_rfc2822_date(max_utc));
227    }
228}