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:
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user