From a8b3b24d59b73d03adfa9f13d3e9d1c5d60383f5 Mon Sep 17 00:00:00 2001 From: rustmailer Date: Sun, 15 Mar 2026 18:55:24 +0800 Subject: [PATCH] feat: support nested EML attachment preview and download #150 --- .gitignore | 1 + config.toml | 2 +- src/modules/envelope/extractor.rs | 75 ++++++++ src/modules/indexer/manager.rs | 99 ++++++++-- src/modules/message/content.rs | 84 +++++++++ src/modules/rest/api/message.rs | 68 ++++++- web/src/api/mailbox/envelope/api.ts | 43 +++-- web/src/features/search/mail-message-view.tsx | 43 ++++- .../features/search/nested-email-dialog.tsx | 176 ++++++++++++++++++ 9 files changed, 553 insertions(+), 38 deletions(-) create mode 100644 web/src/features/search/nested-email-dialog.tsx diff --git a/.gitignore b/.gitignore index 454e0a3..e4bb476 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /target .vscode .idea +config.toml \ No newline at end of file diff --git a/config.toml b/config.toml index 9046753..0c15d30 100644 --- a/config.toml +++ b/config.toml @@ -1,2 +1,2 @@ base_url = "http://localhost:15630" -api_token = "lZHmfpH1CRr9XsRiOGd1RnOr" +api_token = "2g2viN7zi4fKU1YgY50aTjl4" diff --git a/src/modules/envelope/extractor.rs b/src/modules/envelope/extractor.rs index 9e30748..d9e426f 100644 --- a/src/modules/envelope/extractor.rs +++ b/src/modules/envelope/extractor.rs @@ -197,6 +197,81 @@ fn extract_envelope_core( Ok((envelope, attachments)) } +pub fn extract_envelope_from_message( + message: Message<'_>, + account_id: u64, +) -> BichonResult { + let text = if let Some(text) = message.body_text(0).map(|cow| cow.into_owned()) { + text + } else if let Some(html) = message.body_html(0).map(|cow| cow.into_owned()) { + extract_text(html) + } else { + String::new() + }; + + let message_id = message + .message_id() + .map(String::from) + .unwrap_or_else(generate_message_id); + + let in_reply_to = message.in_reply_to().as_text().map(String::from); + let references = extract_references(&message); + let thread_id = compute_thread_id(in_reply_to, references, &message_id); + + let mut subject = message.subject().map(String::from).unwrap_or_default(); + if subject.contains('\u{FFFD}') { + subject = normalize_subject(message.header_raw(HeaderName::Subject)); + } + + let date = message.date().map(|d| d.to_timestamp() * 1000).unwrap_or(0); + + let parse_addrs = |addrs: Option<&Address<'_>>| { + addrs + .map(|addr| { + AddrVec::from(addr) + .0 + .into_iter() + .filter_map(|a| a.address) + .collect() + }) + .unwrap_or_default() + }; + + let bcc = parse_addrs(message.bcc()); + let cc = parse_addrs(message.cc()); + let to = parse_addrs(message.to()); + + let from = message + .from() + .and_then(|addr| AddrVec::from(addr).0.into_iter().next()) + .and_then(|add| add.address) + .unwrap_or_else(|| "unknown".to_string()); + + let envelope = Envelope { + id: 0, + message_id, + account_id, + mailbox_id: 0, + uid: 0, + subject, + text, + from, + to, + cc, + bcc, + date, + internal_date: 0, + size: 0, + thread_id, + attachment_count: 0, + tags: None, + account_email: None, + mailbox_name: None, + }; + + Ok(envelope) +} + pub fn compute_thread_id( in_reply_to: Option, references: Option>, diff --git a/src/modules/indexer/manager.rs b/src/modules/indexer/manager.rs index d375baf..90f8bba 100644 --- a/src/modules/indexer/manager.rs +++ b/src/modules/indexer/manager.rs @@ -545,12 +545,12 @@ impl EmlIndexManager { Ok(file) } - pub async fn get_attachment( + pub async fn get_attachment_content( &self, account_id: u64, eid: u64, file_name: &str, - ) -> BichonResult { + ) -> BichonResult> { let envelope = duckdb()? .get_envelope_by_id(account_id, eid)? .ok_or_else(|| { @@ -578,25 +578,96 @@ impl EmlIndexManager { ErrorCode::InternalError ) })?; - let target_attachment = message + + let content = message .attachments() - .find(|p| p.attachment_name().is_some_and(|name| name == file_name)); - let content = match target_attachment { - Some(att) => att.contents(), - None => { - return Err(raise_error!( - "Attachment not found".into(), + .find(|att| { + att.attachment_name() + .map(|name| name == file_name) + .unwrap_or(false) + }) + .map(|att| att.contents().to_vec()) + .ok_or_else(|| { + raise_error!( + format!("Attachment '{}' not found in email {}", file_name, eid), ErrorCode::ResourceNotFound - )) - } - }; + ) + })?; + + Ok(content) + } + + pub async fn get_attachment( + &self, + account_id: u64, + eid: u64, + file_name: &str, + ) -> BichonResult { + let content = self + .get_attachment_content(account_id, eid, file_name) + .await?; let mut path = DATA_DIR_MANAGER.temp_dir.clone(); - path.push(format!("{eid}.{file_name}.eml")); + path.push(format!("{eid}.{file_name}.attachment")); { let mut file = File::create(&path) .await .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?; - file.write_all(content) + file.write_all(&content) + .await + .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?; + } + let file = File::open(&path) + .await + .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?; + Ok(file) + } + + pub async fn get_nested_attachment( + &self, + account_id: u64, + eid: u64, + file_name: &str, + nested_file_name: &str, + ) -> BichonResult { + let content = self + .get_attachment_content(account_id, eid, file_name) + .await?; + + let message = MessageParser::default().parse(&content).ok_or_else(|| { + raise_error!( + format!( + "Failed to parse email: account_id={}, eid={}", + account_id, eid + ), + ErrorCode::InternalError + ) + })?; + + let content = message + .attachments() + .find(|att| { + att.attachment_name() + .map(|name| name == nested_file_name) + .unwrap_or(false) + }) + .map(|att| att.contents().to_vec()) + .ok_or_else(|| { + raise_error!( + format!( + "Nested attachment '{}' not found in email {}", + nested_file_name, eid + ), + ErrorCode::ResourceNotFound + ) + })?; + + let mut path = DATA_DIR_MANAGER.temp_dir.clone(); + path.push(format!("{eid}.{file_name}.{nested_file_name}.attachment")); + { + let mut file = File::create(&path) + .await + .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?; + file.write_all(&content) .await .map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?; } diff --git a/src/modules/message/content.rs b/src/modules/message/content.rs index 66e5b3b..b5708fa 100644 --- a/src/modules/message/content.rs +++ b/src/modules/message/content.rs @@ -18,7 +18,9 @@ use crate::base64_encode; use crate::modules::account::migration::AccountModel; +use crate::modules::envelope::extractor::extract_envelope_from_message; use crate::modules::error::code::ErrorCode; +use crate::modules::indexer::envelope::Envelope; use crate::modules::indexer::manager::{EML_INDEX_MANAGER, ENVELOPE_INDEX_MANAGER}; use crate::modules::utils::create_hash; use crate::{modules::error::BichonResult, raise_error}; @@ -130,6 +132,18 @@ pub struct FullMessageContent { pub attachments: Option>, } +#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)] +pub struct FullNestedMessageContent { + /// Optional plain text version of the message. + pub text: Option, + /// Optional HTML version of the message. + pub html: Option, + // all Attachments include inline attachments + pub attachments: Option>, + /// Metadata for the email envelope. + pub envelope: Envelope, +} + pub async fn retrieve_email_content(account_id: u64, id: u64) -> BichonResult { AccountModel::check_account_exists(account_id).await?; let envelope = ENVELOPE_INDEX_MANAGER @@ -225,3 +239,73 @@ pub async fn retrieve_email_content(account_id: u64, id: u64) -> BichonResult BichonResult { + let attachment_content = EML_INDEX_MANAGER + .get_attachment_content(account_id, envelope_id, name) + .await?; + let message = MessageParser::default().parse(&attachment_content).ok_or_else(|| { + raise_error!( + format!( + "Unable to parse '{}' as an email. It may not be in RFC822 format or the file is corrupted.", + name + ), + ErrorCode::InternalError + ) + })?; + + let mut html: Option = message.body_html(0).map(|cow| cow.into_owned()); + let text: Option = message.body_text(0).map(|cow| cow.into_owned()); + let mut attachments = Vec::new(); + + for attachment in message.attachments() { + let content_type = attachment.content_type(); + let file_type = content_type.map_or_else( + || "application/octet-stream".to_string(), + |ct| format!("{}/{}", ct.c_type, ct.c_subtype.as_deref().unwrap_or("")), + ); + + let filename = attachment + .attachment_name() + .map(|n| n.to_string()) + .unwrap_or_else(|| format!("attached_file_{}", attachment.raw_body_offset())); + + let disposition = attachment.content_disposition(); + let is_inline = disposition.map(|d| d.is_inline()).unwrap_or(false); + let cid = attachment.content_id(); + + if is_inline && cid.is_some() { + if let (Some(html_str), Some(content_id)) = (html.as_mut(), cid) { + if html_str.contains(content_id) { + let data = attachment.contents(); + let base64_encoded = base64_encode!(data); + *html_str = html_str.replace( + &format!("cid:{}", content_id), + &format!("data:{};base64,{}", file_type, base64_encoded), + ); + } + } + continue; + } + + attachments.push(AttachmentInfo { + filename, + size: attachment.contents().len(), + inline: is_inline, + file_type, + content_id: cid.map(Into::into), + }); + } + + let envelope = extract_envelope_from_message(message, account_id)?; + Ok(FullNestedMessageContent { + text, + html, + attachments: Some(attachments), + envelope, + }) +} diff --git a/src/modules/rest/api/message.rs b/src/modules/rest/api/message.rs index bf7b9c5..ce227a5 100644 --- a/src/modules/rest/api/message.rs +++ b/src/modules/rest/api/message.rs @@ -23,6 +23,8 @@ use crate::modules::indexer::manager::EML_INDEX_MANAGER; use crate::modules::indexer::manager::ENVELOPE_INDEX_MANAGER; use crate::modules::message::append::restore_emails; use crate::modules::message::append::RestoreMessagesRequest; +use crate::modules::message::content::retrieve_nested_eml_content; +use crate::modules::message::content::FullNestedMessageContent; use crate::modules::message::content::{retrieve_email_content, FullMessageContent}; use crate::modules::message::delete::delete_messages_impl; use crate::modules::message::list::{get_thread_messages, list_messages_impl}; @@ -168,6 +170,31 @@ impl MessageApi { )) } + /// Retrieves the content of an email embedded as an attachment. + #[oai( + path = "/nested-message-content/:account_id/:envelope_id", + method = "get", + operation_id = "fetch_nested_message_content" + )] + async fn fetch_nested_message_content( + &self, + /// The ID of the account. + account_id: Path, + /// The ID of the message to fetch. + envelope_id: Path, + name: Query, + context: ClientContext, + ) -> ApiResult> { + let account_id = account_id.0; + context + .require_permission(Some(account_id), Permission::DATA_READ) + .await?; + let name = name.0.trim(); + Ok(Json( + retrieve_nested_eml_content(account_id, envelope_id.0, name).await?, + )) + } + /// Retrieves the envelope (metadata) of a specific message. #[oai( path = "/envelope/:account_id/:envelope_id", @@ -221,7 +248,9 @@ impl MessageApi { .require_permission(Some(account_id), Permission::DATA_RAW_DOWNLOAD) .await?; let envelope_id = envelope_id.0; - let reader = EML_INDEX_MANAGER.get_reader(account_id, envelope_id).await?; + let reader = EML_INDEX_MANAGER + .get_reader(account_id, envelope_id) + .await?; let body = Body::from_async_read(reader); let attachment = Attachment::new(body) .attachment_type(AttachmentType::Attachment) @@ -229,6 +258,7 @@ impl MessageApi { Ok(attachment) } + /// Restore an email to an account's IMAP server. #[oai( path = "/restore-messages/:account_id", method = "post", @@ -279,6 +309,41 @@ impl MessageApi { .filename(name); Ok(attachment) } + + /// Downloads an attachment from within a nested email (EML file). + #[oai( + path = "/download-nested-attachment/:account_id/:envelope_id", + method = "get", + operation_id = "download_nested_attachment" + )] + async fn download_nested_attachment( + &self, + /// The ID of the account. + account_id: Path, + /// The ID of the message containing the attachment. + envelope_id: Path, + /// The filename of the attachment to download. + name: Query, + nested_name: Query, + context: ClientContext, + ) -> ApiResult> { + let account_id = account_id.0; + AccountModel::check_account_exists(account_id).await?; + context + .require_permission(Some(account_id), Permission::DATA_READ) + .await?; + let name = name.0.trim(); + let nested_name = nested_name.0.trim(); + let reader = EML_INDEX_MANAGER + .get_nested_attachment(account_id, envelope_id.0, name, nested_name) + .await?; + let body = Body::from_async_read(reader); + let attachment = Attachment::new(body) + .attachment_type(AttachmentType::Attachment) + .filename(name); + Ok(attachment) + } + /// Returns all facets in the index along with their document counts. #[oai(path = "/all-tags", method = "get", operation_id = "get_all_tags")] async fn get_all_tags(&self, context: ClientContext) -> ApiResult>> { @@ -324,6 +389,7 @@ impl MessageApi { Ok(()) } + /// Retrieves a unique list of all contact email addresses across authorized accounts. #[oai( path = "/all-contacts", method = "get", diff --git a/web/src/api/mailbox/envelope/api.ts b/web/src/api/mailbox/envelope/api.ts index 9ffdf9e..6a440d1 100644 --- a/web/src/api/mailbox/envelope/api.ts +++ b/web/src/api/mailbox/envelope/api.ts @@ -21,18 +21,18 @@ import { EmailEnvelope, PaginatedResponse } from "@/api"; import axiosInstance from "@/api/axiosInstance"; import { saveAs } from 'file-saver'; -export const list_messages = async (accountId: number, mailbox_id: number, page: number, page_size: number) => { - const params = new URLSearchParams({ - mailbox_id: String(mailbox_id), - page: String(page), - page_size: String(page_size), - }); +// export const list_messages = async (accountId: number, mailbox_id: number, page: number, page_size: number) => { +// const params = new URLSearchParams({ +// mailbox_id: String(mailbox_id), +// page: String(page), +// page_size: String(page_size), +// }); - const response = await axiosInstance.get>( - `api/v1/list-messages/${accountId}?${params.toString()}` - ); - return response.data; -}; +// const response = await axiosInstance.get>( +// `api/v1/list-messages/${accountId}?${params.toString()}` +// ); +// return response.data; +// }; export const get_thread_messages = async (accountId: number, thread_id: number, page: number, page_size: number) => { const params = new URLSearchParams({ @@ -53,7 +53,11 @@ export const download_attachment = async (accountId: number, id: number, attachm saveAs(blob, attachmentFileName); }; - +export const download_nested_attachment = async (accountId: number, id: number, attachmentFileName: string, nestedAttachmentFileName: string) => { + const response = await axiosInstance.get(`api/v1/download-nested-attachment/${accountId}/${id}?name=${attachmentFileName}&nested_name=${nestedAttachmentFileName}`, { responseType: 'blob' }); + const blob = new Blob([response.data]); + saveAs(blob, nestedAttachmentFileName); +}; export interface AttachmentInfo { /** MIME content type of the attachment (e.g., `image/png`, `application/pdf`). */ file_type: string; @@ -66,13 +70,19 @@ export interface AttachmentInfo { /** Size of the attachment in bytes. */ size: number; } - export interface MessageContentResponse { text?: string; html?: string; attachments?: AttachmentInfo[] } +export interface NestedMessageContentResponse { + text?: string; + html?: string; + attachments?: AttachmentInfo[]; + envelope: EmailEnvelope; +} + export const getContent = (messageContent: MessageContentResponse): string | null => { if (messageContent.html) { return messageContent.html; @@ -87,6 +97,11 @@ export const load_message = async (accountId: number, id: number) => { return response.data; }; +export const load_nested_message = async (accountId: number, id: number, attachmentFileName: string) => { + const response = await axiosInstance.get(`api/v1/nested-message-content/${accountId}/${id}?name=${attachmentFileName}`); + return response.data; +}; + export const delete_messages = async (payload: Record) => { const response = await axiosInstance.post("api/v1/delete-messages", payload); return response.data; @@ -98,8 +113,6 @@ export const download_message = async (accountId: number, id: number) => { saveAs(blob, `${id}.eml`); }; - - export const restore_message = async (accountId: number, messageIds: number[]) => { const response = await axiosInstance.post(`api/v1/restore-messages/${accountId}`, { message_ids: messageIds, diff --git a/web/src/features/search/mail-message-view.tsx b/web/src/features/search/mail-message-view.tsx index 019b18f..0d5279f 100644 --- a/web/src/features/search/mail-message-view.tsx +++ b/web/src/features/search/mail-message-view.tsx @@ -39,6 +39,7 @@ import { useSearchContext } from './context'; import { MailThreadDialog } from './thread-dialog'; import useMinimalAccountList from '@/hooks/use-minimal-account-list'; import { useTranslation } from 'react-i18next'; +import { NestedEmailDialog } from './nested-email-dialog'; interface MailMessageViewProps { @@ -84,7 +85,7 @@ const Multilines: React.FC<{ title: string; lines: string[] }> = ({ title, lines ); }; -const getFileConfig = (mimeType: string) => { +export const getFileConfig = (mimeType: string) => { const type = mimeType.toLowerCase(); if (type.includes('pdf')) { return { icon: , color: 'text-red-600 bg-red-50 border-red-100' }; @@ -120,13 +121,12 @@ export function MailMessageView({ }: MailMessageViewProps) { const { t } = useTranslation() const { setToDelete, setOpen, setSelected } = useSearchContext(); - const [content, setContent] = useState(null); const [contentType, setContentType] = useState<'Plain' | 'Html' | null>(null); const [attachments, setAttachments] = useState(null); const [loading, setLoading] = useState(true); const [downloadingAttachmentFileName, setDownloadingAttachmentFileName] = useState(null); - + const [nestedEmlFile, setNestedEmlFile] = useState(null); const { getEmailById } = useMinimalAccountList(); const [threadOpen, setThreadOpen] = useState(false); @@ -168,6 +168,10 @@ export function MailMessageView({ }, [envelope.id]); + const handleViewNestedEml = (filename: string) => { + setNestedEmlFile(filename); + }; + const toggleToDelete = (accountId: number, mailId: number) => { setToDelete(prev => { const next = new Map(prev); @@ -193,6 +197,7 @@ export function MailMessageView({ } } + const downloadEmlFile = async () => { try { toast({ title: t('mail.downloadStarted'), description: t('mail.isBeingDownloaded', { id: envelope.id }) }); @@ -312,6 +317,8 @@ export function MailMessageView({
{nonInline.map((attachment, i) => { const { icon, color } = getFileConfig(attachment.file_type); + const isNestedEmail = attachment.file_type.toLowerCase() === 'message/rfc822'; + return
@@ -324,12 +331,27 @@ export function MailMessageView({ > {attachment.filename} - - {attachment.file_type.split('/').pop()?.toUpperCase()} + + {attachment.file_type.split('/').pop()}
-
+
+ {isNestedEmail && ( + + + + + {t('mail.viewNestedEmail', 'View Embedded Email')} + + )} {formatBytes(attachment.size)} @@ -380,11 +402,18 @@ export function MailMessageView({
+ !open && setNestedEmlFile(null)} + accountId={envelope.account_id} + envelopeId={envelope.id} + fileName={nestedEmlFile || ''} + />
); } -function formatTimestamp(milliseconds: number): string { +export function formatTimestamp(milliseconds: number): string { const date = new Date(milliseconds); const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); diff --git a/web/src/features/search/nested-email-dialog.tsx b/web/src/features/search/nested-email-dialog.tsx new file mode 100644 index 0000000..927fbf1 --- /dev/null +++ b/web/src/features/search/nested-email-dialog.tsx @@ -0,0 +1,176 @@ +import { EmailEnvelope } from '@/api'; +import { AttachmentInfo, download_nested_attachment, load_nested_message } from '@/api/mailbox/envelope/api'; +import EmailIframe from '@/components/mail-iframe'; +import { Dialog, DialogContent } from '@/components/ui/dialog'; +import { Separator } from '@/components/ui/separator'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { formatBytes, formatTimestamp } from '@/lib/utils'; +import { useQuery } from '@tanstack/react-query'; +import { Download, Loader, Mail } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { getFileConfig } from './mail-message-view'; +import { Button } from '@/components/ui/button'; + +const MessageHeader = ({ + envelope, + attachments, + onDownload +}: { + envelope: EmailEnvelope, + attachments?: AttachmentInfo[], + onDownload: (fileName: string) => void +}) => { + const { t } = useTranslation(); + const displayAttachments = attachments || []; + + return ( +
+
+

+ {envelope.subject || `(${t('mail.noSubject')})`} +

+
+ {formatTimestamp(envelope.date)} +
+
+ + +
+ {/* From */} +
+ + {t('mail.from')} + + + {envelope.from} + +
+ + {envelope.to && envelope.to.length > 0 && ( +
+ + {t('mail.to')} + +
+ {envelope.to.map((addr, i) => ( + + {addr}{i < envelope.to.length - 1 ? ',' : ''} + + ))} +
+
+ )} + + {envelope.cc && envelope.cc.length > 0 && ( +
+ + {t('mail.cc')} + +
+ {envelope.cc.map((addr, i) => ( + + {addr}{i < envelope.cc.length - 1 ? ',' : ''} + + ))} +
+
+ )} + + {envelope.bcc && envelope.bcc.length > 0 && ( +
+ + {t('mail.bcc')} + +
+ {envelope.bcc.map((addr, i) => ( + + {addr}{i < envelope.bcc.length - 1 ? ',' : ''} + + ))} +
+
+ )} +
+ + {displayAttachments.length > 0 && ( +
+
+ {displayAttachments.map((att, i) => { + const { icon, color } = getFileConfig(att.file_type); + return ( + + + + + {t('mail.clickToDownload')} + + ); + })} +
+
+ )} +
+ ); +}; + + + +export function NestedEmailDialog({ open, onOpenChange, accountId, envelopeId, fileName }: any) { + const { data, isLoading } = useQuery({ + queryKey: ['nested-message', accountId, envelopeId, fileName], + queryFn: () => load_nested_message(accountId, envelopeId, fileName), + enabled: open && !!fileName, + }); + + return ( + + +
+
+ + {fileName} +
+ +
+ +
+ {isLoading ? ( +
+ ) : data && ( +
+ download_nested_attachment(accountId, envelopeId, fileName, nestedFileName)} + /> + +
+ {data.html ? ( + + ) : ( +
+                                        {data.text}
+                                    
+ )} +
+
+ )} +
+
+
+ ); +}