fix: implement unwrapping of embedded message/rfc822 attachments and enhance HTML body validation

This commit is contained in:
Linus Rath
2026-03-18 17:32:25 +01:00
parent cdc521b693
commit 2edbf379e2
+146 -13
View File
@@ -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(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;/gi, ' ')
.replace(/&#160;/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<string | null>(null);
const [tnefAttachments, setTnefAttachments] = useState<TnefAttachment[]>([]);
// Embedded message/rfc822 unwrapping (Outlook forward-as-attachment)
const [embeddedEmailHtml, setEmbeddedEmailHtml] = useState<string | null>(null);
const [embeddedEmailText, setEmbeddedEmailText] = useState<string | null>(null);
const [embeddedEmailAttachments, setEmbeddedEmailAttachments] = useState<PostalMimeAttachment[]>([]);
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 + &nbsp;) — 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, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>');
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>');
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);