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
+1
View File
@@ -1,3 +1,4 @@
/target /target
.vscode .vscode
.idea .idea
config.toml
+1 -1
View File
@@ -1,2 +1,2 @@
base_url = "http://localhost:15630" base_url = "http://localhost:15630"
api_token = "lZHmfpH1CRr9XsRiOGd1RnOr" api_token = "2g2viN7zi4fKU1YgY50aTjl4"
+75
View File
@@ -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>>,
+85 -14
View File
@@ -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))?;
} }
+84
View File
@@ -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,
})
}
+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::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",
+28 -15
View File
@@ -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,
+36 -7
View File
@@ -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>
);
}