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:
@@ -1,3 +1,4 @@
|
|||||||
/target
|
/target
|
||||||
.vscode
|
.vscode
|
||||||
.idea
|
.idea
|
||||||
|
config.toml
|
||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
base_url = "http://localhost:15630"
|
base_url = "http://localhost:15630"
|
||||||
api_token = "lZHmfpH1CRr9XsRiOGd1RnOr"
|
api_token = "2g2viN7zi4fKU1YgY50aTjl4"
|
||||||
|
|||||||
@@ -197,6 +197,81 @@ fn extract_envelope_core(
|
|||||||
Ok((envelope, attachments))
|
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(
|
pub fn compute_thread_id(
|
||||||
in_reply_to: Option<String>,
|
in_reply_to: Option<String>,
|
||||||
references: Option<Vec<String>>,
|
references: Option<Vec<String>>,
|
||||||
|
|||||||
@@ -545,12 +545,12 @@ impl EmlIndexManager {
|
|||||||
Ok(file)
|
Ok(file)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_attachment(
|
pub async fn get_attachment_content(
|
||||||
&self,
|
&self,
|
||||||
account_id: u64,
|
account_id: u64,
|
||||||
eid: u64,
|
eid: u64,
|
||||||
file_name: &str,
|
file_name: &str,
|
||||||
) -> BichonResult<File> {
|
) -> BichonResult<Vec<u8>> {
|
||||||
let envelope = duckdb()?
|
let envelope = duckdb()?
|
||||||
.get_envelope_by_id(account_id, eid)?
|
.get_envelope_by_id(account_id, eid)?
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
@@ -578,25 +578,96 @@ impl EmlIndexManager {
|
|||||||
ErrorCode::InternalError
|
ErrorCode::InternalError
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
let target_attachment = message
|
|
||||||
|
let content = message
|
||||||
.attachments()
|
.attachments()
|
||||||
.find(|p| p.attachment_name().is_some_and(|name| name == file_name));
|
.find(|att| {
|
||||||
let content = match target_attachment {
|
att.attachment_name()
|
||||||
Some(att) => att.contents(),
|
.map(|name| name == file_name)
|
||||||
None => {
|
.unwrap_or(false)
|
||||||
return Err(raise_error!(
|
})
|
||||||
"Attachment not found".into(),
|
.map(|att| att.contents().to_vec())
|
||||||
|
.ok_or_else(|| {
|
||||||
|
raise_error!(
|
||||||
|
format!("Attachment '{}' not found in email {}", file_name, eid),
|
||||||
ErrorCode::ResourceNotFound
|
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();
|
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)
|
let mut file = File::create(&path)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
.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
|
.await
|
||||||
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
.map_err(|e| raise_error!(format!("{:#?}", e), ErrorCode::InternalError))?;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,9 @@
|
|||||||
|
|
||||||
use crate::base64_encode;
|
use crate::base64_encode;
|
||||||
use crate::modules::account::migration::AccountModel;
|
use crate::modules::account::migration::AccountModel;
|
||||||
|
use crate::modules::envelope::extractor::extract_envelope_from_message;
|
||||||
use crate::modules::error::code::ErrorCode;
|
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::indexer::manager::{EML_INDEX_MANAGER, ENVELOPE_INDEX_MANAGER};
|
||||||
use crate::modules::utils::create_hash;
|
use crate::modules::utils::create_hash;
|
||||||
use crate::{modules::error::BichonResult, raise_error};
|
use crate::{modules::error::BichonResult, raise_error};
|
||||||
@@ -130,6 +132,18 @@ pub struct FullMessageContent {
|
|||||||
pub attachments: Option<Vec<AttachmentInfo>>,
|
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> {
|
pub async fn retrieve_email_content(account_id: u64, id: u64) -> BichonResult<FullMessageContent> {
|
||||||
AccountModel::check_account_exists(account_id).await?;
|
AccountModel::check_account_exists(account_id).await?;
|
||||||
let envelope = ENVELOPE_INDEX_MANAGER
|
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),
|
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::indexer::manager::ENVELOPE_INDEX_MANAGER;
|
||||||
use crate::modules::message::append::restore_emails;
|
use crate::modules::message::append::restore_emails;
|
||||||
use crate::modules::message::append::RestoreMessagesRequest;
|
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::content::{retrieve_email_content, FullMessageContent};
|
||||||
use crate::modules::message::delete::delete_messages_impl;
|
use crate::modules::message::delete::delete_messages_impl;
|
||||||
use crate::modules::message::list::{get_thread_messages, list_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.
|
/// Retrieves the envelope (metadata) of a specific message.
|
||||||
#[oai(
|
#[oai(
|
||||||
path = "/envelope/:account_id/:envelope_id",
|
path = "/envelope/:account_id/:envelope_id",
|
||||||
@@ -221,7 +248,9 @@ impl MessageApi {
|
|||||||
.require_permission(Some(account_id), Permission::DATA_RAW_DOWNLOAD)
|
.require_permission(Some(account_id), Permission::DATA_RAW_DOWNLOAD)
|
||||||
.await?;
|
.await?;
|
||||||
let envelope_id = envelope_id.0;
|
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 body = Body::from_async_read(reader);
|
||||||
let attachment = Attachment::new(body)
|
let attachment = Attachment::new(body)
|
||||||
.attachment_type(AttachmentType::Attachment)
|
.attachment_type(AttachmentType::Attachment)
|
||||||
@@ -229,6 +258,7 @@ impl MessageApi {
|
|||||||
Ok(attachment)
|
Ok(attachment)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Restore an email to an account's IMAP server.
|
||||||
#[oai(
|
#[oai(
|
||||||
path = "/restore-messages/:account_id",
|
path = "/restore-messages/:account_id",
|
||||||
method = "post",
|
method = "post",
|
||||||
@@ -279,6 +309,41 @@ impl MessageApi {
|
|||||||
.filename(name);
|
.filename(name);
|
||||||
Ok(attachment)
|
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.
|
/// Returns all facets in the index along with their document counts.
|
||||||
#[oai(path = "/all-tags", method = "get", operation_id = "get_all_tags")]
|
#[oai(path = "/all-tags", method = "get", operation_id = "get_all_tags")]
|
||||||
async fn get_all_tags(&self, context: ClientContext) -> ApiResult<Json<Vec<TagCount>>> {
|
async fn get_all_tags(&self, context: ClientContext) -> ApiResult<Json<Vec<TagCount>>> {
|
||||||
@@ -324,6 +389,7 @@ impl MessageApi {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Retrieves a unique list of all contact email addresses across authorized accounts.
|
||||||
#[oai(
|
#[oai(
|
||||||
path = "/all-contacts",
|
path = "/all-contacts",
|
||||||
method = "get",
|
method = "get",
|
||||||
|
|||||||
@@ -21,18 +21,18 @@ import { EmailEnvelope, PaginatedResponse } from "@/api";
|
|||||||
import axiosInstance from "@/api/axiosInstance";
|
import axiosInstance from "@/api/axiosInstance";
|
||||||
import { saveAs } from 'file-saver';
|
import { saveAs } from 'file-saver';
|
||||||
|
|
||||||
export const list_messages = async (accountId: number, mailbox_id: number, page: number, page_size: number) => {
|
// export const list_messages = async (accountId: number, mailbox_id: number, page: number, page_size: number) => {
|
||||||
const params = new URLSearchParams({
|
// const params = new URLSearchParams({
|
||||||
mailbox_id: String(mailbox_id),
|
// mailbox_id: String(mailbox_id),
|
||||||
page: String(page),
|
// page: String(page),
|
||||||
page_size: String(page_size),
|
// page_size: String(page_size),
|
||||||
});
|
// });
|
||||||
|
|
||||||
const response = await axiosInstance.get<PaginatedResponse<EmailEnvelope>>(
|
// const response = await axiosInstance.get<PaginatedResponse<EmailEnvelope>>(
|
||||||
`api/v1/list-messages/${accountId}?${params.toString()}`
|
// `api/v1/list-messages/${accountId}?${params.toString()}`
|
||||||
);
|
// );
|
||||||
return response.data;
|
// return response.data;
|
||||||
};
|
// };
|
||||||
|
|
||||||
export const get_thread_messages = async (accountId: number, thread_id: number, page: number, page_size: number) => {
|
export const get_thread_messages = async (accountId: number, thread_id: number, page: number, page_size: number) => {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
@@ -53,7 +53,11 @@ export const download_attachment = async (accountId: number, id: number, attachm
|
|||||||
saveAs(blob, attachmentFileName);
|
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 {
|
export interface AttachmentInfo {
|
||||||
/** MIME content type of the attachment (e.g., `image/png`, `application/pdf`). */
|
/** MIME content type of the attachment (e.g., `image/png`, `application/pdf`). */
|
||||||
file_type: string;
|
file_type: string;
|
||||||
@@ -66,13 +70,19 @@ export interface AttachmentInfo {
|
|||||||
/** Size of the attachment in bytes. */
|
/** Size of the attachment in bytes. */
|
||||||
size: number;
|
size: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MessageContentResponse {
|
export interface MessageContentResponse {
|
||||||
text?: string;
|
text?: string;
|
||||||
html?: string;
|
html?: string;
|
||||||
attachments?: AttachmentInfo[]
|
attachments?: AttachmentInfo[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface NestedMessageContentResponse {
|
||||||
|
text?: string;
|
||||||
|
html?: string;
|
||||||
|
attachments?: AttachmentInfo[];
|
||||||
|
envelope: EmailEnvelope;
|
||||||
|
}
|
||||||
|
|
||||||
export const getContent = (messageContent: MessageContentResponse): string | null => {
|
export const getContent = (messageContent: MessageContentResponse): string | null => {
|
||||||
if (messageContent.html) {
|
if (messageContent.html) {
|
||||||
return messageContent.html;
|
return messageContent.html;
|
||||||
@@ -87,6 +97,11 @@ export const load_message = async (accountId: number, id: number) => {
|
|||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const load_nested_message = async (accountId: number, id: number, attachmentFileName: string) => {
|
||||||
|
const response = await axiosInstance.get<NestedMessageContentResponse>(`api/v1/nested-message-content/${accountId}/${id}?name=${attachmentFileName}`);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
export const delete_messages = async (payload: Record<string, number[]>) => {
|
export const delete_messages = async (payload: Record<string, number[]>) => {
|
||||||
const response = await axiosInstance.post("api/v1/delete-messages", payload);
|
const response = await axiosInstance.post("api/v1/delete-messages", payload);
|
||||||
return response.data;
|
return response.data;
|
||||||
@@ -98,8 +113,6 @@ export const download_message = async (accountId: number, id: number) => {
|
|||||||
saveAs(blob, `${id}.eml`);
|
saveAs(blob, `${id}.eml`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export const restore_message = async (accountId: number, messageIds: number[]) => {
|
export const restore_message = async (accountId: number, messageIds: number[]) => {
|
||||||
const response = await axiosInstance.post(`api/v1/restore-messages/${accountId}`, {
|
const response = await axiosInstance.post(`api/v1/restore-messages/${accountId}`, {
|
||||||
message_ids: messageIds,
|
message_ids: messageIds,
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import { useSearchContext } from './context';
|
|||||||
import { MailThreadDialog } from './thread-dialog';
|
import { MailThreadDialog } from './thread-dialog';
|
||||||
import useMinimalAccountList from '@/hooks/use-minimal-account-list';
|
import useMinimalAccountList from '@/hooks/use-minimal-account-list';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { NestedEmailDialog } from './nested-email-dialog';
|
||||||
|
|
||||||
|
|
||||||
interface MailMessageViewProps {
|
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();
|
const type = mimeType.toLowerCase();
|
||||||
if (type.includes('pdf')) {
|
if (type.includes('pdf')) {
|
||||||
return { icon: <FileText className="h-4 w-4" />, color: 'text-red-600 bg-red-50 border-red-100' };
|
return { icon: <FileText className="h-4 w-4" />, color: 'text-red-600 bg-red-50 border-red-100' };
|
||||||
@@ -120,13 +121,12 @@ export function MailMessageView({
|
|||||||
}: MailMessageViewProps) {
|
}: MailMessageViewProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { setToDelete, setOpen, setSelected } = useSearchContext();
|
const { setToDelete, setOpen, setSelected } = useSearchContext();
|
||||||
|
|
||||||
const [content, setContent] = useState<string | null>(null);
|
const [content, setContent] = useState<string | null>(null);
|
||||||
const [contentType, setContentType] = useState<'Plain' | 'Html' | null>(null);
|
const [contentType, setContentType] = useState<'Plain' | 'Html' | null>(null);
|
||||||
const [attachments, setAttachments] = useState<AttachmentInfo[] | null>(null);
|
const [attachments, setAttachments] = useState<AttachmentInfo[] | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [downloadingAttachmentFileName, setDownloadingAttachmentFileName] = useState<string | null>(null);
|
const [downloadingAttachmentFileName, setDownloadingAttachmentFileName] = useState<string | null>(null);
|
||||||
|
const [nestedEmlFile, setNestedEmlFile] = useState<string | null>(null);
|
||||||
const { getEmailById } = useMinimalAccountList();
|
const { getEmailById } = useMinimalAccountList();
|
||||||
const [threadOpen, setThreadOpen] = useState(false);
|
const [threadOpen, setThreadOpen] = useState(false);
|
||||||
|
|
||||||
@@ -168,6 +168,10 @@ export function MailMessageView({
|
|||||||
}, [envelope.id]);
|
}, [envelope.id]);
|
||||||
|
|
||||||
|
|
||||||
|
const handleViewNestedEml = (filename: string) => {
|
||||||
|
setNestedEmlFile(filename);
|
||||||
|
};
|
||||||
|
|
||||||
const toggleToDelete = (accountId: number, mailId: number) => {
|
const toggleToDelete = (accountId: number, mailId: number) => {
|
||||||
setToDelete(prev => {
|
setToDelete(prev => {
|
||||||
const next = new Map(prev);
|
const next = new Map(prev);
|
||||||
@@ -193,6 +197,7 @@ export function MailMessageView({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const downloadEmlFile = async () => {
|
const downloadEmlFile = async () => {
|
||||||
try {
|
try {
|
||||||
toast({ title: t('mail.downloadStarted'), description: t('mail.isBeingDownloaded', { id: envelope.id }) });
|
toast({ title: t('mail.downloadStarted'), description: t('mail.isBeingDownloaded', { id: envelope.id }) });
|
||||||
@@ -312,6 +317,8 @@ export function MailMessageView({
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{nonInline.map((attachment, i) => {
|
{nonInline.map((attachment, i) => {
|
||||||
const { icon, color } = getFileConfig(attachment.file_type);
|
const { icon, color } = getFileConfig(attachment.file_type);
|
||||||
|
const isNestedEmail = attachment.file_type.toLowerCase() === 'message/rfc822';
|
||||||
|
|
||||||
return <div key={i} className="flex items-center">
|
return <div key={i} className="flex items-center">
|
||||||
<div className="group flex items-center gap-2 p-1 hover:bg-muted/60 rounded transition-colors min-w-0 w-full">
|
<div className="group flex items-center gap-2 p-1 hover:bg-muted/60 rounded transition-colors min-w-0 w-full">
|
||||||
<div className={`flex-shrink-0 ${color}`}>
|
<div className={`flex-shrink-0 ${color}`}>
|
||||||
@@ -324,12 +331,27 @@ export function MailMessageView({
|
|||||||
>
|
>
|
||||||
{attachment.filename}
|
{attachment.filename}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex-shrink-0 text-[9px] font-bold text-muted-foreground/60 bg-muted px-1 py-0.5 rounded uppercase tracking-tighter group-hover:text-foreground transition-colors">
|
<span className="flex-shrink-0 text-[9px] font-bold text-muted-foreground/60 bg-muted px-1 py-0.5 rounded uppercase">
|
||||||
{attachment.file_type.split('/').pop()?.toUpperCase()}
|
{attachment.file_type.split('/').pop()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-4 ml-auto">
|
<div className="flex items-center space-x-3 ml-auto pr-1">
|
||||||
|
{isNestedEmail && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 w-7 p-0 text-orange-600 hover:text-orange-700 hover:bg-orange-50"
|
||||||
|
onClick={() => handleViewNestedEml(attachment.filename)}
|
||||||
|
>
|
||||||
|
<MessageSquareMore className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{t('mail.viewNestedEmail', 'View Embedded Email')}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
<span className="text-gray-500 text-xs shrink-0">
|
<span className="text-gray-500 text-xs shrink-0">
|
||||||
{formatBytes(attachment.size)}
|
{formatBytes(attachment.size)}
|
||||||
</span>
|
</span>
|
||||||
@@ -380,11 +402,18 @@ export function MailMessageView({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<MailThreadDialog open={threadOpen} onOpenChange={setThreadOpen} />
|
<MailThreadDialog open={threadOpen} onOpenChange={setThreadOpen} />
|
||||||
|
<NestedEmailDialog
|
||||||
|
open={!!nestedEmlFile}
|
||||||
|
onOpenChange={(open: boolean) => !open && setNestedEmlFile(null)}
|
||||||
|
accountId={envelope.account_id}
|
||||||
|
envelopeId={envelope.id}
|
||||||
|
fileName={nestedEmlFile || ''}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTimestamp(milliseconds: number): string {
|
export function formatTimestamp(milliseconds: number): string {
|
||||||
const date = new Date(milliseconds);
|
const date = new Date(milliseconds);
|
||||||
const year = date.getFullYear();
|
const year = date.getFullYear();
|
||||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className="space-y-4 mb-4 bg-white p-5 rounded-xl border shadow-sm">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h1 className="text-lg font-bold text-slate-900 leading-snug">
|
||||||
|
{envelope.subject || `(${t('mail.noSubject')})`}
|
||||||
|
</h1>
|
||||||
|
<div className="text-[11px] text-slate-400">
|
||||||
|
{formatTimestamp(envelope.date)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator className="opacity-50" />
|
||||||
|
<div className="grid grid-cols-1 gap-y-3">
|
||||||
|
{/* From */}
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<span className="w-12 text-[10px] font-bold uppercase text-slate-400 shrink-0">
|
||||||
|
{t('mail.from')}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-medium text-slate-700 truncate">
|
||||||
|
{envelope.from}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{envelope.to && envelope.to.length > 0 && (
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<span className="w-12 text-[10px] font-bold uppercase text-slate-400 shrink-0">
|
||||||
|
{t('mail.to')}
|
||||||
|
</span>
|
||||||
|
<div className="flex flex-wrap gap-x-2 gap-y-1">
|
||||||
|
{envelope.to.map((addr, i) => (
|
||||||
|
<span key={i} className="text-sm text-slate-600">
|
||||||
|
{addr}{i < envelope.to.length - 1 ? ',' : ''}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{envelope.cc && envelope.cc.length > 0 && (
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<span className="w-12 text-[10px] font-bold uppercase text-slate-400 shrink-0">
|
||||||
|
{t('mail.cc')}
|
||||||
|
</span>
|
||||||
|
<div className="flex flex-wrap gap-x-2 gap-y-1 text-slate-500 italic">
|
||||||
|
{envelope.cc.map((addr, i) => (
|
||||||
|
<span key={i} className="text-xs">
|
||||||
|
{addr}{i < envelope.cc.length - 1 ? ',' : ''}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{envelope.bcc && envelope.bcc.length > 0 && (
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<span className="w-12 text-[10px] font-bold uppercase text-slate-400 shrink-0">
|
||||||
|
{t('mail.bcc')}
|
||||||
|
</span>
|
||||||
|
<div className="flex flex-wrap gap-x-2 gap-y-1 text-slate-500 italic">
|
||||||
|
{envelope.bcc.map((addr, i) => (
|
||||||
|
<span key={i} className="text-xs">
|
||||||
|
{addr}{i < envelope.bcc.length - 1 ? ',' : ''}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{displayAttachments.length > 0 && (
|
||||||
|
<div className="pt-2 border-t border-dashed">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{displayAttachments.map((att, i) => {
|
||||||
|
const { icon, color } = getFileConfig(att.file_type);
|
||||||
|
return (
|
||||||
|
<Tooltip key={i}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
onClick={() => onDownload(att.filename)}
|
||||||
|
className="group flex items-center gap-2 px-3 py-1.5 bg-slate-50 border border-slate-200 rounded-lg hover:bg-blue-50 hover:border-blue-200 transition-all text-slate-600 hover:text-blue-700"
|
||||||
|
>
|
||||||
|
<span className={`${color} p-0.5 rounded`}>{icon}</span>
|
||||||
|
<span className="text-xs font-medium truncate max-w-[180px]">
|
||||||
|
{att.filename}
|
||||||
|
</span>
|
||||||
|
<span className="text-[9px] text-slate-400 group-hover:text-blue-400">
|
||||||
|
({formatBytes(att.size)})
|
||||||
|
</span>
|
||||||
|
<Download className="h-3 w-3 ml-1 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{t('mail.clickToDownload')}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-4xl h-[90vh] flex flex-col p-0 overflow-hidden border-none shadow-2xl">
|
||||||
|
<div className="text-white px-4 py-3 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Mail className="h-4 w-4 text-blue-400" />
|
||||||
|
<span className="text-sm font-medium truncate max-w-[400px] opacity-90">{fileName}</span>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => onOpenChange(false)} className="text-white hover:bg-white/10 h-8 w-8 p-0">
|
||||||
|
✕
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-auto bg-white p-8">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="h-full flex items-center justify-center"><Loader className="animate-spin" /></div>
|
||||||
|
) : data && (
|
||||||
|
<div className="max-w-3xl mx-auto">
|
||||||
|
<MessageHeader
|
||||||
|
envelope={data.envelope}
|
||||||
|
attachments={data.attachments}
|
||||||
|
onDownload={(nestedFileName) => download_nested_attachment(accountId, envelopeId, fileName, nestedFileName)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mt-8 pt-8 border-t border-slate-100">
|
||||||
|
{data.html ? (
|
||||||
|
<EmailIframe emailHtml={data.html} />
|
||||||
|
) : (
|
||||||
|
<pre className="whitespace-pre-wrap font-sans text-sm text-slate-800 leading-relaxed">
|
||||||
|
{data.text}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user