feat: add in-browser attachment preview for images, PDFs, and text files #303

This commit is contained in:
rustmailer
2026-06-25 04:51:05 +08:00
parent cad447275f
commit db36272bae
24 changed files with 533 additions and 13 deletions
+12
View File
@@ -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]);
@@ -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 <http://www.gnu.org/licenses/>.
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<string | null>(null);
const [textContent, setTextContent] = useState<string | null>(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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="w-[calc(100vw-2rem)] max-w-4xl h-[85vh] flex flex-col p-0 gap-0"
onInteractOutside={(e) => {
// Don't close when interacting with the PDF viewer toolbar
if (isPdf) e.preventDefault();
}}
>
{/* Toolbar */}
<div className="flex items-center justify-between px-4 py-2 border-b shrink-0">
<div className="flex items-center gap-2 min-w-0">
<div className={color}>{icon}</div>
<span className="text-sm font-medium truncate max-w-[400px]">
{fileName}
</span>
</div>
<div className="flex items-center gap-1 pr-16">
{isImage && blobUrl && (
<>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setImageZoom((z) => Math.min(z + 0.25, 3))}
>
<ZoomIn className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{t('attachment_preview.zoomIn')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setImageZoom((z) => Math.max(z - 0.25, 0.25))}
>
<ZoomOut className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{t('attachment_preview.zoomOut')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setImageZoom(1)}
>
<RotateCcw className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{t('attachment_preview.resetZoom')}</TooltipContent>
</Tooltip>
<Separator orientation="vertical" className="h-5 mx-1" />
</>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={handleDownload}
>
<Download className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{t('attachment.download')}</TooltipContent>
</Tooltip>
</div>
</div>
{/* Preview body */}
<div className="flex-1 min-h-0 bg-muted/30">
{previewMutation.isPending ? (
<div className="flex items-center justify-center h-full">
<div className="flex flex-col items-center gap-3">
<Skeleton className="w-64 h-4" />
<Skeleton className="w-48 h-4" />
<Skeleton className="w-56 h-4" />
</div>
</div>
) : isImage && blobUrl ? (
<div className="w-full h-full overflow-auto flex items-center justify-center">
<img
src={blobUrl}
alt={fileName}
className="max-w-full"
style={{
transform: `scale(${imageZoom})`,
transformOrigin: 'center center',
}}
/>
</div>
) : isPdf && blobUrl ? (
<iframe
src={blobUrl}
className="w-full h-full border-0"
title={fileName}
/>
) : isText && textContent !== null ? (
<pre className="w-full h-full overflow-auto whitespace-pre-wrap text-sm font-mono p-6">
{textContent}
</pre>
) : !previewMutation.isPending ? (
<div className="flex items-center justify-center h-full">
<div className="flex flex-col items-center gap-4 text-muted-foreground">
<FileIcon className="h-16 w-16 opacity-30" />
<p className="text-sm">{t('attachment_preview.notAvailable')}</p>
<p className="text-xs text-center max-w-md">
{t('attachment_preview.notAvailableDesc', {
type: contentType || 'unknown',
})}
</p>
<Button variant="outline" size="sm" onClick={handleDownload}>
<Download className="h-4 w-4 mr-2" />
{t('attachment.download')}
</Button>
</div>
</div>
) : null}
</div>
</DialogContent>
</Dialog>
);
}
@@ -19,7 +19,7 @@
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 { Loader, Download, Trash2, MessageSquareMore, FileText, FileImage, FileVideo, FileArchive, FileSpreadsheet, FileCode, FileIcon, FileAudio, Upload, ShieldCheck, Eye } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
@@ -40,6 +40,7 @@ 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 from './attachment-preview';
import { EmailEnvelope } from '@/api';
@@ -123,6 +124,7 @@ export function MailMessageView({
const [threadOpen, setThreadOpen] = useState(false);
const [blockRemote, setBlockRemote] = useState(true);
const [hasRemoteContent, setHasRemoteContent] = useState(false);
const [previewAttachment, setPreviewAttachment] = useState<{ content_hash: string; file_type: string; filename: string } | null>(null);
const toggleBlockRemote = () => {
setBlockRemote((prev) => !prev);
@@ -328,12 +330,20 @@ export function MailMessageView({
{icon}
</div>
<div className="flex items-center justify-between min-w-0 flex-1 gap-2">
<span
className="truncate text-xs font-medium text-foreground/90"
<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({
content_hash: attachment.content_hash,
file_type: attachment.file_type,
filename: attachment.filename,
})
}
>
{attachment.filename}
</span>
</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>
@@ -360,11 +370,21 @@ export function MailMessageView({
<span className="text-gray-500 text-xs shrink-0">
{formatBytes(attachment.size)}
</span>
<Eye
className="w-5 h-5 cursor-pointer hover:text-primary transition-colors"
onClick={() =>
setPreviewAttachment({
content_hash: attachment.content_hash,
file_type: attachment.file_type,
filename: attachment.filename,
})
}
/>
{downloadingAttachmentFileName === attachment.filename ? (
<Loader className="w-4 h-4 animate-spin" />
<Loader className="w-5 h-5 animate-spin" />
) : (
<Download
className="w-4 h-4 cursor-pointer"
className="w-5 h-5 cursor-pointer"
onClick={() => {
setDownloadingAttachmentFileName(attachment.filename);
downloadAttachmentMutation.mutate({ content_hash: attachment.content_hash });
@@ -439,6 +459,17 @@ export function MailMessageView({
fileName={nestedEmlFile?.filename || ''}
content_hash={nestedEmlFile?.content_hash}
/>
{previewAttachment && (
<AttachmentPreview
open={!!previewAttachment}
onOpenChange={(open) => !open && setPreviewAttachment(null)}
accountId={envelope.account_id}
envelopeId={envelope.id}
contentHash={previewAttachment.content_hash}
contentType={previewAttachment.file_type}
fileName={previewAttachment.filename}
/>
)}
</div>
);
}
@@ -18,6 +18,7 @@
import { Row } from '@tanstack/react-table'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
@@ -28,12 +29,13 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { useTranslation } from 'react-i18next'
import { Copy, Download, MoreVertical } from 'lucide-react'
import { Copy, Download, Eye, MoreVertical } from 'lucide-react'
import { AttachmentModel } from '@/api/attachment/api'
import { useSearchAttachments } from '@/hooks/use-search-attachments'
import { useToast } from '@/hooks/use-toast'
import { useMutation } from '@tanstack/react-query'
import { download_attachment } from '@/api/mailbox/envelope/api'
import AttachmentPreview from '@/features/attachment/attachment-preview'
interface DataTableRowActionsProps {
row: Row<AttachmentModel>
@@ -43,6 +45,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
const { setFilter } = useSearchAttachments();
const { t } = useTranslation()
const { toast } = useToast();
const [previewOpen, setPreviewOpen] = useState(false);
const downloadMutation = useMutation({
mutationFn: (content_hash: string) =>
@@ -87,6 +90,18 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className='text-xs'
onClick={(e) => {
e.stopPropagation();
setPreviewOpen(true);
}}
>
{t('attachment.preview')}
<DropdownMenuShortcut>
<Eye size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem
className='text-xs'
disabled={downloadMutation.isPending}
@@ -105,6 +120,15 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<AttachmentPreview
open={previewOpen}
onOpenChange={setPreviewOpen}
accountId={row.original.account_id}
envelopeId={row.original.envelope_id}
contentHash={row.original.content_hash}
contentType={row.original.content_type}
fileName={row.original.name ?? row.original.id}
/>
</>
)
}
+37 -6
View File
@@ -19,7 +19,7 @@
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 { Loader, Download, Trash2, MessageSquareMore, FileText, FileImage, FileVideo, FileArchive, FileSpreadsheet, FileCode, FileIcon, FileAudio, Upload, ShieldCheck, Eye } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
@@ -40,6 +40,7 @@ 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 from '@/features/attachment/attachment-preview';
interface MailMessageViewProps {
@@ -131,6 +132,7 @@ export function MailMessageView({
const [threadOpen, setThreadOpen] = useState(false);
const [blockRemote, setBlockRemote] = useState(true);
const [hasRemoteContent, setHasRemoteContent] = useState(false);
const [previewAttachment, setPreviewAttachment] = useState<{ content_hash: string; file_type: string; filename: string } | null>(null);
const toggleBlockRemote = () => {
setBlockRemote((prev) => !prev);
@@ -336,12 +338,20 @@ export function MailMessageView({
{icon}
</div>
<div className="flex items-center justify-between min-w-0 flex-1 gap-2">
<span
className="truncate text-xs font-medium text-foreground/90"
<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({
content_hash: attachment.content_hash,
file_type: attachment.file_type,
filename: attachment.filename,
})
}
>
{attachment.filename}
</span>
</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>
@@ -368,11 +378,21 @@ export function MailMessageView({
<span className="text-gray-500 text-xs shrink-0">
{formatBytes(attachment.size)}
</span>
<Eye
className="w-5 h-5 cursor-pointer hover:text-primary transition-colors"
onClick={() =>
setPreviewAttachment({
content_hash: attachment.content_hash,
file_type: attachment.file_type,
filename: attachment.filename,
})
}
/>
{downloadingAttachmentFileName === attachment.filename ? (
<Loader className="w-4 h-4 animate-spin" />
<Loader className="w-5 h-5 animate-spin" />
) : (
<Download
className="w-4 h-4 cursor-pointer"
className="w-5 h-5 cursor-pointer"
onClick={() => {
setDownloadingAttachmentFileName(attachment.filename);
downloadAttachmentMutation.mutate({ content_hash: attachment.content_hash });
@@ -447,6 +467,17 @@ export function MailMessageView({
fileName={nestedEmlFile?.filename || ''}
content_hash={nestedEmlFile?.content_hash}
/>
{previewAttachment && (
<AttachmentPreview
open={!!previewAttachment}
onOpenChange={(open) => !open && setPreviewAttachment(null)}
accountId={envelope.account_id}
envelopeId={envelope.id}
contentHash={previewAttachment.content_hash}
contentType={previewAttachment.file_type}
fileName={previewAttachment.filename}
/>
)}
</div>
);
}
+8
View File
@@ -413,6 +413,14 @@
"subject": "الموضوع",
"viewEmbeddedEmail": "عرض البريد الإلكتروني المضمن"
},
"attachment_preview": {
"failedToLoad": "فشل تحميل المعاينة",
"notAvailable": "المعاينة غير متوفرة",
"notAvailableDesc": "لا يمكن معاينة هذا النوع من الملفات.",
"resetZoom": "إعادة تعيين التكبير",
"zoomIn": "تكبير",
"zoomOut": "تصغير"
},
"auth": {
"areYouSureYouWantToLogOut": "هل أنت متأكد أنك تريد تسجيل الخروج؟",
"invalidPassword": "كلمة مرور غير صالحة. الرجاء المحاولة مرة أخرى.",
+8
View File
@@ -413,6 +413,14 @@
"subject": "Emne",
"viewEmbeddedEmail": "Vis indlejret e-mail"
},
"attachment_preview": {
"failedToLoad": "Kunne ikke indlæse forhåndsvisning",
"notAvailable": "Forhåndsvisning ikke tilgængelig",
"notAvailableDesc": "Denne filtype kan ikke forhåndsvises.",
"resetZoom": "Nulstil zoom",
"zoomIn": "Zoom ind",
"zoomOut": "Zoom ud"
},
"auth": {
"areYouSureYouWantToLogOut": "Er du sikker på, du vil logge ud?",
"invalidPassword": "Ugyldig adgangskode. Prøv venligst igen.",
+8
View File
@@ -413,6 +413,14 @@
"subject": "Betreff",
"viewEmbeddedEmail": "Eingebettete E-Mail anzeigen"
},
"attachment_preview": {
"failedToLoad": "Vorschau konnte nicht geladen werden",
"notAvailable": "Vorschau nicht verfügbar",
"notAvailableDesc": "Vorschau für diesen Dateityp nicht möglich.",
"resetZoom": "Zoom zurücksetzen",
"zoomIn": "Vergrößern",
"zoomOut": "Verkleinern"
},
"auth": {
"areYouSureYouWantToLogOut": "Sind Sie sicher, dass Sie sich abmelden möchten?",
"invalidPassword": "Ungültiges Passwort. Bitte versuchen Sie es erneut.",
+9
View File
@@ -404,6 +404,7 @@
"downloading": "Downloading...",
"emailMessageNotFound": "Unable to find the original email. It may have been deleted.",
"name": "Filename",
"preview": "Preview",
"search_input_placeholder": "Search attachments (use \" \" for phrase search)",
"sender": "Sender",
"sender_with_count": "Sender ({{count}})",
@@ -413,6 +414,14 @@
"subject": "Subject",
"viewEmbeddedEmail": "View embedded email"
},
"attachment_preview": {
"failedToLoad": "Failed to load preview",
"notAvailable": "Preview not available",
"notAvailableDesc": "Preview is not supported for this file type.",
"resetZoom": "Reset zoom",
"zoomIn": "Zoom in",
"zoomOut": "Zoom out"
},
"auth": {
"areYouSureYouWantToLogOut": "Are you sure you want to log out?",
"invalidPassword": "Invalid password. Please try again.",
+8
View File
@@ -413,6 +413,14 @@
"subject": "Asunto",
"viewEmbeddedEmail": "Ver correo electrónico incrustado"
},
"attachment_preview": {
"failedToLoad": "Error al cargar la vista previa",
"notAvailable": "Vista previa no disponible",
"notAvailableDesc": "No se puede previsualizar este tipo de archivo.",
"resetZoom": "Restablecer zoom",
"zoomIn": "Acercar",
"zoomOut": "Alejar"
},
"auth": {
"areYouSureYouWantToLogOut": "¿Estás seguro de que quieres cerrar sesión?",
"invalidPassword": "Contraseña inválida. Inténtalo de nuevo.",
+8
View File
@@ -413,6 +413,14 @@
"subject": "Aihe",
"viewEmbeddedEmail": "Näytä upotettu sähköposti"
},
"attachment_preview": {
"failedToLoad": "Esikatselun lataaminen epäonnistui",
"notAvailable": "Esikatselua ei saatavilla",
"notAvailableDesc": "Tätä tiedostotyyppiä ei voi esikatsella.",
"resetZoom": "Nollaa zoomaus",
"zoomIn": "Lähennä",
"zoomOut": "Loitonna"
},
"auth": {
"areYouSureYouWantToLogOut": "Oletko varma, että haluat kirjautua ulos?",
"invalidPassword": "Virheellinen salasana. Yritä uudelleen.",
+8
View File
@@ -413,6 +413,14 @@
"subject": "Objet",
"viewEmbeddedEmail": "Voir l'e-mail intégré"
},
"attachment_preview": {
"failedToLoad": "Échec du chargement de l'aperçu",
"notAvailable": "Aperçu non disponible",
"notAvailableDesc": "Aperçu non disponible pour ce type de fichier.",
"resetZoom": "Réinitialiser le zoom",
"zoomIn": "Zoom avant",
"zoomOut": "Zoom arrière"
},
"auth": {
"areYouSureYouWantToLogOut": "Êtes-vous sûr de vouloir vous déconnecter ?",
"invalidPassword": "Mot de passe non valide. Veuillez réessayer.",
+8
View File
@@ -413,6 +413,14 @@
"subject": "Oggetto",
"viewEmbeddedEmail": "Visualizza email incorporata"
},
"attachment_preview": {
"failedToLoad": "Impossibile caricare l'anteprima",
"notAvailable": "Anteprima non disponibile",
"notAvailableDesc": "Anteprima non supportata per questo tipo di file.",
"resetZoom": "Ripristina zoom",
"zoomIn": "Ingrandisci",
"zoomOut": "Rimpicciolisci"
},
"auth": {
"areYouSureYouWantToLogOut": "Sei sicuro di voler uscire?",
"invalidPassword": "Password non valida. Riprova.",
+8
View File
@@ -413,6 +413,14 @@
"subject": "件名",
"viewEmbeddedEmail": "埋め込みメールを表示"
},
"attachment_preview": {
"failedToLoad": "プレビューの読み込みに失敗しました",
"notAvailable": "プレビューは利用できません",
"notAvailableDesc": "このファイル形式はプレビューできません。",
"resetZoom": "ズームをリセット",
"zoomIn": "拡大",
"zoomOut": "縮小"
},
"auth": {
"areYouSureYouWantToLogOut": "ログアウトしてもよろしいですか?",
"invalidPassword": "パスワードが無効です。もう一度お試しください。",
+8
View File
@@ -413,6 +413,14 @@
"subject": "제목",
"viewEmbeddedEmail": "포함된 메일 보기"
},
"attachment_preview": {
"failedToLoad": "미리보기를 불러오지 못했습니다",
"notAvailable": "미리보기를 사용할 수 없습니다",
"notAvailableDesc": "이 파일 형식은 미리볼 수 없습니다.",
"resetZoom": "확대/축소 초기화",
"zoomIn": "확대",
"zoomOut": "축소"
},
"auth": {
"areYouSureYouWantToLogOut": "정말로 로그아웃하시겠습니까?",
"invalidPassword": "비밀번호가 유효하지 않습니다. 다시 시도해 주세요.",
+8
View File
@@ -413,6 +413,14 @@
"subject": "Onderwerp",
"viewEmbeddedEmail": "Ingesloten e-mail bekijken"
},
"attachment_preview": {
"failedToLoad": "Voorpreview laden mislukt",
"notAvailable": "Voorpreview niet beschikbaar",
"notAvailableDesc": "Voorpreview niet ondersteund voor dit bestandstype.",
"resetZoom": "Zoom herstellen",
"zoomIn": "Inzoomen",
"zoomOut": "Uitzoomen"
},
"auth": {
"areYouSureYouWantToLogOut": "Weet u zeker dat u wilt uitloggen?",
"invalidPassword": "Ongeldig wachtwoord. Probeer het opnieuw.",
+8
View File
@@ -413,6 +413,14 @@
"subject": "Emne",
"viewEmbeddedEmail": "Vis innebygd e-post"
},
"attachment_preview": {
"failedToLoad": "Kunne ikke laste forhåndsvisning",
"notAvailable": "Forhåndsvisning ikke tilgjengelig",
"notAvailableDesc": "Denne filtypen kan ikke forhåndsvises.",
"resetZoom": "Nullstill zoom",
"zoomIn": "Zoom inn",
"zoomOut": "Zoom ut"
},
"auth": {
"areYouSureYouWantToLogOut": "Er du sikker på at du vil logge ut?",
"invalidPassword": "Ugyldig passord. Vennligst prøv igjen.",
+8
View File
@@ -413,6 +413,14 @@
"subject": "Temat",
"viewEmbeddedEmail": "Wyświetl osadzony e-mail"
},
"attachment_preview": {
"failedToLoad": "Nie udało się załadować podglądu",
"notAvailable": "Podgląd niedostępny",
"notAvailableDesc": "Podgląd tego typu pliku nie jest obsługiwany.",
"resetZoom": "Resetuj powiększenie",
"zoomIn": "Powiększ",
"zoomOut": "Pomniejsz"
},
"auth": {
"areYouSureYouWantToLogOut": "Czy na pewno chcesz się wylogować?",
"invalidPassword": "Nieprawidłowe hasło, spróbuj ponownie",
+8
View File
@@ -413,6 +413,14 @@
"subject": "Assunto",
"viewEmbeddedEmail": "Ver e-mail incorporado"
},
"attachment_preview": {
"failedToLoad": "Falha ao carregar a visualização",
"notAvailable": "Visualização não disponível",
"notAvailableDesc": "Não é possível visualizar este tipo de arquivo.",
"resetZoom": "Redefinir zoom",
"zoomIn": "Mais zoom",
"zoomOut": "Menos zoom"
},
"auth": {
"areYouSureYouWantToLogOut": "Tem certeza que deseja sair?",
"invalidPassword": "Senha inválida, por favor, tente novamente.",
+8
View File
@@ -413,6 +413,14 @@
"subject": "Тема",
"viewEmbeddedEmail": "Просмотреть вложенное письмо"
},
"attachment_preview": {
"failedToLoad": "Не удалось загрузить предпросмотр",
"notAvailable": "Предпросмотр недоступен",
"notAvailableDesc": "Предпросмотр для этого типа файла не поддерживается.",
"resetZoom": "Сбросить масштаб",
"zoomIn": "Увеличить масштаб",
"zoomOut": "Уменьшить масштаб"
},
"auth": {
"areYouSureYouWantToLogOut": "Вы уверены, что хотите выйти?",
"invalidPassword": "Неверный пароль. Пожалуйста, попробуйте снова.",
+8
View File
@@ -413,6 +413,14 @@
"subject": "Ämne",
"viewEmbeddedEmail": "Visa inbäddat e-postmeddelande"
},
"attachment_preview": {
"failedToLoad": "Misslyckades att ladda förhandsgranskning",
"notAvailable": "Förhandsgranskning inte tillgänglig",
"notAvailableDesc": "Det går inte att förhandsgranska den här filtypen.",
"resetZoom": "Återställ zoom",
"zoomIn": "Zooma in",
"zoomOut": "Zooma ut"
},
"auth": {
"areYouSureYouWantToLogOut": "Är du säker på att du vill logga ut?",
"invalidPassword": "Ogiltigt lösenord. Var god försök igen.",
+8
View File
@@ -413,6 +413,14 @@
"subject": "主旨",
"viewEmbeddedEmail": "檢視內嵌郵件"
},
"attachment_preview": {
"failedToLoad": "無法載入預覽",
"notAvailable": "無法預覽",
"notAvailableDesc": "此檔案類型不支援預覽。",
"resetZoom": "重設縮放",
"zoomIn": "放大",
"zoomOut": "縮小"
},
"auth": {
"areYouSureYouWantToLogOut": "確定要登出嗎?",
"invalidPassword": "密碼無效,請再試一次。",
+8
View File
@@ -413,6 +413,14 @@
"subject": "主题",
"viewEmbeddedEmail": "查看内嵌邮件"
},
"attachment_preview": {
"failedToLoad": "无法加载预览",
"notAvailable": "无法预览",
"notAvailableDesc": "此文件类型不支持预览。",
"resetZoom": "重置缩放",
"zoomIn": "放大",
"zoomOut": "缩小"
},
"auth": {
"areYouSureYouWantToLogOut": "您确定要退出登录吗?",
"invalidPassword": "密码无效,请重试。",