//! MIME parsing for fetched email: body/attachment extraction and the //! pre-parse nesting guard. Split out of `imap_client` so that module stays //! focused on IMAP protocol IO. use chrono::{DateTime, TimeZone, Utc}; use super::imap_client::{AttachmentPart, ParsedEmail}; /// Maximum number of `multipart/...` markers permitted in one message. `mailparse` /// recurses once per nesting level with no depth cap, so a deeply nested message, /// cheap to encode and well within `MAX_EMAIL_SIZE`, can overflow the stack, which /// is an uncatchable abort that kills the whole process. Each nesting level needs a /// `multipart/...` Content-Type, so capping their count bounds the recursion before /// `parse_mail` runs. Real emails use a handful; 256 is far above any legitimate value. const MAX_MIME_MULTIPART_PARTS: usize = 256; /// Depth bound for our own MIME-tree walks (belt-and-braces with the pre-parse /// guard above; the parsed tree can be no deeper than the multipart-marker count). const MAX_MIME_DEPTH: usize = 256; /// Rejects pathologically nested multipart content before `parse_mail` recurses. /// Counts `multipart/` markers (case-insensitive) in the raw message. pub(super) fn mime_nesting_within_limit(body: &[u8]) -> bool { const NEEDLE: &[u8] = b"multipart/"; let count = body .windows(NEEDLE.len()) .filter(|w| (w[0] | 0x20) == b'm' && w.eq_ignore_ascii_case(NEEDLE)) .count(); count <= MAX_MIME_MULTIPART_PARTS } /// Build a `ParsedEmail` from a parsed message and its IMAP metadata. /// /// Shared by both fetch loops (full-folder and incremental) so the header /// extraction lives in one place. pub(super) fn build_parsed_email( parsed: &mailparse::ParsedMail, uid: u32, folder_name: &str, is_read: bool, ) -> ParsedEmail { let header = |key: &str| { parsed .headers .iter() .find(|h| h.get_key().to_lowercase() == key) .map(mailparse::MailHeader::get_value) }; let date = header("date") .and_then(|s| mailparse::dateparse(&s).ok()) .and_then(|ts| Utc.timestamp_opt(ts, 0).single()) .unwrap_or(DateTime::UNIX_EPOCH); let (body_text, html_body) = extract_body_with_html(parsed); let attachments = extract_attachment_parts(parsed); ParsedEmail { message_id: header("message-id"), in_reply_to: header("in-reply-to"), references_root: extract_references_root(&parsed.headers), imap_uid: uid, source_folder: folder_name.to_string(), from: header("from").unwrap_or_default(), to: header("to").unwrap_or_default(), subject: header("subject").unwrap_or_default(), body: body_text, html_body, date, is_read, attachments, } } /// Extracts body text and optionally HTML from a parsed email. /// Returns (plain_text_body, optional_html_body). fn extract_body_with_html(mail: &mailparse::ParsedMail) -> (String, Option) { let mut plain_text: Option = None; let mut html_body: Option = None; collect_body_parts(mail, &mut plain_text, &mut html_body, 0); // Build final result - prefer plain text, fall back to pter markdown conversion let body_text = if let Some(ref plain) = plain_text { // If we have plain text but it looks like it contains HTML tags, // we should convert them (some emails have incorrect content-types) if plain.contains(", html_body: &mut Option, depth: usize, ) { if depth > MAX_MIME_DEPTH { return; } let mime_type = mail.ctype.mimetype.to_lowercase(); if mail.subparts.is_empty() { // Leaf node - check content type if mime_type == "text/plain" && plain_text.is_none() { *plain_text = Some(mail.get_body().unwrap_or_default()); } else if mime_type == "text/html" && html_body.is_none() { *html_body = Some(mail.get_body().unwrap_or_default()); } } else { // Multipart - recurse into all subparts for part in &mail.subparts { collect_body_parts(part, plain_text, html_body, depth + 1); // Stop early if we've found both if plain_text.is_some() && html_body.is_some() { break; } } } } // strip_html, extract_href, strip_tags_simple, decode_html_entities // removed, replaced by pter::convert(). /// Recursively extract attachment parts from a MIME tree. /// /// Extracts the first message-ID from the References header (the thread root). fn extract_references_root(headers: &[mailparse::MailHeader]) -> Option { headers .iter() .find(|h| h.get_key().to_lowercase() == "references") .and_then(|h| { h.get_value() .split_whitespace() .find(|s| s.starts_with('<') && s.ends_with('>')) .map(std::string::ToString::to_string) }) } /// Walks the MIME structure and collects non-text leaf parts as attachments. /// Skips text/plain and text/html (those are body parts), and parts with empty bodies. pub(super) fn extract_attachment_parts(mail: &mailparse::ParsedMail) -> Vec { let mut parts = Vec::new(); collect_attachment_parts(mail, &mut parts, 0); parts } fn collect_attachment_parts( mail: &mailparse::ParsedMail, parts: &mut Vec, depth: usize, ) { if depth > MAX_MIME_DEPTH { return; } let mime_type = mail.ctype.mimetype.to_lowercase(); if mail.subparts.is_empty() { // Leaf node, skip text/plain and text/html (body parts) if mime_type == "text/plain" || mime_type == "text/html" { return; } // Get the raw decoded body let data = match mail.get_body_raw() { Ok(d) if !d.is_empty() => d, _ => return, // Skip parts with empty body }; // Extract filename: Content-Disposition filename, then Content-Type name, then fallback let filename = mail .get_content_disposition() .params .get("filename") .cloned() .or_else(|| mail.ctype.params.get("name").cloned()) .unwrap_or_else(|| "attachment".to_string()); parts.push(AttachmentPart { filename, mime_type: mail.ctype.mimetype.clone(), data, }); } else { // Multipart, recurse into subparts for part in &mail.subparts { collect_attachment_parts(part, parts, depth + 1); } } } #[cfg(test)] mod tests { // strip_tags_simple, extract_href, decode_html_entities, and strip_html // tests removed, these functions were replaced by pter::convert(). #[test] fn rejects_pathologically_nested_mime() { // Many more multipart markers than any real email: rejected before parse. let mut body = Vec::new(); for _ in 0..(super::MAX_MIME_MULTIPART_PARTS + 10) { body.extend_from_slice(b"Content-Type: multipart/mixed; boundary=b\r\n"); } assert!(!super::mime_nesting_within_limit(&body)); // A normal message with a couple of multiparts passes (case-insensitive). let ok = b"Content-Type: Multipart/Alternative; boundary=b\r\n\r\nhi"; assert!(super::mime_nesting_within_limit(ok)); } #[test] fn pter_replaces_strip_html() { // Verify pter handles what strip_html used to do let html = "

