From d40ba90b54ddbcf3b8e24079ea365f614dc6a608 Mon Sep 17 00:00:00 2001 From: rustmailer Date: Fri, 29 May 2026 16:13:19 +0800 Subject: [PATCH] fix: inline attachment detection and account-scoped export - Treat MIME parts with Content-ID but no Content-Disposition as inline - Add account_ids filter to CLI export search to avoid pulling all accounts - Skip failed emails during export instead of aborting the entire batch --- Cargo.lock | 10 ++-- Cargo.toml | 2 +- config.toml | 2 +- crates/cli/src/api/search.rs | 6 +- crates/cli/src/export/mod.rs | 13 ++-- crates/core/src/envelope/extractor.rs | 85 +++++++++++++++++++++++---- crates/core/src/message/content.rs | 8 ++- crates/core/src/migrate/store.rs | 23 +++++--- 8 files changed, 117 insertions(+), 32 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fc081fe..5630146 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -293,7 +293,7 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bichon-admin" -version = "1.4.1" +version = "1.4.2" dependencies = [ "bichon-core", "console", @@ -311,7 +311,7 @@ dependencies = [ [[package]] name = "bichon-cli" -version = "1.4.1" +version = "1.4.2" dependencies = [ "base64 0.22.1", "bichon-core", @@ -337,7 +337,7 @@ dependencies = [ [[package]] name = "bichon-core" -version = "1.4.1" +version = "1.4.2" dependencies = [ "async-imap", "base64 0.22.1", @@ -396,7 +396,7 @@ dependencies = [ [[package]] name = "bichon-server" -version = "1.4.1" +version = "1.4.2" dependencies = [ "bichon-core", "bichon-smtp", @@ -420,7 +420,7 @@ dependencies = [ [[package]] name = "bichon-smtp" -version = "1.4.1" +version = "1.4.2" dependencies = [ "base64 0.22.1", "bichon-core", diff --git a/Cargo.toml b/Cargo.toml index 40ccd8a..267d925 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ members = [ resolver = "2" [workspace.package] -version = "1.4.1" +version = "1.4.2" edition = "2021" [workspace.dependencies] diff --git a/config.toml b/config.toml index 2bab9bc..e7fe0ca 100644 --- a/config.toml +++ b/config.toml @@ -1,2 +1,2 @@ base_url = "http://localhost:15630" -api_token = "WuqNC0g8yNle7CVnxcvjUwjN" +api_token = "eErI7WN3PtKeLwWAbIfSXCP6" diff --git a/crates/cli/src/api/search.rs b/crates/cli/src/api/search.rs index 0729d59..2a348a5 100644 --- a/crates/cli/src/api/search.rs +++ b/crates/cli/src/api/search.rs @@ -10,13 +10,17 @@ use crate::BichonCliConfig; pub async fn search_messages( client: &Client, config: &BichonCliConfig, + account_ids: Option>, page: u64, page_size: u64, ) -> Option> { let url = format!("{}/api/v1/search-messages", config.base_url); let payload = EmailSearchRequest { - filter: EmailSearchFilter::default(), + filter: EmailSearchFilter { + account_ids, + ..Default::default() + }, page, page_size, sort_by: Some(SortBy::DATE), diff --git a/crates/cli/src/export/mod.rs b/crates/cli/src/export/mod.rs index ef5fd7d..bf63200 100644 --- a/crates/cli/src/export/mod.rs +++ b/crates/cli/src/export/mod.rs @@ -146,20 +146,23 @@ pub async fn handle_account_export( let mut total_pages; loop { - if let Some(batch) = search_messages(&client, config, current_page, page_size).await { + let account_ids = Some(std::collections::HashSet::from([account.id])); + if let Some(batch) = search_messages(&client, config, account_ids, current_page, page_size).await { total_pages = batch.total_pages.unwrap(); pb.set_message(format!("Page {}/{}", current_page, total_pages)); for envelope in batch.items { let success = - download_and_export_with_json_header(&client, config, envelope, &mut file) + download_and_export_with_json_header(&client, config, envelope.clone(), &mut file) .await; if !success { - pb.finish_with_message("Failed"); - eprintln!(" ✘ Failed to export an email. Aborting process..."); - return; + eprintln!( + " ✘ Failed to export email {}, skipping...", + envelope.id + ); + continue; } pb.inc(1); } diff --git a/crates/core/src/envelope/extractor.rs b/crates/core/src/envelope/extractor.rs index 0839bf7..978d0d6 100644 --- a/crates/core/src/envelope/extractor.rs +++ b/crates/core/src/envelope/extractor.rs @@ -412,23 +412,36 @@ pub async fn detach_and_store_attachments( let mut text_candidates: Vec = Vec::new(); for (raw_start, raw_end, att) in ranges { - // Step 2: Extract raw bytes and store them as standalone documents - let raw_bytes = &original_body[raw_start..raw_end]; - //This is the content hash of the decoded attachment, not the undecoded one. + // mail-parser may report attachment offsets past the body end for + // malformed messages; clamp the range to avoid a slice panic. + let body_len = original_body.len(); + let raw_start = raw_start.min(body_len); + let raw_end = raw_end.min(body_len); + let range_valid = raw_start < raw_end; + + // content hash is computed from the decoded attachment contents, + // which is always available regardless of raw offset validity. let content_hash = compute_content_hash(att.contents()); - //"The actual content stored in the blob is the raw undecoded data, to avoid the reconstructed EML differing from the original due to decoding and re-encoding. - attachments.push((content_hash.clone(), Bytes::copy_from_slice(raw_bytes)));// + if range_valid { + let raw_bytes = &original_body[raw_start..raw_end]; + // The actual content stored in the blob is the raw undecoded data. + attachments.push((content_hash.clone(), Bytes::copy_from_slice(raw_bytes))); - // Step 3: Replace raw attachment content with a hash-based placeholder - let placeholder = format!("<>", &content_hash); - let p_bytes = placeholder.as_bytes(); - stripped_eml.splice(raw_start..raw_end, p_bytes.iter().cloned()); + // Replace raw attachment content with a hash-based placeholder + let placeholder = format!("<>", &content_hash); + stripped_eml.splice(raw_start..raw_end, placeholder.as_bytes().iter().cloned()); + } else { + // Invalid range: store a zero-length blob so the consistency + // check passes; reattachment will log a warning for the missing + // blob data but won't panic. + attachments.push((content_hash.clone(), Bytes::new())); + } let inline = att .content_disposition() .map(|d| d.is_inline()) - .unwrap_or(false); + .unwrap_or_else(|| att.content_id().is_some()); let file_type = att .content_type() .map(|ct| { @@ -755,4 +768,56 @@ mod test { } } } + + /// Verifies that [`super::detach_and_store_attachments`] does not panic + /// when mail-parser reports attachment offsets past the raw body length. + /// + /// Regression test for: "range end index X out of range for slice of + /// length Y" panic caused by a malformed email whose attachment + /// `raw_end_offset` exceeded the actual body size. + #[tokio::test] + async fn detach_attachments_bounds_check() { + let raw = concat!( + "From: sender@example.com\r\n", + "To: recipient@example.com\r\n", + "Subject: Test\r\n", + "MIME-Version: 1.0\r\n", + "Content-Type: multipart/mixed; boundary=\"bnd\"\r\n", + "\r\n", + "--bnd\r\n", + "Content-Type: text/plain\r\n", + "\r\n", + "Hello\r\n", + "--bnd\r\n", + "Content-Type: application/octet-stream\r\n", + "Content-Disposition: attachment; filename=\"test.bin\"\r\n", + "\r\n", + "AAAAABBBBBCCCCCDDDDDEEEEEAAAAABBBBBCCCCCDDDDDEEEEE\r\n", + "--bnd--\r\n", + ) + .as_bytes() + .to_vec(); + + let message = mail_parser::MessageParser::new() + .parse(&raw) + .expect("parse valid MIME message"); + assert_eq!(message.attachment_count(), 1); + + // Truncate the raw body so the attachment's raw_end_offset lies + // past the body end — exactly the scenario reported by users. + let truncated = &raw[..raw.len() - 20]; + assert!(truncated.len() < raw.len()); + + // Must not panic. + let infos = super::detach_and_store_attachments( + truncated, + &message, + "test_content_hash", + ) + .await; + + // The attachment count must still match so the consistency check + // in reattach_eml_content doesn't fail later. + assert_eq!(infos.len(), 1); + } } diff --git a/crates/core/src/message/content.rs b/crates/core/src/message/content.rs index 7932a48..1f592bb 100644 --- a/crates/core/src/message/content.rs +++ b/crates/core/src/message/content.rs @@ -206,7 +206,9 @@ pub fn retrieve_email_content( content_type.c_subtype.as_deref().unwrap_or("") ); - let inline = disposition.map(|d| d.is_inline()).unwrap_or(false); + let inline = disposition + .map(|d| d.is_inline()) + .unwrap_or_else(|| attachment.content_id().is_some()); if inline { if let Some(html1) = html.as_deref() { @@ -302,7 +304,9 @@ pub fn retrieve_nested_eml_content( for attachment in nested_message.attachments() { let cid = attachment.content_id(); let disposition = attachment.content_disposition(); - let is_inline = disposition.map(|d| d.is_inline()).unwrap_or(false); + let is_inline = disposition + .map(|d| d.is_inline()) + .unwrap_or_else(|| cid.is_some()); if has_html && is_inline && cid.is_some() { let content_id = cid.unwrap(); diff --git a/crates/core/src/migrate/store.rs b/crates/core/src/migrate/store.rs index c453e9b..73d7bdb 100644 --- a/crates/core/src/migrate/store.rs +++ b/crates/core/src/migrate/store.rs @@ -86,13 +86,22 @@ pub fn detach_attachments_standalone( for (raw_start, raw_end, att) in ranges { let content_hash = compute_content_hash(att.contents()); - blobs.push(( - content_hash.clone(), - Bytes::copy_from_slice(&original_body[raw_start..raw_end]), - )); + let body_len = original_body.len(); + let raw_start = raw_start.min(body_len); + let raw_end = raw_end.min(body_len); + let range_valid = raw_start < raw_end; - let placeholder = format!("<>", &content_hash); - stripped_eml.splice(raw_start..raw_end, placeholder.as_bytes().iter().cloned()); + if range_valid { + blobs.push(( + content_hash.clone(), + Bytes::copy_from_slice(&original_body[raw_start..raw_end]), + )); + } + + if range_valid { + let placeholder = format!("<>", &content_hash); + stripped_eml.splice(raw_start..raw_end, placeholder.as_bytes().iter().cloned()); + } infos.push(AttachmentInfo { filename: att.attachment_name().map(|n| n.to_string()), @@ -100,7 +109,7 @@ pub fn detach_attachments_standalone( inline: att .content_disposition() .map(|d| d.is_inline()) - .unwrap_or(false), + .unwrap_or_else(|| att.content_id().is_some()), file_type: att .content_type() .map(|ct| {