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// ---------------------------------------------------------------------------
13// Validated newtypes
14// ---------------------------------------------------------------------------
15
16/// Error returned when constructing a validated protocol type from an invalid string.
17///
18/// Shared across `daaki-imap` and `daaki-smtp` for validated newtypes
19/// such as `SequenceSet`, `MailboxName`, `Domain`, `ForwardPath`, etc.
20/// Each crate's [`Error`] type provides a blanket `From<ValidationError>`
21/// conversion so these can be used with `?` in application code.
22#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
23#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
24#[error("{0}")]
25pub struct ValidationError(String);
26
27impl ValidationError {
28    /// Create a new validation error with the given message.
29    pub fn new(message: impl Into<String>) -> Self {
30        Self(message.into())
31    }
32
33    /// Return the error message as a string slice.
34    pub fn message(&self) -> &str {
35        &self.0
36    }
37}
38
39/// A validated RFC 5322 header field name.
40///
41/// RFC 5322 Section 2.2 defines a field name as `1*ftext`, where
42/// `ftext = %d33-57 / %d59-126` — printable US-ASCII excluding the colon
43/// (`:`). This newtype enforces that constraint at construction time so
44/// that downstream code can rely on the name being well-formed.
45///
46/// # Case Insensitivity
47///
48/// Comparison and hashing are case-insensitive per RFC 5322 Section 2.2,
49/// so `HeaderName::new("Subject")` and `HeaderName::new("subject")` are equal.
50///
51/// # References
52/// - RFC 5322 Section 2.2 (header field syntax)
53#[non_exhaustive]
54#[derive(Debug, Clone)]
55#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
56#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))]
57pub struct HeaderName(String);
58
59impl HeaderName {
60    /// Creates a new `HeaderName` after validating ftext syntax
61    /// (RFC 5322 Section 2.2).
62    ///
63    /// The name must be non-empty and consist solely of printable US-ASCII
64    /// characters (33..=126) excluding colon (58).
65    ///
66    /// # Errors
67    ///
68    /// Returns [`crate::error::Error::InvalidHeaderName`] if the name is
69    /// empty or contains invalid characters.
70    ///
71    /// # References
72    /// - RFC 5322 Section 2.2
73    pub fn new(s: impl Into<String>) -> crate::Result<Self> {
74        let s = s.into();
75        validate_ftext(&s)?;
76        Ok(Self(s))
77    }
78
79    /// Creates a `HeaderName` without validation — test-only.
80    #[cfg(test)]
81    pub(crate) fn new_unchecked(s: impl Into<String>) -> Self {
82        Self(s.into())
83    }
84
85    /// Returns the header name as a string slice.
86    pub fn as_str(&self) -> &str {
87        &self.0
88    }
89
90    /// Consumes self and returns the inner `String`.
91    pub fn into_inner(self) -> String {
92        self.0
93    }
94}
95
96impl AsRef<str> for HeaderName {
97    fn as_ref(&self) -> &str {
98        &self.0
99    }
100}
101
102/// Internet message field names are case-insensitive during comparison.
103///
104/// RFC 822 Section 3.4.7 states this rule explicitly, and RFC 5322 retains
105/// the same `field-name` model for message headers.
106impl PartialEq for HeaderName {
107    fn eq(&self, other: &Self) -> bool {
108        self.0.eq_ignore_ascii_case(&other.0)
109    }
110}
111
112impl Eq for HeaderName {}
113
114/// Hash header names case-insensitively so hashing matches equality.
115///
116/// RFC 822 Section 3.4.7: field-name case is not distinguished. RFC 5322
117/// continues the same header field model.
118impl std::hash::Hash for HeaderName {
119    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
120        for byte in self.0.as_bytes() {
121            byte.to_ascii_lowercase().hash(state);
122        }
123    }
124}
125
126/// Order header names case-insensitively (RFC 5322 Section 2.2).
127impl PartialOrd for HeaderName {
128    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
129        Some(self.cmp(other))
130    }
131}
132
133/// Total ordering on header names, case-insensitive (RFC 5322 Section 2.2).
134impl Ord for HeaderName {
135    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
136        let a = self.0.as_bytes().iter().map(u8::to_ascii_lowercase);
137        let b = other.0.as_bytes().iter().map(u8::to_ascii_lowercase);
138        a.cmp(b)
139    }
140}
141
142impl std::fmt::Display for HeaderName {
143    /// Displays the header name verbatim (RFC 5322 Section 2.2).
144    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
145        f.write_str(&self.0)
146    }
147}
148
149impl TryFrom<String> for HeaderName {
150    type Error = crate::error::Error;
151
152    /// Converts a `String` into a validated `HeaderName` (RFC 5322 Section 2.2).
153    fn try_from(s: String) -> Result<Self, Self::Error> {
154        Self::new(s)
155    }
156}
157
158impl TryFrom<&str> for HeaderName {
159    type Error = crate::error::Error;
160
161    /// Converts a `&str` into a validated `HeaderName` (RFC 5322 Section 2.2).
162    fn try_from(s: &str) -> Result<Self, Self::Error> {
163        Self::new(s)
164    }
165}
166
167impl From<HeaderName> for String {
168    fn from(h: HeaderName) -> Self {
169        h.into_inner()
170    }
171}
172
173/// Validates that `s` is a valid `ftext` sequence per RFC 5322 Section 2.2.
174///
175/// `field-name = 1*ftext` where `ftext = %d33-57 / %d59-126` — printable
176/// US-ASCII characters excluding colon (`:`).
177///
178/// # References
179/// - RFC 5322 Section 2.2
180fn validate_ftext(s: &str) -> crate::Result<()> {
181    if s.is_empty() {
182        return Err(crate::error::Error::InvalidHeaderName(
183            "header name must not be empty".into(),
184        ));
185    }
186    if let Some(bad) = s.chars().find(|&c| {
187        // ftext: printable ASCII (33..=126) excluding ':' (58)
188        let b = c as u32;
189        !(33..=126).contains(&b) || c == ':'
190    }) {
191        return Err(crate::error::Error::InvalidHeaderName(format!(
192            "header name contains invalid character {bad:?} (RFC 5322 Section 2.2)"
193        )));
194    }
195    Ok(())
196}
197
198/// A validated RFC 5322 message-ID (bare, without angle brackets).
199///
200/// RFC 5322 Section 3.6.4 defines `msg-id = "<" id-left "@" id-right ">"`.
201/// This type stores only the bare `id-left "@" id-right` content. Angle
202/// brackets are added by the builder when emitting wire format.
203///
204/// # Validation rules
205/// - Non-empty
206/// - Exactly one `@` separator
207/// - `id-left` and `id-right` must each be non-empty
208/// - No whitespace, angle brackets, or control characters
209///
210/// # References
211/// - RFC 5322 Section 3.6.4 (message identification)
212#[non_exhaustive]
213#[derive(Debug, Clone, PartialEq, Eq, Hash)]
214#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
215#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))]
216pub struct MessageId(String);
217
218impl MessageId {
219    /// Creates a new `MessageId` after validation (RFC 5322 Section 3.6.4).
220    ///
221    /// The value must be a bare message-ID (no angle brackets) containing
222    /// exactly one `@` with non-empty parts on each side, and no
223    /// whitespace, angle brackets, or control characters.
224    ///
225    /// # Errors
226    ///
227    /// Returns [`crate::error::Error::InvalidMessageId`] if validation fails.
228    ///
229    /// # References
230    /// - RFC 5322 Section 3.6.4
231    pub fn new(s: impl Into<String>) -> crate::Result<Self> {
232        let s = s.into();
233        validate_message_id(&s)?;
234        Ok(Self(s))
235    }
236
237    /// Creates a `MessageId` without validation — test-only.
238    #[cfg(test)]
239    pub(crate) fn new_unchecked(s: impl Into<String>) -> Self {
240        Self(s.into())
241    }
242
243    /// Returns the message-ID as a string slice.
244    pub fn as_str(&self) -> &str {
245        &self.0
246    }
247
248    /// Consumes self and returns the inner `String`.
249    pub fn into_inner(self) -> String {
250        self.0
251    }
252}
253
254impl AsRef<str> for MessageId {
255    fn as_ref(&self) -> &str {
256        &self.0
257    }
258}
259
260impl std::fmt::Display for MessageId {
261    /// Displays the bare message-ID (RFC 5322 Section 3.6.4).
262    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
263        f.write_str(&self.0)
264    }
265}
266
267impl TryFrom<String> for MessageId {
268    type Error = crate::error::Error;
269
270    /// Converts a `String` into a validated `MessageId` (RFC 5322 Section 3.6.4).
271    fn try_from(s: String) -> Result<Self, Self::Error> {
272        Self::new(s)
273    }
274}
275
276impl TryFrom<&str> for MessageId {
277    type Error = crate::error::Error;
278
279    /// Converts a `&str` into a validated `MessageId` (RFC 5322 Section 3.6.4).
280    fn try_from(s: &str) -> Result<Self, Self::Error> {
281        Self::new(s)
282    }
283}
284
285impl From<MessageId> for String {
286    fn from(m: MessageId) -> Self {
287        m.into_inner()
288    }
289}
290
291/// Validates a bare message-ID per RFC 5322 Section 3.6.4.
292///
293/// The value must contain exactly one `@`. For outbound/public API values,
294/// RFC 5322 Section 3.6.4 permits only modern generated forms:
295/// `id-left = dot-atom-text` and `id-right = dot-atom-text / no-fold-literal`.
296/// Obsolete parser-only forms remain accepted by the inbound parser via
297/// separate recovery helpers.
298///
299/// RFC 6532 Section 3.2 extends the relevant `atext`, `qtext`, and `dtext`
300/// productions to permit UTF-8 non-ASCII.
301///
302/// # References
303/// - RFC 5322 Section 3.6.4
304/// - RFC 5322 Section 3.2.3
305/// - RFC 5322 Section 3.2.4
306/// - RFC 6532 Section 3.2
307fn validate_message_id(s: &str) -> crate::Result<()> {
308    if s.is_empty() {
309        return Err(crate::error::Error::InvalidMessageId(
310            "message-ID must not be empty".into(),
311        ));
312    }
313
314    let Some((id_left, id_right)) = split_strict_message_id_parts(s) else {
315        return Err(crate::error::Error::InvalidMessageId(
316            "message-ID must contain exactly one '@' \
317             (RFC 5322 Section 3.6.4)"
318                .into(),
319        ));
320    };
321
322    if id_left.is_empty() {
323        return Err(crate::error::Error::InvalidMessageId(
324            "message-ID id-left (before '@') must not be empty \
325             (RFC 5322 Section 3.6.4)"
326                .into(),
327        ));
328    }
329    if id_right.is_empty() {
330        return Err(crate::error::Error::InvalidMessageId(
331            "message-ID id-right (after '@') must not be empty \
332             (RFC 5322 Section 3.6.4)"
333                .into(),
334        ));
335    }
336
337    // RFC 5322 Section 3.6.4 prose says generated msg-id syntax "only permits
338    // the dot-atom-text form on the left-hand side of the @". Appendix A notes
339    // that no-fold-quote was removed from the modern msg-id syntax, even
340    // though obsolete parser forms still exist in Section 4.5.4.
341    if !is_message_id_dot_atom_text(id_left) {
342        return Err(crate::error::Error::InvalidMessageId(
343            "message-ID id-left must be dot-atom-text \
344             (RFC 5322 Section 3.6.4)"
345                .into(),
346        ));
347    }
348    if !(is_message_id_dot_atom_text(id_right) || is_message_id_no_fold_literal(id_right)) {
349        return Err(crate::error::Error::InvalidMessageId(
350            "message-ID id-right must be dot-atom-text or no-fold-literal \
351             (RFC 5322 Sections 3.6.4 and 3.2.3)"
352                .into(),
353        ));
354    }
355
356    Ok(())
357}
358
359/// Returns `true` when `s` is a syntactically valid bare message-ID body for
360/// liberal inbound parsing.
361///
362/// RFC 5322 Section 4.5.4 still defines obsolete `obs-id-left` /
363/// `obs-id-right` forms that parsers may recover from inbound messages, even
364/// though generators and strict public validators should not emit them.
365///
366/// # References
367/// - RFC 5322 Section 3.6.4
368/// - RFC 5322 Section 4.5.4
369/// - RFC 5322 Section 3.2.3
370/// - RFC 5322 Section 3.2.4
371/// - RFC 6532 Section 3.2
372pub(crate) fn is_valid_bare_message_id_body(s: &str) -> bool {
373    split_liberal_message_id_parts(s).is_some_and(|(id_left, id_right)| {
374        !id_left.is_empty()
375            && !id_right.is_empty()
376            && (is_message_id_dot_atom_text(id_left) || is_message_id_no_fold_quote(id_left))
377            && (is_message_id_dot_atom_text(id_right) || is_message_id_no_fold_literal(id_right))
378    })
379}
380
381/// Returns `true` when `s` is a syntactically valid bare message-ID body for
382/// generated output and strict public API validation.
383///
384/// Unlike liberal parser recovery, this follows the modern generation rule
385/// from RFC 5322 Section 3.6.4: `id-left` is limited to `dot-atom-text`.
386///
387/// # References
388/// - RFC 5322 Section 3.6.4
389/// - RFC 5322 Section 3.2.3
390/// - RFC 6532 Section 3.2
391pub(crate) fn is_strict_bare_message_id_body(s: &str) -> bool {
392    split_strict_message_id_parts(s).is_some_and(|(id_left, id_right)| {
393        !id_left.is_empty()
394            && !id_right.is_empty()
395            && is_message_id_dot_atom_text(id_left)
396            && (is_message_id_dot_atom_text(id_right) || is_message_id_no_fold_literal(id_right))
397    })
398}
399
400/// Split a bare message-ID body into `id-left` and `id-right` at the single
401/// `@` separator for strict/generated validation.
402///
403/// RFC 5322 Section 3.6.4 limits generated `msg-id` values to dot-atom
404/// `id-left`, so any additional `@` character makes the identifier invalid.
405///
406/// # References
407/// - RFC 5322 Section 3.6.4
408fn split_strict_message_id_parts(s: &str) -> Option<(&str, &str)> {
409    let (id_left, id_right) = s.split_once('@')?;
410    if id_right.contains('@') {
411        return None;
412    }
413    Some((id_left, id_right))
414}
415
416/// Split a bare message-ID body into `id-left` and `id-right` at the single
417/// unquoted `@` separator for liberal inbound parsing.
418///
419/// RFC 5322 Section 4.5.4 allows obsolete local-part syntax on the left-hand
420/// side, so `@` characters inside quoted strings are not treated as
421/// separators during parser recovery.
422///
423/// # References
424/// - RFC 5322 Section 3.6.4
425/// - RFC 5322 Section 4.5.4
426/// - RFC 5322 Section 3.2.4
427fn split_liberal_message_id_parts(s: &str) -> Option<(&str, &str)> {
428    let bytes = s.as_bytes();
429    let mut in_quotes = false;
430    let mut escaped = false;
431    let mut at_pos = None;
432
433    for (index, &byte) in bytes.iter().enumerate() {
434        if in_quotes {
435            if escaped {
436                escaped = false;
437                continue;
438            }
439
440            match byte {
441                b'\\' => escaped = true,
442                b'"' => in_quotes = false,
443                _ => {}
444            }
445            continue;
446        }
447
448        match byte {
449            b'"' => in_quotes = true,
450            b'@' => {
451                if at_pos.is_some() {
452                    return None;
453                }
454                at_pos = Some(index);
455            }
456            _ => {}
457        }
458    }
459
460    if in_quotes || escaped {
461        return None;
462    }
463
464    at_pos.map(|index| (&s[..index], &s[index + 1..]))
465}
466
467/// Returns `true` when `s` matches RFC 5322 Section 3.2.3 `dot-atom-text`.
468///
469/// RFC 6532 Section 3.2 extends `atext` to include UTF-8 non-ASCII octets.
470///
471/// # References
472/// - RFC 5322 Section 3.2.3
473/// - RFC 6532 Section 3.2
474fn is_message_id_dot_atom_text(s: &str) -> bool {
475    if s.is_empty() || s.starts_with('.') || s.ends_with('.') || s.contains("..") {
476        return false;
477    }
478
479    s.bytes()
480        .all(|byte| is_message_id_atext(byte) || byte == b'.' || byte >= 0x80)
481}
482
483/// Returns `true` when `s` matches the quoted-string form allowed by obsolete
484/// `obs-id-left` parser recovery.
485///
486/// RFC 6532 Section 3.2 extends `qtext` to include UTF-8 non-ASCII octets.
487///
488/// # References
489/// - RFC 5322 Section 4.5.4
490/// - RFC 5322 Section 3.4.1
491/// - RFC 5322 Section 3.2.4
492/// - RFC 6532 Section 3.2
493fn is_message_id_no_fold_quote(s: &str) -> bool {
494    let Some(inner) = s
495        .strip_prefix('"')
496        .and_then(|value| value.strip_suffix('"'))
497    else {
498        return false;
499    };
500
501    let bytes = inner.as_bytes();
502    let mut index = 0;
503    while index < bytes.len() {
504        let byte = bytes[index];
505        if byte == b'\\' {
506            index += 1;
507            if index >= bytes.len() {
508                return false;
509            }
510
511            let escaped = bytes[index];
512            if !(escaped == b'\t' || (0x20..=0x7E).contains(&escaped)) {
513                return false;
514            }
515        } else if !(matches!(byte, 33 | 35..=91 | 93..=126) || byte >= 0x80) {
516            return false;
517        }
518        index += 1;
519    }
520
521    true
522}
523
524/// Returns `true` when `s` matches RFC 5322 Section 3.6.4 `no-fold-literal`.
525///
526/// RFC 6532 Section 3.2 extends `dtext` to include UTF-8 non-ASCII octets.
527///
528/// # References
529/// - RFC 5322 Section 3.6.4
530/// - RFC 6532 Section 3.2
531fn is_message_id_no_fold_literal(s: &str) -> bool {
532    let Some(inner) = s
533        .strip_prefix('[')
534        .and_then(|value| value.strip_suffix(']'))
535    else {
536        return false;
537    };
538
539    inner
540        .bytes()
541        .all(|byte| matches!(byte, 33..=90 | 94..=126) || byte >= 0x80)
542}
543
544/// Returns `true` when `byte` is RFC 5322 `atext`.
545///
546/// # References
547/// - RFC 5322 Section 3.2.3
548fn is_message_id_atext(byte: u8) -> bool {
549    byte.is_ascii_alphanumeric()
550        || matches!(
551            byte,
552            b'!' | b'#'
553                | b'$'
554                | b'%'
555                | b'&'
556                | b'\''
557                | b'*'
558                | b'+'
559                | b'-'
560                | b'/'
561                | b'='
562                | b'?'
563                | b'^'
564                | b'_'
565                | b'`'
566                | b'{'
567                | b'|'
568                | b'}'
569                | b'~'
570        )
571}
572
573// ---------------------------------------------------------------------------
574
575/// A parsed email message.
576///
577/// Produced by [`crate::parse_email`]. All fields are owned.
578/// Missing or unparseable optional fields are `None`.
579///
580/// # References
581/// - RFC 5322 (message structure)
582/// - RFC 2045–2046 (MIME body parts)
583#[non_exhaustive]
584#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
585#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
586pub struct ParsedEmail {
587    /// Message-ID with angle brackets stripped (RFC 5322 Section 3.6.4).
588    ///
589    /// This field uses `String` rather than [`MessageId`] because parsed
590    /// messages may originate from non-conformant sources whose message IDs
591    /// do not pass strict RFC 5322 validation. The parser follows Postel's
592    /// law: accept what you can, even if the value is technically malformed.
593    /// [`OutgoingEmail::in_reply_to`] and [`OutgoingEmail::references`] use
594    /// the validated [`MessageId`] type to ensure outgoing messages are
595    /// strictly conformant.
596    pub message_id: Option<String>,
597    /// All `In-Reply-To` message-ids, angle brackets stripped (RFC 5322 Section 3.6.4).
598    ///
599    /// RFC 5322: `in-reply-to = "In-Reply-To:" 1*msg-id CRLF` — multiple
600    /// message-IDs are valid. Each element is a single message-ID without
601    /// angle brackets.
602    ///
603    /// Uses `String` rather than [`MessageId`] for the same reason as
604    /// [`message_id`](Self::message_id): parsed messages may contain
605    /// non-conformant IDs that would fail strict validation.
606    pub in_reply_to: Vec<String>,
607    /// All `References` message-ids, angle brackets stripped (RFC 5322 Section 3.6.4).
608    ///
609    /// RFC 5322: `references = "References:" 1*msg-id CRLF`. Each element is
610    /// a single message-ID without angle brackets.
611    ///
612    /// Uses `String` rather than [`MessageId`] for the same reason as
613    /// [`message_id`](Self::message_id): parsed messages may contain
614    /// non-conformant IDs that would fail strict validation.
615    pub references: Vec<String>,
616    /// Decoded subject (RFC 5322 Section 3.6.5, RFC 2047 encoded words decoded).
617    pub subject: Option<String>,
618    /// Originator mailboxes (RFC 5322 Section 3.6.2: `from = "From:" mailbox-list`).
619    pub from: Vec<Address>,
620    /// The agent responsible for actual transmission of the message
621    /// (RFC 5322 Section 3.6.2: `sender = "Sender:" mailbox`).
622    ///
623    /// Contains exactly one mailbox. Required when `From` contains multiple
624    /// addresses; otherwise optional. `None` when the header is absent.
625    pub sender: Option<Address>,
626    /// To recipients (RFC 5322 Section 3.6.3).
627    pub to: Vec<Address>,
628    /// Cc recipients (RFC 5322 Section 3.6.3).
629    pub cc: Vec<Address>,
630    /// Bcc recipients (RFC 5322 Section 3.6.3).
631    pub bcc: Vec<Address>,
632    /// Reply-To addresses (RFC 5322 Section 3.6.2).
633    pub reply_to: Vec<Address>,
634    /// Parsed date with original timezone offset preserved (RFC 5322 Section 3.3).
635    pub date: Option<DateTime>,
636    /// First `text/plain` body part, decoded to UTF-8.
637    pub body_text: Option<String>,
638    /// First `text/html` body part, decoded to UTF-8.
639    pub body_html: Option<String>,
640    /// Detected attachments with IMAP section numbers.
641    pub attachments: Vec<ParsedAttachment>,
642    /// Raw header bytes as a `String` (everything before the header/body separator).
643    pub raw_headers: String,
644    /// Optional fields not defined by RFC 5322 (RFC 5322 Section 3.6.8).
645    ///
646    /// Contains all headers that are not one of the well-known structured
647    /// headers (From, To, Cc, Bcc, Reply-To, Subject, Date, Message-ID,
648    /// In-Reply-To, References, Sender, Content-Type,
649    /// Content-Transfer-Encoding, MIME-Version), plus any top-level
650    /// `Content-Disposition` or `Content-ID` field preserved for RFC 2183
651    /// Section 2.10 and RFC 2045 Section 7 consumers. Each entry is a
652    /// `(lowercase-name, decoded-value)` pair, preserving the order they
653    /// appeared in the message.
654    pub extra_headers: Vec<(String, String)>,
655    /// Total byte count of the input.
656    pub size: u64,
657}
658
659impl ParsedEmail {
660    /// Convert extra headers to validated `(HeaderName, String)` pairs
661    /// suitable for use in [`OutgoingEmail::extra_headers`].
662    ///
663    /// Headers whose names fail RFC 5322 Section 2.2 validation are
664    /// silently dropped — such names originate from non-conformant
665    /// messages and cannot be reproduced in well-formed output.
666    pub fn validated_extra_headers(&self) -> Vec<(HeaderName, String)> {
667        self.extra_headers
668            .iter()
669            .filter_map(|(name, value)| {
670                HeaderName::new(name.clone())
671                    .ok()
672                    .map(|hn| (hn, value.clone()))
673            })
674            .collect()
675    }
676}
677
678/// An email address with optional display name.
679///
680/// # References
681/// - RFC 5322 Section 3.4 (address specification)
682#[non_exhaustive]
683#[derive(Debug, Clone, PartialEq, Eq, Hash)]
684#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
685pub struct Address {
686    /// Display name, decoded from RFC 2047 encoded words if present.
687    pub name: Option<String>,
688    /// Email address (`addr-spec` form).
689    pub email: String,
690}
691
692impl Address {
693    /// Creates an address with only an email, validating the syntax.
694    ///
695    /// # Errors
696    ///
697    /// Returns [`crate::Error::InvalidAddress`] if the email is not valid
698    /// per RFC 5322 Section 3.4.
699    pub fn new(email: impl Into<String>) -> crate::Result<Self> {
700        let addr = Self {
701            name: None,
702            email: email.into(),
703        };
704        crate::builder::validate_address(&addr)?;
705        Ok(addr)
706    }
707
708    /// Creates an address with a display name and email, validating the syntax.
709    ///
710    /// # Errors
711    ///
712    /// Returns [`crate::Error::InvalidAddress`] if the email is not valid
713    /// per RFC 5322 Section 3.4.
714    pub fn with_name(name: impl Into<String>, email: impl Into<String>) -> crate::Result<Self> {
715        let addr = Self {
716            name: Some(name.into()),
717            email: email.into(),
718        };
719        crate::builder::validate_address(&addr)?;
720        Ok(addr)
721    }
722
723    /// Creates an `Address` **without validating** the email syntax.
724    ///
725    /// # You almost certainly want [`Address::new`] or [`Address::with_name`] instead
726    ///
727    /// Those constructors validate the email address against RFC 5322
728    /// Section 3.4 and reject malformed input. An `Address` built with
729    /// `new_unchecked` may contain syntax that is invalid for outgoing
730    /// mail, which can cause:
731    ///
732    /// - Rejected `RCPT TO` commands at the SMTP layer.
733    /// - Malformed `From`/`To`/`Cc` headers that downstream MTAs or MUAs
734    ///   cannot parse.
735    /// - Messages that silently disappear into spam filters or bounce
736    ///   queues.
737    ///
738    /// # When this method is appropriate
739    ///
740    /// This exists for **parser and protocol-conversion code** that must
741    /// accept addresses already received from the network, where Postel's
742    /// law applies (RFC 5322 Section 3.4): be liberal in what you accept.
743    /// For example, an IMAP ENVELOPE address or a `From:` header parsed
744    /// from an incoming message may contain syntax that is technically
745    /// non-conformant but still meaningful. Rejecting it would discard
746    /// data the user needs to see.
747    ///
748    /// If you are **building a new outgoing message**, use [`Address::new`]
749    /// or [`Address::with_name`] — they enforce the same validation the
750    /// message builder applies and will catch mistakes at construction
751    /// time rather than at send time.
752    pub fn new_unchecked(name: Option<String>, email: String) -> Self {
753        Self { name, email }
754    }
755}
756
757/// RFC 5322 specials that require a display name to be quoted.
758const SPECIALS: &[char] = &[
759    '(', ')', '<', '>', '[', ']', ':', ';', '@', '\\', ',', '.', '"',
760];
761
762impl std::fmt::Display for Address {
763    /// Formats the address per RFC 5322 Section 3.4.
764    ///
765    /// - Non-ASCII display names are RFC 2047 encoded (RFC 5322 Section 2.2
766    ///   requires header field bodies to be US-ASCII; RFC 2047 Section 5
767    ///   defines the encoded-word mechanism for non-ASCII text).
768    /// - ASCII display names containing specials are quoted per RFC 5322 Section 3.2.4.
769    /// - Without display name: bare `email`
770    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
771        match &self.name {
772            // RFC 5322 Section 3.4: display-name is a `phrase` (`1*word`).
773            // Whitespace alone does not form a valid word, so treat it as absent.
774            Some(name) if !name.trim().is_empty() => {
775                if !name.is_ascii() || name.bytes().any(|b| (b < 0x20 && b != b'\t') || b == 0x7F) {
776                    // RFC 5322 Section 2.2: field bodies must be US-ASCII printable.
777                    // RFC 5322 Section 3.2.3: atext excludes control characters.
778                    // HTAB (0x09) is valid WSP per RFC 5322 Section 2.2.
779                    // RFC 2047 Section 5: use encoded-words for non-ASCII/non-printable text.
780                    let encoded = crate::builder::encode_rfc2047_if_needed(name);
781                    write!(f, "{encoded} <{}>", self.email)
782                } else if name.contains("=?") {
783                    // RFC 2047 Section 5: an unquoted phrase may contain
784                    // encoded-words. A display name that literally contains
785                    // `=?` would be mis-decoded by the parser. Quoting
786                    // prevents this: RFC 2047 Section 5 says encoded-words
787                    // MUST NOT appear inside a quoted-string.
788                    let escaped = name.replace('\\', "\\\\").replace('"', "\\\"");
789                    write!(f, "\"{escaped}\" <{}>", self.email)
790                } else if name.chars().any(|c| SPECIALS.contains(&c)) {
791                    let escaped = name.replace('\\', "\\\\").replace('"', "\\\"");
792                    write!(f, "\"{escaped}\" <{}>", self.email)
793                } else {
794                    write!(f, "{name} <{}>", self.email)
795                }
796            }
797            _ => write!(f, "{}", self.email),
798        }
799    }
800}
801
802impl std::str::FromStr for Address {
803    type Err = crate::error::Error;
804
805    /// Parses an address from a string (RFC 5322 Section 3.4).
806    ///
807    /// Accepts both `Display Name <email>` and bare `email` forms.
808    /// Decodes RFC 2047 encoded words in the display name (RFC 2047 Section 5).
809    #[allow(clippy::too_many_lines)]
810    fn from_str(s: &str) -> Result<Self, Self::Err> {
811        let s = s.trim();
812        if s.is_empty() {
813            return Err(crate::error::Error::InvalidAddress(
814                "empty address string".into(),
815            ));
816        }
817
818        // Try "Display Name <email>" form
819        if let Some(angle_start) = s.rfind('<') {
820            if let Some(angle_end) = s.rfind('>') {
821                if angle_end > angle_start {
822                    let trailing = &s[angle_end + 1..];
823                    validate_trailing_cfws(
824                        trailing,
825                        s,
826                        "angle-addr",
827                        "RFC 5322 Section 3.4 / Section 3.2.2",
828                    )?;
829                    let mut email = s[angle_start + 1..angle_end].trim().to_string();
830                    // RFC 5322 Section 4.4: strip obsolete source route
831                    // (obs-route = obs-domain-list ":"). Example:
832                    // `<@hop1,@hop2:user@domain>` → `user@domain`.
833                    if email.starts_with('@') {
834                        if let Some(colon) = email.find(':') {
835                            email = email[colon + 1..].trim().to_string();
836                        }
837                    }
838                    let name_part = s[..angle_start].trim();
839                    let name = crate::parser::normalize_display_name_phrase(name_part);
840                    if email.is_empty() {
841                        return Err(crate::error::Error::InvalidAddress(
842                            "empty email in angle brackets".into(),
843                        ));
844                    }
845                    let addr = Self { name, email };
846                    // RFC 5322 Section 3.4.1 / RFC 6531 Section 3.3: parse
847                    // the display name leniently, but validate the addr-spec
848                    // with the same rules the builder enforces for emission.
849                    crate::builder::validate_address(&addr)?;
850                    return Ok(addr);
851                }
852            }
853        }
854
855        // Bare email — RFC 5322 Section 3.4.1: `addr-spec = local-part "@" domain`.
856        // Both local-part and domain must be non-empty.
857        //
858        // Also handles comments (RFC 5322 Section 3.2.2) before or after the
859        // addr-spec: `addr-spec (comment)` or `(comment) addr-spec`.  The
860        // comment text is extracted as the display name, matching the behavior
861        // of `parse_single_address` in the parser.
862        if s.find('@').is_some() {
863            // Check for a parenthesized comment: `user@host (Name)` (trailing)
864            // or `(Name) user@host` (leading).
865            // RFC 5322 Section 3.2.2: `comment = "(" *([FWS] ccontent) [FWS] ")"`.
866            let (addr_part, comment_name) = if let Some(paren_start) =
867                crate::parser::find_paren_outside_quotes(s)
868            {
869                let before_paren = s[..paren_start].trim();
870                let comment_and_rest = &s[paren_start..];
871                // Extract comment content, handling nested parentheses
872                // (RFC 5322 Section 3.2.2).
873                let after_paren = &s[paren_start + 1..];
874                let name = strip_comment_delimiters(after_paren);
875                let comment_name = if name.is_empty() { None } else { Some(name) };
876
877                if !before_paren.is_empty() && before_paren.contains('@') {
878                    // Trailing comment: `user@host (Name)`
879                    validate_trailing_cfws(
880                        comment_and_rest,
881                        s,
882                        "addr-spec comment",
883                        "RFC 5322 Section 3.4.1 / Section 3.2.2",
884                    )?;
885                    (before_paren, comment_name)
886                } else {
887                    // Leading comment: `(Name) user@host`
888                    // RFC 5322 Section 3.2.2: CFWS (including comments) may
889                    // appear before the addr-spec.
890                    // Find the text after the closing paren for the addr-spec.
891                    let close_paren = find_matching_close_paren(&s[paren_start..]);
892                    let after_comment = s.get(paren_start + close_paren + 1..).unwrap_or("").trim();
893                    if !after_comment.is_empty() && after_comment.contains('@') {
894                        (after_comment, comment_name)
895                    } else {
896                        // `@` was inside the comment, not in the address
897                        (before_paren, comment_name)
898                    }
899                }
900            } else {
901                (s, None)
902            };
903
904            // Find `@` in the addr_part (not in the original string `s`),
905            // since `addr_part` may be a shorter substring when a comment
906            // was stripped. Fall back to the full addr_part length if there
907            // is no `@` in addr_part (which means the `@` was inside the
908            // comment, not in the address).
909            let at_in_addr = addr_part.find('@').unwrap_or(addr_part.len());
910            let local = &addr_part[..at_in_addr];
911            let domain = addr_part.get(at_in_addr + 1..).unwrap_or("");
912            if local.is_empty() || domain.is_empty() {
913                return Err(crate::error::Error::InvalidAddress(format!(
914                    "invalid bare email (empty local-part or domain): {s} \
915                     (RFC 5322 Section 3.4.1)"
916                )));
917            }
918            let addr = Self {
919                name: comment_name,
920                email: addr_part.to_string(),
921            };
922            // RFC 5322 Section 3.4.1 / RFC 6531 Section 3.3: bare addr-spec
923            // syntax must satisfy the same validation rules used by the
924            // message builder so public parsing and emission stay aligned.
925            crate::builder::validate_address(&addr)?;
926            return Ok(addr);
927        }
928
929        Err(crate::error::Error::InvalidAddress(format!(
930            "cannot parse address: {s}"
931        )))
932    }
933}
934
935/// Finds the offset (from the start of `s`) of the closing `)` that matches
936/// the opening `(` at position 0, respecting nesting and backslash escapes.
937///
938/// Returns `s.len() - 1` if no matching close paren is found (Postel's law:
939/// treat the rest of the string as the comment body).
940///
941/// # References
942/// - RFC 5322 Section 3.2.2 (nested comments, quoted-pair in comments)
943fn find_matching_close_paren(s: &str) -> usize {
944    let bytes = s.as_bytes();
945    if bytes.is_empty() || bytes[0] != b'(' {
946        return 0;
947    }
948    let mut depth: u32 = 0;
949    let mut i = 0;
950    while i < bytes.len() {
951        match bytes[i] {
952            b'\\' => {
953                // Skip escaped character (RFC 5322 Section 3.2.4 quoted-pair).
954                i += 2;
955                continue;
956            }
957            b'(' => {
958                depth = depth.saturating_add(1);
959            }
960            b')' => {
961                depth = depth.saturating_sub(1);
962                if depth == 0 {
963                    return i;
964                }
965            }
966            _ => {}
967        }
968        i += 1;
969    }
970    // No matching close paren found — use end of string.
971    s.len().saturating_sub(1)
972}
973
974/// Extracts the text content from inside a parenthesized comment, handling
975/// nested parentheses and backslash escaping per RFC 5322 Section 3.2.2.
976///
977/// `input` should be the text AFTER the opening `(`.  Returns the trimmed
978/// content with the trailing `)` stripped.  Nested parentheses are preserved
979/// in the output.
980///
981/// # References
982/// - RFC 5322 Section 3.2.2 (comment = "(" *([FWS] ccontent) [FWS] ")")
983/// - RFC 5322 Section 3.2.4 (quoted-pair = "\" (VCHAR / WSP))
984fn strip_comment_delimiters(input: &str) -> String {
985    let bytes = input.as_bytes();
986    let mut depth: u32 = 1;
987    let mut end = input.len();
988    let mut i = 0;
989    while i < bytes.len() {
990        match bytes[i] {
991            b'\\' => {
992                // Skip the next character (quoted-pair per RFC 5322 Section 3.2.4).
993                i += 2;
994                continue;
995            }
996            b'(' => {
997                depth = depth.saturating_add(1);
998            }
999            b')' => {
1000                depth = depth.saturating_sub(1);
1001                if depth == 0 {
1002                    end = i;
1003                    break;
1004                }
1005            }
1006            _ => {}
1007        }
1008        i += 1;
1009    }
1010    // RFC 5322 Section 3.2.2: resolve quoted-pair escapes (`\X` → `X`)
1011    // within the extracted comment content.
1012    unescape_quoted_string(input[..end].trim())
1013}
1014
1015/// Returns `true` if the input contains only CFWS after trimming leading FWS.
1016///
1017/// Used to validate text after an `angle-addr` where RFC 5322 Section 3.4
1018/// permits only trailing CFWS, not arbitrary atoms or phrases.
1019///
1020/// # References
1021/// - RFC 5322 Section 3.2.2 (comments / CFWS)
1022/// - RFC 5322 Section 3.4 (angle-addr)
1023fn is_cfws_only(mut input: &str) -> bool {
1024    loop {
1025        input = input.trim_start();
1026        if input.is_empty() {
1027            return true;
1028        }
1029        if !input.starts_with('(') {
1030            return false;
1031        }
1032
1033        let close = find_matching_close_paren(input);
1034        if input.as_bytes().get(close) != Some(&b')') {
1035            return false;
1036        }
1037        input = &input[close + 1..];
1038    }
1039}
1040
1041/// Reject trailing non-CFWS text after a parsed address component.
1042///
1043/// # References
1044/// - RFC 5322 Section 3.2.2 (comments / CFWS)
1045/// - RFC 5322 Sections 3.4-3.4.1 (address forms)
1046fn validate_trailing_cfws(
1047    trailing: &str,
1048    original: &str,
1049    context: &str,
1050    rfc_sections: &str,
1051) -> Result<(), crate::error::Error> {
1052    if trailing.is_empty() || is_cfws_only(trailing) {
1053        return Ok(());
1054    }
1055    Err(crate::error::Error::InvalidAddress(format!(
1056        "invalid trailing text after {context}: {original} ({rfc_sections})"
1057    )))
1058}
1059
1060/// Unescapes a quoted-string: removes backslash from `\\` → `\` and `\"` → `"`.
1061///
1062/// Per RFC 5322 Section 3.2.4, a `quoted-pair` is `"\" (VCHAR / WSP)`.
1063fn unescape_quoted_string(input: &str) -> String {
1064    let mut result = String::with_capacity(input.len());
1065    let mut chars = input.chars();
1066    while let Some(c) = chars.next() {
1067        if c == '\\' {
1068            if let Some(next) = chars.next() {
1069                result.push(next);
1070            } else {
1071                result.push(c);
1072            }
1073        } else {
1074            result.push(c);
1075        }
1076    }
1077    result
1078}
1079
1080/// Metadata for an attachment found during parsing.
1081///
1082/// Does not contain the attachment binary data — use the `section` field
1083/// to fetch the content on-demand via IMAP `BODY.PEEK[section]`.
1084///
1085/// # References
1086/// - RFC 2183 (Content-Disposition)
1087/// - RFC 3501 Section 6.4.5 (MIME section numbers)
1088#[non_exhaustive]
1089#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1090#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
1091pub struct ParsedAttachment {
1092    /// Filename from `Content-Disposition` or `Content-Type` name parameter.
1093    pub filename: Option<String>,
1094    /// MIME content type (e.g., `"application/pdf"`).
1095    pub content_type: String,
1096    /// Content-ID header value, stripped of angle brackets (RFC 2392).
1097    pub content_id: Option<String>,
1098    /// `true` if `Content-Disposition: inline` or has a `Content-ID`.
1099    pub is_inline: bool,
1100    /// Size of the MIME part body in bytes (if available).
1101    pub size: Option<u64>,
1102    /// IMAP MIME section number (e.g., `"2"`, `"1.2"`) for on-demand fetch.
1103    pub section: Option<String>,
1104}
1105
1106/// Day-of-week abbreviations per RFC 5322 Section 3.3.
1107const DOW_NAMES: [&str; 7] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
1108
1109/// Month abbreviations per RFC 5322 Section 3.3.
1110const MONTH_NAMES: [&str; 12] = [
1111    "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
1112];
1113
1114/// Date-time with timezone offset preserved.
1115///
1116/// # Equality and Ordering
1117///
1118/// `PartialEq`, `Eq`, `Hash`, `PartialOrd`, and `Ord` are implemented manually
1119/// rather than derived. All comparisons normalize to UTC first, so two values
1120/// representing the same instant with different timezone offsets are considered
1121/// equal (e.g., `2025-01-01T00:00:00+0000` == `2024-12-31T19:00:00-0500`).
1122///
1123/// # References
1124/// - RFC 5322 Section 3.3 (date-time specification)
1125#[non_exhaustive]
1126#[derive(Debug, Clone)]
1127#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
1128pub struct DateTime {
1129    /// Year (e.g., 2025).
1130    pub(crate) year: u16,
1131    /// Month (1–12).
1132    pub(crate) month: u8,
1133    /// Day of month (1–31).
1134    pub(crate) day: u8,
1135    /// Hour (0–23).
1136    pub(crate) hour: u8,
1137    /// Minute (0–59).
1138    pub(crate) minute: u8,
1139    /// Second (0–59).
1140    pub(crate) second: u8,
1141    /// Timezone offset from UTC in minutes (e.g., +0530 → 330, −0800 → −480).
1142    pub(crate) tz_offset_minutes: i16,
1143}
1144
1145impl DateTime {
1146    /// Creates a new `DateTime` from individual components.
1147    ///
1148    /// Fields are stored as-is; clamping to valid ranges happens at
1149    /// formatting / comparison time (see [`Self::to_rfc5322_string`],
1150    /// [`Self::to_unix_timestamp`]).
1151    ///
1152    /// # References
1153    /// - RFC 5322 Section 3.3 (date-time specification)
1154    pub fn new(
1155        year: u16,
1156        month: u8,
1157        day: u8,
1158        hour: u8,
1159        minute: u8,
1160        second: u8,
1161        tz_offset_minutes: i16,
1162    ) -> Self {
1163        Self {
1164            year,
1165            month,
1166            day,
1167            hour,
1168            minute,
1169            second,
1170            tz_offset_minutes,
1171        }
1172    }
1173
1174    /// Returns the year (e.g., 2025).
1175    pub fn year(&self) -> u16 {
1176        self.year
1177    }
1178
1179    /// Returns the month, clamped to 1–12 (RFC 5322 Section 3.3).
1180    pub fn month(&self) -> u8 {
1181        self.month.clamp(1, 12)
1182    }
1183
1184    /// Returns the day of month, clamped to a valid range for the
1185    /// current month and year (RFC 5322 Section 3.3).
1186    pub fn day(&self) -> u8 {
1187        let month = self.month.clamp(1, 12);
1188        let max_day = Self::days_in_month(month, self.year);
1189        self.day.clamp(1, max_day)
1190    }
1191
1192    /// Returns the hour, clamped to 0–23 (RFC 5322 Section 3.3).
1193    pub fn hour(&self) -> u8 {
1194        self.hour.clamp(0, 23)
1195    }
1196
1197    /// Returns the minute, clamped to 0–59 (RFC 5322 Section 3.3).
1198    pub fn minute(&self) -> u8 {
1199        self.minute.clamp(0, 59)
1200    }
1201
1202    /// Returns the second, clamped to 0–60 (60 is valid for leap
1203    /// seconds per RFC 5322 Section 3.3).
1204    pub fn second(&self) -> u8 {
1205        self.second.clamp(0, 60)
1206    }
1207
1208    /// Returns the timezone offset from UTC in minutes (e.g., +0530 → 330,
1209    /// −0800 → −480), clamped to ±1439 (RFC 5322 Section 3.3).
1210    pub fn tz_offset_minutes(&self) -> i16 {
1211        self.tz_offset_minutes.clamp(-1439, 1439)
1212    }
1213
1214    /// Converts this date-time to a Unix timestamp (seconds since 1970-01-01T00:00:00Z).
1215    ///
1216    /// The timezone offset is applied so the result is always UTC-based.
1217    /// Uses the same civil-to-days algorithm as the builder.
1218    ///
1219    /// # References
1220    /// - RFC 5322 Section 3.3
1221    pub fn to_unix_timestamp(&self) -> i64 {
1222        // Clamp all fields to valid ranges, consistent with the string
1223        // formatters (RFC 5322 §3.3). This ensures the timestamp matches
1224        // the date-time that would be printed by to_rfc5322_string().
1225        let (month, day, hour, minute, second) = self.clamped_fields();
1226        let days = Self::civil_to_days(i32::from(self.year), u32::from(month), u32::from(day));
1227        // RFC 5322 Section 3.3 allows second=60 (leap second), but Unix
1228        // timestamps have no leap-second representation.  Clamp to 59 so
1229        // the timestamp stays within the correct minute (RFC 5322 §3.3).
1230        let second = if second >= 60 { 59 } else { second };
1231        let secs =
1232            days * 86400 + i64::from(hour) * 3600 + i64::from(minute) * 60 + i64::from(second);
1233        // Clamp timezone offset to the valid RFC 5322 Section 3.3 range
1234        // (±23:59 = ±1439 minutes), consistent with tz_parts() used by
1235        // to_rfc5322_string(). Without this clamp, an out-of-range offset
1236        // produces a timestamp that disagrees with the formatted string.
1237        let tz = i64::from(self.tz_offset_minutes.clamp(-1439, 1439));
1238        secs - tz * 60
1239    }
1240
1241    /// Creates a `DateTime` from a Unix timestamp (seconds since epoch) and a
1242    /// timezone offset in minutes.
1243    ///
1244    /// # References
1245    /// - RFC 5322 Section 3.3
1246    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
1247    pub fn from_unix_timestamp(timestamp: i64, tz_offset_minutes: i16) -> Self {
1248        // Clamp timezone offset to the valid RFC 5322 Section 3.3 range
1249        // (±23:59 = ±1439 minutes) before computing local time. This
1250        // ensures the stored offset matches what to_rfc5322_string() and
1251        // to_unix_timestamp() will use, preserving round-trip consistency.
1252        let tz_offset_minutes = tz_offset_minutes.clamp(-1439, 1439);
1253        // Apply timezone offset to get local time
1254        let local_secs = timestamp + i64::from(tz_offset_minutes) * 60;
1255        let days = local_secs.div_euclid(86400);
1256        let time_secs = local_secs.rem_euclid(86400) as u64;
1257
1258        let (year, month, day) = Self::civil_from_days(days);
1259
1260        // Clamp year to 0–9999 to prevent silent truncation when casting
1261        // i32 → u16. Negative years (BCE dates) and years above 9999 are
1262        // outside the RFC 5322 Section 3.3 representable range (year =
1263        // 4*DIGIT). Without this clamp, negative years wrap to large u16
1264        // values, corrupting the date and breaking round-trips.
1265        let year = year.clamp(0, 9999) as u16;
1266
1267        Self {
1268            year,
1269            month: month as u8,
1270            day: day as u8,
1271            hour: (time_secs / 3600) as u8,
1272            minute: ((time_secs % 3600) / 60) as u8,
1273            second: (time_secs % 60) as u8,
1274            tz_offset_minutes,
1275        }
1276    }
1277
1278    /// Returns the current UTC time as a `DateTime`.
1279    ///
1280    /// # References
1281    /// - RFC 5322 Section 3.3
1282    #[allow(
1283        clippy::cast_possible_truncation,
1284        clippy::cast_sign_loss,
1285        clippy::cast_possible_wrap
1286    )]
1287    pub fn now() -> Self {
1288        use std::time::{SystemTime, UNIX_EPOCH};
1289
1290        let secs = SystemTime::now()
1291            .duration_since(UNIX_EPOCH)
1292            .unwrap_or_default()
1293            .as_secs();
1294
1295        Self::from_unix_timestamp(secs as i64, 0)
1296    }
1297
1298    /// Returns the number of days in the given month, accounting for leap years.
1299    ///
1300    /// The RFC 5322 Section 3.3 grammar allows day values 1-31 for all months,
1301    /// but calendar-valid dates require per-month limits (e.g., Feb has 28 or 29).
1302    ///
1303    /// # References
1304    /// - RFC 5322 Section 3.3 (date-time specification)
1305    fn days_in_month(month: u8, year: u16) -> u8 {
1306        match month {
1307            2 => {
1308                // Leap year: divisible by 4, except centuries unless divisible by 400
1309                let y = u32::from(year);
1310                if (y % 4 == 0 && y % 100 != 0) || (y % 400 == 0) {
1311                    29
1312                } else {
1313                    28
1314                }
1315            }
1316            4 | 6 | 9 | 11 => 30,
1317            // Months 1, 3, 5, 7, 8, 10, 12 all have 31 days.
1318            // month is clamped to 1-12 before calling; wildcard covers 31-day months.
1319            _ => 31,
1320        }
1321    }
1322
1323    /// Returns clamped date-time fields conforming to RFC 5322 Section 3.3.
1324    ///
1325    /// Clamps each field to its valid range:
1326    /// - month: 1–12
1327    /// - day: 1–`days_in_month(month, year)` (accounts for per-month limits and
1328    ///   leap years, so invalid dates like Feb 31 are clamped to Feb 28/29)
1329    /// - hour: 0–23
1330    /// - minute: 0–59
1331    /// - second: 0–60 (60 is valid for leap seconds per RFC 5322 Section 3.3)
1332    ///
1333    /// # References
1334    /// - RFC 5322 Section 3.3 (date-time specification)
1335    fn clamped_fields(&self) -> (u8, u8, u8, u8, u8) {
1336        let month = self.month.clamp(1, 12);
1337        // Clamp day against the actual number of days in the clamped month
1338        // and year, not a flat 31 (RFC 5322 Section 3.3).
1339        let max_day = Self::days_in_month(month, self.year);
1340        let day = self.day.clamp(1, max_day);
1341        (
1342            month,
1343            day,
1344            self.hour.clamp(0, 23),
1345            self.minute.clamp(0, 59),
1346            self.second.clamp(0, 60),
1347        )
1348    }
1349
1350    /// Returns the day of week for this date.
1351    ///
1352    /// Returns 0 for Sunday, 1 for Monday, ..., 6 for Saturday.
1353    /// Uses clamped month/day values for consistency with `to_rfc5322_string()`
1354    /// (RFC 5322 Section 3.3).
1355    ///
1356    /// # References
1357    /// - RFC 5322 Section 3.3
1358    #[allow(clippy::cast_sign_loss)]
1359    pub fn weekday(&self) -> u8 {
1360        let (month, day, _, _, _) = self.clamped_fields();
1361        let days = Self::civil_to_days(i32::from(self.year), u32::from(month), u32::from(day));
1362        // 1970-01-01 was Thursday (4); result is always in [0, 6]
1363        (((days % 7) + 4 + 7) % 7) as u8
1364    }
1365
1366    /// Formats this date-time as an RFC 5322 date-time string.
1367    ///
1368    /// Produces the format: `day-of-week, DD Mon YYYY HH:MM:SS ±HHMM`
1369    /// (e.g., `"Thu, 13 Feb 2025 15:47:33 +0000"`).
1370    ///
1371    /// All fields are clamped to their valid ranges per RFC 5322 Section 3.3
1372    /// to ensure well-formed output (Postel's law: be conservative in what
1373    /// you send).
1374    ///
1375    /// # References
1376    /// - RFC 5322 Section 3.3
1377    pub fn to_rfc5322_string(&self) -> String {
1378        let (month, day, hour, minute, second) = self.clamped_fields();
1379        let dow = self.weekday();
1380        let dow_name = DOW_NAMES[dow as usize];
1381        // month is already clamped to [1, 12] by clamped_fields()
1382        let month_name = MONTH_NAMES[(month - 1) as usize];
1383        let (sign, tz_h, tz_m) = self.tz_parts();
1384        format!(
1385            "{dow_name}, {:02} {month_name} {:04} {:02}:{:02}:{:02} {sign}{tz_h:02}{tz_m:02}",
1386            day, self.year, hour, minute, second,
1387        )
1388    }
1389
1390    /// Formats this date-time as an ISO 8601 / RFC 3339 string.
1391    ///
1392    /// Produces the format: `YYYY-MM-DDTHH:MM:SS±HH:MM`
1393    /// (e.g., `"2025-02-13T15:47:33+00:00"`).
1394    ///
1395    /// All fields are clamped to their valid ranges for consistency with
1396    /// `to_rfc5322_string()`.
1397    ///
1398    /// This format is widely used in JSON APIs and structured data exchange.
1399    ///
1400    /// # References
1401    /// - ISO 8601 (date-time representation)
1402    /// - RFC 3339 (date-time on the Internet)
1403    pub fn to_iso8601_string(&self) -> String {
1404        let (month, day, hour, minute, second) = self.clamped_fields();
1405        let (sign, tz_h, tz_m) = self.tz_parts();
1406        format!(
1407            "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}{sign}{tz_h:02}:{tz_m:02}",
1408            self.year, month, day, hour, minute, second,
1409        )
1410    }
1411
1412    /// Parses an RFC 5322 date-time string into a `DateTime`.
1413    ///
1414    /// Accepts: `[day-of-week ","] day month year hour ":" minute [":" second] zone`
1415    ///
1416    /// Strips CFWS (comments and folding white space) before parsing, as
1417    /// allowed by the obsolete date syntax (RFC 5322 Section 4.3).
1418    ///
1419    /// Returns `None` if the input is not a valid RFC 5322 date-time.
1420    ///
1421    /// # References
1422    /// - RFC 5322 Section 3.3
1423    /// - RFC 5322 Section 4.3 (obsolete syntax)
1424    pub fn parse_rfc5322(input: &str) -> Option<Self> {
1425        crate::parser::parse_rfc5322_date(input)
1426    }
1427
1428    /// Returns the timezone offset decomposed as `(sign, hours, minutes)`.
1429    ///
1430    /// # References
1431    /// - RFC 5322 Section 3.3 (zone = ("+" / "-") 4DIGIT)
1432    fn tz_parts(&self) -> (char, u16, u16) {
1433        // Clamp to the valid RFC 5322 range: zone hours 0-23, minutes 0-59,
1434        // so the maximum magnitude is 23*60+59 = 1439 (RFC 5322 Section 3.3).
1435        let clamped = self.tz_offset_minutes.clamp(-1439, 1439);
1436        let sign = if clamped >= 0 { '+' } else { '-' };
1437        let abs = clamped.unsigned_abs();
1438        (sign, abs / 60, abs % 60)
1439    }
1440
1441    /// Converts days since Unix epoch to `(year, month, day)`.
1442    ///
1443    /// Algorithm from Howard Hinnant's date algorithms.
1444    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
1445    fn civil_from_days(z: i64) -> (i32, u32, u32) {
1446        let z = z + 719_468;
1447        let era = (if z >= 0 { z } else { z - 146_096 }) / 146_097;
1448        let doe = (z - era * 146_097) as u32;
1449        let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
1450        let y = i64::from(yoe) + era * 400;
1451        let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
1452        let mp = (5 * doy + 2) / 153;
1453        let d = doy - (153 * mp + 2) / 5 + 1;
1454        let m = if mp < 10 { mp + 3 } else { mp - 9 };
1455        let y = if m <= 2 { y + 1 } else { y };
1456        (y as i32, m, d)
1457    }
1458
1459    /// Converts `(year, month, day)` to days since Unix epoch.
1460    ///
1461    /// Algorithm from Howard Hinnant's date algorithms.
1462    #[allow(clippy::cast_sign_loss, clippy::cast_possible_wrap)]
1463    fn civil_to_days(year: i32, month: u32, day: u32) -> i64 {
1464        let y = if month <= 2 {
1465            i64::from(year) - 1
1466        } else {
1467            i64::from(year)
1468        };
1469        let m = if month <= 2 { month + 9 } else { month - 3 };
1470        let era = (if y >= 0 { y } else { y - 399 }) / 400;
1471        let yoe = (y - era * 400) as u64;
1472        let doy = (153 * u64::from(m) + 2) / 5 + u64::from(day) - 1;
1473        let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
1474        era * 146_097 + doe as i64 - 719_468
1475    }
1476}
1477
1478impl PartialEq for DateTime {
1479    /// Compares two `DateTime` values by their UTC-normalized timestamps,
1480    /// consistent with the `Ord` implementation.
1481    ///
1482    /// # References
1483    /// - RFC 5322 Section 3.3 (date-time specification)
1484    fn eq(&self, other: &Self) -> bool {
1485        self.to_unix_timestamp() == other.to_unix_timestamp()
1486    }
1487}
1488
1489impl Eq for DateTime {}
1490
1491impl std::hash::Hash for DateTime {
1492    /// Hashes by UTC-normalized timestamp, consistent with `Eq` and `Ord`.
1493    ///
1494    /// Two `DateTime` values representing the same UTC instant (but with
1495    /// different timezone offsets) will produce the same hash, matching the
1496    /// `Eq` behavior.
1497    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
1498        self.to_unix_timestamp().hash(state);
1499    }
1500}
1501
1502impl PartialOrd for DateTime {
1503    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
1504        Some(self.cmp(other))
1505    }
1506}
1507
1508impl Ord for DateTime {
1509    /// Compares two `DateTime` values by their UTC-normalized timestamps.
1510    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
1511        self.to_unix_timestamp().cmp(&other.to_unix_timestamp())
1512    }
1513}
1514
1515impl std::fmt::Display for DateTime {
1516    /// Formats this date-time for human-readable display.
1517    ///
1518    /// All fields are clamped to their valid ranges, consistent with
1519    /// `to_rfc5322_string()` and `to_iso8601_string()` (RFC 5322 §3.3).
1520    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1521        let (month, day, hour, minute, second) = self.clamped_fields();
1522        let (sign, tz_h, tz_m) = self.tz_parts();
1523        write!(
1524            f,
1525            "{:04}-{:02}-{:02} {:02}:{:02}:{:02} {sign}{tz_h:02}{tz_m:02}",
1526            self.year, month, day, hour, minute, second,
1527        )
1528    }
1529}
1530
1531impl std::str::FromStr for DateTime {
1532    type Err = crate::error::Error;
1533
1534    /// Parses an RFC 5322 date-time string into a `DateTime`.
1535    ///
1536    /// Accepts: `[day-of-week ","] day month year hour ":" minute [":" second] zone`
1537    ///
1538    /// This enables the ergonomic `"Thu, 13 Feb 2025 15:47:33 +0000".parse::<DateTime>()`
1539    /// pattern via the standard `FromStr` trait.
1540    ///
1541    /// # References
1542    /// - RFC 5322 Section 3.3
1543    /// - RFC 5322 Section 4.3 (obsolete syntax)
1544    fn from_str(s: &str) -> Result<Self, Self::Err> {
1545        Self::parse_rfc5322(s).ok_or_else(|| {
1546            crate::error::Error::InvalidDate(format!("cannot parse RFC 5322 date: {s}"))
1547        })
1548    }
1549}
1550
1551/// An outgoing email to be built into raw RFC 5322 bytes.
1552///
1553/// Used as input to [`crate::build_message`].
1554///
1555/// # References
1556/// - RFC 5322 (message format)
1557/// - RFC 2046 (MIME multipart)
1558#[non_exhaustive]
1559#[derive(Debug, Default, Clone, PartialEq, Eq, Hash)]
1560#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
1561pub struct OutgoingEmail {
1562    /// Originator mailboxes (RFC 5322 Section 3.6.2: `from = "From:" mailbox-list`).
1563    ///
1564    /// Must contain at least one address. When multiple addresses are present,
1565    /// `sender` **must** be set per RFC 5322 Section 3.6.2:
1566    /// "If the from field contains more than one mailbox specification in the
1567    /// mailbox-list, then the sender field, containing the field value
1568    /// corresponding to the responsible agent, MUST appear in the message."
1569    pub from: Vec<Address>,
1570    /// The agent responsible for actual transmission of the message
1571    /// (RFC 5322 Section 3.6.2: `sender = "Sender:" mailbox`).
1572    ///
1573    /// Required when `from` contains more than one address. Optional when
1574    /// `from` contains exactly one address.
1575    pub sender: Option<Address>,
1576    /// To recipients (RFC 5322 Section 3.6.3).
1577    pub to: Vec<Address>,
1578    /// Cc recipients (RFC 5322 Section 3.6.3).
1579    pub cc: Vec<Address>,
1580    /// Bcc recipients — included in SMTP envelope (`RCPT TO`) but **must not**
1581    /// appear in message headers (RFC 5322 Section 3.6.3).
1582    pub bcc: Vec<Address>,
1583    /// Reply-To addresses (RFC 5322 Section 3.6.2: `address-list`).
1584    pub reply_to: Vec<Address>,
1585    /// Explicit origination date (RFC 5322 Section 3.6.1).
1586    ///
1587    /// When `None`, [`crate::build_message`] uses the current UTC time.
1588    /// Set this when building drafts, re-sending, or importing messages
1589    /// that require a specific date.
1590    pub date: Option<DateTime>,
1591    /// Subject line (RFC 5322 Section 3.6.5).
1592    pub subject: String,
1593    /// Plain text body.
1594    pub body_text: Option<String>,
1595    /// HTML body.
1596    pub body_html: Option<String>,
1597    /// In-Reply-To message-IDs — bare `addr-spec` values (no angle brackets),
1598    /// builder wraps each in angle brackets for the wire format and silently
1599    /// drops malformed IDs (RFC 5322 Section 3.6.4: `in-reply-to = 1*msg-id`).
1600    ///
1601    /// Accepts plain strings so that parsed message-IDs (which may be
1602    /// non-conformant) can flow directly from [`ParsedEmail`] into a reply
1603    /// without fallible conversion.
1604    pub in_reply_to: Vec<String>,
1605    /// References message-IDs — bare `addr-spec` values (no angle brackets),
1606    /// builder wraps each in angle brackets for the wire format and silently
1607    /// drops malformed IDs (RFC 5322 Section 3.6.4: `references = 1*msg-id`).
1608    ///
1609    /// Accepts plain strings for the same reason as [`in_reply_to`](Self::in_reply_to).
1610    pub references: Vec<String>,
1611    /// File attachments.
1612    pub attachments: Vec<OutgoingAttachment>,
1613    /// Additional headers to include verbatim (RFC 5322 Section 3.6.8).
1614    ///
1615    /// Each entry is a `(field-name, unstructured-value)` pair. The
1616    /// [`HeaderName`] newtype enforces RFC 5322 Section 2.2 ftext syntax at
1617    /// construction time. Values are sanitized (CR/LF stripped) and long
1618    /// lines are folded per RFC 5322 Section 2.2.3. Headers are emitted
1619    /// after the standard headers (From, To, Subject, etc.) in the order
1620    /// they appear here.
1621    pub extra_headers: Vec<(HeaderName, String)>,
1622}
1623
1624/// An attachment to include in an outgoing email.
1625///
1626/// # References
1627/// - RFC 2183 (Content-Disposition: attachment / inline)
1628/// - RFC 2392 (Content-ID)
1629/// - RFC 2045 (Content-Transfer-Encoding: base64)
1630#[non_exhaustive]
1631#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1632#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
1633pub struct OutgoingAttachment {
1634    /// Filename for the `Content-Disposition` header.
1635    pub filename: String,
1636    /// MIME content type (e.g., `"application/pdf"`).
1637    pub content_type: String,
1638    /// Raw file data — will be base64-encoded in the message.
1639    pub data: Vec<u8>,
1640    /// When `true`, the part uses `Content-Disposition: inline` instead of
1641    /// `attachment` (RFC 2183 Section 2).  Typically used for images
1642    /// embedded in HTML via `cid:` URLs.
1643    pub is_inline: bool,
1644    /// Optional `Content-ID` header value (RFC 2392).  Should be a bare
1645    /// `addr-spec` (e.g., `"image001@example.com"`); the builder wraps it
1646    /// in angle brackets.  Required for HTML-embedded inline images that
1647    /// are referenced as `<img src="cid:image001@example.com">`.
1648    pub content_id: Option<String>,
1649}
1650
1651impl OutgoingAttachment {
1652    /// Creates a regular (non-inline) attachment.
1653    ///
1654    /// # References
1655    /// - RFC 2183 (Content-Disposition: attachment)
1656    /// - RFC 2045 (Content-Transfer-Encoding: base64)
1657    pub fn new(
1658        filename: impl Into<String>,
1659        content_type: impl Into<String>,
1660        data: impl Into<Vec<u8>>,
1661    ) -> Self {
1662        Self {
1663            filename: filename.into(),
1664            content_type: content_type.into(),
1665            data: data.into(),
1666            is_inline: false,
1667            content_id: None,
1668        }
1669    }
1670
1671    /// Creates an inline attachment with a Content-ID for embedding in HTML.
1672    ///
1673    /// Inline attachments use `Content-Disposition: inline` (RFC 2183
1674    /// Section 2) and are referenced by the HTML body via `cid:` URLs
1675    /// (RFC 2392).
1676    ///
1677    /// # References
1678    /// - RFC 2183 Section 2 (Content-Disposition: inline)
1679    /// - RFC 2392 (Content-ID)
1680    pub fn inline(
1681        filename: impl Into<String>,
1682        content_type: impl Into<String>,
1683        data: impl Into<Vec<u8>>,
1684        content_id: impl Into<String>,
1685    ) -> Self {
1686        Self {
1687            filename: filename.into(),
1688            content_type: content_type.into(),
1689            data: data.into(),
1690            is_inline: true,
1691            content_id: Some(content_id.into()),
1692        }
1693    }
1694}
1695
1696/// The result of building an email message.
1697///
1698/// # References
1699/// - RFC 5322 (message format)
1700/// - RFC 5321 (SMTP envelope)
1701#[non_exhaustive]
1702#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1703#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
1704pub struct BuiltMessage {
1705    /// Raw RFC 5322 message bytes (headers + body). BCC excluded from headers.
1706    pub raw: Vec<u8>,
1707    /// All envelope recipients (to + cc + bcc) for SMTP `RCPT TO`.
1708    pub envelope_recipients: Vec<String>,
1709    /// The generated Message-ID (bare `addr-spec`, no angle brackets).
1710    pub message_id: String,
1711}
1712
1713/// TLS mode for the initial connection (RFC 8314).
1714///
1715/// This type lives in `daaki-message` rather than in the protocol crates
1716/// because both `daaki-imap` and `daaki-smtp` need it, and `daaki-message`
1717/// is already the shared dependency in the workspace. Both protocol crates
1718/// re-export it at their crate root.
1719#[non_exhaustive]
1720#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1721#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
1722pub enum TlsMode {
1723    /// Connect directly over TLS (RFC 8314 Section 3.3).
1724    Implicit,
1725    /// Connect in cleartext, then upgrade via STARTTLS.
1726    StartTls,
1727    /// No TLS (testing only — should not be used in production).
1728    None,
1729}
1730
1731#[cfg(test)]
1732#[path = "types_tests.rs"]
1733mod tests;