Skip to main content

mail_parser/
datetime.rs

1/*
2 * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
3 *
4 * SPDX-License-Identifier: Apache-2.0 OR MIT
5 */
6
7use crate::fields::{date, first_field};
8use std::{cmp::Ordering, fmt};
9
10/// Three-letter day names, Sunday first, indexed by
11/// [`DateTime::day_of_week`].
12pub static DOW: &[&str] = &["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
13/// Three-letter month names, January first.
14pub static MONTH: &[&str] = &[
15    "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
16];
17
18/// An RFC 5322 date and time with its zone offset.
19#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Hash)]
20#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
21pub struct DateTime {
22    /// Year, four digits.
23    pub year: u16,
24    /// Month, 1 to 12.
25    pub month: u8,
26    /// Day of the month, 1 to 31.
27    pub day: u8,
28    /// Hour, 0 to 23.
29    pub hour: u8,
30    /// Minute, 0 to 59.
31    pub minute: u8,
32    /// Second, 0 to 59.
33    pub second: u8,
34    /// Whether the zone offset is negative (west of UTC).
35    pub tz_before_gmt: bool,
36    /// Hours of the zone offset.
37    pub tz_hour: u8,
38    /// Minutes of the zone offset.
39    pub tz_minute: u8,
40}
41
42const RFC3339_WIDTHS: [u32; 8] = [4, 2, 2, 2, 2, 2, 2, 2];
43
44impl DateTime {
45    /// Parses an RFC 5322 date with the header date parser.
46    pub fn parse_rfc822(value: &str) -> Option<Self> {
47        let bytes = value.as_bytes();
48        date::parse_bytes(bytes.get(first_field(bytes, 0..bytes.len()))?)
49    }
50
51    /// Parses an RFC 3339 date.
52    pub fn parse_rfc3339(value: &str) -> Option<Self> {
53        let mut pos = 0usize;
54        let mut parts = [0u32; 8];
55        let mut widths = RFC3339_WIDTHS;
56        let mut skip_digits = false;
57        let mut is_plus = true;
58
59        for &ch in value.as_bytes() {
60            match ch {
61                b'0'..=b'9' if !skip_digits => {
62                    let width = widths.get_mut(pos)?;
63                    if *width == 0 {
64                        return None;
65                    }
66                    *width -= 1;
67                    *parts.get_mut(pos)? += (ch - b'0') as u32 * 10u32.pow(*width);
68                }
69                b'-' if pos <= 1 => pos += 1,
70                b'-' if pos == 5 => {
71                    pos += 1;
72                    is_plus = false;
73                    skip_digits = false;
74                }
75                b'T' if pos == 2 => pos += 1,
76                b':' if matches!(pos, 3 | 4 | 6) => pos += 1,
77                b'+' if pos == 5 => {
78                    pos += 1;
79                    skip_digits = false;
80                }
81                b'.' if pos == 5 => skip_digits = true,
82                b'-' | b'T' | b':' | b'+' | b'.' => return None,
83                _ => (),
84            }
85        }
86
87        let [year, month, day, hour, minute, second, tz_hour, tz_minute] = parts;
88        (pos >= 5).then_some(DateTime {
89            year: year as u16,
90            month: month as u8,
91            day: day as u8,
92            hour: hour as u8,
93            minute: minute as u8,
94            second: second as u8,
95            tz_hour: tz_hour as u8,
96            tz_minute: tz_minute as u8,
97            tz_before_gmt: !is_plus,
98        })
99    }
100
101    fn zone_sign(&self) -> &'static str {
102        if self.tz_before_gmt && (self.tz_hour > 0 || self.tz_minute > 0) {
103            "-"
104        } else {
105            "+"
106        }
107    }
108
109    /// Formats the date as RFC 5322.
110    pub fn to_rfc822(&self) -> String {
111        format!(
112            "{}, {} {} {:04} {:02}:{:02}:{:02} {}{:02}{:02}",
113            DOW.get(self.day_of_week() as usize)
114                .copied()
115                .unwrap_or_default(),
116            self.day,
117            MONTH
118                .get(self.month.saturating_sub(1) as usize)
119                .copied()
120                .unwrap_or_default(),
121            self.year,
122            self.hour,
123            self.minute,
124            self.second,
125            self.zone_sign(),
126            self.tz_hour,
127            self.tz_minute
128        )
129    }
130
131    /// Formats the date as RFC 3339.
132    pub fn to_rfc3339(&self) -> String {
133        if self.tz_hour != 0 || self.tz_minute != 0 {
134            format!(
135                "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}{}{:02}:{:02}",
136                self.year,
137                self.month,
138                self.day,
139                self.hour,
140                self.minute,
141                self.second,
142                self.zone_sign(),
143                self.tz_hour,
144                self.tz_minute
145            )
146        } else {
147            format!(
148                "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
149                self.year, self.month, self.day, self.hour, self.minute, self.second,
150            )
151        }
152    }
153
154    /// Whether every field is in range (years 1900 to 3000).
155    pub fn is_valid(&self) -> bool {
156        (0..=23).contains(&self.tz_hour)
157            && (1900..=3000).contains(&self.year)
158            && (0..=59).contains(&self.tz_minute)
159            && (1..=12).contains(&self.month)
160            && (1..=31).contains(&self.day)
161            && (0..=23).contains(&self.hour)
162            && (0..=59).contains(&self.minute)
163            && (0..=59).contains(&self.second)
164    }
165
166    /// Seconds since the Unix epoch, applying the zone offset.
167    pub fn to_timestamp(&self) -> i64 {
168        self.to_timestamp_local()
169            + ((self.tz_hour as i64 * 3600 + self.tz_minute as i64 * 60)
170                * if self.tz_before_gmt { 1 } else { -1 })
171    }
172
173    /// Seconds since the Unix epoch, ignoring the zone offset.
174    pub fn to_timestamp_local(&self) -> i64 {
175        let month = self.month as u32;
176        let year_base = 4800;
177        let m_adj = month.wrapping_sub(3);
178        let carry = i64::from(m_adj > month);
179        let adjust = if carry > 0 { 12 } else { 0 };
180        let y_adj = self.year as i64 + year_base - carry;
181        let month_days = ((m_adj.wrapping_add(adjust)) * 62719 + 769) / 2048;
182        let leap_days = y_adj / 4 - y_adj / 100 + y_adj / 400;
183        (y_adj * 365 + leap_days + month_days as i64 + (self.day as i64 - 1) - 2472632) * 86400
184            + self.hour as i64 * 3600
185            + self.minute as i64 * 60
186            + self.second as i64
187    }
188
189    /// Creates a UTC date from seconds since the Unix epoch.
190    pub fn from_timestamp(timestamp: i64) -> Self {
191        let (z, seconds) = (
192            (timestamp.div_euclid(86400)) + 719468,
193            timestamp.rem_euclid(86400),
194        );
195        let era: i64 = (if z >= 0 { z } else { z - 146096 }) / 146097;
196        let doe: u64 = (z - era * 146097) as u64;
197        let yoe: u64 = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
198        let y: i64 = (yoe as i64) + era * 400;
199        let doy: u64 = doe - (365 * yoe + yoe / 4 - yoe / 100);
200        let mp = (5 * doy + 2) / 153;
201        let d: u64 = doy - (153 * mp + 2) / 5 + 1;
202        let m: u64 = if mp < 10 { mp + 3 } else { mp - 9 };
203        let (h, mn, s) = (seconds / 3600, (seconds / 60) % 60, seconds % 60);
204
205        DateTime {
206            year: (y + i64::from(m <= 2)) as u16,
207            month: m as u8,
208            day: d as u8,
209            hour: h as u8,
210            minute: mn as u8,
211            second: s as u8,
212            tz_before_gmt: false,
213            tz_hour: 0,
214            tz_minute: 0,
215        }
216    }
217
218    /// Day of the week, 0 (Sunday) to 6 (Saturday).
219    pub fn day_of_week(&self) -> u8 {
220        (((self.to_timestamp_local() as f64 / 86400.0).floor() as i64 + 4).rem_euclid(7)) as u8
221    }
222
223    /// Julian day number.
224    pub fn julian_day(&self) -> i64 {
225        let day = self.day as i64;
226        let (month, year) = if self.month > 2 {
227            ((self.month - 3) as i64, self.year as i64)
228        } else {
229            ((self.month + 9) as i64, (self.year as i64 - 1))
230        };
231
232        let c = year / 100;
233        c * 146097 / 4 + (year - c * 100) * 1461 / 4 + (month * 153 + 2) / 5 + day + 1721119
234    }
235
236    /// The same instant in a zone `tz` seconds away from UTC. Offsets too
237    /// large for the fields saturate instead of overflowing.
238    pub fn to_timezone(&self, tz: i64) -> DateTime {
239        let mut dt = DateTime::from_timestamp(self.to_timestamp().saturating_add(tz));
240        dt.tz_before_gmt = tz < 0;
241        let offset = tz.unsigned_abs();
242        dt.tz_hour = u8::try_from(offset / 3600).unwrap_or(u8::MAX);
243        dt.tz_minute = ((offset % 3600) / 60) as u8;
244        dt
245    }
246}
247
248impl PartialOrd for DateTime {
249    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
250        Some(self.cmp(other))
251    }
252}
253
254impl Ord for DateTime {
255    fn cmp(&self, other: &Self) -> Ordering {
256        self.to_timestamp().cmp(&other.to_timestamp())
257    }
258}
259
260impl fmt::Display for DateTime {
261    fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result {
262        fmt.write_str(&self.to_rfc3339())
263    }
264}
265
266impl From<DateTime> for i64 {
267    fn from(value: DateTime) -> Self {
268        value.to_timestamp()
269    }
270}
271
272#[cfg(test)]
273mod tests {
274    use super::DateTime;
275
276    #[test]
277    fn rfc3339_round_trip() {
278        for input in [
279            "2021-11-20T14:22:01-08:00",
280            "1969-02-13T23:32:00-03:30",
281            "2004-06-28T23:43:45Z",
282        ] {
283            let date = DateTime::parse_rfc3339(input).expect("valid");
284            assert_eq!(date.to_rfc3339(), input);
285        }
286        assert_eq!(
287            DateTime::parse_rfc3339("2004-06-28T23:43:45.000Z").map(|d| d.to_rfc3339()),
288            Some("2004-06-28T23:43:45Z".to_string())
289        );
290        assert!(DateTime::parse_rfc3339("2004-06-28+23:43:45").is_none());
291    }
292
293    #[test]
294    fn timestamps() {
295        let date = DateTime::parse_rfc3339("2021-11-20T14:22:01-08:00").expect("valid");
296        assert_eq!(date.to_timestamp(), 1637446921);
297        assert_eq!(
298            DateTime::from_timestamp(1637446921).to_rfc3339(),
299            "2021-11-20T22:22:01Z"
300        );
301        assert_eq!(date.to_rfc822(), "Sat, 20 Nov 2021 14:22:01 -0800");
302        assert_eq!(date.day_of_week(), 6);
303        assert!(date.is_valid());
304        assert_eq!(i64::from(date), 1637446921);
305        assert_eq!(
306            date.to_timezone(3600).to_rfc3339(),
307            "2021-11-20T23:22:01+01:00"
308        );
309        assert_eq!(date.julian_day(), 2459539);
310    }
311
312    #[test]
313    fn extreme_timezones_saturate() {
314        let date = DateTime::parse_rfc3339("2021-11-20T14:22:01-08:00").expect("valid");
315        for tz in [i64::MIN, i64::MIN + 1, i64::MAX] {
316            let moved = date.to_timezone(tz);
317            assert_eq!(moved.tz_before_gmt, tz < 0);
318            assert_eq!(moved.tz_hour, u8::MAX);
319            assert!(moved.tz_minute < 60);
320        }
321        let moved = date.to_timezone(-(5 * 3600 + 30 * 60));
322        assert_eq!(moved.to_rfc3339(), "2021-11-20T16:52:01-05:30");
323    }
324}