From 2edbf379e2b4f2359feb0e12c4119485f8ae93fb Mon Sep 17 00:00:00 2001 From: Linus Rath Date: Wed, 18 Mar 2026 17:32:25 +0100 Subject: [PATCH] fix: implement unwrapping of embedded message/rfc822 attachments and enhance HTML body validation --- components/email/email-viewer.tsx | 159 +++++++++++++++++++++++++++--- 1 file changed, 146 insertions(+), 13 deletions(-) diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 632df8bb..6b93c485 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -390,6 +390,21 @@ function extractNestedSignedDataCandidate( }; } +/** + * Check if an HTML body string is effectively empty (just boilerplate/whitespace). + * Outlook often generates HTML bodies with Word CSS +   but no real text. + */ +function isHtmlBodyEffectivelyEmpty(html: string): boolean { + const textContent = html + .replace(/]*>[\s\S]*?<\/style>/gi, '') + .replace(/<[^>]+>/g, '') + .replace(/ /gi, ' ') + .replace(/ /g, ' ') + .replace(/\s+/g, '') + .trim(); + return textContent.length === 0; +} + function extractMimePartContent(rawText: string, depth = 0): { html: string | null; text: string | null } { if (depth > 6) { const trimmed = rawText.trim(); @@ -871,6 +886,12 @@ export function EmailViewer({ const [tnefText, setTnefText] = useState(null); const [tnefAttachments, setTnefAttachments] = useState([]); + // Embedded message/rfc822 unwrapping (Outlook forward-as-attachment) + const [embeddedEmailHtml, setEmbeddedEmailHtml] = useState(null); + const [embeddedEmailText, setEmbeddedEmailText] = useState(null); + const [embeddedEmailAttachments, setEmbeddedEmailAttachments] = useState([]); + const [embeddedEmailUnwrapped, setEmbeddedEmailUnwrapped] = useState(false); + // Ensure S/MIME key records are loaded from IndexedDB useLayoutEffect(() => { smimeStore.load(); @@ -1088,6 +1109,10 @@ export function EmailViewer({ setTnefHtml(null); setTnefText(null); setTnefAttachments([]); + setEmbeddedEmailHtml(null); + setEmbeddedEmailText(null); + setEmbeddedEmailAttachments([]); + setEmbeddedEmailUnwrapped(false); }, [email?.id, externalContentPolicy]); const prepareSmimeUnlock = useCallback((keyRecordId: string) => { @@ -1675,15 +1700,20 @@ export function EmailViewer({ debug.group('TNEF Processing'); debug.log('Found TNEF attachment:', tnefAtt.name, 'type:', tnefAtt.type, 'blobId:', tnefAtt.blobId, 'size:', tnefAtt.size); - // Check if the email already has a usable HTML body - const hasHtmlBody = !!( - email.htmlBody?.[0]?.partId && - email.bodyValues?.[email.htmlBody[0].partId]?.value?.trim() - ); - if (hasHtmlBody) { - debug.log('TNEF: Email already has HTML body, will extract attachments only'); + // Check if the email already has a usable HTML body with real content + // Outlook often forwards TNEF emails with an HTML body that's just Word + // boilerplate (CSS +  ) — treat these as effectively empty. + const htmlPartId = email.htmlBody?.[0]?.partId; + const htmlValue = htmlPartId ? email.bodyValues?.[htmlPartId]?.value?.trim() : ''; + let hasRealHtmlBody = !!htmlValue; + if (hasRealHtmlBody && htmlValue && isHtmlBodyEffectivelyEmpty(htmlValue)) { + hasRealHtmlBody = false; + debug.log('TNEF: Email HTML body is effectively empty (only boilerplate/whitespace), treating as no body'); + } + if (hasRealHtmlBody) { + debug.log('TNEF: Email has real HTML body, will extract attachments only'); } else { - debug.log('TNEF: Email has no HTML body, proceeding with full TNEF extraction'); + debug.log('TNEF: Email has no usable HTML body, proceeding with full TNEF extraction'); } let cancelled = false; @@ -1719,10 +1749,10 @@ export function EmailViewer({ debug.log('TNEF parse result — htmlBody:', !!parsed.htmlBody, '(' + (parsed.htmlBody?.length ?? 0) + ' chars)', ', body:', !!parsed.body, '(' + (parsed.body?.length ?? 0) + ' chars)', ', attachments:', parsed.attachments.length); - if (parsed.htmlBody && !hasHtmlBody) { + if (parsed.htmlBody && !hasRealHtmlBody) { setTnefHtml(parsed.htmlBody); } - if (parsed.body && !hasHtmlBody) { + if (parsed.body && !hasRealHtmlBody) { setTnefText(parsed.body); } if (parsed.attachments.length > 0) { @@ -1746,6 +1776,83 @@ export function EmailViewer({ return () => { cancelled = true; }; }, [email, client]); + // Embedded message/rfc822 unwrapping + // When Outlook forwards an email as an attachment, the outer email body is + // often empty Word boilerplate and the real content is inside a message/rfc822 + // attachment. Detect this pattern and unwrap the embedded email. + useEffect(() => { + if (!email?.attachments || !client) return; + + // Find message/rfc822 attachment + const rfc822Att = email.attachments.find( + att => att.type === 'message/rfc822' && att.blobId + ); + if (!rfc822Att?.blobId) return; + + // Only unwrap if the outer body is effectively empty + const htmlPartId = email.htmlBody?.[0]?.partId; + const htmlValue = htmlPartId ? email.bodyValues?.[htmlPartId]?.value?.trim() : ''; + const textPartId = email.textBody?.[0]?.partId; + const textValue = textPartId ? email.bodyValues?.[textPartId]?.value?.trim() : ''; + + const hasRealHtml = !!htmlValue && !isHtmlBodyEffectivelyEmpty(htmlValue); + const hasRealText = !!textValue; + + if (hasRealHtml || hasRealText) { + debug.log('Embedded RFC822: Outer email has real body content, not unwrapping'); + return; + } + + debug.group('Embedded RFC822 Unwrapping'); + debug.log('Found message/rfc822 attachment:', rfc822Att.name, 'blobId:', rfc822Att.blobId, 'size:', rfc822Att.size); + debug.log('Outer email body is empty, will unwrap embedded email'); + + let cancelled = false; + + async function unwrapEmbedded() { + try { + const blobBytes = await client!.fetchBlobArrayBuffer(rfc822Att!.blobId!); + if (cancelled) { debug.groupEnd(); return; } + if (blobBytes.byteLength === 0) { + debug.warn('Embedded RFC822: Fetched blob is empty'); + debug.groupEnd(); + return; + } + + const { default: PostalMime } = await import('postal-mime'); + const parser = new PostalMime(); + const parsed = await parser.parse(new Uint8Array(blobBytes)); + if (cancelled) { debug.groupEnd(); return; } + + debug.log('Embedded RFC822 parsed — html:', !!parsed.html, '(' + (parsed.html?.length ?? 0) + ' chars)', + ', text:', !!parsed.text, '(' + (parsed.text?.length ?? 0) + ' chars)', + ', attachments:', parsed.attachments?.length ?? 0); + + if (parsed.html) { + setEmbeddedEmailHtml(parsed.html); + } + if (parsed.text) { + setEmbeddedEmailText(parsed.text); + } + if (parsed.attachments && parsed.attachments.length > 0) { + setEmbeddedEmailAttachments(parsed.attachments as PostalMimeAttachment[]); + debug.log('Embedded RFC822 attachments:', parsed.attachments.map( + a => (a.filename || 'unnamed') + ' (' + a.mimeType + ')' + ).join(', ')); + } + setEmbeddedEmailUnwrapped(true); + debug.groupEnd(); + } catch (err) { + debug.error('Embedded RFC822 unwrapping failed:', err); + debug.groupEnd(); + } + } + + unwrapEmbedded(); + + return () => { cancelled = true; }; + }, [email, client]); + // Fetch inline CID images with authentication to prevent browser auth dialogs useEffect(() => { let cancelled = false; @@ -1829,6 +1936,8 @@ export function EmailViewer({ const jmapAttachments = (email?.attachments ?? []) // Hide winmail.dat when we have successfully extracted TNEF content or attachments .filter(att => !(tnefHtml || tnefText || tnefAttachments.length > 0) || !isTnefAttachment(att.name, att.type)) + // Hide message/rfc822 when we have unwrapped the embedded email + .filter(att => !embeddedEmailUnwrapped || att.type !== 'message/rfc822') .map((attachment, index) => ({ id: attachment.blobId || `${attachment.name || 'attachment'}-${index}`, name: attachment.name || null, @@ -1847,8 +1956,19 @@ export function EmailViewer({ tnefData: att.data, })); - return [...jmapAttachments, ...tnefExtracted]; - }, [email?.attachments, smimeDecryptedAttachments, tnefHtml, tnefText, tnefAttachments]); + // Append attachments extracted from embedded message/rfc822 + const embeddedExtracted: EffectiveAttachment[] = embeddedEmailAttachments + .filter(att => !att.contentId) // Skip inline CID images + .map((att, index) => ({ + id: `embedded-${index}-${att.filename || att.mimeType}`, + name: att.filename || null, + type: att.mimeType || 'application/octet-stream', + size: getPostalMimeAttachmentSize(att), + decryptedAttachment: att, + })); + + return [...jmapAttachments, ...tnefExtracted, ...embeddedExtracted]; + }, [email?.attachments, smimeDecryptedAttachments, tnefHtml, tnefText, tnefAttachments, embeddedEmailUnwrapped, embeddedEmailAttachments]); // Generate email source for viewing const generateEmailSource = (email: Email): string => { @@ -2189,8 +2309,21 @@ export function EmailViewer({ .replace(/(https?:\/\/[^\s<]+)/g, '$1'); return { html: htmlFromText, isHtml: false }; } + // Embedded message/rfc822 unwrapped content + if (embeddedEmailHtml) { + const cleanHtml = DOMPurify.sanitize(embeddedEmailHtml, EMAIL_SANITIZE_CONFIG); + return { html: cleanHtml, isHtml: true }; + } + if (embeddedEmailText) { + const htmlFromText = embeddedEmailText + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/(https?:\/\/[^\s<]+)/g, '$1'); + return { html: htmlFromText, isHtml: false }; + } return emailContent; - }, [cidBlobUrls, emailContent, smimeDecryptedHtml, smimeDecryptedText, tnefHtml, tnefText]); + }, [cidBlobUrls, emailContent, smimeDecryptedHtml, smimeDecryptedText, tnefHtml, tnefText, embeddedEmailHtml, embeddedEmailText]); const handleEffectiveAttachmentOpen = useCallback((attachment: EffectiveAttachment) => { const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);