Skip to main content

max / goingson

10.5 KB · 313 lines History Blame Raw
1 //! MIME parsing for fetched email: body/attachment extraction and the
2 //! pre-parse nesting guard. Split out of `imap_client` so that module stays
3 //! focused on IMAP protocol IO.
4
5 use chrono::{DateTime, TimeZone, Utc};
6
7 use super::imap_client::{AttachmentPart, ParsedEmail};
8
9 /// Maximum number of `multipart/...` markers permitted in one message. `mailparse`
10 /// recurses once per nesting level with no depth cap, so a deeply nested message,
11 /// cheap to encode and well within `MAX_EMAIL_SIZE`, can overflow the stack, which
12 /// is an uncatchable abort that kills the whole process. Each nesting level needs a
13 /// `multipart/...` Content-Type, so capping their count bounds the recursion before
14 /// `parse_mail` runs. Real emails use a handful; 256 is far above any legitimate value.
15 const MAX_MIME_MULTIPART_PARTS: usize = 256;
16
17 /// Depth bound for our own MIME-tree walks (belt-and-braces with the pre-parse
18 /// guard above; the parsed tree can be no deeper than the multipart-marker count).
19 const MAX_MIME_DEPTH: usize = 256;
20
21 /// Rejects pathologically nested multipart content before `parse_mail` recurses.
22 /// Counts `multipart/` markers (case-insensitive) in the raw message.
23 pub(super) fn mime_nesting_within_limit(body: &[u8]) -> bool {
24 const NEEDLE: &[u8] = b"multipart/";
25 let count = body
26 .windows(NEEDLE.len())
27 .filter(|w| (w[0] | 0x20) == b'm' && w.eq_ignore_ascii_case(NEEDLE))
28 .count();
29 count <= MAX_MIME_MULTIPART_PARTS
30 }
31
32 /// Build a `ParsedEmail` from a parsed message and its IMAP metadata.
33 ///
34 /// Shared by both fetch loops (full-folder and incremental) so the header
35 /// extraction lives in one place.
36 pub(super) fn build_parsed_email(
37 parsed: &mailparse::ParsedMail,
38 uid: u32,
39 folder_name: &str,
40 is_read: bool,
41 ) -> ParsedEmail {
42 let header = |key: &str| {
43 parsed
44 .headers
45 .iter()
46 .find(|h| h.get_key().to_lowercase() == key)
47 .map(mailparse::MailHeader::get_value)
48 };
49
50 let date = header("date")
51 .and_then(|s| mailparse::dateparse(&s).ok())
52 .and_then(|ts| Utc.timestamp_opt(ts, 0).single())
53 .unwrap_or(DateTime::UNIX_EPOCH);
54
55 let (body_text, html_body) = extract_body_with_html(parsed);
56 let attachments = extract_attachment_parts(parsed);
57
58 ParsedEmail {
59 message_id: header("message-id"),
60 in_reply_to: header("in-reply-to"),
61 references_root: extract_references_root(&parsed.headers),
62 imap_uid: uid,
63 source_folder: folder_name.to_string(),
64 from: header("from").unwrap_or_default(),
65 to: header("to").unwrap_or_default(),
66 subject: header("subject").unwrap_or_default(),
67 body: body_text,
68 html_body,
69 date,
70 is_read,
71 attachments,
72 }
73 }
74
75 /// Extracts body text and optionally HTML from a parsed email.
76 /// Returns (plain_text_body, optional_html_body).
77 fn extract_body_with_html(mail: &mailparse::ParsedMail) -> (String, Option<String>) {
78 let mut plain_text: Option<String> = None;
79 let mut html_body: Option<String> = None;
80
81 collect_body_parts(mail, &mut plain_text, &mut html_body, 0);
82
83 // Build final result - prefer plain text, fall back to pter markdown conversion
84 let body_text = if let Some(ref plain) = plain_text {
85 // If we have plain text but it looks like it contains HTML tags,
86 // we should convert them (some emails have incorrect content-types)
87 if plain.contains("<html") || plain.contains("<body") || plain.contains("<div") {
88 pter::convert(plain)
89 } else {
90 plain.clone()
91 }
92 } else if let Some(ref html) = html_body {
93 pter::convert(html)
94 } else {
95 // Fallback to whatever body is available
96 let body = mail.get_body().unwrap_or_default();
97 if body.contains("<html") || body.contains("<body") || body.contains("<div") {
98 pter::convert(&body)
99 } else {
100 body
101 }
102 };
103
104 (body_text, html_body)
105 }
106
107 /// Recursively collects text/plain and text/html parts from a mail structure.
108 fn collect_body_parts(
109 mail: &mailparse::ParsedMail,
110 plain_text: &mut Option<String>,
111 html_body: &mut Option<String>,
112 depth: usize,
113 ) {
114 if depth > MAX_MIME_DEPTH {
115 return;
116 }
117 let mime_type = mail.ctype.mimetype.to_lowercase();
118
119 if mail.subparts.is_empty() {
120 // Leaf node - check content type
121 if mime_type == "text/plain" && plain_text.is_none() {
122 *plain_text = Some(mail.get_body().unwrap_or_default());
123 } else if mime_type == "text/html" && html_body.is_none() {
124 *html_body = Some(mail.get_body().unwrap_or_default());
125 }
126 } else {
127 // Multipart - recurse into all subparts
128 for part in &mail.subparts {
129 collect_body_parts(part, plain_text, html_body, depth + 1);
130 // Stop early if we've found both
131 if plain_text.is_some() && html_body.is_some() {
132 break;
133 }
134 }
135 }
136 }
137
138 // strip_html, extract_href, strip_tags_simple, decode_html_entities
139 // removed, replaced by pter::convert().
140
141 /// Recursively extract attachment parts from a MIME tree.
142 ///
143 /// Extracts the first message-ID from the References header (the thread root).
144 fn extract_references_root(headers: &[mailparse::MailHeader]) -> Option<String> {
145 headers
146 .iter()
147 .find(|h| h.get_key().to_lowercase() == "references")
148 .and_then(|h| {
149 h.get_value()
150 .split_whitespace()
151 .find(|s| s.starts_with('<') && s.ends_with('>'))
152 .map(std::string::ToString::to_string)
153 })
154 }
155
156 /// Walks the MIME structure and collects non-text leaf parts as attachments.
157 /// Skips text/plain and text/html (those are body parts), and parts with empty bodies.
158 pub(super) fn extract_attachment_parts(mail: &mailparse::ParsedMail) -> Vec<AttachmentPart> {
159 let mut parts = Vec::new();
160 collect_attachment_parts(mail, &mut parts, 0);
161 parts
162 }
163
164 fn collect_attachment_parts(
165 mail: &mailparse::ParsedMail,
166 parts: &mut Vec<AttachmentPart>,
167 depth: usize,
168 ) {
169 if depth > MAX_MIME_DEPTH {
170 return;
171 }
172 let mime_type = mail.ctype.mimetype.to_lowercase();
173
174 if mail.subparts.is_empty() {
175 // Leaf node, skip text/plain and text/html (body parts)
176 if mime_type == "text/plain" || mime_type == "text/html" {
177 return;
178 }
179
180 // Get the raw decoded body
181 let data = match mail.get_body_raw() {
182 Ok(d) if !d.is_empty() => d,
183 _ => return, // Skip parts with empty body
184 };
185
186 // Extract filename: Content-Disposition filename, then Content-Type name, then fallback
187 let filename = mail
188 .get_content_disposition()
189 .params
190 .get("filename")
191 .cloned()
192 .or_else(|| mail.ctype.params.get("name").cloned())
193 .unwrap_or_else(|| "attachment".to_string());
194
195 parts.push(AttachmentPart {
196 filename,
197 mime_type: mail.ctype.mimetype.clone(),
198 data,
199 });
200 } else {
201 // Multipart, recurse into subparts
202 for part in &mail.subparts {
203 collect_attachment_parts(part, parts, depth + 1);
204 }
205 }
206 }
207
208 #[cfg(test)]
209 mod tests {
210 // strip_tags_simple, extract_href, decode_html_entities, and strip_html
211 // tests removed, these functions were replaced by pter::convert().
212
213 #[test]
214 fn rejects_pathologically_nested_mime() {
215 // Many more multipart markers than any real email: rejected before parse.
216 let mut body = Vec::new();
217 for _ in 0..(super::MAX_MIME_MULTIPART_PARTS + 10) {
218 body.extend_from_slice(b"Content-Type: multipart/mixed; boundary=b\r\n");
219 }
220 assert!(!super::mime_nesting_within_limit(&body));
221
222 // A normal message with a couple of multiparts passes (case-insensitive).
223 let ok = b"Content-Type: Multipart/Alternative; boundary=b\r\n\r\nhi";
224 assert!(super::mime_nesting_within_limit(ok));
225 }
226
227 #[test]
228 fn pter_replaces_strip_html() {
229 // Verify pter handles what strip_html used to do
230 let html = "<p>Hello <strong>world</strong></p>";
231 let result = pter::convert(html);
232 assert!(result.contains("Hello"));
233 assert!(result.contains("**world**"));
234 }
235
236 // --- extract_attachment_parts ---
237
238 #[test]
239 fn extract_attachments_simple() {
240 // Multipart email with text/plain + application/pdf
241 let raw = b"MIME-Version: 1.0\r\n\
242 Content-Type: multipart/mixed; boundary=\"boundary1\"\r\n\
243 \r\n\
244 --boundary1\r\n\
245 Content-Type: text/plain\r\n\
246 \r\n\
247 Hello world\r\n\
248 --boundary1\r\n\
249 Content-Type: application/pdf\r\n\
250 Content-Disposition: attachment; filename=\"report.pdf\"\r\n\
251 Content-Transfer-Encoding: base64\r\n\
252 \r\n\
253 JVBERi0xLjQK\r\n\
254 --boundary1--\r\n";
255
256 let parsed = mailparse::parse_mail(raw).unwrap();
257 let parts = super::extract_attachment_parts(&parsed);
258 assert_eq!(parts.len(), 1);
259 assert_eq!(parts[0].filename, "report.pdf");
260 assert_eq!(parts[0].mime_type, "application/pdf");
261 assert!(!parts[0].data.is_empty());
262 }
263
264 #[test]
265 fn extract_attachments_none_text_only() {
266 // Plain text email with no attachments
267 let raw = b"MIME-Version: 1.0\r\n\
268 Content-Type: text/plain\r\n\
269 \r\n\
270 Just a plain text message.\r\n";
271
272 let parsed = mailparse::parse_mail(raw).unwrap();
273 let parts = super::extract_attachment_parts(&parsed);
274 assert!(parts.is_empty());
275 }
276
277 #[test]
278 fn extract_attachments_multipart_mixed() {
279 // Multipart with text + image + pdf
280 let raw = b"MIME-Version: 1.0\r\n\
281 Content-Type: multipart/mixed; boundary=\"outer\"\r\n\
282 \r\n\
283 --outer\r\n\
284 Content-Type: text/plain\r\n\
285 \r\n\
286 Body text\r\n\
287 --outer\r\n\
288 Content-Type: image/png; name=\"photo.png\"\r\n\
289 Content-Transfer-Encoding: base64\r\n\
290 \r\n\
291 iVBORw0KGgo=\r\n\
292 --outer\r\n\
293 Content-Type: application/pdf\r\n\
294 Content-Disposition: attachment; filename=\"doc.pdf\"\r\n\
295 Content-Transfer-Encoding: base64\r\n\
296 \r\n\
297 JVBERi0xLjQK\r\n\
298 --outer--\r\n";
299
300 let parsed = mailparse::parse_mail(raw).unwrap();
301 let parts = super::extract_attachment_parts(&parsed);
302 assert_eq!(parts.len(), 2);
303
304 // Image with name from Content-Type
305 assert_eq!(parts[0].filename, "photo.png");
306 assert_eq!(parts[0].mime_type, "image/png");
307
308 // PDF with name from Content-Disposition
309 assert_eq!(parts[1].filename, "doc.pdf");
310 assert_eq!(parts[1].mime_type, "application/pdf");
311 }
312 }
313