// Copyright © 2025 rustmailer.com // Licensed under RustMailer License Agreement v1.0 // Unauthorized copying, modification, or distribution is prohibited. use crate::modules::account::v2::AccountV2; use crate::modules::error::code::ErrorCode; use crate::modules::imap::section::Encoding; use crate::modules::message::attachment::inline_attachment_diskcache_key; use crate::{ encode_mailbox_name, modules::{ cache::disk::DISK_CACHE, context::executors::RUST_MAIL_CONTEXT, error::RustMailerResult, imap::section::{EmailBodyPart, ImapAttachment, PartType, SegmentPath}, }, raise_error, }; use cacache::Reader; use mime_guess::from_ext; use poem_openapi::Object; use serde::{Deserialize, Serialize}; use tokio::io::AsyncReadExt; const MAX_BODY_SIZE: usize = 2 * 1024 * 1024; /// Request for fetching the content of a specific email message /// /// This struct is used with the `fetch_message_content` method to retrieve /// the detailed content of an email message. The message identifier (mailbox and uid) /// typically comes from the results returned by the `list_messages` method. /// /// When you call `list_messages`, it returns a paginated list of `EmailEnvelope` objects, /// each containing metadata about an email message. To fetch the full content of a specific /// message, you use the mailbox name and uid from that envelope in this request. /// /// @param mailbox - The name of the mailbox containing the message (from list_messages results) /// @param uid - The unique identifier of the message within the mailbox (from list_messages results) /// @param max_length - Optional maximum length to retrieve for text parts (limits large messages) /// @param sections - Specific email body parts to retrieve (e.g., TEXT, HTML) /// @param inline - Optional list of attachments to include inline in the response #[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)] pub struct MessageContentRequest { /// The name of the mailbox containing the message pub mailbox: String, /// The unique identifier of the message within the mailbox pub uid: u32, /// Optional maximum length to retrieve for text parts (useful for large messages) pub max_length: Option, /// Specific email body parts to retrieve (e.g., TEXT, HTML) pub sections: Vec, /// Optional list of attachments to include inline in the response pub inline: Option>, } /// Represents the content of an email message in both plain text and HTML formats. /// /// This struct contains optional fields for plain text and HTML versions of /// the email message body. At least one of them may be present. /// /// # Fields /// /// - `plain`: The plain text version of the message, if available. /// - `html`: The HTML version of the message, if available. #[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)] pub struct MessageContent { /// Optional plain text version of the message. pub plain: Option, /// Optional HTML version of the message. pub html: Option<String>, } impl MessageContent { pub fn html(&self) -> Option<&str> { self.html.as_deref() } pub fn plain(&self) -> Option<&str> { self.plain.as_ref().map(|plain| plain.content.as_str()) } } /// Represents the plain text content of an email message. /// /// This struct includes the actual plain text and a flag indicating whether /// the content has been truncated. /// /// # Fields /// /// - `content`: The plain text body of the message. /// - `truncated`: Indicates whether the content has been truncated, /// for example, due to length limitations. #[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)] pub struct PlainText { /// The plain text content of the message. pub content: String, /// Whether the content has been truncated. pub truncated: bool, } fn email_content_diskcache_key( account_id: u64, mailbox_name: &str, uid: u32, segment_path: SegmentPath, ) -> String { format!( "email_content_{}_{}_{}_{}", account_id, mailbox_name, uid, segment_path ) } async fn read_text_from_reader( reader: &mut Reader, max_length: Option<usize>, actual_size: usize, ) -> RustMailerResult<PlainText> { let length_to_read = match max_length { Some(max) => max.min(actual_size).min(MAX_BODY_SIZE), None => actual_size, }; let mut buffer = vec![0u8; length_to_read]; let bytes_read = reader .read(&mut buffer) .await .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?; let truncated = bytes_read < actual_size; let content = match std::str::from_utf8(&buffer[..bytes_read]) { Ok(valid_str) => valid_str.into(), Err(_) => "???".into(), }; Ok(PlainText { content, truncated }) } async fn read_html_from_reader( reader: &mut Reader, actual_size: usize, ) -> RustMailerResult<String> { let mut buffer = vec![0u8; actual_size]; let bytes_read = reader .read(&mut buffer) .await .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?; let content = match std::str::from_utf8(&buffer[..bytes_read]) { Ok(valid_str) => valid_str.into(), Err(_) => "???".into(), }; Ok(content) } fn to_string(data: &[u8]) -> RustMailerResult<String> { let content = match std::str::from_utf8(data) { Ok(valid_str) => valid_str.into(), Err(_) => "???".into(), }; Ok(content) } async fn replace_inline_attachments( account_id: u64, mailbox: &str, uid: u32, html_content: &mut String, inline_attachments: &[ImapAttachment], skip_cache: bool, ) -> RustMailerResult<()> { for attachment in inline_attachments { // only process inline attachments with content_id and base64 encoding, skip others if !attachment.inline || attachment.content_id.is_none() || attachment.transfer_encoding != Encoding::Base64 { continue; } let cid = attachment .content_id .as_deref() .unwrap() .trim_matches(|c| c == '<' || c == '>'); if !html_content.contains(cid) { continue; } let attachment_content = if skip_cache { // Skip cache and fetch directly from server let executor = RUST_MAIL_CONTEXT.imap(account_id).await?; let result = executor .uid_fetch_single_part( uid.to_string().as_str(), encode_mailbox_name!(mailbox).as_str(), attachment.path.to_string().as_str(), ) .await?; let target = result.iter().find(|f| f.uid == Some(uid)).ok_or_else(|| { raise_error!( "Failed to fetch attachment from server".into(), ErrorCode::InternalError ) })?; let encoded = attachment.encoded(target).ok_or_else(|| { raise_error!( "Failed to parse inline attachment content from result".into(), ErrorCode::InternalError ) })?; String::from_utf8_lossy(&encoded).into_owned() } else { // Try cache first, then fall back to server let inline_cache_key = inline_attachment_diskcache_key(account_id, mailbox, uid, attachment.path.clone()); match DISK_CACHE.get_cache(&inline_cache_key).await? { Some(mut reader) => { let mut str = String::new(); reader .read_to_string(&mut str) .await .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?; reader .check() .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?; str } None => { let executor = RUST_MAIL_CONTEXT.imap(account_id).await?; let result = executor .uid_fetch_single_part( uid.to_string().as_str(), encode_mailbox_name!(mailbox).as_str(), attachment.path.to_string().as_str(), ) .await?; let target = result.iter().find(|f| f.uid == Some(uid)).ok_or_else(|| { raise_error!( "Failed to fetch attachment from server".into(), ErrorCode::InternalError ) })?; let encoded = attachment.encoded(target).ok_or_else(|| { raise_error!( "Failed to parse inline attachment content from result".into(), ErrorCode::InternalError ) })?; DISK_CACHE .put_cache(&inline_cache_key, &encoded, false) .await?; String::from_utf8_lossy(&encoded).into_owned() } } }; let content_type = &attachment.file_type.to_ascii_lowercase(); let mime_type = from_ext(content_type).first_or_octet_stream(); let cleaned_content = attachment_content.replace("\n", "").replace("\r", ""); *html_content = html_content.replace( &format!("cid:{}", cid), &format!("data:{};base64,{}", mime_type, cleaned_content.trim()), ); } Ok(()) } pub async fn retrieve_email_content( account_id: u64, request: MessageContentRequest, skip_cache: bool, ) -> RustMailerResult<MessageContent> { AccountV2::check_account_active(account_id).await?; let mut plain: Option<PlainText> = None; let mut html: Option<String> = None; // Find Plain part if let Some(part) = request .sections .iter() .find(|p| p.part_type == PartType::Plain) { let content = if skip_cache { // Skip cache and fetch directly let decoded_content = fetch_mail_part_from_imap(account_id, request.uid, &request.mailbox, part).await?; let mut decoded_content = to_string(&decoded_content)?; // Handle max_length truncation if matches!(request.max_length, Some(max) if decoded_content.len() > max) { decoded_content.truncate(request.max_length.unwrap()); PlainText { content: decoded_content, truncated: true, } } else { PlainText { content: decoded_content, truncated: false, } } } else { // Try cache first let cache_key = email_content_diskcache_key( account_id, &request.mailbox, request.uid, part.path.clone(), ); if let Some(mut reader) = DISK_CACHE.get_cache(&cache_key).await? { read_text_from_reader(&mut reader, request.max_length, part.size).await? } else { // Fetch from IMAP if not in cache let decoded_content = fetch_mail_part_from_imap(account_id, request.uid, &request.mailbox, part) .await?; // Cache the decoded content DISK_CACHE .put_cache(&cache_key, decoded_content.as_slice(), false) .await?; let mut decoded_content = to_string(&decoded_content)?; // Handle max_length truncation if matches!(request.max_length, Some(max) if decoded_content.len() > max) { decoded_content.truncate(request.max_length.unwrap()); PlainText { content: decoded_content, truncated: true, } } else { PlainText { content: decoded_content, truncated: false, } } } }; plain = Some(content); } // Find HTML part if let Some(part) = request .sections .iter() .find(|p| p.part_type == PartType::Html) { let content = if skip_cache { // Skip cache and fetch directly let decoded_content = fetch_mail_part_from_imap(account_id, request.uid, &request.mailbox, part).await?; let mut decoded_content = to_string(&decoded_content)?; // Handle inline attachments if let Some(inline) = &request.inline { replace_inline_attachments( account_id, &request.mailbox, request.uid, &mut decoded_content, inline, skip_cache, ) .await?; } decoded_content } else { // Try cache first let cache_key = email_content_diskcache_key( account_id, &request.mailbox, request.uid, part.path.clone(), ); if let Some(mut reader) = DISK_CACHE.get_cache(&cache_key).await? { let mut content = read_html_from_reader(&mut reader, part.size).await?; if let Some(inline) = &request.inline { replace_inline_attachments( account_id, &request.mailbox, request.uid, &mut content, inline, skip_cache, ) .await?; } content } else { // Fetch from IMAP if not in cache let decoded_content = fetch_mail_part_from_imap(account_id, request.uid, &request.mailbox, part) .await?; // Cache the decoded content DISK_CACHE .put_cache(&cache_key, decoded_content.as_slice(), false) .await?; let mut decoded_content = to_string(&decoded_content)?; // Handle inline attachments if let Some(inline) = &request.inline { replace_inline_attachments( account_id, &request.mailbox, request.uid, &mut decoded_content, inline, skip_cache, ) .await?; } decoded_content } }; html = Some(content); } Ok(MessageContent { plain, html }) } async fn fetch_mail_part_from_imap( account_id: u64, uid: u32, mailbox: &str, part: &EmailBodyPart, ) -> RustMailerResult<Vec<u8>> { let executor = RUST_MAIL_CONTEXT.imap(account_id).await?; let result = executor .uid_fetch_single_part( uid.to_string().as_str(), encode_mailbox_name!(mailbox).as_str(), part.path.to_string().as_str(), ) .await?; let target = result.iter().find(|f| f.uid == Some(uid)).ok_or_else(|| { raise_error!( "Failed to fetch the specified content from the IMAP server".into(), ErrorCode::InternalError ) })?; part.decode(target).ok_or_else(|| { raise_error!( "Failed to decode the email body content from the result".into(), ErrorCode::InternalError ) }) } #[cfg(test)] mod tests { use super::*; #[test] fn test_all_mime_types() { assert_eq!(from_ext("png").first_or_octet_stream(), "image/png"); assert_eq!(from_ext("jpeg").first_or_octet_stream(), "image/jpeg"); assert_eq!(from_ext("jpg").first_or_octet_stream(), "image/jpeg"); assert_eq!(from_ext("json").first_or_octet_stream(), "application/json"); assert_eq!(from_ext("txt").first_or_octet_stream(), "text/plain"); assert_eq!( from_ext("unknown").first_or_octet_stream(), "application/octet-stream" ); assert_eq!(from_ext("PNG").first_or_octet_stream(), "image/png"); assert_eq!(from_ext("JpG").first_or_octet_stream(), "image/jpeg"); assert_eq!(from_ext("mp4").first_or_octet_stream(), "video/mp4"); assert_eq!(from_ext("webm").first_or_octet_stream(), "video/webm"); assert_eq!(from_ext("avi").first_or_octet_stream(), "video/x-msvideo"); } }