Skip to main content

parse_net/date/
mail_date.rs

1//! RFC 5322 section 3.3 `date-time`, with the obsolete forms of section 4.3
2//! on request.
3
4use super::{
5    DAY_NAMES, MAX_SECS, MIN_SECS, MONTH_NAMES, SECS_PER_DAY, civil_from_days, days_from_civil,
6    days_in_month, weekday_from_days,
7};
8use crate::{
9    error::{ErrorKind, ParseError, Result, WriteError, err},
10    limits::Limits,
11    scan::control_kind,
12    text::comment_end_folded,
13    write::{Sink, reserve},
14};
15
16/// The largest zone offset, `+9959`, in minutes.
17const MAX_OFFSET: i16 = 99 * 60 + 59;
18
19/// A point in time with a zone offset, from an email `Date` or `Received`
20/// field (RFC 5322 section 3.3).
21///
22/// # Examples
23///
24/// ```
25/// use parse_net::MailDate;
26///
27/// let d = MailDate::parse(b"Wed, 21 Oct 2026 17:38:00 +0200", false)?;
28/// assert_eq!(d.offset_minutes(), 120);
29/// let mut buf = [0u8; 64];
30/// let mut out = parse_net::SliceSink::new(&mut buf);
31/// d.write_to(&mut out).expect("room");
32/// assert_eq!(out.written(), b"Wed, 21 Oct 2026 17:38:00 +0200");
33/// assert!(MailDate::parse(b"21 Oct 26 17:38 EDT", false).is_err());
34/// assert_eq!(MailDate::parse(b"21 Oct 26 17:38 EDT", true)?.offset_minutes(), -240);
35/// # Ok::<(), parse_net::ParseError>(())
36/// ```
37#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
38pub struct MailDate {
39    utc: i64,
40    offset: i16,
41    offset_unknown: bool,
42    leap_second: bool,
43}
44
45impl MailDate {
46    /// The time `utc_secs` (seconds since the Unix epoch) shown in the zone
47    /// `offset_minutes` east of UTC.
48    ///
49    /// # Errors
50    ///
51    /// [`ErrorKind::OutOfRange`] for an offset beyond plus or minus 99:59 or
52    /// a local time outside years 1 to 9999.
53    ///
54    /// # Examples
55    ///
56    /// ```
57    /// use parse_net::MailDate;
58    ///
59    /// let d = MailDate::new(0, -300)?;
60    /// assert_eq!(&d.format().0[..d.format().1], b"Wed, 31 Dec 1969 19:00:00 -0500");
61    /// assert!(MailDate::new(0, 6000).is_err());
62    /// # Ok::<(), parse_net::ParseError>(())
63    /// ```
64    pub fn new(utc_secs: i64, offset_minutes: i16) -> Result<Self> {
65        if offset_minutes.unsigned_abs() > MAX_OFFSET.unsigned_abs() {
66            return err(ErrorKind::OutOfRange, 0);
67        }
68        let local = utc_secs.saturating_add(i64::from(offset_minutes) * 60);
69        if !(MIN_SECS..=MAX_SECS).contains(&local) {
70            return err(ErrorKind::OutOfRange, 0);
71        }
72        Ok(Self {
73            utc: utc_secs,
74            offset: offset_minutes,
75            offset_unknown: false,
76            leap_second: false,
77        })
78    }
79
80    /// Seconds since the Unix epoch, UTC.
81    ///
82    /// # Examples
83    ///
84    /// ```
85    /// let d = parse_net::MailDate::parse(b"1 Jan 1970 01:00:00 +0100", false)?;
86    /// assert_eq!(d.utc(), 0);
87    /// # Ok::<(), parse_net::ParseError>(())
88    /// ```
89    #[must_use]
90    pub const fn utc(self) -> i64 {
91        self.utc
92    }
93
94    /// The zone offset in minutes east of UTC (`0` when unknown).
95    ///
96    /// # Examples
97    ///
98    /// ```
99    /// let d = parse_net::MailDate::parse(b"21 Oct 2026 08:08:00 -0330", false)?;
100    /// assert_eq!(d.offset_minutes(), -210);
101    /// # Ok::<(), parse_net::ParseError>(())
102    /// ```
103    #[must_use]
104    pub const fn offset_minutes(self) -> i16 {
105        self.offset
106    }
107
108    /// Whether the zone was `-0000`: the time is UTC and the local zone is
109    /// unknown (RFC 5322 section 3.3), or an obsolete military zone, which
110    /// the RFC says to read the same way.
111    ///
112    /// # Examples
113    ///
114    /// ```
115    /// use parse_net::MailDate;
116    ///
117    /// assert!(MailDate::parse(b"21 Oct 2026 17:38:00 -0000", false)?.offset_unknown());
118    /// assert!(!MailDate::parse(b"21 Oct 2026 17:38:00 +0000", false)?.offset_unknown());
119    /// # Ok::<(), parse_net::ParseError>(())
120    /// ```
121    #[must_use]
122    pub const fn offset_unknown(self) -> bool {
123        self.offset_unknown
124    }
125
126    /// Whether the seconds were `60` (read as `59`).
127    ///
128    /// # Examples
129    ///
130    /// ```
131    /// use parse_net::MailDate;
132    ///
133    /// let leap = MailDate::parse(b"31 Dec 2016 23:59:60 +0000", false)?;
134    /// assert!(leap.leap_second());
135    /// assert_eq!(leap.utc(), MailDate::parse(b"31 Dec 2016 23:59:59 +0000", false)?.utc());
136    /// # Ok::<(), parse_net::ParseError>(())
137    /// ```
138    #[must_use]
139    pub const fn leap_second(self) -> bool {
140        self.leap_second
141    }
142
143    /// The value as `Wed, 21 Oct 2026 17:38:00 +0200`: weekday, two-digit
144    /// day, four-digit year, seconds, and a numeric zone (`-0000` when the
145    /// offset is unknown). Returns the bytes and their length (always 31).
146    ///
147    /// # Examples
148    ///
149    /// ```
150    /// let d = parse_net::MailDate::parse(b"21 oct 2026 17:38 +0200", false)?;
151    /// let (bytes, len) = d.format();
152    /// assert_eq!(&bytes[..len], b"Wed, 21 Oct 2026 17:38:00 +0200");
153    /// # Ok::<(), parse_net::ParseError>(())
154    /// ```
155    #[must_use]
156    pub fn format(self) -> ([u8; 31], usize) {
157        let local = self.utc + i64::from(self.offset) * 60;
158        let days = local.div_euclid(SECS_PER_DAY);
159        let secs = local.rem_euclid(SECS_PER_DAY);
160        let (year, month, day) = civil_from_days(days);
161        let mut out = [b' '; 31];
162        let wd = usize::from(weekday_from_days(days)).saturating_sub(1);
163        put(
164            &mut out,
165            0,
166            DAY_NAMES
167                .get(wd)
168                .map_or(b"???".as_slice(), |d| d.as_slice()),
169        );
170        put(&mut out, 3, b",");
171        two(&mut out, 5, day);
172        let mon = MONTH_NAMES
173            .get(usize::from(month).saturating_sub(1))
174            .map_or(b"???".as_slice(), |m| m.as_slice());
175        put(&mut out, 8, mon);
176        // Years 1 to 9999 by construction.
177        let y = u32::try_from(year).unwrap_or(0);
178        two(&mut out, 12, (y / 100) as u8);
179        two(&mut out, 14, (y % 100) as u8);
180        two(&mut out, 17, (secs / 3600) as u8);
181        put(&mut out, 19, b":");
182        two(&mut out, 20, (secs / 60 % 60) as u8);
183        put(&mut out, 22, b":");
184        two(&mut out, 23, (secs % 60) as u8);
185        let (sign, abs) = if self.offset < 0 || self.offset_unknown {
186            (b"-", self.offset.unsigned_abs())
187        } else {
188            (b"+", self.offset.unsigned_abs())
189        };
190        put(&mut out, 26, sign);
191        two(&mut out, 27, (abs / 60) as u8);
192        two(&mut out, 29, (abs % 60) as u8);
193        (out, 31)
194    }
195
196    /// Writes [`format`](Self::format)'s text.
197    ///
198    /// # Errors
199    ///
200    /// [`WriteError::Full`]; nothing is written when the sink reports its
201    /// room.
202    ///
203    /// # Examples
204    ///
205    /// ```
206    /// use parse_net::{MailDate, SliceSink, WriteError};
207    ///
208    /// let d = MailDate::new(0, 0).expect("in range");
209    /// let mut buf = [0u8; 40];
210    /// let mut out = SliceSink::new(&mut buf);
211    /// d.write_to(&mut out)?;
212    /// assert_eq!(out.written(), b"Thu, 01 Jan 1970 00:00:00 +0000");
213    /// assert_eq!(d.write_to(&mut SliceSink::new(&mut [0u8; 10])), Err(WriteError::Full));
214    /// # Ok::<(), parse_net::WriteError>(())
215    /// ```
216    pub fn write_to(self, out: &mut impl Sink) -> core::result::Result<(), WriteError> {
217        let (bytes, len) = self.format();
218        reserve(out, len)?;
219        out.put(bytes.get(..len).unwrap_or(&bytes))?;
220        Ok(())
221    }
222
223    /// Parses an RFC 5322 `date-time`. Names are case-insensitive (RFC 5234
224    /// quoted strings); comments and folding whitespace may separate the
225    /// tokens. With `obsolete`, also the section 4.3 forms: two- and
226    /// three-digit years (below 50 are 20xx, others 19xx; three digits add
227    /// 1900, D-036), `UT`, `GMT`, the North American zones, and the military
228    /// zones (read as `-0000`).
229    ///
230    /// # Errors
231    ///
232    /// [`ErrorKind::InvalidDate`] (a field out of range, a missing token, an
233    /// obsolete form without `obsolete`); [`ErrorKind::WeekdayMismatch`];
234    /// [`ErrorKind::OutOfRange`] (a year outside 1 to 9999);
235    /// [`ErrorKind::TrailingData`]; [`ErrorKind::UnbalancedComment`];
236    /// [`ErrorKind::CommentTooDeep`]; [`ErrorKind::BareCr`] and
237    /// [`ErrorKind::LineFeed`] for a line break that is not folding.
238    ///
239    /// # Examples
240    ///
241    /// ```
242    /// use parse_net::{ErrorKind, MailDate};
243    ///
244    /// let d = MailDate::parse(b"1 jan 2026 00:00 (morning) -0000", false)?;
245    /// assert!(d.offset_unknown());
246    /// assert_eq!(MailDate::parse(b"Mon, 21 Oct 2026 17:38:00 +0200", false).unwrap_err().kind(), ErrorKind::WeekdayMismatch);
247    /// # Ok::<(), parse_net::ParseError>(())
248    /// ```
249    pub fn parse(value: &[u8], obsolete: bool) -> Result<Self> {
250        // The canonical form mailers write is read by position; anything
251        // else, and anything that fails a check there, takes the general
252        // path below, so results and errors are the same either way.
253        if let Some(d) = canonical(value) {
254            return Ok(d);
255        }
256        let mut c = Cur {
257            v: value,
258            i: 0,
259            limits: Limits::DEFAULT,
260        };
261        let _ = c.cfws()?;
262        let weekday_at = c.i;
263        let weekday = if c.peek().is_some_and(|b| b.is_ascii_alphabetic()) {
264            let wd = c.name(&DAY_NAMES)?;
265            let _ = c.cfws()?;
266            if c.peek() != Some(b',') {
267                return Err(c.fail(ErrorKind::InvalidDate));
268            }
269            c.i += 1;
270            let _ = c.cfws()?;
271            Some(wd + 1)
272        } else {
273            None
274        };
275        let day_at = c.i;
276        let (day, n) = c.digits()?;
277        if !(1..=2).contains(&n) {
278            return Err(c.fail_at(ErrorKind::InvalidDate, c.i - n));
279        }
280        c.space()?;
281        let month = c.name(&MONTH_NAMES)? + 1;
282        c.space()?;
283        let at = c.i;
284        let (y, n) = c.digits()?;
285        let year = match n {
286            4.. => y,
287            2 if obsolete => {
288                if y < 50 {
289                    2000 + y
290                } else {
291                    1900 + y
292                }
293            }
294            3 if obsolete => 1900 + y,
295            _ => return Err(c.fail_at(ErrorKind::InvalidDate, at)),
296        };
297        if !(1..=9999).contains(&year) {
298            return err(ErrorKind::OutOfRange, at);
299        }
300        c.space()?;
301        let (hour, minute, second) = c.time(obsolete)?;
302        c.space()?;
303        let (offset, unknown) = c.zone(obsolete)?;
304        let _ = c.cfws()?;
305        if c.i < value.len() {
306            return Err(c.fail(ErrorKind::TrailingData));
307        }
308        let (year, month, day) = (year as i32, month as u8, day as u8);
309        if day == 0 || day > days_in_month(year, month) {
310            return err(ErrorKind::InvalidDate, day_at);
311        }
312        let days = days_from_civil(year, month, day);
313        if weekday.is_some_and(|w| w as u8 != weekday_from_days(days)) {
314            return err(ErrorKind::WeekdayMismatch, weekday_at);
315        }
316        let leap_second = second == 60;
317        let second = if leap_second { 59 } else { second };
318        let local = days * SECS_PER_DAY + hour * 3600 + minute * 60 + second;
319        Ok(Self {
320            utc: local - i64::from(offset) * 60,
321            offset,
322            offset_unknown: unknown,
323            leap_second,
324        })
325    }
326}
327
328/// The index of a three-letter name in `names`, ASCII case-insensitive.
329fn name3(b: &[u8], names: &[&[u8; 3]]) -> Option<usize> {
330    let [x, y, z] = *b else { return None };
331    if !(x.is_ascii_alphabetic() && y.is_ascii_alphabetic() && z.is_ascii_alphabetic()) {
332        return None;
333    }
334    // For letters, `| 0x20` is exact lowercase.
335    let key = [x | 0x20, y | 0x20, z | 0x20];
336    names
337        .iter()
338        .position(|n| [n[0] | 0x20, n[1] | 0x20, n[2] | 0x20] == key)
339}
340
341/// Two ASCII digits at `b`.
342fn pair(b: &[u8]) -> Option<u8> {
343    match *b {
344        [h @ b'0'..=b'9', l @ b'0'..=b'9'] => Some((h - b'0') * 10 + (l - b'0')),
345        _ => None,
346    }
347}
348
349/// `Ddd, DD Mmm YYYY HH:MM:SS +HHMM`, exactly 31 bytes, read by position.
350/// `None` for any other shape or any failed check.
351fn canonical(v: &[u8]) -> Option<MailDate> {
352    if v.len() != 31 {
353        return None;
354    }
355    let at = |i: usize| v.get(i).copied();
356    let punct = at(3) == Some(b',')
357        && at(4) == Some(b' ')
358        && at(7) == Some(b' ')
359        && at(11) == Some(b' ')
360        && at(16) == Some(b' ')
361        && at(19) == Some(b':')
362        && at(22) == Some(b':')
363        && at(25) == Some(b' ');
364    if !punct {
365        return None;
366    }
367    let weekday = name3(v.get(0..3)?, &DAY_NAMES)? as u8 + 1;
368    let day = pair(v.get(5..7)?)?;
369    let month = name3(v.get(8..11)?, &MONTH_NAMES)? as u8 + 1;
370    let year = u16::from(pair(v.get(12..14)?)?) * 100 + u16::from(pair(v.get(14..16)?)?);
371    let (hour, minute, second) = (
372        pair(v.get(17..19)?)?,
373        pair(v.get(20..22)?)?,
374        pair(v.get(23..25)?)?,
375    );
376    let sign = at(26)?;
377    let (zh, zm) = (pair(v.get(27..29)?)?, pair(v.get(29..31)?)?);
378    if !matches!(sign, b'+' | b'-')
379        || zm > 59
380        || hour > 23
381        || minute > 59
382        || second > 60
383        || year == 0
384    {
385        return None;
386    }
387    let year = i32::from(year);
388    if day == 0 || day > days_in_month(year, month) {
389        return None;
390    }
391    let days = days_from_civil(year, month, day);
392    if weekday != weekday_from_days(days) {
393        return None;
394    }
395    let minutes = i16::from(zh) * 60 + i16::from(zm);
396    let (offset, unknown) = if sign == b'-' {
397        (-minutes, minutes == 0)
398    } else {
399        (minutes, false)
400    };
401    let leap_second = second == 60;
402    let second = i64::from(if leap_second { 59 } else { second });
403    let local = days * SECS_PER_DAY + i64::from(hour) * 3600 + i64::from(minute) * 60 + second;
404    Some(MailDate {
405        utc: local - i64::from(offset) * 60,
406        offset,
407        offset_unknown: unknown,
408        leap_second,
409    })
410}
411
412fn put(out: &mut [u8], at: usize, bytes: &[u8]) {
413    if let Some(dst) = out.get_mut(at..at + bytes.len()) {
414        dst.copy_from_slice(bytes);
415    }
416}
417
418fn two(out: &mut [u8], at: usize, v: u8) {
419    put(out, at, &[b'0' + v / 10, b'0' + v % 10]);
420}
421
422/// A position in a `date-time`.
423struct Cur<'a> {
424    v: &'a [u8],
425    i: usize,
426    limits: Limits,
427}
428
429impl Cur<'_> {
430    fn peek(&self) -> Option<u8> {
431        self.v.get(self.i).copied()
432    }
433
434    fn fail_at(&self, kind: ErrorKind, at: usize) -> ParseError {
435        match self.v.get(at) {
436            Some(&b) if crate::scan::is_control(b) => ParseError::new(control_kind(b), at),
437            _ => ParseError::new(kind, at),
438        }
439    }
440
441    fn fail(&self, kind: ErrorKind) -> ParseError {
442        self.fail_at(kind, self.i)
443    }
444
445    /// Skips CFWS: WSP, `CRLF WSP`, and comments. Returns whether anything
446    /// was skipped.
447    fn cfws(&mut self) -> Result<bool> {
448        let start = self.i;
449        loop {
450            match self.peek() {
451                Some(b' ' | b'\t') => self.i += 1,
452                Some(b'\r') => {
453                    if self.v.get(self.i + 1) == Some(&b'\n')
454                        && matches!(self.v.get(self.i + 2), Some(b' ' | b'\t'))
455                    {
456                        self.i += 3;
457                    } else {
458                        return Err(self.fail(ErrorKind::BareCr));
459                    }
460                }
461                Some(b'(') => self.i = comment_end_folded(self.v, self.i, &self.limits)?.0,
462                _ => return Ok(self.i > start),
463            }
464        }
465    }
466
467    /// CFWS that must be present (the grammar's `FWS`).
468    fn space(&mut self) -> Result<()> {
469        if self.cfws()? {
470            Ok(())
471        } else {
472            Err(self.fail(ErrorKind::InvalidDate))
473        }
474    }
475
476    /// A run of digits: its value (saturating) and length.
477    fn digits(&mut self) -> Result<(u32, usize)> {
478        let start = self.i;
479        let mut v: u32 = 0;
480        while let Some(d) = self.peek().filter(u8::is_ascii_digit) {
481            v = v.saturating_mul(10).saturating_add(u32::from(d - b'0'));
482            self.i += 1;
483        }
484        if self.i == start {
485            return Err(self.fail(ErrorKind::InvalidDate));
486        }
487        Ok((v, self.i - start))
488    }
489
490    /// Exactly two digits.
491    fn two(&mut self) -> Result<i64> {
492        let at = self.i;
493        let (v, n) = self.digits()?;
494        if n != 2 {
495            return Err(self.fail_at(ErrorKind::InvalidDate, at));
496        }
497        Ok(i64::from(v))
498    }
499
500    /// A three-letter name from `names`, ASCII case-insensitive; its index.
501    fn name(&mut self, names: &[&[u8; 3]]) -> Result<u32> {
502        let found = self
503            .v
504            .get(self.i..self.i + 3)
505            .and_then(|w| name3(w, names))
506            .filter(|_| !self.v.get(self.i + 3).is_some_and(u8::is_ascii_alphabetic));
507        match found {
508            Some(idx) => {
509                self.i += 3;
510                Ok(idx as u32)
511            }
512            None => Err(self.fail(ErrorKind::InvalidDate)),
513        }
514    }
515
516    /// `hour ":" minute [ ":" second ]`; with `obsolete`, CFWS around the
517    /// colons.
518    fn time(&mut self, obsolete: bool) -> Result<(i64, i64, i64)> {
519        let at = self.i;
520        let hour = self.two()?;
521        let minute = {
522            self.colon(obsolete)?;
523            self.two()?
524        };
525        let mark = self.i;
526        let second = if obsolete {
527            let _ = self.cfws()?;
528            if self.peek() == Some(b':') {
529                self.colon(obsolete)?;
530                self.two()?
531            } else {
532                self.i = mark;
533                0
534            }
535        } else if self.peek() == Some(b':') {
536            self.i += 1;
537            self.two()?
538        } else {
539            0
540        };
541        if hour > 23 || minute > 59 || second > 60 {
542            return err(ErrorKind::InvalidDate, at);
543        }
544        Ok((hour, minute, second))
545    }
546
547    fn colon(&mut self, obsolete: bool) -> Result<()> {
548        if obsolete {
549            let _ = self.cfws()?;
550        }
551        if self.peek() != Some(b':') {
552            return Err(self.fail(ErrorKind::InvalidDate));
553        }
554        self.i += 1;
555        if obsolete {
556            let _ = self.cfws()?;
557        }
558        Ok(())
559    }
560
561    /// `( "+" / "-" ) 4DIGIT`, or with `obsolete` the section 4.3 names.
562    /// Returns the offset and whether it is unknown.
563    fn zone(&mut self, obsolete: bool) -> Result<(i16, bool)> {
564        let at = self.i;
565        match self.peek() {
566            Some(sign @ (b'+' | b'-')) => {
567                self.i += 1;
568                let (v, n) = self.digits()?;
569                if n != 4 || v % 100 > 59 {
570                    return err(ErrorKind::InvalidDate, at);
571                }
572                let minutes = (v / 100 * 60 + v % 100) as i16;
573                if sign == b'-' {
574                    Ok((-minutes, minutes == 0))
575                } else {
576                    Ok((minutes, false))
577                }
578            }
579            Some(b) if obsolete && b.is_ascii_alphabetic() => {
580                let n = self
581                    .v
582                    .get(self.i..)
583                    .unwrap_or(&[])
584                    .iter()
585                    .take_while(|b| b.is_ascii_alphabetic())
586                    .count();
587                let name = self.v.get(self.i..self.i + n).unwrap_or(&[]);
588                const ZONES: [(&[u8], i16); 10] = [
589                    (b"UT", 0),
590                    (b"GMT", 0),
591                    (b"EST", -300),
592                    (b"EDT", -240),
593                    (b"CST", -360),
594                    (b"CDT", -300),
595                    (b"MST", -420),
596                    (b"MDT", -360),
597                    (b"PST", -480),
598                    (b"PDT", -420),
599                ];
600                let found = ZONES.iter().find(|(z, _)| z.eq_ignore_ascii_case(name));
601                self.i += n;
602                match found {
603                    Some(&(_, off)) => Ok((off, false)),
604                    // A military zone (any letter except J) is read as
605                    // "-0000" (RFC 5322 section 4.3).
606                    None if n == 1 && !name.eq_ignore_ascii_case(b"j") => Ok((0, true)),
607                    None => err(ErrorKind::InvalidDate, at),
608                }
609            }
610            _ => Err(self.fail(ErrorKind::InvalidDate)),
611        }
612    }
613}
614
615#[cfg(test)]
616mod tests {
617    use super::*;
618
619    fn p(v: &[u8]) -> MailDate {
620        MailDate::parse(v, false).unwrap()
621    }
622
623    fn k(v: &[u8], obsolete: bool) -> ErrorKind {
624        MailDate::parse(v, obsolete).unwrap_err().kind()
625    }
626
627    fn text(d: MailDate) -> std::string::String {
628        let (b, n) = d.format();
629        std::string::String::from_utf8(b[..n].to_vec()).unwrap()
630    }
631
632    #[test]
633    fn test_mail_date_edge_cases() {
634        let d = p(b"Wed, 21 Oct 2026 17:38:00 +0200");
635        assert_eq!(d.utc(), p(b"21 Oct 2026 15:38:00 +0000").utc());
636        assert_eq!(
637            text(p(b"21 Oct 2026 17:38 +0200")),
638            "Wed, 21 Oct 2026 17:38:00 +0200"
639        );
640        assert_eq!(
641            text(p(b"1 Jan 2026 00:00:00 +0000")),
642            "Thu, 01 Jan 2026 00:00:00 +0000"
643        );
644        assert_eq!(
645            k(b"Mon, 21 Oct 2026 17:38:00 +0200", false),
646            ErrorKind::WeekdayMismatch
647        );
648        assert_eq!(p(b"21 Oct 2026 17:38 +2400").offset_minutes(), 1440);
649        assert_eq!(k(b"21 Oct 2026 17:38 +0060", false), ErrorKind::InvalidDate);
650        let u = p(b"21 Oct 2026 17:38 -0000");
651        assert!(u.offset_unknown() && u.offset_minutes() == 0);
652        assert_eq!(text(u), "Wed, 21 Oct 2026 17:38:00 -0000");
653        assert!(!p(b"21 Oct 2026 17:38 +0000").offset_unknown());
654        let c = p(b" (a (b)) Wed , 21 (x) oct 2026 17:38:00 (y)\r\n +0200 (z) ");
655        assert_eq!(c.utc(), d.utc());
656        let l = p(b"31 Dec 2016 23:59:60 +0000");
657        assert!(l.leap_second());
658        assert_eq!(l.utc(), p(b"31 Dec 2016 23:59:59 +0000").utc());
659        assert_eq!(k(b"30 Feb 2026 00:00 +0000", false), ErrorKind::InvalidDate);
660        assert_eq!(k(b"21 Oct 2026 24:00 +0000", false), ErrorKind::InvalidDate);
661        assert_eq!(
662            k(b"21 Oct 2026 17:38 +0000 x", false),
663            ErrorKind::TrailingData
664        );
665        assert_eq!(
666            k(b"21 Oct 2026 17:38 +0000 (", false),
667            ErrorKind::UnbalancedComment
668        );
669        assert_eq!(k(b"21 Oct 2026 17:38\r\n+0000", false), ErrorKind::BareCr);
670        assert_eq!(
671            k(b"21 Octo 2026 17:38 +0000", false),
672            ErrorKind::InvalidDate
673        );
674        assert_eq!(k(b"21 Oct 0000 17:38 +0000", false), ErrorKind::OutOfRange);
675        assert_eq!(k(b"21 Oct 10000 17:38 +0000", false), ErrorKind::OutOfRange);
676        assert_eq!(k(b"21Oct 2026 17:38 +0000", false), ErrorKind::InvalidDate);
677        assert_eq!(k(b"", false), ErrorKind::InvalidDate);
678        let deep = b"21 Oct 2026 17:38 +0000 (((((((((x)))))))))";
679        assert_eq!(k(deep, false), ErrorKind::CommentTooDeep);
680    }
681
682    #[test]
683    fn test_mail_date_obsolete_forms() {
684        assert_eq!(k(b"21 Oct 26 17:38 EDT", false), ErrorKind::InvalidDate);
685        let d = MailDate::parse(b"21 Oct 26 17:38 EDT", true).unwrap();
686        assert_eq!(
687            (d.offset_minutes(), text(d).as_str()),
688            (-240, "Wed, 21 Oct 2026 17:38:00 -0400")
689        );
690        assert_eq!(
691            text(MailDate::parse(b"1 Jan 70 00:00 GMT", true).unwrap()),
692            "Thu, 01 Jan 1970 00:00:00 +0000"
693        );
694        assert_eq!(
695            text(MailDate::parse(b"1 Jan 49 00:00 UT", true).unwrap()),
696            "Fri, 01 Jan 2049 00:00:00 +0000"
697        );
698        assert_eq!(
699            text(MailDate::parse(b"1 Jan 100 00:00 PST", true).unwrap()),
700            "Sat, 01 Jan 2000 00:00:00 -0800"
701        );
702        assert!(
703            MailDate::parse(b"1 Jan 2026 00:00 Z", true)
704                .unwrap()
705                .offset_unknown()
706        );
707        assert_eq!(k(b"1 Jan 2026 00:00 J", true), ErrorKind::InvalidDate);
708        assert_eq!(k(b"1 Jan 2026 00:00 XYZ", true), ErrorKind::InvalidDate);
709        assert_eq!(
710            MailDate::parse(b"1 Jan 2026 00 : 00 : 30 +0000", true)
711                .unwrap()
712                .utc()
713                % 60,
714            30
715        );
716        assert_eq!(
717            k(b"1 Jan 2026 00 : 00 +0000", false),
718            ErrorKind::InvalidDate
719        );
720    }
721
722    #[test]
723    fn test_mail_date_new_and_write() {
724        assert_eq!(
725            MailDate::new(0, 6000).unwrap_err().kind(),
726            ErrorKind::OutOfRange
727        );
728        assert_eq!(
729            MailDate::new(0, -6000).unwrap_err().kind(),
730            ErrorKind::OutOfRange
731        );
732        assert!(MailDate::new(0, 5999).is_ok() && MailDate::new(0, -5999).is_ok());
733        assert_eq!(
734            MailDate::new(MIN_SECS, -1).unwrap_err().kind(),
735            ErrorKind::OutOfRange
736        );
737        assert_eq!(
738            MailDate::new(MAX_SECS, 1).unwrap_err().kind(),
739            ErrorKind::OutOfRange
740        );
741        assert_eq!(
742            text(MailDate::new(MIN_SECS, 0).unwrap()),
743            "Mon, 01 Jan 0001 00:00:00 +0000"
744        );
745        let mut buf = [0u8; 30];
746        let mut out = crate::SliceSink::new(&mut buf);
747        assert_eq!(
748            MailDate::new(0, 0).unwrap().write_to(&mut out),
749            Err(WriteError::Full)
750        );
751        assert!(out.is_empty());
752        for secs in [-62_135_596_800i64, -1, 0, 1_792_540_800, 253_402_300_799] {
753            for off in [-5999i16, -300, 0, 330, 5999] {
754                if let Ok(d) = MailDate::new(secs, off) {
755                    let (b, n) = d.format();
756                    let back = MailDate::parse(&b[..n], false).unwrap();
757                    assert_eq!((back.utc(), back.offset_minutes()), (secs, off));
758                }
759            }
760        }
761    }
762}