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
8pub fn format_rfc2822_date<Tz: TimeZone>(date: DateTime<Tz>) -> String {
14 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 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#[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
83fn rewrite_unknown_alphabetic_zone(input: &str) -> Option<String> {
91 let cleaned = crate::rfc5322_parser::strip_cfws(input)?;
92 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
102fn 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 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 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 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 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 #[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 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 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}