//
// 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 && (
setExpanded(!expanded)}
>
{expanded ? t('common.showLess') : t('common.showMore')}
)}
);
};
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')}
setThreadOpen(true)}
>
{t('mail.viewThread')}
{
setSelected(new Map())
setOpen('restore')
}}
>
{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}
setPreviewAttachment({
attachments: nonInline.map((a) => ({
content_hash: a.content_hash,
file_type: a.file_type,
filename: a.filename,
})),
index: i,
})
}
>
{attachment.filename}
{attachment.file_type.split('/').pop()}
{is_message && (
{
handleViewNestedEml(attachment);
}}
>
{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}`;
}