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
+28 -15
View File
@@ -21,18 +21,18 @@ import { EmailEnvelope, PaginatedResponse } from "@/api";
import axiosInstance from "@/api/axiosInstance";
import { saveAs } from 'file-saver';
export const list_messages = async (accountId: number, mailbox_id: number, page: number, page_size: number) => {
const params = new URLSearchParams({
mailbox_id: String(mailbox_id),
page: String(page),
page_size: String(page_size),
});
// export const list_messages = async (accountId: number, mailbox_id: number, page: number, page_size: number) => {
// const params = new URLSearchParams({
// mailbox_id: String(mailbox_id),
// page: String(page),
// page_size: String(page_size),
// });
const response = await axiosInstance.get<PaginatedResponse<EmailEnvelope>>(
`api/v1/list-messages/${accountId}?${params.toString()}`
);
return response.data;
};
// const response = await axiosInstance.get<PaginatedResponse<EmailEnvelope>>(
// `api/v1/list-messages/${accountId}?${params.toString()}`
// );
// return response.data;
// };
export const get_thread_messages = async (accountId: number, thread_id: number, page: number, page_size: number) => {
const params = new URLSearchParams({
@@ -53,7 +53,11 @@ export const download_attachment = async (accountId: number, id: number, attachm
saveAs(blob, attachmentFileName);
};
export const download_nested_attachment = async (accountId: number, id: number, attachmentFileName: string, nestedAttachmentFileName: string) => {
const response = await axiosInstance.get(`api/v1/download-nested-attachment/${accountId}/${id}?name=${attachmentFileName}&nested_name=${nestedAttachmentFileName}`, { responseType: 'blob' });
const blob = new Blob([response.data]);
saveAs(blob, nestedAttachmentFileName);
};
export interface AttachmentInfo {
/** MIME content type of the attachment (e.g., `image/png`, `application/pdf`). */
file_type: string;
@@ -66,13 +70,19 @@ export interface AttachmentInfo {
/** Size of the attachment in bytes. */
size: number;
}
export interface MessageContentResponse {
text?: string;
html?: string;
attachments?: AttachmentInfo[]
}
export interface NestedMessageContentResponse {
text?: string;
html?: string;
attachments?: AttachmentInfo[];
envelope: EmailEnvelope;
}
export const getContent = (messageContent: MessageContentResponse): string | null => {
if (messageContent.html) {
return messageContent.html;
@@ -87,6 +97,11 @@ export const load_message = async (accountId: number, id: number) => {
return response.data;
};
export const load_nested_message = async (accountId: number, id: number, attachmentFileName: string) => {
const response = await axiosInstance.get<NestedMessageContentResponse>(`api/v1/nested-message-content/${accountId}/${id}?name=${attachmentFileName}`);
return response.data;
};
export const delete_messages = async (payload: Record<string, number[]>) => {
const response = await axiosInstance.post("api/v1/delete-messages", payload);
return response.data;
@@ -98,8 +113,6 @@ export const download_message = async (accountId: number, id: number) => {
saveAs(blob, `${id}.eml`);
};
export const restore_message = async (accountId: number, messageIds: number[]) => {
const response = await axiosInstance.post(`api/v1/restore-messages/${accountId}`, {
message_ids: messageIds,
+36 -7
View File
@@ -39,6 +39,7 @@ import { useSearchContext } from './context';
import { MailThreadDialog } from './thread-dialog';
import useMinimalAccountList from '@/hooks/use-minimal-account-list';
import { useTranslation } from 'react-i18next';
import { NestedEmailDialog } from './nested-email-dialog';
interface MailMessageViewProps {
@@ -84,7 +85,7 @@ const Multilines: React.FC<{ title: string; lines: string[] }> = ({ title, lines
);
};
const getFileConfig = (mimeType: string) => {
export const getFileConfig = (mimeType: string) => {
const type = mimeType.toLowerCase();
if (type.includes('pdf')) {
return { icon: <FileText className="h-4 w-4" />, color: 'text-red-600 bg-red-50 border-red-100' };
@@ -120,13 +121,12 @@ export function MailMessageView({
}: MailMessageViewProps) {
const { t } = useTranslation()
const { setToDelete, setOpen, setSelected } = useSearchContext();
const [content, setContent] = useState<string | null>(null);
const [contentType, setContentType] = useState<'Plain' | 'Html' | null>(null);
const [attachments, setAttachments] = useState<AttachmentInfo[] | null>(null);
const [loading, setLoading] = useState(true);
const [downloadingAttachmentFileName, setDownloadingAttachmentFileName] = useState<string | null>(null);
const [nestedEmlFile, setNestedEmlFile] = useState<string | null>(null);
const { getEmailById } = useMinimalAccountList();
const [threadOpen, setThreadOpen] = useState(false);
@@ -168,6 +168,10 @@ export function MailMessageView({
}, [envelope.id]);
const handleViewNestedEml = (filename: string) => {
setNestedEmlFile(filename);
};
const toggleToDelete = (accountId: number, mailId: number) => {
setToDelete(prev => {
const next = new Map(prev);
@@ -193,6 +197,7 @@ export function MailMessageView({
}
}
const downloadEmlFile = async () => {
try {
toast({ title: t('mail.downloadStarted'), description: t('mail.isBeingDownloaded', { id: envelope.id }) });
@@ -312,6 +317,8 @@ export function MailMessageView({
<div className="space-y-2">
{nonInline.map((attachment, i) => {
const { icon, color } = getFileConfig(attachment.file_type);
const isNestedEmail = attachment.file_type.toLowerCase() === 'message/rfc822';
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={`flex-shrink-0 ${color}`}>
@@ -324,12 +331,27 @@ export function MailMessageView({
>
{attachment.filename}
</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">
{attachment.file_type.split('/').pop()?.toUpperCase()}
<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()}
</span>
</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">
{formatBytes(attachment.size)}
</span>
@@ -380,11 +402,18 @@ export function MailMessageView({
</div>
<MailThreadDialog open={threadOpen} onOpenChange={setThreadOpen} />
<NestedEmailDialog
open={!!nestedEmlFile}
onOpenChange={(open: boolean) => !open && setNestedEmlFile(null)}
accountId={envelope.account_id}
envelopeId={envelope.id}
fileName={nestedEmlFile || ''}
/>
</div>
);
}
function formatTimestamp(milliseconds: number): string {
export function formatTimestamp(milliseconds: number): string {
const date = new Date(milliseconds);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
@@ -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>
);
}