Hello world

"; let result = pter::convert(html); assert!(result.contains("Hello")); assert!(result.contains("**world**")); } // --- extract_attachment_parts --- #[test] fn extract_attachments_simple() { // Multipart email with text/plain + application/pdf let raw = b"MIME-Version: 1.0\r\n\ Content-Type: multipart/mixed; boundary=\"boundary1\"\r\n\ \r\n\ --boundary1\r\n\ Content-Type: text/plain\r\n\ \r\n\ Hello world\r\n\ --boundary1\r\n\ Content-Type: application/pdf\r\n\ Content-Disposition: attachment; filename=\"report.pdf\"\r\n\ Content-Transfer-Encoding: base64\r\n\ \r\n\ JVBERi0xLjQK\r\n\ --boundary1--\r\n"; let parsed = mailparse::parse_mail(raw).unwrap(); let parts = super::extract_attachment_parts(&parsed); assert_eq!(parts.len(), 1); assert_eq!(parts[0].filename, "report.pdf"); assert_eq!(parts[0].mime_type, "application/pdf"); assert!(!parts[0].data.is_empty()); } #[test] fn extract_attachments_none_text_only() { // Plain text email with no attachments let raw = b"MIME-Version: 1.0\r\n\ Content-Type: text/plain\r\n\ \r\n\ Just a plain text message.\r\n"; let parsed = mailparse::parse_mail(raw).unwrap(); let parts = super::extract_attachment_parts(&parsed); assert!(parts.is_empty()); } #[test] fn extract_attachments_multipart_mixed() { // Multipart with text + image + pdf let raw = b"MIME-Version: 1.0\r\n\ Content-Type: multipart/mixed; boundary=\"outer\"\r\n\ \r\n\ --outer\r\n\ Content-Type: text/plain\r\n\ \r\n\ Body text\r\n\ --outer\r\n\ Content-Type: image/png; name=\"photo.png\"\r\n\ Content-Transfer-Encoding: base64\r\n\ \r\n\ iVBORw0KGgo=\r\n\ --outer\r\n\ Content-Type: application/pdf\r\n\ Content-Disposition: attachment; filename=\"doc.pdf\"\r\n\ Content-Transfer-Encoding: base64\r\n\ \r\n\ JVBERi0xLjQK\r\n\ --outer--\r\n"; let parsed = mailparse::parse_mail(raw).unwrap(); let parts = super::extract_attachment_parts(&parsed); assert_eq!(parts.len(), 2); // Image with name from Content-Type assert_eq!(parts[0].filename, "photo.png"); assert_eq!(parts[0].mime_type, "image/png"); // PDF with name from Content-Disposition assert_eq!(parts[1].filename, "doc.pdf"); assert_eq!(parts[1].mime_type, "application/pdf"); } }