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;