|
| 1 | +import PostalMime from 'postal-mime'; |
| 2 | +import { Email } from '../../util/email'; |
| 3 | + |
| 4 | +/** |
| 5 | + * Email loading/parsing helpers for `limel-file-viewer`. |
| 6 | + * |
| 7 | + * Parses an RFC 5322 / MIME email message (commonly stored as a `.eml` file) |
| 8 | + * and returns a simplified `Email` view-model. |
| 9 | + */ |
| 10 | + |
| 11 | +/** |
| 12 | + * Fetches and parses an email message. |
| 13 | + * |
| 14 | + * - Prefers `email.html` if present, otherwise falls back to `email.text`. |
| 15 | + * - Attempts to resolve inline images referenced via `cid:` by replacing |
| 16 | + * `<img src="cid:...">` with `data:` URLs generated from inline attachments. |
| 17 | + * |
| 18 | + * @param url - URL to an email message, usually ending in `.eml`. |
| 19 | + * @returns A simplified `Email` object for rendering. |
| 20 | + */ |
| 21 | +export async function loadEmail(url: string): Promise<Email> { |
| 22 | + const response = await fetch(url); |
| 23 | + const buffer = await response.arrayBuffer(); |
| 24 | + |
| 25 | + const email = await PostalMime.parse(buffer, { |
| 26 | + attachmentEncoding: 'arraybuffer', |
| 27 | + }); |
| 28 | + |
| 29 | + const parsedEmail: Email = { |
| 30 | + subject: email.subject || undefined, |
| 31 | + from: formatAddress(email.from), |
| 32 | + to: formatAddresses(email.to), |
| 33 | + cc: formatAddresses(email.cc), |
| 34 | + date: email.date || undefined, |
| 35 | + }; |
| 36 | + |
| 37 | + const attachments: Email['attachments'] = []; |
| 38 | + |
| 39 | + const cidUrlById = new Map<string, string>(); |
| 40 | + for (const attachment of email.attachments || []) { |
| 41 | + const contentId = normalizeContentId(attachment.contentId); |
| 42 | + const isInline = |
| 43 | + attachment.related || attachment.disposition === 'inline'; |
| 44 | + |
| 45 | + if (!isInline) { |
| 46 | + const size = |
| 47 | + attachment.content instanceof ArrayBuffer |
| 48 | + ? attachment.content.byteLength |
| 49 | + : undefined; |
| 50 | + |
| 51 | + attachments.push({ |
| 52 | + filename: attachment.filename || undefined, |
| 53 | + mimeType: attachment.mimeType || undefined, |
| 54 | + size, |
| 55 | + }); |
| 56 | + continue; |
| 57 | + } |
| 58 | + |
| 59 | + if (!contentId) { |
| 60 | + continue; |
| 61 | + } |
| 62 | + |
| 63 | + if (!(attachment.content instanceof ArrayBuffer)) { |
| 64 | + continue; |
| 65 | + } |
| 66 | + |
| 67 | + const mimeType = attachment.mimeType || 'application/octet-stream'; |
| 68 | + const base64 = arrayBufferToBase64(attachment.content); |
| 69 | + const dataUrl = `data:${mimeType};base64,${base64}`; |
| 70 | + cidUrlById.set(contentId, dataUrl); |
| 71 | + } |
| 72 | + |
| 73 | + if (attachments.length > 0) { |
| 74 | + parsedEmail.attachments = attachments; |
| 75 | + } |
| 76 | + |
| 77 | + const html = (email.html || '').trim(); |
| 78 | + if (html) { |
| 79 | + parsedEmail.bodyHtml = replaceCidReferences(html, cidUrlById); |
| 80 | + } else { |
| 81 | + parsedEmail.bodyText = (email.text || '').trim() || undefined; |
| 82 | + } |
| 83 | + |
| 84 | + return parsedEmail; |
| 85 | +} |
| 86 | + |
| 87 | +/** |
| 88 | + * Normalizes a Content-ID by removing surrounding angle brackets. |
| 89 | + * |
| 90 | + * Example: `<image@id>` -> `image@id` |
| 91 | + * |
| 92 | + * @param contentId - The Content-ID to normalize, optionally surrounded by angle brackets. |
| 93 | + */ |
| 94 | +function normalizeContentId(contentId?: string): string { |
| 95 | + if (!contentId) { |
| 96 | + return ''; |
| 97 | + } |
| 98 | + |
| 99 | + return contentId.replaceAll(/(^<|>$)/g, '').trim(); |
| 100 | +} |
| 101 | + |
| 102 | +function replaceCidReferences( |
| 103 | + html: string, |
| 104 | + cidUrlById: Map<string, string> |
| 105 | +): string { |
| 106 | + if (cidUrlById.size === 0) { |
| 107 | + return html; |
| 108 | + } |
| 109 | + |
| 110 | + return html.replaceAll( |
| 111 | + /(src\s*=\s*["']?)cid:([^"'\s>]+)(["']?)/gi, |
| 112 | + (match, prefix, cid, suffix) => { |
| 113 | + const normalized = normalizeContentId(cid); |
| 114 | + const replacement = cidUrlById.get(normalized); |
| 115 | + if (!replacement) { |
| 116 | + return match; |
| 117 | + } |
| 118 | + |
| 119 | + return `${prefix}${replacement}${suffix}`; |
| 120 | + } |
| 121 | + ); |
| 122 | +} |
| 123 | + |
| 124 | +function arrayBufferToBase64(buffer: ArrayBuffer): string { |
| 125 | + const bytes = new Uint8Array(buffer); |
| 126 | + |
| 127 | + if (typeof btoa === 'function') { |
| 128 | + let binary = ''; |
| 129 | + for (const byte of bytes) { |
| 130 | + binary += String.fromCodePoint(byte); |
| 131 | + } |
| 132 | + return btoa(binary); |
| 133 | + } |
| 134 | + |
| 135 | + // Jest/Node fallback |
| 136 | + return (globalThis as any).Buffer.from(bytes).toString('base64'); |
| 137 | +} |
| 138 | + |
| 139 | +/** |
| 140 | + * Formats one or many address objects returned by PostalMime. |
| 141 | + * |
| 142 | + * @param addresses - Address object(s) to format. |
| 143 | + * @returns Formatted address string, or undefined if no valid addresses. |
| 144 | + */ |
| 145 | +function formatAddresses(addresses: any): string { |
| 146 | + if (!addresses) { |
| 147 | + return undefined; |
| 148 | + } |
| 149 | + |
| 150 | + const list = Array.isArray(addresses) ? addresses : [addresses]; |
| 151 | + const parts = list.map((addr) => formatAddress(addr)).filter(Boolean); |
| 152 | + |
| 153 | + return parts.length > 0 ? parts.join(', ') : undefined; |
| 154 | +} |
| 155 | + |
| 156 | +function formatAddress(address: any): string { |
| 157 | + if (!address) { |
| 158 | + return undefined; |
| 159 | + } |
| 160 | + |
| 161 | + if (Array.isArray(address)) { |
| 162 | + return formatAddresses(address); |
| 163 | + } |
| 164 | + |
| 165 | + if (address.group && Array.isArray(address.group)) { |
| 166 | + const groupName = (address.name || '').trim(); |
| 167 | + const groupMembers = address.group |
| 168 | + .map((m) => formatAddress(m)) |
| 169 | + .filter(Boolean) |
| 170 | + .join(', '); |
| 171 | + return groupName ? `${groupName}: ${groupMembers}` : groupMembers; |
| 172 | + } |
| 173 | + |
| 174 | + const name = (address.name || '').trim(); |
| 175 | + const email = (address.address || '').trim(); |
| 176 | + |
| 177 | + if (name && email) { |
| 178 | + return `${name} <${email}>`; |
| 179 | + } |
| 180 | + |
| 181 | + return name || email || undefined; |
| 182 | +} |
0 commit comments