mirror of
https://github.com/rustmailer/bichon.git
synced 2026-08-03 07:48:34 +02:00
485 lines
20 KiB
TypeScript
485 lines
20 KiB
TypeScript
//
|
|
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
|
|
//
|
|
// This file is part of the Bichon Email Archiving Project
|
|
//
|
|
// This program is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU Affero General Public License as published by
|
|
// the Free Software Foundation, either version 3 of the License, or
|
|
// (at your option) any later version.
|
|
//
|
|
// This program is distributed in the hope that it will be useful,
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU Affero General Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU Affero General Public License
|
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
|
|
|
|
import { useEffect, useState } from 'react';
|
|
import { useMutation } from '@tanstack/react-query';
|
|
import { Loader, Download, Trash2, MessageSquareMore, FileText, FileImage, FileVideo, FileArchive, FileSpreadsheet, FileCode, FileIcon, FileAudio, Upload, ShieldCheck } from 'lucide-react';
|
|
|
|
import { Button } from '@/components/ui/button';
|
|
import { Separator } from '@/components/ui/separator';
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
|
import { toast } from '@/hooks/use-toast';
|
|
import { formatBytes } from '@/lib/utils';
|
|
import EmailIframe from '@/components/mail-iframe';
|
|
import {
|
|
AttachmentInfo,
|
|
download_attachment,
|
|
download_message,
|
|
getContent,
|
|
load_message,
|
|
} from '@/api/mailbox/envelope/api';
|
|
import { AxiosError } from 'axios';
|
|
import { useAttachmentContext } 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';
|
|
import AttachmentPreview, { type PreviewAttachment } from './attachment-preview';
|
|
import { EmailEnvelope } from '@/api';
|
|
|
|
|
|
interface MailMessageViewProps {
|
|
envelope: EmailEnvelope;
|
|
showActions?: boolean;
|
|
showHeader?: boolean;
|
|
showAttachments?: boolean;
|
|
}
|
|
|
|
const Multilines: React.FC<{ title: string; lines: string[] }> = ({ title, lines }) => {
|
|
const { t } = useTranslation()
|
|
const [expanded, setExpanded] = useState(false);
|
|
return (
|
|
<div className="text-xs">
|
|
<div className="flex items-start space-x-2">
|
|
<span className="font-medium text-gray-400 whitespace-nowrap">{title}:</span>
|
|
<div className="flex-1">
|
|
<ul className="list-disc list-inside">
|
|
{lines.slice(0, expanded ? lines.length : 3).map((ref, i) => (
|
|
<li key={i} className="line-clamp-1">{ref}</li>
|
|
))}
|
|
</ul>
|
|
{lines.length > 3 && (
|
|
<button
|
|
className="text-blue-500 hover:underline text-xs"
|
|
onClick={() => setExpanded(!expanded)}
|
|
>
|
|
{expanded ? t('common.showLess') : t('common.showMore')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
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' };
|
|
}
|
|
if (type.includes('image/')) {
|
|
return { icon: <FileImage className="h-4 w-4" />, color: 'text-blue-600 bg-blue-50 border-blue-100' };
|
|
}
|
|
if (type.includes('audio/')) {
|
|
return { icon: <FileAudio className="h-4 w-4" />, color: 'text-purple-600 bg-purple-50 border-purple-100' };
|
|
}
|
|
|
|
if (type.includes('video/')) {
|
|
return { icon: <FileVideo className="h-4 w-4" />, color: 'text-indigo-600 bg-indigo-50 border-indigo-100' };
|
|
}
|
|
if (type.includes('spreadsheet') || type.includes('excel') || type.includes('csv')) {
|
|
return { icon: <FileSpreadsheet className="h-4 w-4" />, color: 'text-green-600 bg-green-50 border-green-100' };
|
|
}
|
|
if (type.includes('zip') || type.includes('compressed') || type.includes('archive')) {
|
|
return { icon: <FileArchive className="h-4 w-4" />, color: 'text-orange-600 bg-orange-50 border-orange-100' };
|
|
}
|
|
if (type.includes('text/') || type.includes('json') || type.includes('javascript')) {
|
|
return { icon: <FileCode className="h-4 w-4" />, color: 'text-slate-600 bg-slate-50 border-slate-100' };
|
|
}
|
|
|
|
return { icon: <FileIcon className="h-4 w-4" />, color: 'text-gray-600 bg-gray-50 border-gray-100' };
|
|
};
|
|
|
|
export function MailMessageView({
|
|
envelope,
|
|
showActions = true,
|
|
showAttachments = true,
|
|
showHeader = true
|
|
}: MailMessageViewProps) {
|
|
const { t } = useTranslation()
|
|
const { setToDelete, setOpen, setSelected } = useAttachmentContext();
|
|
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<AttachmentInfo | null>(null);
|
|
const { getEmailById } = useMinimalAccountList();
|
|
const [threadOpen, setThreadOpen] = useState(false);
|
|
const [blockRemote, setBlockRemote] = useState(true);
|
|
const [hasRemoteContent, setHasRemoteContent] = useState(false);
|
|
const [previewAttachment, setPreviewAttachment] = useState<{ attachments: PreviewAttachment[]; index: number } | null>(null);
|
|
|
|
const toggleBlockRemote = () => {
|
|
setBlockRemote((prev) => !prev);
|
|
};
|
|
|
|
const downloadAttachmentMutation = useMutation({
|
|
mutationFn: ({ content_hash }: { content_hash: string }) =>
|
|
download_attachment(envelope.account_id, envelope.id, content_hash, downloadingAttachmentFileName!),
|
|
onSuccess: () => setDownloadingAttachmentFileName(null),
|
|
onError: (error: any) => {
|
|
setDownloadingAttachmentFileName(null);
|
|
toast({
|
|
title: t('mail.failedToDownloadFile'),
|
|
description: error.message,
|
|
variant: 'destructive',
|
|
});
|
|
},
|
|
});
|
|
|
|
const loadMessageMutation = useMutation({
|
|
mutationFn: () => load_message(envelope.account_id, envelope.id, blockRemote),
|
|
onSuccess: (data) => {
|
|
setLoading(false);
|
|
setContent(getContent(data));
|
|
if (data.attachments) setAttachments(data.attachments);
|
|
setContentType(data.html ? 'Html' : 'Plain');
|
|
setHasRemoteContent(!!data.has_remote_content);
|
|
},
|
|
onError: (error: any) => {
|
|
setLoading(false);
|
|
toast({
|
|
title: t('mail.failedToLoadEmail'),
|
|
description: error.message,
|
|
variant: 'destructive',
|
|
});
|
|
},
|
|
});
|
|
|
|
useEffect(() => {
|
|
setBlockRemote(true);
|
|
}, [envelope.id]);
|
|
|
|
useEffect(() => {
|
|
setLoading(true);
|
|
loadMessageMutation.mutate();
|
|
}, [envelope.id, blockRemote]);
|
|
|
|
|
|
const handleViewNestedEml = (attachment: AttachmentInfo) => {
|
|
setNestedEmlFile(attachment);
|
|
};
|
|
|
|
const toggleToDelete = (accountId: number, mailId: string) => {
|
|
setToDelete(prev => {
|
|
const next = new Map(prev);
|
|
const set = new Set(next.get(accountId) || []);
|
|
|
|
if (set.has(mailId)) {
|
|
set.delete(mailId);
|
|
if (set.size === 0) next.delete(accountId);
|
|
else next.set(accountId, set);
|
|
} else {
|
|
set.add(mailId);
|
|
next.set(accountId, set);
|
|
}
|
|
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const handleDelete = () => {
|
|
if (envelope) {
|
|
toggleToDelete(envelope.account_id, envelope.id)
|
|
setOpen("delete")
|
|
}
|
|
}
|
|
|
|
|
|
const downloadEmlFile = async () => {
|
|
try {
|
|
toast({ title: t('mail.downloadStarted'), description: t('mail.isBeingDownloaded', { id: envelope.id }) });
|
|
await download_message(envelope.account_id, envelope.id);
|
|
toast({ title: t('mail.downloadComplete'), description: t('mail.downloaded', { id: envelope.id }) });
|
|
} catch (error) {
|
|
let msg = t('mail.downloadFailed');
|
|
if (error instanceof AxiosError) {
|
|
msg = error.response?.data?.message || error.response?.data?.error || error.message;
|
|
if (error.response?.status) msg = `${error.response.status}: ${msg}`;
|
|
} else if (error instanceof Error) {
|
|
msg = error.message;
|
|
}
|
|
toast({ title: t('mail.downloadFailed'), description: msg, variant: 'destructive' });
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="flex flex-col">
|
|
{showHeader && <div className="grid gap-1 text-xs">
|
|
<div className="flex space-x-2">
|
|
<span className="font-medium text-gray-400">{t('mail.account')}:</span>
|
|
<span>{getEmailById(envelope.account_id)}</span>
|
|
</div>
|
|
<div className="flex space-x-2">
|
|
<span className="font-medium text-gray-400">{t('mail.id')}:</span>
|
|
<span>{envelope.id}</span>
|
|
</div>
|
|
{envelope.from && (
|
|
<div className="flex space-x-2">
|
|
<span className="font-medium text-gray-400">{t('mail.from')}:</span>
|
|
<span>{envelope.from}</span>
|
|
</div>
|
|
)}
|
|
{envelope.to && envelope.to.length > 0 && <Multilines title={t('mail.to')} lines={envelope.to} />}
|
|
{envelope.cc && envelope.cc.length > 0 && <Multilines title={t('mail.cc')} lines={envelope.cc} />}
|
|
{envelope.bcc && envelope.bcc.length > 0 && <Multilines title={t('mail.bcc')} lines={envelope.bcc} />}
|
|
{envelope.subject && (
|
|
<div className="flex space-x-2">
|
|
<span className="font-medium text-gray-400">{t('mail.subject')}:</span>
|
|
<span>{envelope.subject}</span>
|
|
</div>
|
|
)}
|
|
{envelope.internal_date && (
|
|
<div className="flex space-x-2">
|
|
<span className="font-medium text-gray-400">{t('mail.date')}:</span>
|
|
<span>{formatTimestamp(envelope.internal_date)}</span>
|
|
</div>
|
|
)}
|
|
</div>}
|
|
|
|
{showActions && (
|
|
<>
|
|
<div className="flex items-center mt-2 space-x-2">
|
|
<Separator orientation="horizontal" className="flex-1 bg-border" />
|
|
</div>
|
|
<div className="flex items-center justify-start gap-3 text-xs text-gray-500">
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button variant="ghost" size="icon" onClick={handleDelete} className="hover:text-destructive">
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>{t('mail.delete')}</TooltipContent>
|
|
</Tooltip>
|
|
<Separator orientation="vertical" className="h-5" />
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button variant="ghost" size="icon" onClick={downloadEmlFile}>
|
|
<Download className="h-4 w-4" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>{t('mail.download')}</TooltipContent>
|
|
</Tooltip>
|
|
<Separator orientation="vertical" className="h-5" />
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => setThreadOpen(true)}
|
|
>
|
|
<MessageSquareMore className="h-4 w-4" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>{t('mail.viewThread')}</TooltipContent>
|
|
</Tooltip>
|
|
<Separator orientation="vertical" className="h-5" />
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => {
|
|
setSelected(new Map())
|
|
setOpen('restore')
|
|
}}
|
|
>
|
|
<Upload className="h-4 w-4" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>{t('restore_message.restore_to_imap', 'Restore Mail')}</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
</>
|
|
)}
|
|
{showAttachments && <Separator className="my-2" />}
|
|
{showAttachments && (
|
|
<div className="mb-2">
|
|
{loading ? (
|
|
<span className="text-gray-500 text-xs" />
|
|
) : attachments && attachments.length > 0 ? (
|
|
(() => {
|
|
const nonInline = attachments.filter((a) => !a.inline);
|
|
|
|
return nonInline.length > 0 ? (
|
|
<div className="space-y-2">
|
|
{nonInline.map((attachment, i) => {
|
|
const { icon, color } = getFileConfig(attachment.file_type);
|
|
const is_message = attachment.is_message;
|
|
|
|
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}`}>
|
|
{icon}
|
|
</div>
|
|
<div className="flex items-center justify-between min-w-0 flex-1 gap-2">
|
|
<button
|
|
type="button"
|
|
className="truncate text-xs font-medium text-foreground/90 cursor-pointer hover:text-primary hover:underline transition-colors text-left"
|
|
title={attachment.filename}
|
|
onClick={() =>
|
|
setPreviewAttachment({
|
|
attachments: nonInline.map((a) => ({
|
|
content_hash: a.content_hash,
|
|
file_type: a.file_type,
|
|
filename: a.filename,
|
|
})),
|
|
index: i,
|
|
})
|
|
}
|
|
>
|
|
{attachment.filename}
|
|
</button>
|
|
<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-3 ml-auto pr-1">
|
|
{is_message && (
|
|
<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);
|
|
}}
|
|
>
|
|
<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>
|
|
{downloadingAttachmentFileName === attachment.filename ? (
|
|
<Loader className="w-5 h-5 animate-spin" />
|
|
) : (
|
|
<Download
|
|
className="w-5 h-5 cursor-pointer"
|
|
onClick={() => {
|
|
setDownloadingAttachmentFileName(attachment.filename);
|
|
downloadAttachmentMutation.mutate({ content_hash: attachment.content_hash });
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
})}
|
|
</div>
|
|
) : (
|
|
<span className="text-gray-500 text-xs italic">
|
|
{t('mail.onlyNonInlineAttachments')}
|
|
</span>
|
|
);
|
|
})()
|
|
) : (
|
|
<span className="text-gray-500 text-xs">{t('mail.noAttachments')}</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
{showAttachments && <Separator className="mb-2" />}
|
|
{hasRemoteContent && (
|
|
<div className="flex items-center justify-between bg-muted border px-3 py-1.5 mb-3 text-xs">
|
|
<div className="flex items-center gap-1.5 min-w-0">
|
|
<ShieldCheck className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
|
{blockRemote ? (
|
|
<span className="text-muted-foreground truncate">
|
|
{t('mail.remoteBlocked', 'To protect your privacy, Bichon has blocked remote content in this message.')}
|
|
</span>
|
|
) : (
|
|
<span className="text-muted-foreground truncate">
|
|
{t('mail.remoteShown', 'Remote content is now shown.')}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<span
|
|
className="underline cursor-pointer hover:no-underline text-muted-foreground text-[11px] font-medium shrink-0 ml-2 select-none"
|
|
onClick={toggleBlockRemote}
|
|
>
|
|
{blockRemote
|
|
? t('mail.showRemoteContent', 'Show remote content')
|
|
: t('mail.blockRemoteAgain', 'Block again')}
|
|
</span>
|
|
</div>
|
|
)}
|
|
<div>
|
|
{loading ? (
|
|
<div className="flex justify-center items-center py-8">
|
|
<Loader className="w-6 h-6 animate-spin" />
|
|
<span className="ml-2 text-sm text-muted-foreground">loading...</span>
|
|
</div>
|
|
) : content ? (
|
|
<div className="bg-gray-100 rounded-lg border border-gray-300 p-4">
|
|
{contentType === 'Html' ? (
|
|
<EmailIframe emailHtml={content} />
|
|
) : (
|
|
<pre className="whitespace-pre-wrap text-gray-800 text-sm font-sans">{content}</pre>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="text-center text-muted-foreground text-sm">No content available</div>
|
|
)}
|
|
</div>
|
|
|
|
<MailThreadDialog open={threadOpen} onOpenChange={setThreadOpen} currentEnvelope={envelope} />
|
|
<NestedEmailDialog
|
|
open={!!nestedEmlFile}
|
|
onOpenChange={(open: boolean) => !open && setNestedEmlFile(null)}
|
|
accountId={envelope.account_id}
|
|
envelopeId={envelope.id}
|
|
fileName={nestedEmlFile?.filename || ''}
|
|
content_hash={nestedEmlFile?.content_hash}
|
|
/>
|
|
{previewAttachment?.attachments?.[previewAttachment.index] && (
|
|
<AttachmentPreview
|
|
open={!!previewAttachment}
|
|
onOpenChange={(open) => !open && setPreviewAttachment(null)}
|
|
accountId={envelope.account_id}
|
|
envelopeId={envelope.id}
|
|
contentHash={previewAttachment.attachments[previewAttachment.index].content_hash}
|
|
contentType={previewAttachment.attachments[previewAttachment.index].file_type}
|
|
fileName={previewAttachment.attachments[previewAttachment.index].filename}
|
|
attachments={previewAttachment.attachments}
|
|
attachmentIndex={previewAttachment.index}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function formatTimestamp(milliseconds: number): string {
|
|
const date = new Date(milliseconds);
|
|
const year = date.getFullYear();
|
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
const day = String(date.getDate()).padStart(2, '0');
|
|
const hours = String(date.getHours()).padStart(2, '0');
|
|
const minutes = String(date.getMinutes()).padStart(2, '0');
|
|
const seconds = String(date.getSeconds()).padStart(2, '0');
|
|
const timezoneOffset = date.getTimezoneOffset();
|
|
const offsetSign = timezoneOffset > 0 ? '-' : '+';
|
|
const offsetHours = String(Math.floor(Math.abs(timezoneOffset) / 60)).padStart(2, '0');
|
|
const offsetMinutes = String(Math.abs(timezoneOffset) % 60).padStart(2, '0');
|
|
return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}${offsetSign}${offsetHours}:${offsetMinutes}`;
|
|
} |