Merge branch 'main' of https://github.com/bulwarkmail/webmail
This commit is contained in:
@@ -11,6 +11,7 @@ import { debug } from "@/lib/debug";
|
|||||||
import { toast } from "@/stores/toast-store";
|
import { toast } from "@/stores/toast-store";
|
||||||
import { sanitizeSignatureHtml, sanitizeEmailHtml } from "@/lib/email-sanitization";
|
import { sanitizeSignatureHtml, sanitizeEmailHtml } from "@/lib/email-sanitization";
|
||||||
import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix";
|
import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix";
|
||||||
|
import { isFilePreviewable } from "@/lib/file-preview";
|
||||||
import { buildQuotedHtmlBlock, serializeEditorContent } from "@/components/email/quoted-html";
|
import { buildQuotedHtmlBlock, serializeEditorContent } from "@/components/email/quoted-html";
|
||||||
import { emailHooks, contactHooks } from "@/lib/plugin-hooks";
|
import { emailHooks, contactHooks } from "@/lib/plugin-hooks";
|
||||||
import type { OutgoingEmail, RecipientSuggestion } from "@/lib/plugin-types";
|
import type { OutgoingEmail, RecipientSuggestion } from "@/lib/plugin-types";
|
||||||
@@ -25,6 +26,7 @@ import { buildMimeMessage, wrapCmsAsSmimeMessage } from "@/lib/smime/mime-builde
|
|||||||
import type { MimeAttachment } from "@/lib/smime/mime-builder";
|
import type { MimeAttachment } from "@/lib/smime/mime-builder";
|
||||||
import { smimeSign } from "@/lib/smime/smime-sign";
|
import { smimeSign } from "@/lib/smime/smime-sign";
|
||||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||||
|
import { FilePreviewModal } from "@/components/files/file-preview-modal";
|
||||||
import { smimeEncrypt } from "@/lib/smime/smime-encrypt";
|
import { smimeEncrypt } from "@/lib/smime/smime-encrypt";
|
||||||
import { useContactStore } from "@/stores/contact-store";
|
import { useContactStore } from "@/stores/contact-store";
|
||||||
import { useTemplateStore } from "@/stores/template-store";
|
import { useTemplateStore } from "@/stores/template-store";
|
||||||
@@ -452,6 +454,7 @@ export function EmailComposer({
|
|||||||
const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false);
|
const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false);
|
||||||
const [showCloseDialog, setShowCloseDialog] = useState(false);
|
const [showCloseDialog, setShowCloseDialog] = useState(false);
|
||||||
const [showAllAttachments, setShowAllAttachments] = useState(false);
|
const [showAllAttachments, setShowAllAttachments] = useState(false);
|
||||||
|
const [previewAttachment, setPreviewAttachment] = useState<ComposerAttachment | null>(null);
|
||||||
const [smimeSign_, setSmimeSign] = useState(false);
|
const [smimeSign_, setSmimeSign] = useState(false);
|
||||||
const [smimeEncrypt_, setSmimeEncrypt] = useState(false);
|
const [smimeEncrypt_, setSmimeEncrypt] = useState(false);
|
||||||
const [smimePassphrasePrompt, setSmimePassphrasePrompt] = useState<{ keyId: string; resolve: (passphrase: string) => void; reject: () => void } | null>(null);
|
const [smimePassphrasePrompt, setSmimePassphrasePrompt] = useState<{ keyId: string; resolve: (passphrase: string) => void; reject: () => void } | null>(null);
|
||||||
@@ -1123,6 +1126,43 @@ export function EmailComposer({
|
|||||||
setAttachments(prev => prev.filter((_, i) => i !== index));
|
setAttachments(prev => prev.filter((_, i) => i !== index));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Inline preview for composer attachments, reusing the message viewer's
|
||||||
|
// FilePreviewModal (so previewability and the open-in-new-tab safety gate are
|
||||||
|
// handled there). Prefer the local File - no network - and fall back to the
|
||||||
|
// uploaded blob (forwarded attachments, which carry only a blobId).
|
||||||
|
const getPreviewAttachmentContent = useCallback(async () => {
|
||||||
|
if (!previewAttachment) throw new Error('No attachment selected');
|
||||||
|
if (previewAttachment.file) {
|
||||||
|
return {
|
||||||
|
blob: previewAttachment.file,
|
||||||
|
contentType: previewAttachment.type || previewAttachment.file.type || 'application/octet-stream',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (composerClient && previewAttachment.blobId) {
|
||||||
|
const blob = await composerClient.fetchBlob(previewAttachment.blobId, previewAttachment.name, previewAttachment.type);
|
||||||
|
return { blob, contentType: previewAttachment.type || blob.type || 'application/octet-stream' };
|
||||||
|
}
|
||||||
|
throw new Error('No attachment content available');
|
||||||
|
}, [previewAttachment, composerClient]);
|
||||||
|
|
||||||
|
const handlePreviewAttachmentDownload = useCallback(async () => {
|
||||||
|
if (!previewAttachment) return;
|
||||||
|
if (previewAttachment.file) {
|
||||||
|
const url = URL.createObjectURL(previewAttachment.file);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = previewAttachment.name;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (composerClient && previewAttachment.blobId) {
|
||||||
|
await composerClient.downloadBlob(previewAttachment.blobId, previewAttachment.name, previewAttachment.type);
|
||||||
|
}
|
||||||
|
}, [previewAttachment, composerClient]);
|
||||||
|
|
||||||
// Auto-save draft functionality
|
// Auto-save draft functionality
|
||||||
const saveDraftOnce = async (): Promise<string | null> => {
|
const saveDraftOnce = async (): Promise<string | null> => {
|
||||||
if (!client || !composerClient) return null;
|
if (!client || !composerClient) return null;
|
||||||
@@ -2207,7 +2247,21 @@ export function EmailComposer({
|
|||||||
{attachments.length > 0 && (
|
{attachments.length > 0 && (
|
||||||
<div className="px-4 py-2 border-t shrink-0">
|
<div className="px-4 py-2 border-t shrink-0">
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{(showAllAttachments ? attachments : attachments.slice(0, 3)).map((att, index) => (
|
{(showAllAttachments ? attachments : attachments.slice(0, 3)).map((att, index) => {
|
||||||
|
// Clickable to preview only once it has content (local File or an
|
||||||
|
// uploaded blob) and the type is previewable; never mid-upload.
|
||||||
|
const canPreview = !att.uploading && !att.error
|
||||||
|
&& (!!att.file || !!att.blobId)
|
||||||
|
&& isFilePreviewable(att.name, att.type);
|
||||||
|
const label = (
|
||||||
|
<>
|
||||||
|
<span className="max-w-[150px] md:max-w-[200px] truncate">{att.name}</span>
|
||||||
|
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||||
|
({formatFileSize(att.size)})
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -2229,10 +2283,18 @@ export function EmailComposer({
|
|||||||
) : (
|
) : (
|
||||||
<Paperclip className="w-3 h-3 flex-shrink-0" />
|
<Paperclip className="w-3 h-3 flex-shrink-0" />
|
||||||
)}
|
)}
|
||||||
<span className="max-w-[150px] md:max-w-[200px] truncate">{att.name}</span>
|
{canPreview ? (
|
||||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
<button
|
||||||
({formatFileSize(att.size)})
|
type="button"
|
||||||
</span>
|
onClick={() => setPreviewAttachment(att)}
|
||||||
|
title={att.name}
|
||||||
|
className="flex items-center gap-2 min-w-0 hover:underline"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2 min-w-0">{label}</div>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => removeAttachment(index)}
|
onClick={() => removeAttachment(index)}
|
||||||
className="ml-1 hover:text-red-500 min-w-[20px] min-h-[20px] flex items-center justify-center"
|
className="ml-1 hover:text-red-500 min-w-[20px] min-h-[20px] flex items-center justify-center"
|
||||||
@@ -2242,7 +2304,8 @@ export function EmailComposer({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
{attachments.length > 3 && (
|
{attachments.length > 3 && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowAllAttachments(prev => !prev)}
|
onClick={() => setShowAllAttachments(prev => !prev)}
|
||||||
@@ -2572,6 +2635,15 @@ export function EmailComposer({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{previewAttachment && (
|
||||||
|
<FilePreviewModal
|
||||||
|
name={previewAttachment.name}
|
||||||
|
onClose={() => setPreviewAttachment(null)}
|
||||||
|
onDownload={handlePreviewAttachmentDownload}
|
||||||
|
getFileContent={getPreviewAttachmentContent}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<PluginSlot
|
<PluginSlot
|
||||||
name="composer-sidebar-right"
|
name="composer-sidebar-right"
|
||||||
|
|||||||
@@ -2899,7 +2899,10 @@ export function EmailViewer({
|
|||||||
<html style="color-scheme: ${colorScheme};"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
<html style="color-scheme: ${colorScheme};"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<meta http-equiv="Content-Security-Policy" content="${iframeCsp}">
|
<meta http-equiv="Content-Security-Policy" content="${iframeCsp}">
|
||||||
<style>
|
<style>
|
||||||
html, body { overflow: hidden; }
|
/* Force content height: some emails set html/body { height: 100% }, which -
|
||||||
|
combined with overflow:hidden and our scrollHeight-based auto-resize -
|
||||||
|
collapses the measured height and clips everything below the fold. */
|
||||||
|
html, body { overflow: hidden; height: auto !important; }
|
||||||
body { margin: 0; padding: ${bodyPadding}; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 14px; line-height: 1.6; color: #1a1a1a; background: #ffffff; word-wrap: break-word; overflow-wrap: break-word; }
|
body { margin: 0; padding: ${bodyPadding}; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 14px; line-height: 1.6; color: #1a1a1a; background: #ffffff; word-wrap: break-word; overflow-wrap: break-word; }
|
||||||
@media (max-width: 640px) { body { padding-left: ${mobileBodyPaddingX}; padding-right: ${mobileBodyPaddingX}; } }
|
@media (max-width: 640px) { body { padding-left: ${mobileBodyPaddingX}; padding-right: ${mobileBodyPaddingX}; } }
|
||||||
img { max-width: 100% !important; height: auto !important; }
|
img { max-width: 100% !important; height: auto !important; }
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { Paperclip, Download } from "lucide-react";
|
||||||
|
import { sanitizeEmailHtmlForIframe } from "@/lib/email-sanitization";
|
||||||
|
|
||||||
|
// A parsed message/rfc822 (.eml), as produced by postal-mime. Only the fields
|
||||||
|
// this preview renders are typed.
|
||||||
|
export type ParsedEml = {
|
||||||
|
subject?: string;
|
||||||
|
from?: { name?: string; address?: string };
|
||||||
|
to?: Array<{ name?: string; address?: string }>;
|
||||||
|
date?: string;
|
||||||
|
html?: string;
|
||||||
|
text?: string;
|
||||||
|
attachments?: Array<{ filename?: string; mimeType?: string; content?: ArrayBuffer | Uint8Array }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatAddress(a?: { name?: string; address?: string }): string {
|
||||||
|
if (!a) return "";
|
||||||
|
if (a.name && a.address) return `${a.name} <${a.address}>`;
|
||||||
|
return a.address || a.name || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s: string): string {
|
||||||
|
return s
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Renders a .eml attachment like an email: header (from/to/subject/date) + the
|
||||||
|
// body, plus the message's own attachments. The body is sanitized with
|
||||||
|
// DOMPurify AND rendered in a fully-locked sandbox iframe (sandbox="" - no
|
||||||
|
// scripts, no same-origin), so a script-bearing .eml can never execute in our
|
||||||
|
// origin. Parsing happens in the caller (FilePreviewModal); this is pure
|
||||||
|
// presentation.
|
||||||
|
export function EmlPreview({ message }: { message: ParsedEml }) {
|
||||||
|
const t = useTranslations("email_viewer");
|
||||||
|
|
||||||
|
const bodyDoc = message.html
|
||||||
|
? sanitizeEmailHtmlForIframe(message.html)
|
||||||
|
: message.text
|
||||||
|
? `<pre style="white-space:pre-wrap;word-break:break-word;font-family:ui-monospace,monospace;margin:0;padding:8px">${escapeHtml(message.text)}</pre>`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const downloadAttachment = (att: NonNullable<ParsedEml["attachments"]>[number]) => {
|
||||||
|
if (!att.content) return;
|
||||||
|
// content is a real ArrayBuffer/Uint8Array at runtime; cast for the strict
|
||||||
|
// BlobPart lib type (Uint8Array<ArrayBufferLike> vs ArrayBuffer).
|
||||||
|
const url = URL.createObjectURL(new Blob([att.content as BlobPart], { type: att.mimeType || "application/octet-stream" }));
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = att.filename || "attachment";
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="w-full max-w-3xl max-h-full self-start overflow-auto rounded-lg border border-border bg-background shadow-2xl p-4"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h2 className="text-lg font-semibold text-foreground break-words">{message.subject || ""}</h2>
|
||||||
|
<div className="mt-2 space-y-0.5 text-sm text-muted-foreground border-b border-border pb-3">
|
||||||
|
{message.from && (
|
||||||
|
<div><span className="font-medium text-foreground">{t("from")}: </span>{formatAddress(message.from)}</div>
|
||||||
|
)}
|
||||||
|
{message.to && message.to.length > 0 && (
|
||||||
|
<div><span className="font-medium text-foreground">{t("to")}: </span>{message.to.map(formatAddress).join(", ")}</div>
|
||||||
|
)}
|
||||||
|
{message.date && (
|
||||||
|
<div><span className="font-medium text-foreground">{t("date")}: </span>{new Date(message.date).toLocaleString()}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{bodyDoc && (
|
||||||
|
<iframe
|
||||||
|
title={message.subject || "email"}
|
||||||
|
sandbox=""
|
||||||
|
srcDoc={bodyDoc}
|
||||||
|
className="w-full min-h-[55vh] mt-3 rounded bg-white"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{message.attachments && message.attachments.length > 0 && (
|
||||||
|
<div className="mt-4 border-t border-border pt-3">
|
||||||
|
<div className="text-xs font-medium text-muted-foreground mb-2">{t("attachments")}</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{message.attachments.map((att, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
type="button"
|
||||||
|
onClick={() => downloadAttachment(att)}
|
||||||
|
title={t("download")}
|
||||||
|
className="flex items-center gap-2 px-3 py-1.5 rounded-md text-sm bg-muted text-foreground hover:bg-muted/70"
|
||||||
|
>
|
||||||
|
<Paperclip className="w-3 h-3 flex-shrink-0" />
|
||||||
|
<span className="max-w-[200px] truncate">{att.filename || "attachment"}</span>
|
||||||
|
<Download className="w-3 h-3 flex-shrink-0 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { X, Download, Loader2, ExternalLink } from "lucide-react";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { getFilePreviewKind, isMimeTypeSafeForInlinePreview } from "@/lib/file-preview";
|
import { getFilePreviewKind, isMimeTypeSafeForInlinePreview } from "@/lib/file-preview";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
|
import { EmlPreview, type ParsedEml } from "@/components/files/eml-preview";
|
||||||
|
|
||||||
// pdf.js-based inline viewer for mobile (no native inline PDF viewer). Loaded
|
// pdf.js-based inline viewer for mobile (no native inline PDF viewer). Loaded
|
||||||
// only on the mobile PDF path so pdfjs-dist + its worker never reach the
|
// only on the mobile PDF path so pdfjs-dist + its worker never reach the
|
||||||
@@ -153,6 +154,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
|
|||||||
// navigation. Blob URLs inherit our origin, so opening a script-bearing type
|
// navigation. Blob URLs inherit our origin, so opening a script-bearing type
|
||||||
// (text/html, image/svg+xml, ...) in a new tab would execute it in-origin.
|
// (text/html, image/svg+xml, ...) in a new tab would execute it in-origin.
|
||||||
const [canOpenInNewTab, setCanOpenInNewTab] = useState(false);
|
const [canOpenInNewTab, setCanOpenInNewTab] = useState(false);
|
||||||
|
const [emlContent, setEmlContent] = useState<ParsedEml | null>(null);
|
||||||
|
|
||||||
// Decide whether to render the PDF in a plain <iframe> (desktop) or with the
|
// Decide whether to render the PDF in a plain <iframe> (desktop) or with the
|
||||||
// pdf.js canvas viewer (mobile). navigator.pdfViewerEnabled is the standard
|
// pdf.js canvas viewer (mobile). navigator.pdfViewerEnabled is the standard
|
||||||
@@ -205,6 +207,7 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
|
|||||||
setContent(null);
|
setContent(null);
|
||||||
setObjectUrl(null);
|
setObjectUrl(null);
|
||||||
setCanOpenInNewTab(false);
|
setCanOpenInNewTab(false);
|
||||||
|
setEmlContent(null);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(false);
|
setError(false);
|
||||||
setResolvedFileType(getFilePreviewKind(name));
|
setResolvedFileType(getFilePreviewKind(name));
|
||||||
@@ -221,6 +224,12 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
|
|||||||
if (previewType === "text" || previewType === "markdown") {
|
if (previewType === "text" || previewType === "markdown") {
|
||||||
const text = await blob.text();
|
const text = await blob.text();
|
||||||
if (!cancelled) setContent(text);
|
if (!cancelled) setContent(text);
|
||||||
|
} else if (previewType === "eml") {
|
||||||
|
// Parse the embedded message (postal-mime, dynamic-imported so it
|
||||||
|
// stays off the bundle) and render it like an email via EmlPreview.
|
||||||
|
const { default: PostalMime } = await import("postal-mime");
|
||||||
|
const parsed = await new PostalMime().parse(await blob.arrayBuffer());
|
||||||
|
if (!cancelled) setEmlContent(parsed as ParsedEml);
|
||||||
} else {
|
} else {
|
||||||
// Stalwart's download endpoint can return generic
|
// Stalwart's download endpoint can return generic
|
||||||
// application/octet-stream for attachments even when the email's
|
// application/octet-stream for attachments even when the email's
|
||||||
@@ -329,6 +338,10 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && fileType === "eml" && emlContent && (
|
||||||
|
<EmlPreview message={emlContent} />
|
||||||
|
)}
|
||||||
|
|
||||||
{!loading && !error && fileType === "image" && objectUrl && (
|
{!loading && !error && fileType === "image" && objectUrl && (
|
||||||
<img
|
<img
|
||||||
src={objectUrl}
|
src={objectUrl}
|
||||||
|
|||||||
+7
-1
@@ -1,4 +1,4 @@
|
|||||||
export type FilePreviewKind = 'image' | 'html' | 'text' | 'markdown' | 'pdf' | 'audio' | 'video' | 'unsupported';
|
export type FilePreviewKind = 'image' | 'html' | 'eml' | 'text' | 'markdown' | 'pdf' | 'audio' | 'video' | 'unsupported';
|
||||||
|
|
||||||
const IMAGE_EXTENSIONS = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'avif', 'bmp', 'ico']);
|
const IMAGE_EXTENSIONS = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'avif', 'bmp', 'ico']);
|
||||||
const AUDIO_EXTENSIONS = new Set(['mp3', 'wav', 'ogg', 'm4a', 'flac', 'aac', 'opus']);
|
const AUDIO_EXTENSIONS = new Set(['mp3', 'wav', 'ogg', 'm4a', 'flac', 'aac', 'opus']);
|
||||||
@@ -38,6 +38,12 @@ export function getFilePreviewKind(name?: string, type?: string): FilePreviewKin
|
|||||||
return 'html';
|
return 'html';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Embedded email message (e.g. a bounce/DSN, or forward-as-attachment).
|
||||||
|
// Rendered by parsing it and showing it like an email, not as a blob.
|
||||||
|
if (mimeType === 'message/rfc822' || ext === 'eml') {
|
||||||
|
return 'eml';
|
||||||
|
}
|
||||||
|
|
||||||
if (mimeType === 'application/pdf' || ext === 'pdf') {
|
if (mimeType === 'application/pdf' || ext === 'pdf') {
|
||||||
return 'pdf';
|
return 'pdf';
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user