diff --git a/crates/server/src/rest/api/message.rs b/crates/server/src/rest/api/message.rs index e4f4477..b717d99 100644 --- a/crates/server/src/rest/api/message.rs +++ b/crates/server/src/rest/api/message.rs @@ -280,6 +280,33 @@ impl MessageApi { Ok(attachment) } + /// Returns raw attachment content for in-browser preview with + /// `Content-Disposition: inline` and the correct MIME type. + #[oai( + path = "/preview-attachment/:account_id/:envelope_id", + method = "get", + operation_id = "preview_attachment" + )] + async fn preview_attachment( + &self, + /// The ID of the account. + account_id: Path, + /// The ID of the message containing the attachment. + envelope_id: Path, + /// The content_hash of the attachment to preview. + content_hash: Query, + context: WrappedContext, + ) -> ApiResult> { + let account_id = account_id.0; + let envelope_id = envelope_id.0.trim().to_string(); + AccountModel::check_account_exists(account_id)?; + context.require_permission(Some(account_id), Permission::DATA_READ)?; + let content_hash = content_hash.0.trim(); + let reader = retrieve_attachment_content(account_id, envelope_id, content_hash)?; + let body = Body::from_async_read(reader); + Ok(Attachment::new(body).attachment_type(AttachmentType::Inline)) + } + /// Downloads an attachment from within a nested email (EML file). #[oai( path = "/download-nested-attachment/:account_id/:envelope_id", diff --git a/web/src/api/mailbox/envelope/api.ts b/web/src/api/mailbox/envelope/api.ts index a5a3dab..02cee15 100644 --- a/web/src/api/mailbox/envelope/api.ts +++ b/web/src/api/mailbox/envelope/api.ts @@ -41,6 +41,18 @@ export const download_attachment = async (accountId: number, id: string, content saveAs(blob, fileName); }; +/** Fetch raw attachment content for in-browser preview (Content-Disposition: inline). */ +export const preview_attachment = async (accountId: number, id: string, content_hash: string) => { + const response = await axiosInstance.get( + `api/v1/preview-attachment/${accountId}/${id}`, + { + params: { content_hash }, + responseType: 'blob', + } + ); + return response.data as Blob; +}; + export const download_nested_attachment = async (accountId: number, id: string, content_hash: string, nested_content_hash: string, fileName: string) => { const response = await axiosInstance.get(`api/v1/download-nested-attachment/${accountId}/${id}?content_hash=${content_hash}&nested_content_hash=${nested_content_hash}`, { responseType: 'blob' }); const blob = new Blob([response.data]); diff --git a/web/src/features/attachment/attachment-preview.tsx b/web/src/features/attachment/attachment-preview.tsx new file mode 100644 index 0000000..5d13f0c --- /dev/null +++ b/web/src/features/attachment/attachment-preview.tsx @@ -0,0 +1,250 @@ +// +// 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, useMemo, useState } from 'react'; +import { useMutation } from '@tanstack/react-query'; +import { Download, FileIcon, ZoomIn, ZoomOut, RotateCcw } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { Dialog, DialogContent } from '@/components/ui/dialog'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { Separator } from '@/components/ui/separator'; +import { Skeleton } from '@/components/ui/skeleton'; +import { toast } from '@/hooks/use-toast'; +import { useTranslation } from 'react-i18next'; + +import { preview_attachment, download_attachment } from '@/api/mailbox/envelope/api'; +import { getFileConfig } from './mail-message-view'; + +const PREVIEWABLE_IMAGE = /^image\/(png|jpeg|gif|webp|svg\+xml)$/; +const PREVIEWABLE_TEXT = /^(text\/(plain|csv|html|xml|css|javascript|markdown)|application\/(json|xml|javascript|x-httpd-php|x-sh|x-perl|x-python|x-ruby))$/; + +interface AttachmentPreviewProps { + open: boolean; + onOpenChange: (open: boolean) => void; + accountId: number; + envelopeId: string; + contentHash: string; + contentType: string; + fileName: string; +} + +function isImagePreview(contentType: string) { + return PREVIEWABLE_IMAGE.test(contentType); +} + +function isPdfPreview(contentType: string) { + return contentType === 'application/pdf'; +} + +function isTextPreview(contentType: string) { + return PREVIEWABLE_TEXT.test(contentType); +} + +export default function AttachmentPreview({ + open, + onOpenChange, + accountId, + envelopeId, + contentHash, + contentType, + fileName, +}: AttachmentPreviewProps) { + const { t } = useTranslation(); + const [blobUrl, setBlobUrl] = useState(null); + const [textContent, setTextContent] = useState(null); + const [imageZoom, setImageZoom] = useState(1); + + const previewMutation = useMutation({ + mutationFn: () => preview_attachment(accountId, envelopeId, contentHash), + onSuccess: (blob) => { + if (isTextPreview(contentType)) { + blob.text().then(setTextContent); + } else { + // Re-wrap with the actual MIME type so browsers render PDFs/images inline + // instead of triggering a download (the HTTP response uses application/octet-stream). + const typedBlob = new Blob([blob], { type: contentType }); + setBlobUrl(URL.createObjectURL(typedBlob)); + } + }, + onError: (error: any) => { + toast({ + title: t('attachment_preview.failedToLoad'), + description: error.message, + variant: 'destructive', + }); + }, + }); + + useEffect(() => { + if (open) { + setBlobUrl(null); + setTextContent(null); + setImageZoom(1); + previewMutation.mutate(); + } + }, [open]); + + useEffect(() => { + return () => { + if (blobUrl) URL.revokeObjectURL(blobUrl); + }; + }, [blobUrl]); + + const handleDownload = () => { + download_attachment(accountId, envelopeId, contentHash, fileName); + }; + + const { icon, color } = useMemo(() => getFileConfig(contentType), [contentType]); + + const isImage = isImagePreview(contentType); + const isPdf = isPdfPreview(contentType); + const isText = isTextPreview(contentType); + + return ( + + { + // Don't close when interacting with the PDF viewer toolbar + if (isPdf) e.preventDefault(); + }} + > + {/* Toolbar */} +
+
+
{icon}
+ + {fileName} + +
+
+ {isImage && blobUrl && ( + <> + + + + + {t('attachment_preview.zoomIn')} + + + + + + {t('attachment_preview.zoomOut')} + + + + + + {t('attachment_preview.resetZoom')} + + + + )} + + + + + {t('attachment.download')} + +
+
+ + {/* Preview body */} +
+ {previewMutation.isPending ? ( +
+
+ + + +
+
+ ) : isImage && blobUrl ? ( +
+ {fileName} +
+ ) : isPdf && blobUrl ? ( +