mirror of
https://github.com/rustmailer/bichon.git
synced 2026-08-03 07:48:34 +02:00
feat: support nested EML attachment preview and download #150
This commit is contained in:
@@ -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>>,
|
||||
|
||||
@@ -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))?;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user