// // 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 . 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 (
{title}:
    {lines.slice(0, expanded ? lines.length : 3).map((ref, i) => (
  • {ref}
  • ))}
{lines.length > 3 && ( )}
); }; export const getFileConfig = (mimeType: string) => { const type = mimeType.toLowerCase(); if (type.includes('pdf')) { return { icon: , color: 'text-red-600 bg-red-50 border-red-100' }; } if (type.includes('image/')) { return { icon: , color: 'text-blue-600 bg-blue-50 border-blue-100' }; } if (type.includes('audio/')) { return { icon: , color: 'text-purple-600 bg-purple-50 border-purple-100' }; } if (type.includes('video/')) { return { icon: , color: 'text-indigo-600 bg-indigo-50 border-indigo-100' }; } if (type.includes('spreadsheet') || type.includes('excel') || type.includes('csv')) { return { icon: , color: 'text-green-600 bg-green-50 border-green-100' }; } if (type.includes('zip') || type.includes('compressed') || type.includes('archive')) { return { icon: , color: 'text-orange-600 bg-orange-50 border-orange-100' }; } if (type.includes('text/') || type.includes('json') || type.includes('javascript')) { return { icon: , color: 'text-slate-600 bg-slate-50 border-slate-100' }; } return { icon: , 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(null); const [contentType, setContentType] = useState<'Plain' | 'Html' | null>(null); const [attachments, setAttachments] = useState(null); const [loading, setLoading] = useState(true); const [downloadingAttachmentFileName, setDownloadingAttachmentFileName] = useState(null); const [nestedEmlFile, setNestedEmlFile] = useState(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 (
{showHeader &&
{t('mail.account')}: {getEmailById(envelope.account_id)}
{t('mail.id')}: {envelope.id}
{envelope.from && (
{t('mail.from')}: {envelope.from}
)} {envelope.to && envelope.to.length > 0 && } {envelope.cc && envelope.cc.length > 0 && } {envelope.bcc && envelope.bcc.length > 0 && } {envelope.subject && (
{t('mail.subject')}: {envelope.subject}
)} {envelope.internal_date && (
{t('mail.date')}: {formatTimestamp(envelope.internal_date)}
)}
} {showActions && ( <>
{t('mail.delete')} {t('mail.download')} {t('mail.viewThread')} {t('restore_message.restore_to_imap', 'Restore Mail')}
)} {showAttachments && } {showAttachments && (
{loading ? ( ) : attachments && attachments.length > 0 ? ( (() => { const nonInline = attachments.filter((a) => !a.inline); return nonInline.length > 0 ? (
{nonInline.map((attachment, i) => { const { icon, color } = getFileConfig(attachment.file_type); const is_message = attachment.is_message; return
{icon}
{attachment.file_type.split('/').pop()}
{is_message && ( {t('mail.viewNestedEmail', 'View Embedded Email')} )} {formatBytes(attachment.size)} {downloadingAttachmentFileName === attachment.filename ? ( ) : ( { setDownloadingAttachmentFileName(attachment.filename); downloadAttachmentMutation.mutate({ content_hash: attachment.content_hash }); }} /> )}
})}
) : ( {t('mail.onlyNonInlineAttachments')} ); })() ) : ( {t('mail.noAttachments')} )}
)} {showAttachments && } {hasRemoteContent && (
{blockRemote ? ( {t('mail.remoteBlocked', 'To protect your privacy, Bichon has blocked remote content in this message.')} ) : ( {t('mail.remoteShown', 'Remote content is now shown.')} )}
{blockRemote ? t('mail.showRemoteContent', 'Show remote content') : t('mail.blockRemoteAgain', 'Block again')}
)}
{loading ? (
loading...
) : content ? (
{contentType === 'Html' ? ( ) : (
{content}
)}
) : (
No content available
)}
!open && setNestedEmlFile(null)} accountId={envelope.account_id} envelopeId={envelope.id} fileName={nestedEmlFile?.filename || ''} content_hash={nestedEmlFile?.content_hash} /> {previewAttachment?.attachments?.[previewAttachment.index] && ( !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} /> )}
); } 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}`; }