Skip to main content

max / goingson

32.5 KB · 918 lines History Blame Raw
1 //! IMAP client for fetching and parsing email messages.
2
3 use async_imap::Client;
4 use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
5 use chrono::{DateTime, TimeZone, Utc};
6 use futures_util::StreamExt;
7 use goingson_core::{EmailAccount, FolderSyncState};
8 use tokio::net::TcpStream;
9 use tokio_native_tls::native_tls::TlsConnector as NativeTlsConnector;
10 use tokio_native_tls::TlsConnector;
11
12 type ImapSession = async_imap::Session<tokio_native_tls::TlsStream<TcpStream>>;
13
14 /// Maximum email size to download (25 MB). Emails exceeding this are skipped during sync.
15 const MAX_EMAIL_SIZE: u32 = 25 * 1024 * 1024;
16
17 /// Raw attachment data extracted during IMAP RFC822 parse.
18 #[derive(Debug, Clone)]
19 pub struct AttachmentPart {
20 pub filename: String,
21 pub mime_type: String,
22 pub data: Vec<u8>,
23 }
24
25 #[derive(Debug, Clone)]
26 pub struct ParsedEmail {
27 pub message_id: Option<String>,
28 pub in_reply_to: Option<String>,
29 /// First entry from the References header (thread root).
30 pub references_root: Option<String>,
31 pub imap_uid: u32,
32 pub source_folder: String,
33 pub from: String,
34 pub to: String,
35 pub subject: String,
36 pub body: String,
37 /// Original HTML body for "Open in Browser" feature.
38 pub html_body: Option<String>,
39 pub date: DateTime<Utc>,
40 pub is_read: bool,
41 /// Attachment parts extracted from MIME structure.
42 pub attachments: Vec<AttachmentPart>,
43 }
44
45 /// Result of fetching from a single IMAP folder, including sync state metadata.
46 pub struct FolderFetchResult {
47 pub emails: Vec<ParsedEmail>,
48 pub uid_validity: Option<u32>,
49 pub max_uid_fetched: Option<u32>,
50 pub debug_info: String,
51 }
52
53 /// Authentication method for IMAP
54 #[derive(Debug, Clone)]
55 pub enum ImapAuth {
56 /// Traditional username/password
57 Password { username: String, password: String },
58 /// OAuth2 XOAUTH2 mechanism
59 XOAuth2 {
60 email: String,
61 access_token: String,
62 },
63 }
64
65 pub struct ImapClient {
66 server: String,
67 port: u16,
68 auth: ImapAuth,
69 }
70
71 impl ImapClient {
72 /// Create an IMAP client with explicit password authentication.
73 ///
74 /// Use this when retrieving credentials from secure storage (keychain).
75 pub fn with_password(account: &EmailAccount, password: &str) -> Self {
76 Self {
77 server: account.imap_server.trim().to_string(),
78 port: account.imap_port as u16,
79 auth: ImapAuth::Password {
80 username: account.username.trim().to_string(),
81 password: password.to_string(),
82 },
83 }
84 }
85
86 /// Create an IMAP client with OAuth2 XOAUTH2 authentication
87 pub fn with_oauth(server: &str, port: u16, email: &str, access_token: &str) -> Self {
88 Self {
89 server: server.to_string(),
90 port,
91 auth: ImapAuth::XOAuth2 {
92 email: email.to_string(),
93 access_token: access_token.to_string(),
94 },
95 }
96 }
97
98 /// Connect to IMAP server and return an authenticated session
99 async fn connect(&self) -> Result<ImapSession, String> {
100 let addr = format!("{}:{}", self.server, self.port);
101
102 let tcp_stream = tokio::time::timeout(
103 std::time::Duration::from_secs(30),
104 TcpStream::connect(&addr),
105 )
106 .await
107 .map_err(|_| format!("Connection to {} timed out after 30s", addr))?
108 .map_err(|e| format!("Connection error: {}", e))?;
109
110 let tls_connector = NativeTlsConnector::new()
111 .map_err(|e| format!("TLS setup error: {}", e))?;
112 let tls = TlsConnector::from(tls_connector);
113 let tls_stream = tokio::time::timeout(
114 std::time::Duration::from_secs(30),
115 tls.connect(&self.server, tcp_stream),
116 )
117 .await
118 .map_err(|_| format!("TLS handshake with {} timed out after 30s", self.server))?
119 .map_err(|e| format!("TLS error: {}", e))?;
120
121 let client = Client::new(tls_stream);
122
123 match &self.auth {
124 ImapAuth::Password { username, password } => {
125 let session = client
126 .login(username, password)
127 .await
128 .map_err(|e| format!("Login error: {}", e.0))?;
129 Ok(session)
130 }
131 ImapAuth::XOAuth2 { email, access_token } => {
132 // XOAUTH2 format: base64("user=" + email + "\x01auth=Bearer " + token + "\x01\x01")
133 let auth_string = format!("user={}\x01auth=Bearer {}\x01\x01", email, access_token);
134 let auth_base64 = BASE64.encode(auth_string.as_bytes());
135
136 let session = client
137 .authenticate("XOAUTH2", XOAuth2Authenticator(auth_base64))
138 .await
139 .map_err(|e| format!("XOAUTH2 login error: {}", e.0))?;
140 Ok(session)
141 }
142 }
143 }
144
145 /// Fetch emails from a specific folder with debug info
146 pub async fn fetch_emails_from_folder_debug(
147 &self,
148 folder: &str,
149 since: Option<DateTime<Utc>>,
150 ) -> Result<(Vec<ParsedEmail>, String), String> {
151 let mut debug = Vec::new();
152 let mut session = self.connect().await?;
153
154 session
155 .select(folder)
156 .await
157 .map_err(|e| format!("Select {} error: {}", folder, e))?;
158
159 // Build search query
160 let search_query = if let Some(since_date) = since {
161 format!("SINCE {}", since_date.format("%d-%b-%Y"))
162 } else {
163 "ALL".to_string()
164 };
165
166 debug.push(format!("search: {}", search_query));
167
168 let search_result = session
169 .search(&search_query)
170 .await
171 .map_err(|e| format!("Search error: {}", e))?;
172
173 // Limit to last 50 if no date filter
174 let sequence_nums: Vec<u32> = if since.is_none() {
175 let mut nums: Vec<u32> = search_result.into_iter().collect();
176 nums.sort();
177 nums.into_iter().rev().take(50).collect()
178 } else {
179 search_result.into_iter().collect()
180 };
181
182 debug.push(format!("seq_nums: {}", sequence_nums.len()));
183
184 if sequence_nums.is_empty() {
185 session.logout().await.ok();
186 return Ok((Vec::new(), debug.join(", ")));
187 }
188
189 let sequence_set = sequence_nums
190 .iter()
191 .map(|n| n.to_string())
192 .collect::<Vec<_>>()
193 .join(",");
194
195 // Pre-filter oversized emails
196 let mut size_stream = session
197 .fetch(&sequence_set, "(UID RFC822.SIZE)")
198 .await
199 .map_err(|e| format!("Size fetch error: {}", e))?;
200
201 let mut safe_seqs: Vec<u32> = Vec::new();
202 let mut skipped_large = 0usize;
203 // Build a set of UIDs that are safe to fetch
204 while let Some(result) = size_stream.next().await {
205 if let Ok(msg) = result {
206 let over_limit = msg.size.map_or(false, |s| s > MAX_EMAIL_SIZE);
207 if over_limit {
208 skipped_large += 1;
209 tracing::warn!(uid = ?msg.uid, size = ?msg.size, folder = %folder, "Skipping oversized email");
210 continue;
211 }
212 // Use the sequence number (message index in stream matches input order)
213 // Re-collect the UIDs we want, then re-fetch by UID
214 if let Some(uid) = msg.uid {
215 safe_seqs.push(uid);
216 }
217 }
218 }
219 drop(size_stream);
220
221 if skipped_large > 0 {
222 debug.push(format!("skipped_large: {}", skipped_large));
223 }
224
225 if safe_seqs.is_empty() {
226 session.logout().await.ok();
227 return Ok((Vec::new(), debug.join(", ")));
228 }
229
230 let safe_uid_set = safe_seqs.iter().map(|n| n.to_string()).collect::<Vec<_>>().join(",");
231
232 // Fetch full bodies only for safe-sized emails
233 let mut messages = session
234 .uid_fetch(&safe_uid_set, "(UID FLAGS RFC822)")
235 .await
236 .map_err(|e| format!("Fetch error: {}", e))?;
237
238 let mut emails = Vec::new();
239 let folder_name = folder.to_string();
240 let mut msg_count = 0;
241 let mut body_count = 0;
242 let mut parse_errors = 0;
243
244 while let Some(result) = messages.next().await {
245 msg_count += 1;
246 let message = match result {
247 Ok(m) => m,
248 Err(e) => {
249 debug.push(format!("msg_err: {}", e));
250 continue;
251 }
252 };
253 let uid = match message.uid {
254 Some(u) => u,
255 None => {
256 tracing::warn!(folder = %folder_name, "IMAP message missing UID, skipping");
257 continue;
258 }
259 };
260
261 // Check if \Seen flag is present (email has been read)
262 let is_read = message.flags().any(|f| {
263 matches!(f, async_imap::types::Flag::Seen)
264 });
265
266 if let Some(body) = message.body() {
267 body_count += 1;
268 match mailparse::parse_mail(body) {
269 Ok(parsed) => {
270 let message_id = parsed
271 .headers
272 .iter()
273 .find(|h| h.get_key().to_lowercase() == "message-id")
274 .map(|h| h.get_value());
275
276 let in_reply_to = parsed
277 .headers
278 .iter()
279 .find(|h| h.get_key().to_lowercase() == "in-reply-to")
280 .map(|h| h.get_value());
281
282 let references_root = extract_references_root(&parsed.headers);
283
284 let from = parsed
285 .headers
286 .iter()
287 .find(|h| h.get_key().to_lowercase() == "from")
288 .map(|h| h.get_value())
289 .unwrap_or_default();
290
291 let to = parsed
292 .headers
293 .iter()
294 .find(|h| h.get_key().to_lowercase() == "to")
295 .map(|h| h.get_value())
296 .unwrap_or_default();
297
298 let subject = parsed
299 .headers
300 .iter()
301 .find(|h| h.get_key().to_lowercase() == "subject")
302 .map(|h| h.get_value())
303 .unwrap_or_default();
304
305 let date_str = parsed
306 .headers
307 .iter()
308 .find(|h| h.get_key().to_lowercase() == "date")
309 .map(|h| h.get_value());
310
311 let date = date_str
312 .and_then(|s| mailparse::dateparse(&s).ok())
313 .and_then(|ts| Utc.timestamp_opt(ts, 0).single())
314 .unwrap_or(DateTime::UNIX_EPOCH);
315
316 let (body_text, html_body) = Self::extract_body_with_html(&parsed);
317 let attachments = extract_attachment_parts(&parsed);
318
319 emails.push(ParsedEmail {
320 message_id,
321 in_reply_to,
322 references_root,
323 imap_uid: uid,
324 source_folder: folder_name.clone(),
325 from,
326 to,
327 subject,
328 body: body_text,
329 html_body,
330 date,
331 is_read,
332 attachments,
333 });
334 }
335 Err(e) => {
336 tracing::debug!(uid, folder = %folder_name, error = %e, "Failed to parse email");
337 parse_errors += 1;
338 }
339 }
340 }
341 }
342
343 debug.push(format!("msgs: {}, bodies: {}, parsed: {}, errs: {}", msg_count, body_count, emails.len(), parse_errors));
344 drop(messages);
345 session.logout().await.ok();
346 Ok((emails, debug.join(", ")))
347 }
348
349 /// Fetch emails incrementally using IMAP UIDs when sync state is available,
350 /// falling back to SINCE-based search otherwise.
351 pub async fn fetch_emails_incremental(
352 &self,
353 folder: &str,
354 sync_state: Option<&FolderSyncState>,
355 since_fallback: Option<DateTime<Utc>>,
356 ) -> Result<FolderFetchResult, String> {
357 let mut debug = Vec::new();
358 let mut session = self.connect().await?;
359
360 let mailbox = session
361 .select(folder)
362 .await
363 .map_err(|e| format!("Select {} error: {}", folder, e))?;
364
365 let server_uid_validity = mailbox.uid_validity;
366
367 // Decide whether to use UID-based or SINCE-based search
368 let use_uid = match (sync_state, server_uid_validity) {
369 (Some(state), Some(validity)) if validity == state.uid_validity => {
370 debug.push(format!("uid_mode: UID {}:* (validity {})", state.last_seen_uid + 1, validity));
371 true
372 }
373 (Some(state), Some(validity)) => {
374 debug.push(format!("uid_validity_mismatch: stored={} server={}, falling back to SINCE", state.uid_validity, validity));
375 false
376 }
377 (Some(_), None) => {
378 debug.push("server_no_uidvalidity, falling back to SINCE".to_string());
379 false
380 }
381 (None, _) => {
382 debug.push("no_sync_state, using SINCE fallback".to_string());
383 false
384 }
385 };
386
387 let uids: Vec<u32> = if use_uid {
388 let state = sync_state.unwrap();
389 let search_query = format!("UID {}:*", state.last_seen_uid.saturating_add(1));
390 debug.push(format!("uid_search: {}", search_query));
391
392 let search_result = session
393 .uid_search(&search_query)
394 .await
395 .map_err(|e| format!("UID search error: {}", e))?;
396
397 // Filter out the last_seen_uid itself (IMAP UID ranges are inclusive,
398 // and `n:*` returns n even if it's the only match)
399 search_result
400 .into_iter()
401 .filter(|&uid| uid > state.last_seen_uid)
402 .collect()
403 } else {
404 // Fall back to SINCE-based search, then get UIDs via uid_search
405 let search_query = if let Some(since_date) = since_fallback {
406 format!("SINCE {}", since_date.format("%d-%b-%Y"))
407 } else {
408 "ALL".to_string()
409 };
410 debug.push(format!("since_search: {}", search_query));
411
412 let search_result = session
413 .uid_search(&search_query)
414 .await
415 .map_err(|e| format!("Search error: {}", e))?;
416
417 let mut nums: Vec<u32> = search_result.into_iter().collect();
418 if since_fallback.is_none() {
419 nums.sort();
420 nums = nums.into_iter().rev().take(50).collect();
421 }
422 nums
423 };
424
425 debug.push(format!("uids_to_fetch: {}", uids.len()));
426
427 if uids.is_empty() {
428 session.logout().await.ok();
429 return Ok(FolderFetchResult {
430 emails: Vec::new(),
431 uid_validity: server_uid_validity,
432 max_uid_fetched: None,
433 debug_info: debug.join(", "),
434 });
435 }
436
437 // Pre-filter oversized emails by fetching sizes first
438 let uid_set = uids.iter().map(|n| n.to_string()).collect::<Vec<_>>().join(",");
439 let mut size_stream = session
440 .uid_fetch(&uid_set, "(UID RFC822.SIZE)")
441 .await
442 .map_err(|e| format!("UID size fetch error: {}", e))?;
443
444 let mut safe_uids: Vec<u32> = Vec::new();
445 let mut skipped_large = 0usize;
446 while let Some(result) = size_stream.next().await {
447 if let Ok(msg) = result {
448 if let Some(uid) = msg.uid {
449 if let Some(size) = msg.size {
450 if size > MAX_EMAIL_SIZE {
451 skipped_large += 1;
452 tracing::warn!(uid, size, folder = %folder, "Skipping oversized email ({} bytes)", size);
453 continue;
454 }
455 }
456 safe_uids.push(uid);
457 }
458 }
459 }
460 drop(size_stream);
461
462 if skipped_large > 0 {
463 debug.push(format!("skipped_large: {}", skipped_large));
464 }
465
466 if safe_uids.is_empty() {
467 session.logout().await.ok();
468 return Ok(FolderFetchResult {
469 emails: Vec::new(),
470 uid_validity: server_uid_validity,
471 max_uid_fetched: uids.iter().copied().max(),
472 debug_info: debug.join(", "),
473 });
474 }
475
476 let safe_uid_set = safe_uids.iter().map(|n| n.to_string()).collect::<Vec<_>>().join(",");
477
478 let mut messages = session
479 .uid_fetch(&safe_uid_set, "(UID FLAGS RFC822)")
480 .await
481 .map_err(|e| format!("UID fetch error: {}", e))?;
482
483 let mut emails = Vec::new();
484 let folder_name = folder.to_string();
485 let mut msg_count = 0;
486 let mut body_count = 0;
487 let mut parse_errors = 0;
488 // Seed max_uid from all UIDs (including skipped large ones) so they aren't re-fetched
489 let mut max_uid: Option<u32> = uids.iter().copied().max();
490
491 while let Some(result) = messages.next().await {
492 msg_count += 1;
493 let message = match result {
494 Ok(m) => m,
495 Err(e) => {
496 debug.push(format!("msg_err: {}", e));
497 continue;
498 }
499 };
500 let uid = match message.uid {
501 Some(u) => u,
502 None => {
503 tracing::warn!(folder = %folder_name, "IMAP message missing UID, skipping");
504 continue;
505 }
506 };
507
508 max_uid = Some(max_uid.map_or(uid, |m: u32| m.max(uid)));
509
510 let is_read = message.flags().any(|f| matches!(f, async_imap::types::Flag::Seen));
511
512 if let Some(body) = message.body() {
513 body_count += 1;
514 match mailparse::parse_mail(body) {
515 Ok(parsed) => {
516 let message_id = parsed.headers.iter()
517 .find(|h| h.get_key().to_lowercase() == "message-id")
518 .map(|h| h.get_value());
519 let in_reply_to = parsed.headers.iter()
520 .find(|h| h.get_key().to_lowercase() == "in-reply-to")
521 .map(|h| h.get_value());
522 let references_root = extract_references_root(&parsed.headers);
523 let from = parsed.headers.iter()
524 .find(|h| h.get_key().to_lowercase() == "from")
525 .map(|h| h.get_value()).unwrap_or_default();
526 let to = parsed.headers.iter()
527 .find(|h| h.get_key().to_lowercase() == "to")
528 .map(|h| h.get_value()).unwrap_or_default();
529 let subject = parsed.headers.iter()
530 .find(|h| h.get_key().to_lowercase() == "subject")
531 .map(|h| h.get_value()).unwrap_or_default();
532 let date_str = parsed.headers.iter()
533 .find(|h| h.get_key().to_lowercase() == "date")
534 .map(|h| h.get_value());
535 let date = date_str
536 .and_then(|s| mailparse::dateparse(&s).ok())
537 .and_then(|ts| Utc.timestamp_opt(ts, 0).single())
538 .unwrap_or(DateTime::UNIX_EPOCH);
539
540 let (body_text, html_body) = Self::extract_body_with_html(&parsed);
541 let attachments = extract_attachment_parts(&parsed);
542
543 emails.push(ParsedEmail {
544 message_id,
545 in_reply_to,
546 references_root,
547 imap_uid: uid,
548 source_folder: folder_name.clone(),
549 from,
550 to,
551 subject,
552 body: body_text,
553 html_body,
554 date,
555 is_read,
556 attachments,
557 });
558 }
559 Err(e) => {
560 tracing::debug!(uid, folder = %folder_name, error = %e, "Failed to parse email");
561 parse_errors += 1;
562 }
563 }
564 }
565 }
566
567 debug.push(format!("msgs: {}, bodies: {}, parsed: {}, errs: {}", msg_count, body_count, emails.len(), parse_errors));
568 drop(messages);
569 session.logout().await.ok();
570
571 Ok(FolderFetchResult {
572 emails,
573 uid_validity: server_uid_validity,
574 max_uid_fetched: max_uid,
575 debug_info: debug.join(", "),
576 })
577 }
578
579 /// Move a message between folders using IMAP UID MOVE or COPY+DELETE
580 pub async fn move_message(
581 &self,
582 uid: u32,
583 from_folder: &str,
584 to_folder: &str,
585 ) -> Result<(), String> {
586 let mut session = self.connect().await?;
587
588 // Select source folder
589 session
590 .select(from_folder)
591 .await
592 .map_err(|e| format!("Failed to select {}: {}", from_folder, e))?;
593
594 let uid_str = uid.to_string();
595
596 // Try MOVE first (RFC 6851) - more efficient and atomic
597 let move_result = session.uid_mv(&uid_str, to_folder).await;
598
599 match move_result {
600 Ok(_) => {
601 session.logout().await.ok();
602 Ok(())
603 }
604 Err(_) => {
605 // Fallback to COPY + DELETE + EXPUNGE
606 session
607 .uid_copy(&uid_str, to_folder)
608 .await
609 .map_err(|e| format!("Copy failed: {}", e))?;
610
611 // Mark as deleted - drop the stream since operation is done
612 let _ = session
613 .uid_store(&uid_str, "+FLAGS (\\Deleted)")
614 .await
615 .map_err(|e| format!("Delete flag failed: {}", e))?;
616
617 // Expunge - drop the stream since operation is done
618 let _ = session
619 .expunge()
620 .await
621 .map_err(|e| format!("Expunge failed: {}", e))?;
622
623 session.logout().await.ok();
624 Ok(())
625 }
626 }
627 }
628
629 /// Archive a message (move from INBOX to Archive folder)
630 pub async fn archive_message(&self, uid: u32, archive_folder: &str) -> Result<(), String> {
631 self.move_message(uid, "INBOX", archive_folder).await
632 }
633
634 /// Unarchive a message (move from Archive folder to INBOX)
635 pub async fn unarchive_message(&self, uid: u32, archive_folder: &str) -> Result<(), String> {
636 self.move_message(uid, archive_folder, "INBOX").await
637 }
638
639 pub async fn test_connection(&self) -> Result<(), String> {
640 let mut session = self.connect().await?;
641 session.logout().await.ok();
642 Ok(())
643 }
644
645 /// List all available folders on the IMAP server
646 pub async fn list_folders(&self) -> Result<Vec<String>, String> {
647 let mut session = self.connect().await?;
648
649 let folders_stream = session
650 .list(Some(""), Some("*"))
651 .await
652 .map_err(|e| format!("List folders error: {}", e))?;
653
654 use futures_util::StreamExt;
655 let folders: Vec<String> = folders_stream
656 .filter_map(|result| async {
657 result.ok().map(|name| name.name().to_string())
658 })
659 .collect()
660 .await;
661
662 session.logout().await.ok();
663 Ok(folders)
664 }
665
666 /// Debug fetch - returns diagnostic info about what's in a folder
667 pub async fn debug_folder(&self, folder: &str) -> Result<String, String> {
668 let mut session = self.connect().await?;
669
670 let mailbox = session
671 .select(folder)
672 .await
673 .map_err(|e| format!("Select {} error: {}", folder, e))?;
674
675 let exists = mailbox.exists;
676 let recent = mailbox.recent;
677
678 // Try a simple search
679 let search_result = session
680 .search("ALL")
681 .await
682 .map_err(|e| format!("Search error: {}", e))?;
683
684 let count: usize = search_result.len();
685
686 session.logout().await.ok();
687
688 Ok(format!("Folder '{}': exists={}, recent={}, search_all={}", folder, exists, recent, count))
689 }
690
691 /// Extracts body text and optionally HTML from a parsed email.
692 /// Returns (plain_text_body, optional_html_body).
693 fn extract_body_with_html(mail: &mailparse::ParsedMail) -> (String, Option<String>) {
694 let mut plain_text: Option<String> = None;
695 let mut html_body: Option<String> = None;
696
697 Self::collect_body_parts(mail, &mut plain_text, &mut html_body);
698
699 // Build final result - prefer plain text, fall back to pter markdown conversion
700 let body_text = if let Some(ref plain) = plain_text {
701 // If we have plain text but it looks like it contains HTML tags,
702 // we should convert them (some emails have incorrect content-types)
703 if plain.contains("<html") || plain.contains("<body") || plain.contains("<div") {
704 pter::convert(plain)
705 } else {
706 plain.clone()
707 }
708 } else if let Some(ref html) = html_body {
709 pter::convert(html)
710 } else {
711 // Fallback to whatever body is available
712 let body = mail.get_body().unwrap_or_default();
713 if body.contains("<html") || body.contains("<body") || body.contains("<div") {
714 pter::convert(&body)
715 } else {
716 body
717 }
718 };
719
720 (body_text, html_body)
721 }
722
723 /// Recursively collects text/plain and text/html parts from a mail structure.
724 fn collect_body_parts(
725 mail: &mailparse::ParsedMail,
726 plain_text: &mut Option<String>,
727 html_body: &mut Option<String>,
728 ) {
729 let mime_type = mail.ctype.mimetype.to_lowercase();
730
731 if mail.subparts.is_empty() {
732 // Leaf node - check content type
733 if mime_type == "text/plain" && plain_text.is_none() {
734 *plain_text = Some(mail.get_body().unwrap_or_default());
735 } else if mime_type == "text/html" && html_body.is_none() {
736 *html_body = Some(mail.get_body().unwrap_or_default());
737 }
738 } else {
739 // Multipart - recurse into all subparts
740 for part in &mail.subparts {
741 Self::collect_body_parts(part, plain_text, html_body);
742 // Stop early if we've found both
743 if plain_text.is_some() && html_body.is_some() {
744 break;
745 }
746 }
747 }
748 }
749
750 // strip_html, extract_href, strip_tags_simple, decode_html_entities
751 // removed — replaced by pter::convert().
752
753 }
754
755 /// Recursively extract attachment parts from a MIME tree.
756 ///
757 /// Extracts the first message-ID from the References header (the thread root).
758 fn extract_references_root(headers: &[mailparse::MailHeader]) -> Option<String> {
759 headers
760 .iter()
761 .find(|h| h.get_key().to_lowercase() == "references")
762 .and_then(|h| {
763 h.get_value()
764 .split_whitespace()
765 .find(|s| s.starts_with('<') && s.ends_with('>'))
766 .map(|s| s.to_string())
767 })
768 }
769
770 /// Walks the MIME structure and collects non-text leaf parts as attachments.
771 /// Skips text/plain and text/html (those are body parts), and parts with empty bodies.
772 fn extract_attachment_parts(mail: &mailparse::ParsedMail) -> Vec<AttachmentPart> {
773 let mut parts = Vec::new();
774 collect_attachment_parts(mail, &mut parts);
775 parts
776 }
777
778 fn collect_attachment_parts(mail: &mailparse::ParsedMail, parts: &mut Vec<AttachmentPart>) {
779 let mime_type = mail.ctype.mimetype.to_lowercase();
780
781 if mail.subparts.is_empty() {
782 // Leaf node — skip text/plain and text/html (body parts)
783 if mime_type == "text/plain" || mime_type == "text/html" {
784 return;
785 }
786
787 // Get the raw decoded body
788 let data = match mail.get_body_raw() {
789 Ok(d) if !d.is_empty() => d,
790 _ => return, // Skip parts with empty body
791 };
792
793 // Extract filename: Content-Disposition filename, then Content-Type name, then fallback
794 let filename = mail.get_content_disposition()
795 .params
796 .get("filename")
797 .cloned()
798 .or_else(|| mail.ctype.params.get("name").cloned())
799 .unwrap_or_else(|| "attachment".to_string());
800
801 parts.push(AttachmentPart {
802 filename,
803 mime_type: mail.ctype.mimetype.clone(),
804 data,
805 });
806 } else {
807 // Multipart — recurse into subparts
808 for part in &mail.subparts {
809 collect_attachment_parts(part, parts);
810 }
811 }
812 }
813
814 struct XOAuth2Authenticator(String);
815
816 impl async_imap::Authenticator for XOAuth2Authenticator {
817 type Response = String;
818
819 fn process(&mut self, _challenge: &[u8]) -> Self::Response {
820 // Return the pre-computed base64 XOAUTH2 string
821 std::mem::take(&mut self.0)
822 }
823 }
824
825 #[cfg(test)]
826 mod tests {
827 use super::ImapClient;
828
829 // strip_tags_simple, extract_href, decode_html_entities, and strip_html
830 // tests removed — these functions were replaced by pter::convert().
831
832 #[test]
833 fn pter_replaces_strip_html() {
834 // Verify pter handles what strip_html used to do
835 let html = "<p>Hello <strong>world</strong></p>";
836 let result = pter::convert(html);
837 assert!(result.contains("Hello"));
838 assert!(result.contains("**world**"));
839 }
840
841 // --- extract_attachment_parts ---
842
843 #[test]
844 fn extract_attachments_simple() {
845 // Multipart email with text/plain + application/pdf
846 let raw = b"MIME-Version: 1.0\r\n\
847 Content-Type: multipart/mixed; boundary=\"boundary1\"\r\n\
848 \r\n\
849 --boundary1\r\n\
850 Content-Type: text/plain\r\n\
851 \r\n\
852 Hello world\r\n\
853 --boundary1\r\n\
854 Content-Type: application/pdf\r\n\
855 Content-Disposition: attachment; filename=\"report.pdf\"\r\n\
856 Content-Transfer-Encoding: base64\r\n\
857 \r\n\
858 JVBERi0xLjQK\r\n\
859 --boundary1--\r\n";
860
861 let parsed = mailparse::parse_mail(raw).unwrap();
862 let parts = super::extract_attachment_parts(&parsed);
863 assert_eq!(parts.len(), 1);
864 assert_eq!(parts[0].filename, "report.pdf");
865 assert_eq!(parts[0].mime_type, "application/pdf");
866 assert!(!parts[0].data.is_empty());
867 }
868
869 #[test]
870 fn extract_attachments_none_text_only() {
871 // Plain text email with no attachments
872 let raw = b"MIME-Version: 1.0\r\n\
873 Content-Type: text/plain\r\n\
874 \r\n\
875 Just a plain text message.\r\n";
876
877 let parsed = mailparse::parse_mail(raw).unwrap();
878 let parts = super::extract_attachment_parts(&parsed);
879 assert!(parts.is_empty());
880 }
881
882 #[test]
883 fn extract_attachments_multipart_mixed() {
884 // Multipart with text + image + pdf
885 let raw = b"MIME-Version: 1.0\r\n\
886 Content-Type: multipart/mixed; boundary=\"outer\"\r\n\
887 \r\n\
888 --outer\r\n\
889 Content-Type: text/plain\r\n\
890 \r\n\
891 Body text\r\n\
892 --outer\r\n\
893 Content-Type: image/png; name=\"photo.png\"\r\n\
894 Content-Transfer-Encoding: base64\r\n\
895 \r\n\
896 iVBORw0KGgo=\r\n\
897 --outer\r\n\
898 Content-Type: application/pdf\r\n\
899 Content-Disposition: attachment; filename=\"doc.pdf\"\r\n\
900 Content-Transfer-Encoding: base64\r\n\
901 \r\n\
902 JVBERi0xLjQK\r\n\
903 --outer--\r\n";
904
905 let parsed = mailparse::parse_mail(raw).unwrap();
906 let parts = super::extract_attachment_parts(&parsed);
907 assert_eq!(parts.len(), 2);
908
909 // Image with name from Content-Type
910 assert_eq!(parts[0].filename, "photo.png");
911 assert_eq!(parts[0].mime_type, "image/png");
912
913 // PDF with name from Content-Disposition
914 assert_eq!(parts[1].filename, "doc.pdf");
915 assert_eq!(parts[1].mime_type, "application/pdf");
916 }
917 }
918