UNPKG

@limetech/lime-elements

Version:
347 lines (346 loc) • 12.2 kB
import PostalMime from "postal-mime"; import { sanitizeEmailHTML } from "./sanitize-email-html"; /** * Email loading/parsing helpers for `limel-file-viewer`. * * Parses an RFC 5322 / MIME email message (commonly stored as a `.eml` file) * and returns a simplified `Email` view-model. */ /** * Fetches and parses an email message. * * - Prefers `email.html` if present, otherwise falls back to `email.text`. * - Attempts to resolve inline images referenced via `cid:` by replacing * `<img src="cid:...">` with `data:` URLs generated from inline attachments. * * @param url - URL to an email message, usually ending in `.eml`. * @returns A simplified `Email` object for rendering. */ export async function loadEmail(url) { const buffer = await fetchEmailBuffer(url); const email = await parseEmail(url, buffer); const parsedEmail = { subject: email.subject || undefined, from: formatAddress(email.from), to: formatAddresses(email.to), cc: formatAddresses(email.cc), date: getRawHeader(email, 'date') || email.date || undefined, }; const { attachments, cidUrlById } = extractAttachments(email); if (attachments.length > 0) { parsedEmail.attachments = attachments; } await applyBody(parsedEmail, email, cidUrlById); return parsedEmail; } async function fetchEmailBuffer(url) { try { const response = await fetch(url); if (!response.ok) { throw new Error(`Failed to fetch email (${response.status} ${response.statusText})`); } return await response.arrayBuffer(); } catch (error) { const message = error instanceof Error ? error.message : String(error); throw new Error(`Failed to load email from ${url}: ${message}`); } } async function parseEmail(url, buffer) { try { return await PostalMime.parse(buffer, { attachmentEncoding: 'arraybuffer', }); } catch (error) { const message = error instanceof Error ? error.message : String(error); throw new Error(`Failed to parse email from ${url}: ${message}`); } } function getRawHeader(email, name) { const header = (email.headers || []).find((h) => h.key === name.toLowerCase()); return (header === null || header === void 0 ? void 0 : header.value) || undefined; } function extractAttachments(email) { const attachments = []; const cidUrlById = new Map(); for (const attachment of email.attachments || []) { const contentId = normalizeContentId(attachment.contentId); const hasContentId = Boolean(contentId); const isInline = (hasContentId && attachment.disposition !== 'attachment') || attachment.related || attachment.disposition === 'inline'; const contentBytes = getAttachmentBytes(attachment.content); if (!isInline) { const size = contentBytes === null || contentBytes === void 0 ? void 0 : contentBytes.byteLength; attachments.push({ filename: attachment.filename || undefined, mimeType: attachment.mimeType || undefined, size, }); continue; } const hasBinaryContent = Boolean(contentBytes); if (!contentId || !hasBinaryContent) { const size = contentBytes === null || contentBytes === void 0 ? void 0 : contentBytes.byteLength; attachments.push({ filename: attachment.filename || undefined, mimeType: attachment.mimeType || 'application/octet-stream', size, }); continue; } const mimeType = resolveDataUrlMimeType(attachment.mimeType, contentBytes, attachment.filename); const base64 = byteArrayToBase64(contentBytes); const dataUrl = `data:${mimeType};base64,${base64}`; cidUrlById.set(contentId, dataUrl); } return { attachments, cidUrlById }; } async function applyBody(parsedEmail, email, cidUrlById) { const html = (email.html || '').trim(); if (html) { const withCidsResolved = replaceCidReferences(html, cidUrlById); parsedEmail.bodyHtml = await sanitizeEmailHTML(withCidsResolved); return; } parsedEmail.bodyText = (email.text || '').trim() || undefined; } /** * Normalizes a Content-ID by removing surrounding angle brackets. * * Example: `<image@id>` -> `image@id` * * @param contentId - The Content-ID to normalize, optionally surrounded by angle brackets. */ function normalizeContentId(contentId) { if (!contentId) { return ''; } let normalized = contentId.trim(); if (normalized.toLowerCase().startsWith('cid:')) { normalized = normalized.slice(4).trim(); } if (normalized.startsWith('<')) { normalized = normalized.slice(1); } if (normalized.endsWith('>')) { normalized = normalized.slice(0, -1); } return normalized.trim(); } function replaceCidReferences(html, cidUrlById) { if (cidUrlById.size === 0) { return html; } return html.replaceAll(/(src\s*=\s*["']?)cid:([^"'\s>]+)(["']?)/gi, (match, prefix, cid, suffix) => { const normalized = normalizeContentId(decodeCidReference(cid)); const replacement = cidUrlById.get(normalized); if (!replacement) { return match; } return `${prefix}${replacement}${suffix}`; }); } function decodeCidReference(cid) { try { return decodeURIComponent(cid); } catch (_a) { return cid; } } function getAttachmentBytes(content) { if (content instanceof ArrayBuffer) { return new Uint8Array(content); } if (ArrayBuffer.isView(content)) { return new Uint8Array(content.buffer, content.byteOffset, content.byteLength); } return undefined; } function byteArrayToBase64(bytes) { if (typeof btoa === 'function') { let binary = ''; for (const byte of bytes) { binary += String['fromCharCode'](byte); } return btoa(binary); } // Jest/Node fallback return globalThis.Buffer.from(bytes).toString('base64'); } function resolveDataUrlMimeType(mimeType, bytes, filename) { const normalizedMimeType = typeof mimeType === 'string' ? mimeType.trim().toLowerCase() : ''; const detectedFromBytes = detectImageMimeTypeFromBytes(bytes); if (normalizedMimeType.startsWith('image/')) { if (isTrustedDeclaredImageMimeType(normalizedMimeType, detectedFromBytes)) { return normalizedMimeType; } if (detectedFromBytes) { return detectedFromBytes; } } if (detectedFromBytes) { return detectedFromBytes; } const detectedFromFilename = detectImageMimeTypeFromFilename(filename); if (detectedFromFilename) { return detectedFromFilename; } return normalizedMimeType || 'application/octet-stream'; } function isTrustedDeclaredImageMimeType(declaredMimeType, detectedMimeType) { if (!detectedMimeType) { return true; } if (declaredMimeType === detectedMimeType) { return true; } if (declaredMimeType === 'image/jpg' && detectedMimeType === 'image/jpeg') { return true; } return (declaredMimeType === 'image/vnd.microsoft.icon' && detectedMimeType === 'image/x-icon'); } function detectImageMimeTypeFromBytes(bytes) { var _a, _b, _c, _d, _e; return ((_e = (_d = (_c = (_b = (_a = detectPngMimeType(bytes)) !== null && _a !== void 0 ? _a : detectJpegMimeType(bytes)) !== null && _b !== void 0 ? _b : detectGifMimeType(bytes)) !== null && _c !== void 0 ? _c : detectWebpMimeType(bytes)) !== null && _d !== void 0 ? _d : detectIconMimeType(bytes)) !== null && _e !== void 0 ? _e : detectSvgMimeType(bytes)); } function detectPngMimeType(bytes) { if (startsWithBytes(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) { return 'image/png'; } } function detectJpegMimeType(bytes) { if (startsWithBytes(bytes, [0xff, 0xd8, 0xff])) { return 'image/jpeg'; } } function detectGifMimeType(bytes) { const firstSix = bytesToAscii(bytes, 0, 6); if (firstSix === 'GIF87a' || firstSix === 'GIF89a') { return 'image/gif'; } } function detectWebpMimeType(bytes) { const riff = bytesToAscii(bytes, 0, 4); const webp = bytesToAscii(bytes, 8, 12); if (riff === 'RIFF' && webp === 'WEBP') { return 'image/webp'; } } function detectIconMimeType(bytes) { if (startsWithBytes(bytes, [0x00, 0x00, 0x01, 0x00])) { return 'image/x-icon'; } } function detectSvgMimeType(bytes) { if (bytes.length < 5) { return; } const utf8Prefix = new TextDecoder('utf8', { fatal: false }).decode(bytes.slice(0, Math.min(bytes.length, 256))); const normalizedPrefix = utf8Prefix.trimStart().toLowerCase(); if (normalizedPrefix.startsWith('<svg') || (normalizedPrefix.startsWith('<?xml') && normalizedPrefix.includes('<svg'))) { return 'image/svg+xml'; } } function startsWithBytes(bytes, prefix) { if (bytes.length < prefix.length) { return false; } return prefix.every((value, index) => bytes[index] === value); } function bytesToAscii(bytes, start, endExclusive) { if (bytes.length < endExclusive) { return ''; } return String.fromCodePoint(...bytes.slice(start, endExclusive)); } function detectImageMimeTypeFromFilename(filename) { const normalized = filename === null || filename === void 0 ? void 0 : filename.trim().toLowerCase(); if (!normalized || !normalized.includes('.')) { return; } const extension = normalized.slice(normalized.lastIndexOf('.') + 1); const mimeTypes = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml', ico: 'image/x-icon', bmp: 'image/bmp', }; return mimeTypes[extension]; } function isNonEmptyString(value) { return typeof value === 'string' && value.length > 0; } /** * Formats one or many address objects returned by PostalMime. * * @param addresses - Address object(s) to format. * @returns Formatted address string, or undefined if no valid addresses. */ function formatAddresses(addresses) { if (!addresses) { return undefined; } const list = Array.isArray(addresses) ? addresses : [addresses]; const parts = list .map((addr) => formatAddress(addr)) .filter(isNonEmptyString); return parts.length > 0 ? parts.join(', ') : undefined; } function formatAddress(address) { if (!address) { return undefined; } if (Array.isArray(address)) { return formatAddresses(address); } if (address.group && Array.isArray(address.group)) { const groupName = (address.name || '').trim(); const groupMembers = address.group .map((m) => formatAddress(m)) .filter(isNonEmptyString) .join(', '); if (groupName && groupMembers) { return `${groupName}: ${groupMembers}`; } return groupName || groupMembers || undefined; } const name = (address.name || '').trim(); const email = (address.address || '').trim(); if (name && email) { const displayName = quoteDisplayNameIfNeeded(name); return `${displayName} <${email}>`; } return name || email || undefined; } function quoteDisplayNameIfNeeded(name) { if (!name) { return ''; } // If the display name contains a comma, it must be quoted for safe parsing // of comma-separated address lists. if (!name.includes(',') && !name.includes('"')) { return name; } const escaped = name.replaceAll('\\', '\\\\').replaceAll('"', '\\' + '"'); return `"${escaped}"`; } export const emailLoaderHelpers = { normalizeContentId, decodeCidReference, replaceCidReferences, getAttachmentBytes, detectImageMimeTypeFromBytes, detectImageMimeTypeFromFilename, resolveDataUrlMimeType, extractAttachments, };