Skip to main content

daaki_message/
types.rs

1//! Public API types for parsed and outgoing email messages.
2//!
3//! All types are fully owned (`String` / `Vec<u8>`) with no lifetime parameters,
4//! per workspace conventions.
5//!
6//! # References
7//! - RFC 5322 (Internet Message Format — address, date-time)
8//! - RFC 2045 (MIME — Content-Transfer-Encoding)
9//! - RFC 2183 (Content-Disposition)
10//! - RFC 3501 Section 6.4.5 (IMAP MIME section numbers)
11
12/// A parsed email message.
13///
14/// Produced by [`crate::parse_email`]. All fields are owned.
15/// Missing or unparseable optional fields are `None`.
16///
17/// # References
18/// - RFC 5322 (message structure)
19/// - RFC 2045–2046 (MIME body parts)
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct ParsedEmail {
22    /// Message-ID with angle brackets stripped (RFC 5322 Section 3.6.4).
23    pub message_id: Option<String>,
24    /// First `In-Reply-To` message-id only (RFC 5322 Section 3.6.4).
25    pub in_reply_to: Option<String>,
26    /// All `References` message-ids, space-joined (RFC 5322 Section 3.6.4).
27    pub references: Option<String>,
28    /// Decoded subject (RFC 5322 Section 3.6.5, RFC 2047 encoded words decoded).
29    pub subject: Option<String>,
30    /// Sender address (RFC 5322 Section 3.6.2).
31    pub from: Address,
32    /// To recipients (RFC 5322 Section 3.6.3).
33    pub to: Vec<Address>,
34    /// Cc recipients (RFC 5322 Section 3.6.3).
35    pub cc: Vec<Address>,
36    /// Bcc recipients (RFC 5322 Section 3.6.3).
37    pub bcc: Vec<Address>,
38    /// Reply-To addresses (RFC 5322 Section 3.6.2).
39    pub reply_to: Vec<Address>,
40    /// Parsed date with original timezone offset preserved (RFC 5322 Section 3.3).
41    pub date: Option<DateTime>,
42    /// First `text/plain` body part, decoded to UTF-8.
43    pub body_text: Option<String>,
44    /// First `text/html` body part, decoded to UTF-8.
45    pub body_html: Option<String>,
46    /// Detected attachments with IMAP section numbers.
47    pub attachments: Vec<ParsedAttachment>,
48    /// Raw header bytes as a `String` (everything before the header/body separator).
49    pub raw_headers: String,
50    /// Total byte count of the input.
51    pub size: u64,
52}
53
54/// An email address with optional display name.
55///
56/// # References
57/// - RFC 5322 Section 3.4 (address specification)
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct Address {
60    /// Display name, decoded from RFC 2047 encoded words if present.
61    pub name: Option<String>,
62    /// Email address (`addr-spec` form).
63    pub email: String,
64}
65
66/// RFC 5322 specials that require a display name to be quoted.
67const SPECIALS: &[char] = &[
68    '(', ')', '<', '>', '[', ']', ':', ';', '@', '\\', ',', '.', '"',
69];
70
71impl std::fmt::Display for Address {
72    /// Formats the address per RFC 5322 Section 3.4.
73    ///
74    /// - Non-ASCII display names are RFC 2047 encoded (RFC 5322 Section 2.2
75    ///   requires header field bodies to be US-ASCII; RFC 2047 Section 5
76    ///   defines the encoded-word mechanism for non-ASCII text).
77    /// - ASCII display names containing specials are quoted per RFC 5322 Section 3.2.4.
78    /// - Without display name: bare `email`
79    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80        match &self.name {
81            Some(name) if !name.is_empty() => {
82                if !name.is_ascii() {
83                    // RFC 5322 Section 2.2: field bodies must be US-ASCII.
84                    // RFC 2047 Section 5: use encoded-words for non-ASCII text.
85                    let encoded = crate::builder::encode_rfc2047_if_needed(name);
86                    write!(f, "{encoded} <{}>", self.email)
87                } else if name.chars().any(|c| SPECIALS.contains(&c)) {
88                    let escaped = name.replace('\\', "\\\\").replace('"', "\\\"");
89                    write!(f, "\"{escaped}\" <{}>", self.email)
90                } else {
91                    write!(f, "{name} <{}>", self.email)
92                }
93            }
94            _ => write!(f, "{}", self.email),
95        }
96    }
97}
98
99impl std::str::FromStr for Address {
100    type Err = crate::error::Error;
101
102    /// Parses an address from a string (RFC 5322 Section 3.4).
103    ///
104    /// Accepts both `Display Name <email>` and bare `email` forms.
105    /// Decodes RFC 2047 encoded words in the display name (RFC 2047 Section 5).
106    fn from_str(s: &str) -> Result<Self, Self::Err> {
107        let s = s.trim();
108        if s.is_empty() {
109            return Err(crate::error::Error::InvalidAddress(
110                "empty address string".into(),
111            ));
112        }
113
114        // Try "Display Name <email>" form
115        if let Some(angle_start) = s.rfind('<') {
116            if let Some(angle_end) = s.rfind('>') {
117                if angle_end > angle_start {
118                    let email = s[angle_start + 1..angle_end].trim().to_string();
119                    let name_part = s[..angle_start].trim();
120                    let name = if name_part.is_empty() {
121                        None
122                    } else {
123                        // Strip only the outer pair of quotes from a quoted-string
124                        // (RFC 5322 Section 3.2.4). Using trim_matches('"') would
125                        // greedily strip multiple quotes and corrupt escaped quotes
126                        // like `\"` at the end of the display name.
127                        let stripped = strip_outer_quotes(name_part);
128                        let name = stripped.trim().to_string();
129                        if name.is_empty() {
130                            None
131                        } else {
132                            // Decode RFC 2047 encoded words in display name
133                            // (RFC 2047 Section 5, RFC 5322 Section 2.2).
134                            let unescaped = unescape_quoted_string(&name);
135                            let decoded = crate::parser::decode_encoded_words(&unescaped);
136                            Some(decoded)
137                        }
138                    };
139                    if email.is_empty() {
140                        return Err(crate::error::Error::InvalidAddress(
141                            "empty email in angle brackets".into(),
142                        ));
143                    }
144                    return Ok(Self { name, email });
145                }
146            }
147        }
148
149        // Bare email
150        if s.contains('@') {
151            return Ok(Self {
152                name: None,
153                email: s.to_string(),
154            });
155        }
156
157        Err(crate::error::Error::InvalidAddress(format!(
158            "cannot parse address: {s}"
159        )))
160    }
161}
162
163/// Strips only the outer pair of quotes from a quoted-string.
164///
165/// If `input` starts with `"` and ends with `"`, removes those two characters.
166/// Otherwise returns the input unchanged. Unlike `trim_matches('"')`, this does
167/// not greedily strip multiple consecutive quotes, which is critical when the
168/// display name ends with an escaped quote like `"She said \"hello\""`.
169///
170/// # References
171/// - RFC 5322 Section 3.2.4 (quoted-string structure)
172fn strip_outer_quotes(input: &str) -> &str {
173    if input.len() >= 2 && input.starts_with('"') && input.ends_with('"') {
174        &input[1..input.len() - 1]
175    } else {
176        input
177    }
178}
179
180/// Unescapes a quoted-string: removes backslash from `\\` → `\` and `\"` → `"`.
181///
182/// Per RFC 5322 Section 3.2.4, a `quoted-pair` is `"\" (VCHAR / WSP)`.
183fn unescape_quoted_string(input: &str) -> String {
184    let mut result = String::with_capacity(input.len());
185    let mut chars = input.chars();
186    while let Some(c) = chars.next() {
187        if c == '\\' {
188            if let Some(next) = chars.next() {
189                result.push(next);
190            } else {
191                result.push(c);
192            }
193        } else {
194            result.push(c);
195        }
196    }
197    result
198}
199
200/// Metadata for an attachment found during parsing.
201///
202/// Does not contain the attachment binary data — use the `section` field
203/// to fetch the content on-demand via IMAP `BODY.PEEK[section]`.
204///
205/// # References
206/// - RFC 2183 (Content-Disposition)
207/// - RFC 3501 Section 6.4.5 (MIME section numbers)
208#[derive(Debug, Clone, PartialEq, Eq)]
209pub struct ParsedAttachment {
210    /// Filename from `Content-Disposition` or `Content-Type` name parameter.
211    pub filename: Option<String>,
212    /// MIME content type (e.g., `"application/pdf"`).
213    pub content_type: String,
214    /// Content-ID header value, stripped of angle brackets (RFC 2392).
215    pub content_id: Option<String>,
216    /// `true` if `Content-Disposition: inline` or has a `Content-ID`.
217    pub is_inline: bool,
218    /// Size of the MIME part body in bytes (if available).
219    pub size: Option<u64>,
220    /// IMAP MIME section number (e.g., `"2"`, `"1.2"`) for on-demand fetch.
221    pub section: Option<String>,
222}
223
224/// Day-of-week abbreviations per RFC 5322 Section 3.3.
225const DOW_NAMES: [&str; 7] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
226
227/// Month abbreviations per RFC 5322 Section 3.3.
228const MONTH_NAMES: [&str; 12] = [
229    "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
230];
231
232/// Date-time with timezone offset preserved.
233///
234/// # References
235/// - RFC 5322 Section 3.3 (date-time specification)
236#[derive(Debug, Clone)]
237pub struct DateTime {
238    /// Year (e.g., 2025).
239    pub year: u16,
240    /// Month (1–12).
241    pub month: u8,
242    /// Day of month (1–31).
243    pub day: u8,
244    /// Hour (0–23).
245    pub hour: u8,
246    /// Minute (0–59).
247    pub minute: u8,
248    /// Second (0–59).
249    pub second: u8,
250    /// Timezone offset from UTC in minutes (e.g., +0530 → 330, −0800 → −480).
251    pub tz_offset_minutes: i16,
252}
253
254impl DateTime {
255    /// Converts this date-time to a Unix timestamp (seconds since 1970-01-01T00:00:00Z).
256    ///
257    /// The timezone offset is applied so the result is always UTC-based.
258    /// Uses the same civil-to-days algorithm as the builder.
259    ///
260    /// # References
261    /// - RFC 5322 Section 3.3
262    pub fn to_unix_timestamp(&self) -> i64 {
263        let days = Self::civil_to_days(
264            i32::from(self.year),
265            u32::from(self.month),
266            u32::from(self.day),
267        );
268        let secs = days * 86400
269            + i64::from(self.hour) * 3600
270            + i64::from(self.minute) * 60
271            + i64::from(self.second);
272        // Subtract timezone offset to normalize to UTC
273        secs - i64::from(self.tz_offset_minutes) * 60
274    }
275
276    /// Creates a `DateTime` from a Unix timestamp (seconds since epoch) and a
277    /// timezone offset in minutes.
278    ///
279    /// # References
280    /// - RFC 5322 Section 3.3
281    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
282    pub fn from_unix_timestamp(timestamp: i64, tz_offset_minutes: i16) -> Self {
283        // Apply timezone offset to get local time
284        let local_secs = timestamp + i64::from(tz_offset_minutes) * 60;
285        let days = local_secs.div_euclid(86400);
286        let time_secs = local_secs.rem_euclid(86400) as u64;
287
288        let (year, month, day) = Self::civil_from_days(days);
289
290        Self {
291            year: year as u16,
292            month: month as u8,
293            day: day as u8,
294            hour: (time_secs / 3600) as u8,
295            minute: ((time_secs % 3600) / 60) as u8,
296            second: (time_secs % 60) as u8,
297            tz_offset_minutes,
298        }
299    }
300
301    /// Returns the current UTC time as a `DateTime`.
302    ///
303    /// # References
304    /// - RFC 5322 Section 3.3
305    #[allow(
306        clippy::cast_possible_truncation,
307        clippy::cast_sign_loss,
308        clippy::cast_possible_wrap
309    )]
310    pub fn now() -> Self {
311        use std::time::{SystemTime, UNIX_EPOCH};
312
313        let secs = SystemTime::now()
314            .duration_since(UNIX_EPOCH)
315            .unwrap_or_default()
316            .as_secs();
317
318        Self::from_unix_timestamp(secs as i64, 0)
319    }
320
321    /// Returns the day of week for this date.
322    ///
323    /// Returns 0 for Sunday, 1 for Monday, ..., 6 for Saturday.
324    ///
325    /// # References
326    /// - RFC 5322 Section 3.3
327    #[allow(clippy::cast_sign_loss)]
328    pub fn weekday(&self) -> u8 {
329        let days = Self::civil_to_days(
330            i32::from(self.year),
331            u32::from(self.month),
332            u32::from(self.day),
333        );
334        // 1970-01-01 was Thursday (4); result is always in [0, 6]
335        (((days % 7) + 4 + 7) % 7) as u8
336    }
337
338    /// Formats this date-time as an RFC 5322 date-time string.
339    ///
340    /// Produces the format: `day-of-week, DD Mon YYYY HH:MM:SS ±HHMM`
341    /// (e.g., `"Thu, 13 Feb 2025 15:47:33 +0000"`).
342    ///
343    /// # References
344    /// - RFC 5322 Section 3.3
345    pub fn to_rfc5322_string(&self) -> String {
346        let dow = self.weekday();
347        let dow_name = DOW_NAMES[dow as usize];
348        // Clamp month to valid range [1, 12] to prevent index-out-of-bounds
349        // panic on malformed DateTime values (RFC 5322 Section 3.3: month = 1–12).
350        let month_idx = self.month.clamp(1, 12).saturating_sub(1) as usize;
351        let month_name = MONTH_NAMES[month_idx];
352        let (sign, tz_h, tz_m) = self.tz_parts();
353        format!(
354            "{dow_name}, {:02} {month_name} {:04} {:02}:{:02}:{:02} {sign}{tz_h:02}{tz_m:02}",
355            self.day, self.year, self.hour, self.minute, self.second,
356        )
357    }
358
359    /// Formats this date-time as an ISO 8601 / RFC 3339 string.
360    ///
361    /// Produces the format: `YYYY-MM-DDTHH:MM:SS±HH:MM`
362    /// (e.g., `"2025-02-13T15:47:33+00:00"`).
363    ///
364    /// This format is widely used in JSON APIs and structured data exchange.
365    ///
366    /// # References
367    /// - ISO 8601 (date-time representation)
368    /// - RFC 3339 (date-time on the Internet)
369    pub fn to_iso8601_string(&self) -> String {
370        let (sign, tz_h, tz_m) = self.tz_parts();
371        format!(
372            "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}{sign}{tz_h:02}:{tz_m:02}",
373            self.year, self.month, self.day, self.hour, self.minute, self.second,
374        )
375    }
376
377    /// Parses an RFC 5322 date-time string into a `DateTime`.
378    ///
379    /// Accepts: `[day-of-week ","] day month year hour ":" minute [":" second] zone`
380    ///
381    /// Strips CFWS (comments and folding white space) before parsing, as
382    /// allowed by the obsolete date syntax (RFC 5322 Section 4.3).
383    ///
384    /// Returns `None` if the input is not a valid RFC 5322 date-time.
385    ///
386    /// # References
387    /// - RFC 5322 Section 3.3
388    /// - RFC 5322 Section 4.3 (obsolete syntax)
389    pub fn parse_rfc5322(input: &str) -> Option<Self> {
390        crate::parser::parse_rfc5322_date(input)
391    }
392
393    /// Returns the timezone offset decomposed as `(sign, hours, minutes)`.
394    ///
395    /// # References
396    /// - RFC 5322 Section 3.3 (zone = ("+" / "-") 4DIGIT)
397    fn tz_parts(&self) -> (char, u16, u16) {
398        let sign = if self.tz_offset_minutes >= 0 {
399            '+'
400        } else {
401            '-'
402        };
403        let abs = self.tz_offset_minutes.unsigned_abs();
404        (sign, abs / 60, abs % 60)
405    }
406
407    /// Converts days since Unix epoch to `(year, month, day)`.
408    ///
409    /// Algorithm from Howard Hinnant's date algorithms.
410    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
411    fn civil_from_days(z: i64) -> (i32, u32, u32) {
412        let z = z + 719_468;
413        let era = (if z >= 0 { z } else { z - 146_096 }) / 146_097;
414        let doe = (z - era * 146_097) as u32;
415        let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
416        let y = i64::from(yoe) + era * 400;
417        let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
418        let mp = (5 * doy + 2) / 153;
419        let d = doy - (153 * mp + 2) / 5 + 1;
420        let m = if mp < 10 { mp + 3 } else { mp - 9 };
421        let y = if m <= 2 { y + 1 } else { y };
422        (y as i32, m, d)
423    }
424
425    /// Converts `(year, month, day)` to days since Unix epoch.
426    ///
427    /// Algorithm from Howard Hinnant's date algorithms.
428    #[allow(clippy::cast_sign_loss, clippy::cast_possible_wrap)]
429    fn civil_to_days(year: i32, month: u32, day: u32) -> i64 {
430        let y = if month <= 2 {
431            i64::from(year) - 1
432        } else {
433            i64::from(year)
434        };
435        let m = if month <= 2 { month + 9 } else { month - 3 };
436        let era = (if y >= 0 { y } else { y - 399 }) / 400;
437        let yoe = (y - era * 400) as u64;
438        let doy = (153 * u64::from(m) + 2) / 5 + u64::from(day) - 1;
439        let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
440        era * 146_097 + doe as i64 - 719_468
441    }
442}
443
444impl PartialEq for DateTime {
445    /// Compares two `DateTime` values by their UTC-normalized timestamps,
446    /// consistent with the `Ord` implementation.
447    ///
448    /// # References
449    /// - RFC 5322 Section 3.3 (date-time specification)
450    fn eq(&self, other: &Self) -> bool {
451        self.to_unix_timestamp() == other.to_unix_timestamp()
452    }
453}
454
455impl Eq for DateTime {}
456
457impl std::hash::Hash for DateTime {
458    /// Hashes by UTC-normalized timestamp, consistent with `Eq` and `Ord`.
459    ///
460    /// Two `DateTime` values representing the same UTC instant (but with
461    /// different timezone offsets) will produce the same hash, matching the
462    /// `Eq` behavior.
463    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
464        self.to_unix_timestamp().hash(state);
465    }
466}
467
468impl PartialOrd for DateTime {
469    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
470        Some(self.cmp(other))
471    }
472}
473
474impl Ord for DateTime {
475    /// Compares two `DateTime` values by their UTC-normalized timestamps.
476    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
477        self.to_unix_timestamp().cmp(&other.to_unix_timestamp())
478    }
479}
480
481impl std::fmt::Display for DateTime {
482    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
483        let (sign, tz_h, tz_m) = self.tz_parts();
484        write!(
485            f,
486            "{:04}-{:02}-{:02} {:02}:{:02}:{:02} {sign}{tz_h:02}{tz_m:02}",
487            self.year, self.month, self.day, self.hour, self.minute, self.second,
488        )
489    }
490}
491
492impl std::str::FromStr for DateTime {
493    type Err = crate::error::Error;
494
495    /// Parses an RFC 5322 date-time string into a `DateTime`.
496    ///
497    /// Accepts: `[day-of-week ","] day month year hour ":" minute [":" second] zone`
498    ///
499    /// This enables the ergonomic `"Thu, 13 Feb 2025 15:47:33 +0000".parse::<DateTime>()`
500    /// pattern via the standard `FromStr` trait.
501    ///
502    /// # References
503    /// - RFC 5322 Section 3.3
504    /// - RFC 5322 Section 4.3 (obsolete syntax)
505    fn from_str(s: &str) -> Result<Self, Self::Err> {
506        Self::parse_rfc5322(s).ok_or_else(|| {
507            crate::error::Error::InvalidDate(format!("cannot parse RFC 5322 date: {s}"))
508        })
509    }
510}
511
512/// An outgoing email to be built into raw RFC 5322 bytes.
513///
514/// Used as input to [`crate::build_message`].
515///
516/// # References
517/// - RFC 5322 (message format)
518/// - RFC 2046 (MIME multipart)
519#[derive(Debug, Clone, PartialEq, Eq)]
520pub struct OutgoingEmail {
521    /// Sender address (RFC 5322 Section 3.6.2).
522    pub from: Address,
523    /// To recipients (RFC 5322 Section 3.6.3).
524    pub to: Vec<Address>,
525    /// Cc recipients (RFC 5322 Section 3.6.3).
526    pub cc: Vec<Address>,
527    /// Bcc recipients — included in SMTP envelope (`RCPT TO`) but **must not**
528    /// appear in message headers (RFC 5322 Section 3.6.3).
529    pub bcc: Vec<Address>,
530    /// Reply-To address (RFC 5322 Section 3.6.2).
531    pub reply_to: Option<Address>,
532    /// Subject line (RFC 5322 Section 3.6.5).
533    pub subject: String,
534    /// Plain text body.
535    pub body_text: Option<String>,
536    /// HTML body.
537    pub body_html: Option<String>,
538    /// In-Reply-To message ID — bare `addr-spec`, builder wraps in angle brackets.
539    pub in_reply_to: Option<String>,
540    /// References — space-separated bare message-ids, builder wraps each in angle brackets.
541    pub references: Option<String>,
542    /// File attachments.
543    pub attachments: Vec<OutgoingAttachment>,
544}
545
546/// An attachment to include in an outgoing email.
547///
548/// # References
549/// - RFC 2183 (Content-Disposition: attachment)
550/// - RFC 2045 (Content-Transfer-Encoding: base64)
551#[derive(Debug, Clone, PartialEq, Eq)]
552pub struct OutgoingAttachment {
553    /// Filename for the `Content-Disposition` header.
554    pub filename: String,
555    /// MIME content type (e.g., `"application/pdf"`).
556    pub content_type: String,
557    /// Raw file data — will be base64-encoded in the message.
558    pub data: Vec<u8>,
559}
560
561/// The result of building an email message.
562///
563/// # References
564/// - RFC 5322 (message format)
565/// - RFC 5321 (SMTP envelope)
566#[derive(Debug, Clone, PartialEq, Eq)]
567pub struct BuiltMessage {
568    /// Raw RFC 5322 message bytes (headers + body). BCC excluded from headers.
569    pub raw: Vec<u8>,
570    /// All envelope recipients (to + cc + bcc) for SMTP `RCPT TO`.
571    pub envelope_recipients: Vec<String>,
572    /// The generated Message-ID (bare `addr-spec`, no angle brackets).
573    pub message_id: String,
574}
575
576#[cfg(test)]
577#[allow(clippy::unwrap_used)]
578mod tests {
579    use super::*;
580
581    #[test]
582    fn address_display_bare_email() {
583        let addr = Address {
584            name: None,
585            email: "user@example.com".into(),
586        };
587        assert_eq!(addr.to_string(), "user@example.com");
588    }
589
590    #[test]
591    fn address_display_with_name() {
592        let addr = Address {
593            name: Some("John Doe".into()),
594            email: "john@example.com".into(),
595        };
596        assert_eq!(addr.to_string(), "John Doe <john@example.com>");
597    }
598
599    #[test]
600    fn address_display_name_with_specials_quoted() {
601        let addr = Address {
602            name: Some("Doe, John".into()),
603            email: "john@example.com".into(),
604        };
605        assert_eq!(addr.to_string(), "\"Doe, John\" <john@example.com>");
606    }
607
608    #[test]
609    fn address_display_name_with_quotes_escaped() {
610        let addr = Address {
611            name: Some("John \"Doc\" Doe".into()),
612            email: "john@example.com".into(),
613        };
614        assert_eq!(
615            addr.to_string(),
616            "\"John \\\"Doc\\\" Doe\" <john@example.com>"
617        );
618    }
619
620    #[test]
621    fn address_from_str_bare_email() {
622        let addr: Address = "user@example.com".parse().unwrap();
623        assert_eq!(addr.email, "user@example.com");
624        assert!(addr.name.is_none());
625    }
626
627    #[test]
628    fn address_from_str_with_name() {
629        let addr: Address = "John Doe <john@example.com>".parse().unwrap();
630        assert_eq!(addr.email, "john@example.com");
631        assert_eq!(addr.name.as_deref(), Some("John Doe"));
632    }
633
634    #[test]
635    fn address_from_str_quoted_name() {
636        let addr: Address = "\"Doe, John\" <john@example.com>".parse().unwrap();
637        assert_eq!(addr.email, "john@example.com");
638        assert_eq!(addr.name.as_deref(), Some("Doe, John"));
639    }
640
641    #[test]
642    fn address_from_str_escaped_quotes_in_name() {
643        let addr: Address = "\"John \\\"Doc\\\" Doe\" <john@example.com>"
644            .parse()
645            .unwrap();
646        assert_eq!(addr.email, "john@example.com");
647        assert_eq!(addr.name.as_deref(), Some("John \"Doc\" Doe"));
648    }
649
650    #[test]
651    fn address_from_str_empty_rejected() {
652        let result: Result<Address, _> = "".parse();
653        assert!(result.is_err());
654    }
655
656    #[test]
657    fn address_from_str_no_at_rejected() {
658        let result: Result<Address, _> = "not-an-email".parse();
659        assert!(result.is_err());
660    }
661
662    #[test]
663    fn address_round_trip_display_from_str() {
664        let original = Address {
665            name: Some("Doe, John".into()),
666            email: "john@example.com".into(),
667        };
668        let displayed = original.to_string();
669        let parsed: Address = displayed.parse().unwrap();
670        assert_eq!(original, parsed);
671    }
672
673    #[test]
674    fn datetime_now_returns_plausible_date() {
675        let now = DateTime::now();
676        // Year should be 2025 or later (test written in 2026)
677        assert!(now.year >= 2025, "DateTime::now() year is {}", now.year);
678        assert!((1..=12).contains(&now.month));
679        assert!((1..=31).contains(&now.day));
680        assert!(now.hour <= 23);
681        assert!(now.minute <= 59);
682        assert!(now.second <= 60);
683        assert_eq!(now.tz_offset_minutes, 0, "now() should return UTC");
684    }
685
686    #[test]
687    fn datetime_weekday_known_dates() {
688        // 2025-02-13 is a Thursday (4)
689        let dt = DateTime {
690            year: 2025,
691            month: 2,
692            day: 13,
693            hour: 0,
694            minute: 0,
695            second: 0,
696            tz_offset_minutes: 0,
697        };
698        assert_eq!(dt.weekday(), 4, "2025-02-13 should be Thursday (4)");
699
700        // 1970-01-01 is a Thursday (4)
701        let epoch = DateTime::from_unix_timestamp(0, 0);
702        assert_eq!(epoch.weekday(), 4, "1970-01-01 should be Thursday (4)");
703
704        // 2025-03-16 is a Sunday (0)
705        let sunday = DateTime {
706            year: 2025,
707            month: 3,
708            day: 16,
709            hour: 0,
710            minute: 0,
711            second: 0,
712            tz_offset_minutes: 0,
713        };
714        assert_eq!(sunday.weekday(), 0, "2025-03-16 should be Sunday (0)");
715    }
716
717    #[test]
718    fn datetime_eq_consistent_with_ord() {
719        // Two DateTime values representing the same UTC instant (2025-01-15 12:00:00 UTC)
720        // but with different timezone offsets.
721        let utc = DateTime {
722            year: 2025,
723            month: 1,
724            day: 15,
725            hour: 12,
726            minute: 0,
727            second: 0,
728            tz_offset_minutes: 0,
729        };
730        let plus_five = DateTime {
731            year: 2025,
732            month: 1,
733            day: 15,
734            hour: 17,
735            minute: 0,
736            second: 0,
737            tz_offset_minutes: 300, // +0500
738        };
739
740        // Both should have the same UTC timestamp
741        assert_eq!(utc.to_unix_timestamp(), plus_five.to_unix_timestamp());
742
743        // Ord says they are equal
744        assert_eq!(
745            utc.cmp(&plus_five),
746            std::cmp::Ordering::Equal,
747            "cmp should consider same-UTC-instant values equal"
748        );
749
750        // PartialEq must agree with Ord — this is the Rust contract
751        assert_eq!(
752            utc, plus_five,
753            "PartialEq must agree with Ord: same UTC instant should be =="
754        );
755    }
756
757    #[test]
758    fn datetime_hash_consistent_with_eq() {
759        use std::collections::hash_map::DefaultHasher;
760        use std::hash::{Hash, Hasher};
761
762        fn hash_of(dt: &DateTime) -> u64 {
763            let mut hasher = DefaultHasher::new();
764            dt.hash(&mut hasher);
765            hasher.finish()
766        }
767
768        // Two DateTime values representing the same UTC instant
769        // (2025-01-15 12:00:00 UTC) but with different timezone offsets.
770        let utc = DateTime {
771            year: 2025,
772            month: 1,
773            day: 15,
774            hour: 12,
775            minute: 0,
776            second: 0,
777            tz_offset_minutes: 0,
778        };
779        let plus_five = DateTime {
780            year: 2025,
781            month: 1,
782            day: 15,
783            hour: 17,
784            minute: 0,
785            second: 0,
786            tz_offset_minutes: 300,
787        };
788
789        // Eq says they are equal
790        assert_eq!(utc, plus_five);
791
792        // Hash MUST agree: equal values must produce equal hashes
793        assert_eq!(
794            hash_of(&utc),
795            hash_of(&plus_five),
796            "Hash must be consistent with Eq: same UTC instant must hash the same"
797        );
798    }
799
800    #[test]
801    fn datetime_to_rfc5322_string_utc() {
802        let dt = DateTime {
803            year: 2025,
804            month: 2,
805            day: 13,
806            hour: 15,
807            minute: 47,
808            second: 33,
809            tz_offset_minutes: 0,
810        };
811        // 2025-02-13 is a Thursday
812        assert_eq!(dt.to_rfc5322_string(), "Thu, 13 Feb 2025 15:47:33 +0000");
813    }
814
815    #[test]
816    fn datetime_to_rfc5322_string_positive_offset() {
817        let dt = DateTime {
818            year: 2025,
819            month: 2,
820            day: 13,
821            hour: 21,
822            minute: 17,
823            second: 33,
824            tz_offset_minutes: 330, // +0530
825        };
826        assert_eq!(dt.to_rfc5322_string(), "Thu, 13 Feb 2025 21:17:33 +0530");
827    }
828
829    #[test]
830    fn datetime_to_rfc5322_string_negative_offset() {
831        let dt = DateTime {
832            year: 2025,
833            month: 3,
834            day: 16,
835            hour: 9,
836            minute: 0,
837            second: 0,
838            tz_offset_minutes: -480, // -0800
839        };
840        // 2025-03-16 is a Sunday
841        assert_eq!(dt.to_rfc5322_string(), "Sun, 16 Mar 2025 09:00:00 -0800");
842    }
843
844    #[test]
845    fn datetime_parse_rfc5322_basic() {
846        let dt = DateTime::parse_rfc5322("Thu, 13 Feb 2025 15:47:33 +0000").unwrap();
847        assert_eq!(dt.year, 2025);
848        assert_eq!(dt.month, 2);
849        assert_eq!(dt.day, 13);
850        assert_eq!(dt.hour, 15);
851        assert_eq!(dt.minute, 47);
852        assert_eq!(dt.second, 33);
853        assert_eq!(dt.tz_offset_minutes, 0);
854    }
855
856    #[test]
857    fn datetime_parse_rfc5322_with_offset() {
858        let dt = DateTime::parse_rfc5322("Fri, 14 Feb 2025 09:15:00 -0800").unwrap();
859        assert_eq!(dt.year, 2025);
860        assert_eq!(dt.tz_offset_minutes, -480);
861    }
862
863    #[test]
864    fn datetime_parse_rfc5322_round_trip() {
865        let original = DateTime {
866            year: 2025,
867            month: 12,
868            day: 25,
869            hour: 0,
870            minute: 0,
871            second: 0,
872            tz_offset_minutes: 330,
873        };
874        let s = original.to_rfc5322_string();
875        let parsed = DateTime::parse_rfc5322(&s).unwrap();
876        assert_eq!(original, parsed);
877    }
878
879    #[test]
880    fn datetime_parse_rfc5322_invalid() {
881        assert!(DateTime::parse_rfc5322("not a date").is_none());
882        assert!(DateTime::parse_rfc5322("").is_none());
883    }
884
885    #[test]
886    fn datetime_to_iso8601_string_utc() {
887        let dt = DateTime {
888            year: 2025,
889            month: 2,
890            day: 13,
891            hour: 15,
892            minute: 47,
893            second: 33,
894            tz_offset_minutes: 0,
895        };
896        assert_eq!(dt.to_iso8601_string(), "2025-02-13T15:47:33+00:00");
897    }
898
899    #[test]
900    fn datetime_to_iso8601_string_positive_offset() {
901        let dt = DateTime {
902            year: 2025,
903            month: 2,
904            day: 13,
905            hour: 21,
906            minute: 17,
907            second: 33,
908            tz_offset_minutes: 330, // +05:30
909        };
910        assert_eq!(dt.to_iso8601_string(), "2025-02-13T21:17:33+05:30");
911    }
912
913    #[test]
914    fn datetime_to_iso8601_string_negative_offset() {
915        let dt = DateTime {
916            year: 2025,
917            month: 3,
918            day: 16,
919            hour: 9,
920            minute: 0,
921            second: 0,
922            tz_offset_minutes: -480, // -08:00
923        };
924        assert_eq!(dt.to_iso8601_string(), "2025-03-16T09:00:00-08:00");
925    }
926
927    /// `Address::Display` must RFC 2047 encode non-ASCII display
928    /// names, since RFC 5322 Section 2.2 requires header field bodies to be
929    /// US-ASCII (RFC 2047 Section 5 defines the encoded-word mechanism).
930    #[test]
931    fn address_display_non_ascii_is_rfc2047_encoded() {
932        let addr = Address {
933            name: Some("José García".into()),
934            email: "jose@example.com".into(),
935        };
936        let displayed = addr.to_string();
937
938        // RFC 5322 Section 2.2: field bodies must be US-ASCII
939        assert!(
940            displayed.is_ascii(),
941            "Display output must be pure ASCII, got: {displayed}"
942        );
943        // RFC 2047 Base64 encoded word marker
944        assert!(
945            displayed.contains("=?UTF-8?B?"),
946            "Non-ASCII name must be RFC 2047 encoded, got: {displayed}"
947        );
948        // Email must still appear in angle brackets
949        assert!(
950            displayed.contains("<jose@example.com>"),
951            "Email must appear in angle brackets, got: {displayed}"
952        );
953    }
954
955    /// ASCII display names must NOT be RFC 2047 encoded — encoding is only
956    /// needed for non-ASCII characters (RFC 2047 Section 5).
957    #[test]
958    fn address_display_ascii_name_unchanged() {
959        let addr = Address {
960            name: Some("John Doe".into()),
961            email: "john@example.com".into(),
962        };
963        let displayed = addr.to_string();
964        assert_eq!(displayed, "John Doe <john@example.com>");
965        // Must not contain encoded-word markers
966        assert!(
967            !displayed.contains("=?"),
968            "ASCII name should not be RFC 2047 encoded, got: {displayed}"
969        );
970    }
971
972    /// Round-trip: non-ASCII name formatted with Display, then parsed back
973    /// with `FromStr`, must recover the original name (RFC 2047 encode/decode).
974    #[test]
975    fn address_display_non_ascii_round_trip() {
976        let original = Address {
977            name: Some("José García".into()),
978            email: "jose@example.com".into(),
979        };
980        let displayed = original.to_string();
981        let parsed: Address = displayed.parse().unwrap();
982        assert_eq!(
983            original, parsed,
984            "Round-trip failed: displayed as '{displayed}', parsed name = {:?}",
985            parsed.name
986        );
987    }
988
989    /// `DateTime` with out-of-range month must not panic in
990    /// `to_rfc5322_string()` or `weekday()` — violates "no panics" rule and
991    /// RFC 5322 Section 3.3 (month is 1–12).
992    #[test]
993    fn datetime_invalid_month_no_panic() {
994        // Month 0 (below valid range)
995        let dt_zero = DateTime {
996            year: 2025,
997            month: 0,
998            day: 15,
999            hour: 12,
1000            minute: 0,
1001            second: 0,
1002            tz_offset_minutes: 0,
1003        };
1004        // Must not panic — should produce a clamped/fallback string
1005        let _ = dt_zero.to_rfc5322_string();
1006        let _ = dt_zero.weekday();
1007
1008        // Month 13 (above valid range)
1009        let dt_thirteen = DateTime {
1010            year: 2025,
1011            month: 13,
1012            day: 15,
1013            hour: 12,
1014            minute: 0,
1015            second: 0,
1016            tz_offset_minutes: 0,
1017        };
1018        // Must not panic
1019        let _ = dt_thirteen.to_rfc5322_string();
1020        let _ = dt_thirteen.weekday();
1021
1022        // Month 255 (u8::MAX)
1023        let dt_max = DateTime {
1024            year: 2025,
1025            month: 255,
1026            day: 15,
1027            hour: 12,
1028            minute: 0,
1029            second: 0,
1030            tz_offset_minutes: 0,
1031        };
1032        // Must not panic
1033        let _ = dt_max.to_rfc5322_string();
1034        let _ = dt_max.weekday();
1035    }
1036
1037    #[test]
1038    fn datetime_from_str_basic() {
1039        let dt: DateTime = "Thu, 13 Feb 2025 15:47:33 +0000".parse().unwrap();
1040        assert_eq!(dt.year, 2025);
1041        assert_eq!(dt.month, 2);
1042        assert_eq!(dt.day, 13);
1043        assert_eq!(dt.hour, 15);
1044        assert_eq!(dt.minute, 47);
1045        assert_eq!(dt.second, 33);
1046        assert_eq!(dt.tz_offset_minutes, 0);
1047    }
1048
1049    #[test]
1050    fn datetime_from_str_with_offset() {
1051        let dt: DateTime = "Fri, 14 Feb 2025 09:15:00 -0800".parse().unwrap();
1052        assert_eq!(dt.year, 2025);
1053        assert_eq!(dt.tz_offset_minutes, -480);
1054    }
1055
1056    #[test]
1057    fn datetime_from_str_invalid() {
1058        let result: Result<DateTime, _> = "not a date".parse();
1059        assert!(result.is_err());
1060    }
1061
1062    #[test]
1063    fn datetime_from_str_empty() {
1064        let result: Result<DateTime, _> = "".parse();
1065        assert!(result.is_err());
1066    }
1067
1068    #[test]
1069    fn datetime_from_str_round_trip() {
1070        let original = DateTime {
1071            year: 2025,
1072            month: 7,
1073            day: 4,
1074            hour: 12,
1075            minute: 30,
1076            second: 0,
1077            tz_offset_minutes: -300,
1078        };
1079        let s = original.to_rfc5322_string();
1080        let parsed: DateTime = s.parse().unwrap();
1081        assert_eq!(original, parsed);
1082    }
1083
1084    #[test]
1085    fn datetime_display_utc() {
1086        let dt = DateTime {
1087            year: 2025,
1088            month: 2,
1089            day: 13,
1090            hour: 15,
1091            minute: 47,
1092            second: 33,
1093            tz_offset_minutes: 0,
1094        };
1095        assert_eq!(dt.to_string(), "2025-02-13 15:47:33 +0000");
1096    }
1097
1098    #[test]
1099    fn datetime_display_positive_offset() {
1100        let dt = DateTime {
1101            year: 2025,
1102            month: 2,
1103            day: 13,
1104            hour: 21,
1105            minute: 17,
1106            second: 33,
1107            tz_offset_minutes: 330, // +0530
1108        };
1109        assert_eq!(dt.to_string(), "2025-02-13 21:17:33 +0530");
1110    }
1111
1112    #[test]
1113    fn datetime_display_negative_offset() {
1114        let dt = DateTime {
1115            year: 2025,
1116            month: 3,
1117            day: 16,
1118            hour: 9,
1119            minute: 0,
1120            second: 0,
1121            tz_offset_minutes: -480, // -0800
1122        };
1123        assert_eq!(dt.to_string(), "2025-03-16 09:00:00 -0800");
1124    }
1125
1126    #[test]
1127    fn datetime_display_non_half_hour_offset() {
1128        // Nepal Standard Time: UTC+05:45
1129        let dt = DateTime {
1130            year: 2025,
1131            month: 6,
1132            day: 1,
1133            hour: 12,
1134            minute: 0,
1135            second: 0,
1136            tz_offset_minutes: 345, // +0545
1137        };
1138        assert_eq!(dt.to_string(), "2025-06-01 12:00:00 +0545");
1139    }
1140
1141    #[test]
1142    fn datetime_display_extreme_offset() {
1143        // +1200 (e.g. Fiji)
1144        let dt = DateTime {
1145            year: 2025,
1146            month: 1,
1147            day: 1,
1148            hour: 0,
1149            minute: 0,
1150            second: 0,
1151            tz_offset_minutes: 720, // +1200
1152        };
1153        assert_eq!(dt.to_string(), "2025-01-01 00:00:00 +1200");
1154
1155        // -1200 (Baker Island)
1156        let dt_neg = DateTime {
1157            year: 2025,
1158            month: 1,
1159            day: 1,
1160            hour: 0,
1161            minute: 0,
1162            second: 0,
1163            tz_offset_minutes: -720, // -1200
1164        };
1165        assert_eq!(dt_neg.to_string(), "2025-01-01 00:00:00 -1200");
1166    }
1167
1168    /// Parsing `<>` must return `Err(InvalidAddress("empty email in angle brackets"))`.
1169    /// Covers the empty-email guard at L139-142 (RFC 5322 Section 3.4).
1170    #[test]
1171    fn address_from_str_empty_angle_brackets_rejected() {
1172        let result: Result<Address, _> = "<>".parse();
1173        let err = result.unwrap_err();
1174        let msg = err.to_string();
1175        assert!(
1176            msg.contains("empty email in angle brackets"),
1177            "expected 'empty email in angle brackets' error, got: {msg}"
1178        );
1179    }
1180
1181    /// Parsing `<user@example.com>` (no display name) must yield `name: None`.
1182    /// Covers the empty `name_part` branch at L120-121 (RFC 5322 Section 3.4).
1183    #[test]
1184    fn address_from_str_angle_brackets_no_name() {
1185        let addr: Address = "<user@example.com>".parse().unwrap();
1186        assert_eq!(addr.email, "user@example.com");
1187        assert!(
1188            addr.name.is_none(),
1189            "expected name to be None for bare angle-bracket address, got: {:?}",
1190            addr.name
1191        );
1192    }
1193
1194    /// Parsing `"" <user@example.com>` (quoted empty display name) must yield `name: None`.
1195    /// After stripping outer quotes, the name is empty, so it becomes `None` at L129-130
1196    /// (RFC 5322 Section 3.2.4 — quoted-string, RFC 5322 Section 3.4).
1197    #[test]
1198    fn address_from_str_quoted_empty_name_is_none() {
1199        let addr: Address = "\"\" <user@example.com>".parse().unwrap();
1200        assert_eq!(addr.email, "user@example.com");
1201        assert!(
1202            addr.name.is_none(),
1203            "expected name to be None for quoted empty name, got: {:?}",
1204            addr.name
1205        );
1206    }
1207
1208    /// Parsing `"   " <user@example.com>` (whitespace-only prefix before `<`) must
1209    /// yield `name: None`. The whitespace-only `name_part` is trimmed to empty at L119-121
1210    /// (RFC 5322 Section 3.4).
1211    #[test]
1212    fn address_from_str_whitespace_only_name_is_none() {
1213        let addr: Address = "   <user@example.com>".parse().unwrap();
1214        assert_eq!(addr.email, "user@example.com");
1215        assert!(
1216            addr.name.is_none(),
1217            "expected name to be None for whitespace-only prefix, got: {:?}",
1218            addr.name
1219        );
1220    }
1221
1222    /// Parsing `<user@example.com` (unclosed angle bracket) must fall through to
1223    /// the bare email path or error, since `rfind('>')` fails or returns a position
1224    /// before `<`. Covers L116/L145-146 (RFC 5322 Section 3.4).
1225    #[test]
1226    fn address_from_str_unclosed_angle_bracket() {
1227        // The `>` is never found, so the angle-bracket branch is skipped.
1228        // The input does contain `@`, so it parses as a bare email.
1229        let result: Result<Address, _> = "<user@example.com".parse();
1230        // Either it parses as a (malformed) bare email or errors — either is acceptable.
1231        // It must NOT panic.
1232        if let Ok(addr) = result {
1233            // Bare email path was taken; name must be None
1234            assert!(addr.name.is_none());
1235            assert!(addr.email.contains("user@example.com"));
1236        }
1237        // Err is also acceptable — malformed input rejected
1238    }
1239
1240    /// `Display` impl must quote and escape backslash and double-quote in
1241    /// display names containing RFC 5322 specials.
1242    /// Covers the `replace('\\', "\\\\").replace('"', "\\\"")` path at L88
1243    /// (RFC 5322 Section 3.2.4 — quoted-pair).
1244    #[test]
1245    fn address_display_name_with_special_chars() {
1246        let addr = Address {
1247            name: Some("O'Brien \\test".into()),
1248            email: "obrien@example.com".into(),
1249        };
1250        let displayed = addr.to_string();
1251        // Backslash is a special char, so the name must be quoted and backslash escaped
1252        assert!(
1253            displayed.contains("\\\\"),
1254            "backslash should be escaped in quoted name, got: {displayed}"
1255        );
1256        assert!(
1257            displayed.starts_with('"'),
1258            "name with specials should be quoted, got: {displayed}"
1259        );
1260        assert!(
1261            displayed.contains("<obrien@example.com>"),
1262            "email must appear in angle brackets, got: {displayed}"
1263        );
1264    }
1265
1266    /// `unescape_quoted_string` with a trailing backslash (no following character)
1267    /// must preserve the backslash rather than dropping it.
1268    /// Covers the `else` branch at L190-192 (RFC 5322 Section 3.2.4 — quoted-pair).
1269    #[test]
1270    fn unescape_trailing_backslash() {
1271        let result = unescape_quoted_string("hello\\");
1272        assert_eq!(
1273            result, "hello\\",
1274            "trailing backslash with no following char should be preserved"
1275        );
1276    }
1277}