Skip to main content

daaki_imap/types/
envelope.rs

1//! IMAP `ENVELOPE` type (RFC 3501 Section 7.4.2, RFC 9051 Section 7.5.2).
2//!
3//! All fields are owned strings. RFC 2047 encoded words are decoded at parse time
4//! unless UTF8=ACCEPT (RFC 6855 Section 3) is active, in which case fields contain
5//! raw UTF-8 per RFC 6532 Section 3. Non-UTF-8 charsets are lossy-converted to UTF-8.
6
7/// A parsed IMAP ENVELOPE structure (RFC 3501 Section 7.4.2 / RFC 9051 Section 7.5.2).
8///
9/// Every field can be `None` because servers may return NIL for any of them.
10#[non_exhaustive]
11#[derive(Debug, Clone, PartialEq, Eq, Default, Hash)]
12#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
13pub struct Envelope {
14    /// Date from the Date header (RFC 5322 Section 3.3, RFC 3501 Section 7.4.2).
15    pub date: Option<String>,
16    /// Decoded subject (RFC 2047 Section 2, RFC 3501 Section 7.4.2).
17    /// When UTF8=ACCEPT (RFC 6855 Section 3) is active, contains raw UTF-8
18    /// per RFC 6532 Section 3.
19    pub subject: Option<String>,
20    /// From addresses (RFC 5322 Section 3.6.2, RFC 3501 Section 7.4.2).
21    pub from: Vec<EnvelopeAddress>,
22    /// Sender addresses (RFC 5322 Section 3.6.2, RFC 3501 Section 7.4.2).
23    pub sender: Vec<EnvelopeAddress>,
24    /// Reply-To addresses (RFC 5322 Section 3.6.2, RFC 3501 Section 7.4.2).
25    pub reply_to: Vec<EnvelopeAddress>,
26    /// To addresses (RFC 5322 Section 3.6.3, RFC 3501 Section 7.4.2).
27    pub to: Vec<EnvelopeAddress>,
28    /// CC addresses (RFC 5322 Section 3.6.3, RFC 3501 Section 7.4.2).
29    pub cc: Vec<EnvelopeAddress>,
30    /// BCC addresses (RFC 5322 Section 3.6.3, RFC 3501 Section 7.4.2).
31    pub bcc: Vec<EnvelopeAddress>,
32    /// In-Reply-To header (RFC 5322 Section 3.6.4, RFC 3501 Section 7.4.2).
33    pub in_reply_to: Option<String>,
34    /// Message-ID header (RFC 5322 Section 3.6.4, RFC 3501 Section 7.4.2).
35    pub message_id: Option<String>,
36}
37
38impl Envelope {
39    /// Extract the first message-id from `in_reply_to`, stripped of angle brackets.
40    ///
41    /// Convenience method — the raw value is preserved in the field.
42    pub fn first_in_reply_to(&self) -> Option<&str> {
43        let raw = self.in_reply_to.as_deref()?;
44        // RFC 5322 Section 3.6.4: only structural angle brackets delimit a
45        // msg-id. Broken servers may include `<...>` inside quoted or
46        // commented explanatory text, which must not displace the real id.
47        if let Some(id) = first_structural_angle_token(raw) {
48            return Some(id);
49        }
50        if find_structural_angle(raw, 0, b'<').is_some() {
51            return None;
52        }
53
54        let trimmed = raw.trim();
55        if trimmed.is_empty() {
56            None
57        } else {
58            Some(trimmed)
59        }
60    }
61
62    /// Return the message-id stripped of angle brackets.
63    ///
64    /// Convenience method — the raw value is preserved in the field.
65    pub fn bare_message_id(&self) -> Option<&str> {
66        let raw = self.message_id.as_deref()?;
67        if let Some(id) = first_structural_angle_token(raw) {
68            return Some(id);
69        }
70        if find_structural_angle(raw, 0, b'<').is_some() {
71            return None;
72        }
73
74        let trimmed = raw.trim();
75        (!trimmed.is_empty()).then_some(trimmed)
76    }
77}
78
79/// Finds the first non-empty `<...>` token outside quoted strings and comments.
80///
81/// RFC 5322 Section 3.6.4 uses angle brackets as the structural `msg-id`
82/// delimiters. Quoted strings (Section 3.2.4) and comments (Section 3.2.2)
83/// may contain literal `<` / `>` characters, so this helper ignores those
84/// contexts when extracting a consumer-facing identifier.
85fn first_structural_angle_token(raw: &str) -> Option<&str> {
86    let mut offset = 0usize;
87    while let Some(start) = find_structural_angle(raw, offset, b'<') {
88        let Some(end) = find_structural_angle(raw, start + 1, b'>') else {
89            break;
90        };
91
92        let inner = raw[start + 1..end].trim();
93        if !inner.is_empty() {
94            return Some(inner);
95        }
96        offset = end + 1;
97    }
98    None
99}
100
101/// Finds the next angle bracket outside quoted strings and comments.
102///
103/// # References
104/// - RFC 5322 Section 3.2.2
105/// - RFC 5322 Section 3.2.4
106fn find_structural_angle(raw: &str, start: usize, target: u8) -> Option<usize> {
107    let bytes = raw.as_bytes();
108    let mut idx = start;
109    let mut comment_depth = 0u32;
110    let mut in_quotes = false;
111    let mut escaped = false;
112
113    while idx < bytes.len() {
114        let byte = bytes[idx];
115        if escaped {
116            escaped = false;
117            idx += 1;
118            continue;
119        }
120
121        if in_quotes {
122            match byte {
123                b'\\' => escaped = true,
124                b'"' => in_quotes = false,
125                _ => {}
126            }
127            idx += 1;
128            continue;
129        }
130
131        if comment_depth > 0 {
132            match byte {
133                b'\\' => escaped = true,
134                b'(' => comment_depth = comment_depth.saturating_add(1),
135                b')' => comment_depth = comment_depth.saturating_sub(1),
136                _ => {}
137            }
138            idx += 1;
139            continue;
140        }
141
142        match byte {
143            b'"' => in_quotes = true,
144            b'(' => comment_depth = 1,
145            _ if byte == target => return Some(idx),
146            _ => {}
147        }
148        idx += 1;
149    }
150
151    None
152}
153
154/// A single address entry from an ENVELOPE (RFC 3501 Section 7.4.2 / RFC 9051 Section 7.5.2).
155///
156/// Named `EnvelopeAddress` to distinguish from `daaki_message::Address` (RFC 5322 Section 3.4),
157/// which is a simpler name+email pair. This type carries the full IMAP 4-tuple.
158///
159/// Group syntax is represented as:
160/// - **Group start**: `host` is `None`, `mailbox` holds the group name.
161/// - **Group end**: both `host` and `mailbox` are `None`.
162///
163/// Use [`EnvelopeAddress::is_group_start`] and [`EnvelopeAddress::is_group_end`] to detect these markers.
164#[non_exhaustive]
165#[derive(Debug, Clone, PartialEq, Eq, Default, Hash)]
166#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
167pub struct EnvelopeAddress {
168    /// Display name, RFC 2047 decoded (RFC 3501 Section 7.4.2 / RFC 9051 Section 7.5.2).
169    /// When UTF8=ACCEPT (RFC 6855 Section 3) is active, contains raw UTF-8
170    /// per RFC 6532 Section 3.
171    pub name: Option<String>,
172    /// SMTP at-domain-list (source route) — almost always `None` in practice
173    /// (RFC 3501 Section 7.4.2 / RFC 9051 Section 7.5.2).
174    pub adl: Option<String>,
175    /// Mailbox (local-part), or group name for group-start markers
176    /// (RFC 3501 Section 7.4.2 / RFC 9051 Section 7.5.2).
177    pub mailbox: Option<String>,
178    /// Host (domain part), or `None` for group markers
179    /// (RFC 3501 Section 7.4.2 / RFC 9051 Section 7.5.2).
180    pub host: Option<String>,
181}
182
183impl EnvelopeAddress {
184    /// Returns the full email address as `mailbox@host`, or `None` if either part is missing
185    /// or empty (including for group markers).
186    ///
187    /// RFC 5322 Section 3.4.1: `addr-spec = local-part "@" domain` — both local-part
188    /// and domain are required to be non-empty.
189    pub fn email(&self) -> Option<String> {
190        match (&self.mailbox, &self.host) {
191            (Some(m), Some(h)) if !m.is_empty() && !h.is_empty() => Some(format!("{m}@{h}")),
192            _ => None,
193        }
194    }
195
196    /// Returns `true` if this is an RFC 5322 group start marker.
197    ///
198    /// Per RFC 3501 Section 7.4.2: host is NIL, mailbox holds the group name.
199    pub fn is_group_start(&self) -> bool {
200        self.host.is_none() && self.mailbox.is_some()
201    }
202
203    /// Returns `true` if this is an RFC 5322 group end marker.
204    ///
205    /// Per RFC 3501 Section 7.4.2: both host and mailbox are NIL.
206    pub fn is_group_end(&self) -> bool {
207        self.host.is_none() && self.mailbox.is_none()
208    }
209
210    /// Returns `true` if this is a real address (not a group marker).
211    pub fn is_address(&self) -> bool {
212        self.host.is_some()
213    }
214
215    /// Converts this IMAP address to a `daaki_message::Address`.
216    ///
217    /// Returns `None` for group markers (where both mailbox and host
218    /// are not present). For real addresses, combines `mailbox@host`
219    /// into the `email` field.
220    ///
221    /// This eliminates the boilerplate conversion that consumers otherwise
222    /// need at every IMAP→message boundary.
223    ///
224    /// # References
225    /// - RFC 3501 Section 7.4.2 (ENVELOPE address structure)
226    /// - RFC 5322 Section 3.4 (address specification)
227    pub fn to_message_address(&self) -> Option<daaki_message::Address> {
228        let email = self.email()?;
229        Some(daaki_message::Address::new_unchecked(
230            self.name.clone(),
231            email,
232        ))
233    }
234}
235
236/// Converts an IMAP ENVELOPE address to a `daaki_message::Address`.
237///
238/// Group markers (where `email()` returns `None`) produce an `Address`
239/// with an empty `email` field. Use [`EnvelopeAddress::to_message_address`] if
240/// you need to filter those out.
241///
242/// # References
243/// - RFC 3501 Section 7.4.2 (ENVELOPE address structure)
244impl From<&EnvelopeAddress> for daaki_message::Address {
245    fn from(addr: &EnvelopeAddress) -> Self {
246        Self::new_unchecked(addr.name.clone(), addr.email().unwrap_or_default())
247    }
248}
249
250/// Owned conversion from IMAP envelope address to message address.
251impl From<EnvelopeAddress> for daaki_message::Address {
252    fn from(addr: EnvelopeAddress) -> Self {
253        let email = addr.email().unwrap_or_default();
254        Self::new_unchecked(addr.name, email)
255    }
256}
257
258#[cfg(test)]
259#[path = "envelope_tests.rs"]
260mod tests;