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 AUDIO_EXTENSIONS = new Set(['mp3', 'wav', 'ogg', 'm4a', 'flac', 'aac', 'opus']); const VIDEO_EXTENSIONS = new Set(['mp4', 'webm', 'ogv', 'mov', 'm4v', 'avi', 'mkv']); const TEXT_EXTENSIONS = new Set([ 'txt', 'text', 'log', 'csv', 'json', 'xml', 'css', 'js', 'mjs', 'cjs', 'ts', 'tsx', 'jsx', 'yaml', 'yml', 'toml', 'ini', 'cfg', 'conf', 'env', 'sql', 'graphql', 'html', 'htm', 'md', 'markdown', ]); const TEXT_MIME_TYPES = new Set([ 'application/json', 'application/ld+json', 'application/xml', 'application/javascript', 'application/x-javascript', 'application/typescript', ]); function normalizeMimeType(type?: string): string { return type?.split(';')[0]?.trim().toLowerCase() || ''; } function getExtension(name?: string): string { const parts = name?.toLowerCase().split('.') || []; return parts.length > 1 ? parts.pop() || '' : ''; } export function getFilePreviewKind(name?: string, type?: string): FilePreviewKind { const ext = getExtension(name); const mimeType = normalizeMimeType(type); if (mimeType.startsWith('image/') || IMAGE_EXTENSIONS.has(ext)) { return 'image'; } if (mimeType === 'text/html' || mimeType === 'application/xhtml+xml' || ext === 'html' || ext === 'htm') { 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') { return 'pdf'; } if (mimeType.startsWith('audio/') || AUDIO_EXTENSIONS.has(ext)) { return 'audio'; } if (mimeType.startsWith('video/') || VIDEO_EXTENSIONS.has(ext)) { return 'video'; } if (ext === 'md' || ext === 'markdown') { return 'markdown'; } if (mimeType.startsWith('text/') || TEXT_MIME_TYPES.has(mimeType) || TEXT_EXTENSIONS.has(ext)) { return 'text'; } return 'unsupported'; } export function isFilePreviewable(name?: string, type?: string): boolean { return getFilePreviewKind(name, type) !== 'unsupported'; } // Canonical types for the extensions the preview renders from a blob: PDF and // every image, audio and video extension above except SVG. No script-capable // type (SVG, HTML) may appear here: the preview trusts these over whatever // the sender declared. const EXT_TO_MIME: Record = { pdf: 'application/pdf', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', avif: 'image/avif', bmp: 'image/bmp', ico: 'image/x-icon', mp3: 'audio/mpeg', wav: 'audio/wav', ogg: 'audio/ogg', m4a: 'audio/mp4', flac: 'audio/flac', aac: 'audio/aac', // Ogg Opus (RFC 7845); audio/opus names the bare RTP payload. opus: 'audio/ogg', mp4: 'video/mp4', webm: 'video/webm', ogv: 'video/ogg', mov: 'video/quicktime', m4v: 'video/mp4', avi: 'video/x-msvideo', mkv: 'video/x-matroska', }; const GENERIC_MIME_TYPES = new Set(['', 'application/octet-stream', 'binary/octet-stream']); // Kinds the preview renders from the file's text, not from a blob: URL. const DOCUMENT_KINDS = new Set(['html', 'eml', 'text', 'markdown']); // Blob types the browser actually shows in each blob-backed preview. const RENDERABLE_FOR_KIND: Partial boolean>> = { pdf: (t) => t === 'application/pdf', image: (t) => t.startsWith('image/'), audio: (t) => t.startsWith('audio/'), video: (t) => t.startsWith('video/'), }; /** * MIME type to give a preview's blob. Browsers pick the viewer from the * blob's type, not the file name, and download anything they cannot show - * under the blob's UUID. Neither the sender's declaration (octet-stream, or a * catch-all such as the application/x-as400attachment IBM i systems put on * every attachment) nor Stalwart's download response is reliably a type the * viewer accepts, so a PDF, image, audio or video preview takes the first * type its viewer can show: declared, then served, then the one the file * name implies. Every other preview keeps the declared type unless it is * generic. */ export function previewBlobType(name: string | undefined, declaredType?: string, blobType?: string): string { const declared = normalizeMimeType(declaredType); const served = normalizeMimeType(blobType); const fromName = EXT_TO_MIME[getExtension(name)]; const kind = getFilePreviewKind(name, declared || served); // Documents are previewed from their text (sanitised, in a srcdoc frame), // never from a blob: URL. Should one still be minted for them, it must be // nothing a browser would render as the webmail origin. if (DOCUMENT_KINDS.has(kind)) return 'application/octet-stream'; const renderable = RENDERABLE_FOR_KIND[kind]; if (renderable) { const match = [declared, served, fromName].find((t): t is string => !!t && renderable(t)); if (match) return match; } if (declaredType && !GENERIC_MIME_TYPES.has(declared)) return declaredType; return fromName ?? blobType ?? ''; } const INLINE_PREVIEW_SAFE_MIME_PREFIXES = ['image/', 'audio/', 'video/']; const INLINE_PREVIEW_SAFE_MIME_TYPES = new Set(['application/pdf', 'text/plain']); const INLINE_PREVIEW_UNSAFE_MIME_TYPES = new Set([ 'image/svg+xml', 'image/svg', ]); // Whether a Blob with this MIME type is safe to open as a top-level navigation // (e.g. window.open on a blob: URL). Blob URLs inherit the creator's origin, so // script-bearing types like text/html, application/xhtml+xml, image/svg+xml, and // XML variants would execute in our origin. Only an explicit allowlist of inert // types is permitted; everything else must be downloaded. export function isMimeTypeSafeForInlinePreview(type?: string): boolean { // new Blob() drops a type with any character outside U+0020-U+007E to "", // and Firefox sniffs an untyped blob: document. A winmail.dat MIME tag is // free text, so judge only types the Blob keeps as written. if (!type || !/^[\x20-\x7e]+$/.test(type)) return false; const mimeType = type.split(';')[0]?.trim().toLowerCase() || ''; if (!mimeType) return false; if (INLINE_PREVIEW_UNSAFE_MIME_TYPES.has(mimeType)) return false; if (INLINE_PREVIEW_SAFE_MIME_TYPES.has(mimeType)) return true; return INLINE_PREVIEW_SAFE_MIME_PREFIXES.some((prefix) => mimeType.startsWith(prefix)); } const INERT_BLOB_TYPE = 'application/octet-stream'; /** * MIME type for a Blob whose blob: URL ends up inside untrusted markup (the * cid: parts of a rendered message body). Blob URLs inherit the creating * document's origin, so a sender-declared text/html or image/svg+xml part * navigated to as a top-level document would run script as the webmail origin * (GHSA-xvjh-v9c6-qcvc). Inert types keep their MIME so img/video/audio keep * rendering them; anything else becomes octet-stream, which no browser executes. */ export function inertBlobType(type?: string): string { return type && isMimeTypeSafeForInlinePreview(type) ? type : INERT_BLOB_TYPE; } /** Re-type a fetched Blob per inertBlobType() without copying its bytes. */ export function toInertBlob(blob: Blob): Blob { return isMimeTypeSafeForInlinePreview(blob.type) ? blob : blob.slice(0, blob.size, INERT_BLOB_TYPE); } /** * A URL an can show whose target is inert when opened on its own * ("Open image in new tab", dragging it to the tab strip). An object URL * shares the webmail origin, so an image/svg+xml one would run the * sender's script there. SVG is handed over as a data: URL instead - its * document gets an opaque origin - and every other type as an object URL * re-typed per inertBlobType(). Revoking the result is safe either way. */ export async function imageBlobUrl(blob: Blob, type?: string): Promise { const mimeType = (type || blob.type).split(';')[0].trim().toLowerCase(); if (INLINE_PREVIEW_UNSAFE_MIME_TYPES.has(mimeType)) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(String(reader.result)); reader.onerror = () => reject(reader.error); reader.readAsDataURL(blob.slice(0, blob.size, 'image/svg+xml')); }); } return URL.createObjectURL(toInertBlob(type ? blob.slice(0, blob.size, type) : blob)); }