Skip to main content

max / goingson

Peel MIME parsing out of imap_client into email/mime_parse Move the MIME cluster (the nesting guard, body/attachment extraction, the References-root helper, the two MIME depth consts, and the three &self-free ImapClient parse methods, now free functions) plus its test module into a new email/mime_parse.rs, leaving imap_client focused on IMAP protocol IO. mime_nesting_within_limit, build_parsed_email, and extract_attachment_parts are pub(super) so imap_client and the tests still reach them; mime_parse imports ParsedEmail and AttachmentPart back from imap_client, which keeps those types (and their crate-wide imap_client:: path) in place. No behavior change; fn/type set conserved, build and clippy clean, all MIME tests and the full desktop suite pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-13 17:42 UTC
Commit: fdb5fd58427a9dbc8804b412b29a29ff3e1cfa98
Parent: ef66d6a
3 files changed, +314 insertions, -302 deletions
@@ -2,40 +2,20 @@
2 2
3 3 use async_imap::Client;
4 4 use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
5 - use chrono::{DateTime, TimeZone, Utc};
5 + use chrono::{DateTime, Utc};
6 6 use futures_util::StreamExt;
7 7 use goingson_core::{EmailAccount, FolderSyncState};
8 8 use tokio::net::TcpStream;
9 9 use tokio_native_tls::native_tls::TlsConnector as NativeTlsConnector;
10 10 use tokio_native_tls::TlsConnector;
11 11
12 + use super::mime_parse::{build_parsed_email, mime_nesting_within_limit};
13 +
12 14 type ImapSession = async_imap::Session<tokio_native_tls::TlsStream<TcpStream>>;
13 15
14 16 /// Maximum email size to download (25 MB). Emails exceeding this are skipped during sync.
15 17 const MAX_EMAIL_SIZE: u32 = 25 * 1024 * 1024;
16 18
17 - /// Maximum number of `multipart/...` markers permitted in one message. `mailparse`
18 - /// recurses once per nesting level with no depth cap, so a deeply nested message —
19 - /// cheap to encode and well within `MAX_EMAIL_SIZE` — can overflow the stack, which
20 - /// is an uncatchable abort that kills the whole process. Each nesting level needs a
21 - /// `multipart/...` Content-Type, so capping their count bounds the recursion before
22 - /// `parse_mail` runs. Real emails use a handful; 256 is far above any legitimate value.
23 - const MAX_MIME_MULTIPART_PARTS: usize = 256;
24 -
25 - /// Depth bound for our own MIME-tree walks (belt-and-braces with the pre-parse
26 - /// guard above; the parsed tree can be no deeper than the multipart-marker count).
27 - const MAX_MIME_DEPTH: usize = 256;
28 -
29 - /// Rejects pathologically nested multipart content before `parse_mail` recurses.
30 - /// Counts `multipart/` markers (case-insensitive) in the raw message.
31 - fn mime_nesting_within_limit(body: &[u8]) -> bool {
32 - const NEEDLE: &[u8] = b"multipart/";
33 - let count = body
34 - .windows(NEEDLE.len())
35 - .filter(|w| (w[0] | 0x20) == b'm' && w.eq_ignore_ascii_case(NEEDLE))
36 - .count();
37 - count <= MAX_MIME_MULTIPART_PARTS
38 - }
39 19
40 20 /// How many UIDs to body-fetch per IMAP command. Bounds the command length and
41 21 /// the in-flight fetch pipeline so a large mailbox doesn't issue one enormous
@@ -300,7 +280,7 @@ impl ImapClient {
300 280 continue;
301 281 }
302 282 match mailparse::parse_mail(body) {
303 - Ok(parsed) => emails.push(Self::build_parsed_email(&parsed, uid, &folder_name, is_read)),
283 + Ok(parsed) => emails.push(build_parsed_email(&parsed, uid, &folder_name, is_read)),
304 284 Err(e) => {
305 285 tracing::debug!(uid, folder = %folder_name, error = %e, "Failed to parse email");
306 286 parse_errors += 1;
@@ -504,7 +484,7 @@ impl ImapClient {
504 484 }
505 485 match mailparse::parse_mail(body) {
506 486 Ok(parsed) => {
507 - emails.push(Self::build_parsed_email(&parsed, uid, &folder_name, is_read));
487 + emails.push(build_parsed_email(&parsed, uid, &folder_name, is_read));
508 488 // Advance only once the message is saved, so a parse
509 489 // failure below is retried rather than silently lost.
510 490 max_uid = Some(max_uid.map_or(uid, |m: u32| m.max(uid)));
@@ -647,177 +627,6 @@ impl ImapClient {
647 627 Ok(format!("Folder '{}': exists={}, recent={}, search_all={}", folder, exists, recent, count))
648 628 }
649 629
650 - /// Build a `ParsedEmail` from a parsed message and its IMAP metadata.
651 - ///
652 - /// Shared by both fetch loops (full-folder and incremental) so the header
653 - /// extraction lives in one place.
654 - fn build_parsed_email(
655 - parsed: &mailparse::ParsedMail,
656 - uid: u32,
657 - folder_name: &str,
658 - is_read: bool,
659 - ) -> ParsedEmail {
660 - let header = |key: &str| {
661 - parsed
662 - .headers
663 - .iter()
664 - .find(|h| h.get_key().to_lowercase() == key)
665 - .map(|h| h.get_value())
666 - };
667 -
668 - let date = header("date")
669 - .and_then(|s| mailparse::dateparse(&s).ok())
670 - .and_then(|ts| Utc.timestamp_opt(ts, 0).single())
671 - .unwrap_or(DateTime::UNIX_EPOCH);
672 -
673 - let (body_text, html_body) = Self::extract_body_with_html(parsed);
674 - let attachments = extract_attachment_parts(parsed);
675 -
676 - ParsedEmail {
677 - message_id: header("message-id"),
678 - in_reply_to: header("in-reply-to"),
679 - references_root: extract_references_root(&parsed.headers),
680 - imap_uid: uid,
681 - source_folder: folder_name.to_string(),
682 - from: header("from").unwrap_or_default(),
683 - to: header("to").unwrap_or_default(),
684 - subject: header("subject").unwrap_or_default(),
685 - body: body_text,
686 - html_body,
687 - date,
688 - is_read,
689 - attachments,
690 - }
691 - }
692 -
693 - /// Extracts body text and optionally HTML from a parsed email.
694 - /// Returns (plain_text_body, optional_html_body).
695 - fn extract_body_with_html(mail: &mailparse::ParsedMail) -> (String, Option<String>) {
696 - let mut plain_text: Option<String> = None;
697 - let mut html_body: Option<String> = None;
698 -
699 - Self::collect_body_parts(mail, &mut plain_text, &mut html_body, 0);
700 -
701 - // Build final result - prefer plain text, fall back to pter markdown conversion
702 - let body_text = if let Some(ref plain) = plain_text {
703 - // If we have plain text but it looks like it contains HTML tags,
704 - // we should convert them (some emails have incorrect content-types)
705 - if plain.contains("<html") || plain.contains("<body") || plain.contains("<div") {
706 - pter::convert(plain)
707 - } else {
708 - plain.clone()
709 - }
710 - } else if let Some(ref html) = html_body {
711 - pter::convert(html)
712 - } else {
713 - // Fallback to whatever body is available
714 - let body = mail.get_body().unwrap_or_default();
715 - if body.contains("<html") || body.contains("<body") || body.contains("<div") {
716 - pter::convert(&body)
717 - } else {
718 - body
719 - }
720 - };
721 -
722 - (body_text, html_body)
723 - }
724 -
725 - /// Recursively collects text/plain and text/html parts from a mail structure.
726 - fn collect_body_parts(
727 - mail: &mailparse::ParsedMail,
728 - plain_text: &mut Option<String>,
729 - html_body: &mut Option<String>,
730 - depth: usize,
731 - ) {
732 - if depth > MAX_MIME_DEPTH {
733 - return;
734 - }
735 - let mime_type = mail.ctype.mimetype.to_lowercase();
736 -
737 - if mail.subparts.is_empty() {
738 - // Leaf node - check content type
739 - if mime_type == "text/plain" && plain_text.is_none() {
740 - *plain_text = Some(mail.get_body().unwrap_or_default());
741 - } else if mime_type == "text/html" && html_body.is_none() {
742 - *html_body = Some(mail.get_body().unwrap_or_default());
743 - }
744 - } else {
745 - // Multipart - recurse into all subparts
746 - for part in &mail.subparts {
747 - Self::collect_body_parts(part, plain_text, html_body, depth + 1);
748 - // Stop early if we've found both
749 - if plain_text.is_some() && html_body.is_some() {
750 - break;
751 - }
752 - }
753 - }
754 - }
755 -
756 - // strip_html, extract_href, strip_tags_simple, decode_html_entities
757 - // removed — replaced by pter::convert().
758 -
759 - }
760 -
761 - /// Recursively extract attachment parts from a MIME tree.
762 - ///
763 - /// Extracts the first message-ID from the References header (the thread root).
764 - fn extract_references_root(headers: &[mailparse::MailHeader]) -> Option<String> {
765 - headers
766 - .iter()
767 - .find(|h| h.get_key().to_lowercase() == "references")
768 - .and_then(|h| {
769 - h.get_value()
770 - .split_whitespace()
771 - .find(|s| s.starts_with('<') && s.ends_with('>'))
772 - .map(|s| s.to_string())
773 - })
774 - }
775 -
776 - /// Walks the MIME structure and collects non-text leaf parts as attachments.
777 - /// Skips text/plain and text/html (those are body parts), and parts with empty bodies.
778 - fn extract_attachment_parts(mail: &mailparse::ParsedMail) -> Vec<AttachmentPart> {
779 - let mut parts = Vec::new();
780 - collect_attachment_parts(mail, &mut parts, 0);
781 - parts
782 - }
783 -
784 - fn collect_attachment_parts(mail: &mailparse::ParsedMail, parts: &mut Vec<AttachmentPart>, depth: usize) {
785 - if depth > MAX_MIME_DEPTH {
786 - return;
787 - }
788 - let mime_type = mail.ctype.mimetype.to_lowercase();
789 -
790 - if mail.subparts.is_empty() {
791 - // Leaf node — skip text/plain and text/html (body parts)
792 - if mime_type == "text/plain" || mime_type == "text/html" {
793 - return;
794 - }
795 -
796 - // Get the raw decoded body
797 - let data = match mail.get_body_raw() {
798 - Ok(d) if !d.is_empty() => d,
799 - _ => return, // Skip parts with empty body
800 - };
801 -
802 - // Extract filename: Content-Disposition filename, then Content-Type name, then fallback
803 - let filename = mail.get_content_disposition()
804 - .params
805 - .get("filename")
806 - .cloned()
807 - .or_else(|| mail.ctype.params.get("name").cloned())
808 - .unwrap_or_else(|| "attachment".to_string());
809 -
810 - parts.push(AttachmentPart {
811 - filename,
812 - mime_type: mail.ctype.mimetype.clone(),
813 - data,
814 - });
815 - } else {
816 - // Multipart — recurse into subparts
817 - for part in &mail.subparts {
818 - collect_attachment_parts(part, parts, depth + 1);
819 - }
820 - }
821 630 }
822 631
823 632 struct XOAuth2Authenticator(String);
@@ -830,109 +639,3 @@ impl async_imap::Authenticator for XOAuth2Authenticator {
830 639 std::mem::take(&mut self.0)
831 640 }
832 641 }
833 -
834 - #[cfg(test)]
835 - mod tests {
836 - // strip_tags_simple, extract_href, decode_html_entities, and strip_html
837 - // tests removed — these functions were replaced by pter::convert().
838 -
839 - #[test]
840 - fn rejects_pathologically_nested_mime() {
841 - // Many more multipart markers than any real email: rejected before parse.
842 - let mut body = Vec::new();
843 - for _ in 0..(super::MAX_MIME_MULTIPART_PARTS + 10) {
844 - body.extend_from_slice(b"Content-Type: multipart/mixed; boundary=b\r\n");
845 - }
846 - assert!(!super::mime_nesting_within_limit(&body));
847 -
848 - // A normal message with a couple of multiparts passes (case-insensitive).
849 - let ok = b"Content-Type: Multipart/Alternative; boundary=b\r\n\r\nhi";
850 - assert!(super::mime_nesting_within_limit(ok));
851 - }
852 -
853 - #[test]
854 - fn pter_replaces_strip_html() {
855 - // Verify pter handles what strip_html used to do
856 - let html = "<p>Hello <strong>world</strong></p>";
857 - let result = pter::convert(html);
858 - assert!(result.contains("Hello"));
859 - assert!(result.contains("**world**"));
860 - }
861 -
862 - // --- extract_attachment_parts ---
863 -
864 - #[test]
865 - fn extract_attachments_simple() {
866 - // Multipart email with text/plain + application/pdf
867 - let raw = b"MIME-Version: 1.0\r\n\
868 - Content-Type: multipart/mixed; boundary=\"boundary1\"\r\n\
869 - \r\n\
870 - --boundary1\r\n\
871 - Content-Type: text/plain\r\n\
872 - \r\n\
873 - Hello world\r\n\
874 - --boundary1\r\n\
875 - Content-Type: application/pdf\r\n\
876 - Content-Disposition: attachment; filename=\"report.pdf\"\r\n\
877 - Content-Transfer-Encoding: base64\r\n\
878 - \r\n\
879 - JVBERi0xLjQK\r\n\
880 - --boundary1--\r\n";
881 -
882 - let parsed = mailparse::parse_mail(raw).unwrap();
883 - let parts = super::extract_attachment_parts(&parsed);
884 - assert_eq!(parts.len(), 1);
885 - assert_eq!(parts[0].filename, "report.pdf");
886 - assert_eq!(parts[0].mime_type, "application/pdf");
887 - assert!(!parts[0].data.is_empty());
888 - }
889 -
890 - #[test]
891 - fn extract_attachments_none_text_only() {
892 - // Plain text email with no attachments
893 - let raw = b"MIME-Version: 1.0\r\n\
894 - Content-Type: text/plain\r\n\
895 - \r\n\
896 - Just a plain text message.\r\n";
897 -
898 - let parsed = mailparse::parse_mail(raw).unwrap();
899 - let parts = super::extract_attachment_parts(&parsed);
900 - assert!(parts.is_empty());
901 - }
902 -
903 - #[test]
904 - fn extract_attachments_multipart_mixed() {
905 - // Multipart with text + image + pdf
906 - let raw = b"MIME-Version: 1.0\r\n\
907 - Content-Type: multipart/mixed; boundary=\"outer\"\r\n\
908 - \r\n\
909 - --outer\r\n\
910 - Content-Type: text/plain\r\n\
911 - \r\n\
912 - Body text\r\n\
913 - --outer\r\n\
914 - Content-Type: image/png; name=\"photo.png\"\r\n\
915 - Content-Transfer-Encoding: base64\r\n\
916 - \r\n\
917 - iVBORw0KGgo=\r\n\
918 - --outer\r\n\
919 - Content-Type: application/pdf\r\n\
920 - Content-Disposition: attachment; filename=\"doc.pdf\"\r\n\
921 - Content-Transfer-Encoding: base64\r\n\
922 - \r\n\
923 - JVBERi0xLjQK\r\n\
924 - --outer--\r\n";
925 -
926 - let parsed = mailparse::parse_mail(raw).unwrap();
927 - let parts = super::extract_attachment_parts(&parsed);
928 - assert_eq!(parts.len(), 2);
929 -
930 - // Image with name from Content-Type
931 - assert_eq!(parts[0].filename, "photo.png");
932 - assert_eq!(parts[0].mime_type, "image/png");
933 -
934 - // PDF with name from Content-Disposition
935 - assert_eq!(parts[1].filename, "doc.pdf");
936 - assert_eq!(parts[1].mime_type, "application/pdf");
937 - }
938 - }
@@ -0,0 +1,308 @@
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(|h| h.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 +
142 + /// Recursively extract attachment parts from a MIME tree.
143 + ///
144 + /// Extracts the first message-ID from the References header (the thread root).
145 + fn extract_references_root(headers: &[mailparse::MailHeader]) -> Option<String> {
146 + headers
147 + .iter()
148 + .find(|h| h.get_key().to_lowercase() == "references")
149 + .and_then(|h| {
150 + h.get_value()
151 + .split_whitespace()
152 + .find(|s| s.starts_with('<') && s.ends_with('>'))
153 + .map(|s| s.to_string())
154 + })
155 + }
156 +
157 + /// Walks the MIME structure and collects non-text leaf parts as attachments.
158 + /// Skips text/plain and text/html (those are body parts), and parts with empty bodies.
159 + pub(super) fn extract_attachment_parts(mail: &mailparse::ParsedMail) -> Vec<AttachmentPart> {
160 + let mut parts = Vec::new();
161 + collect_attachment_parts(mail, &mut parts, 0);
162 + parts
163 + }
164 +
165 + fn collect_attachment_parts(mail: &mailparse::ParsedMail, parts: &mut Vec<AttachmentPart>, depth: usize) {
166 + if depth > MAX_MIME_DEPTH {
167 + return;
168 + }
169 + let mime_type = mail.ctype.mimetype.to_lowercase();
170 +
171 + if mail.subparts.is_empty() {
172 + // Leaf node — skip text/plain and text/html (body parts)
173 + if mime_type == "text/plain" || mime_type == "text/html" {
174 + return;
175 + }
176 +
177 + // Get the raw decoded body
178 + let data = match mail.get_body_raw() {
179 + Ok(d) if !d.is_empty() => d,
180 + _ => return, // Skip parts with empty body
181 + };
182 +
183 + // Extract filename: Content-Disposition filename, then Content-Type name, then fallback
184 + let filename = mail.get_content_disposition()
185 + .params
186 + .get("filename")
187 + .cloned()
188 + .or_else(|| mail.ctype.params.get("name").cloned())
189 + .unwrap_or_else(|| "attachment".to_string());
190 +
191 + parts.push(AttachmentPart {
192 + filename,
193 + mime_type: mail.ctype.mimetype.clone(),
194 + data,
195 + });
196 + } else {
197 + // Multipart — recurse into subparts
198 + for part in &mail.subparts {
199 + collect_attachment_parts(part, parts, depth + 1);
200 + }
201 + }
202 + }
203 +
204 + #[cfg(test)]
205 + mod tests {
206 + // strip_tags_simple, extract_href, decode_html_entities, and strip_html
207 + // tests removed — these functions were replaced by pter::convert().
208 +
209 + #[test]
210 + fn rejects_pathologically_nested_mime() {
211 + // Many more multipart markers than any real email: rejected before parse.
212 + let mut body = Vec::new();
213 + for _ in 0..(super::MAX_MIME_MULTIPART_PARTS + 10) {
214 + body.extend_from_slice(b"Content-Type: multipart/mixed; boundary=b\r\n");
215 + }
216 + assert!(!super::mime_nesting_within_limit(&body));
217 +
218 + // A normal message with a couple of multiparts passes (case-insensitive).
219 + let ok = b"Content-Type: Multipart/Alternative; boundary=b\r\n\r\nhi";
220 + assert!(super::mime_nesting_within_limit(ok));
221 + }
222 +
223 + #[test]
224 + fn pter_replaces_strip_html() {
225 + // Verify pter handles what strip_html used to do
226 + let html = "<p>Hello <strong>world</strong></p>";
227 + let result = pter::convert(html);
228 + assert!(result.contains("Hello"));
229 + assert!(result.contains("**world**"));
230 + }
231 +
232 + // --- extract_attachment_parts ---
233 +
234 + #[test]
235 + fn extract_attachments_simple() {
236 + // Multipart email with text/plain + application/pdf
237 + let raw = b"MIME-Version: 1.0\r\n\
238 + Content-Type: multipart/mixed; boundary=\"boundary1\"\r\n\
239 + \r\n\
240 + --boundary1\r\n\
241 + Content-Type: text/plain\r\n\
242 + \r\n\
243 + Hello world\r\n\
244 + --boundary1\r\n\
245 + Content-Type: application/pdf\r\n\
246 + Content-Disposition: attachment; filename=\"report.pdf\"\r\n\
247 + Content-Transfer-Encoding: base64\r\n\
248 + \r\n\
249 + JVBERi0xLjQK\r\n\
250 + --boundary1--\r\n";
251 +
252 + let parsed = mailparse::parse_mail(raw).unwrap();
253 + let parts = super::extract_attachment_parts(&parsed);
254 + assert_eq!(parts.len(), 1);
255 + assert_eq!(parts[0].filename, "report.pdf");
256 + assert_eq!(parts[0].mime_type, "application/pdf");
257 + assert!(!parts[0].data.is_empty());
258 + }
259 +
260 + #[test]
261 + fn extract_attachments_none_text_only() {
262 + // Plain text email with no attachments
263 + let raw = b"MIME-Version: 1.0\r\n\
264 + Content-Type: text/plain\r\n\
265 + \r\n\
266 + Just a plain text message.\r\n";
267 +
268 + let parsed = mailparse::parse_mail(raw).unwrap();
269 + let parts = super::extract_attachment_parts(&parsed);
270 + assert!(parts.is_empty());
271 + }
272 +
273 + #[test]
274 + fn extract_attachments_multipart_mixed() {
275 + // Multipart with text + image + pdf
276 + let raw = b"MIME-Version: 1.0\r\n\
277 + Content-Type: multipart/mixed; boundary=\"outer\"\r\n\
278 + \r\n\
279 + --outer\r\n\
280 + Content-Type: text/plain\r\n\
281 + \r\n\
282 + Body text\r\n\
283 + --outer\r\n\
284 + Content-Type: image/png; name=\"photo.png\"\r\n\
285 + Content-Transfer-Encoding: base64\r\n\
286 + \r\n\
287 + iVBORw0KGgo=\r\n\
288 + --outer\r\n\
289 + Content-Type: application/pdf\r\n\
290 + Content-Disposition: attachment; filename=\"doc.pdf\"\r\n\
291 + Content-Transfer-Encoding: base64\r\n\
292 + \r\n\
293 + JVBERi0xLjQK\r\n\
294 + --outer--\r\n";
295 +
296 + let parsed = mailparse::parse_mail(raw).unwrap();
297 + let parts = super::extract_attachment_parts(&parsed);
298 + assert_eq!(parts.len(), 2);
299 +
300 + // Image with name from Content-Type
301 + assert_eq!(parts[0].filename, "photo.png");
302 + assert_eq!(parts[0].mime_type, "image/png");
303 +
304 + // PDF with name from Content-Disposition
305 + assert_eq!(parts[1].filename, "doc.pdf");
306 + assert_eq!(parts[1].mime_type, "application/pdf");
307 + }
308 + }
@@ -1,6 +1,7 @@
1 1 //! Email client module supporting IMAP, SMTP, and JMAP protocols.
2 2
3 3 pub mod imap_client;
4 + mod mime_parse;
4 5 pub mod provider;
5 6 pub mod smtp_client;
6 7