feat: support nested EML attachment preview and download #150

This commit is contained in:
rustmailer
2026-03-15 18:55:24 +08:00
parent af0f47c0e3
commit a8b3b24d59
9 changed files with 553 additions and 38 deletions
+75
View File
@@ -197,6 +197,81 @@ fn extract_envelope_core(
Ok((envelope, attachments))
}
pub fn extract_envelope_from_message(
message: Message<'_>,
account_id: u64,
) -> BichonResult<Envelope> {
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<String>,
references: Option<Vec<String>>,
+85 -14
View File
@@ -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<File> {
) -> BichonResult<Vec<u8>> {
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<File> {
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<File> {
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))?;
}
+84
View File
@@ -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<Vec<AttachmentInfo>>,
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize, Object)]
pub struct FullNestedMessageContent {
/// Optional plain text version of the message.
pub text: Option<String>,
/// Optional HTML version of the message.
pub html: Option<String>,
// all Attachments include inline attachments
pub attachments: Option<Vec<AttachmentInfo>>,
/// Metadata for the email envelope.
pub envelope: Envelope,
}
pub async fn retrieve_email_content(account_id: u64, id: u64) -> BichonResult<FullMessageContent> {
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<Fu
attachments: Some(attachments),
})
}
pub async fn retrieve_nested_eml_content(
account_id: u64,
envelope_id: u64,
name: &str,
) -> BichonResult<FullNestedMessageContent> {
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<String> = message.body_html(0).map(|cow| cow.into_owned());
let text: Option<String> = 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,
})
}
+67 -1
View File
@@ -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<u64>,
/// The ID of the message to fetch.
envelope_id: Path<u64>,
name: Query<String>,
context: ClientContext,
) -> ApiResult<Json<FullNestedMessageContent>> {
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<u64>,
/// The ID of the message containing the attachment.
envelope_id: Path<u64>,
/// The filename of the attachment to download.
name: Query<String>,
nested_name: Query<String>,
context: ClientContext,
) -> ApiResult<Attachment<Body>> {
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<Json<Vec<TagCount>>> {
@@ -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",