fix: implement unwrapping of embedded message/rfc822 attachments and enhance HTML body validation
This commit is contained in:
@@ -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(/ /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<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 + ) — 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.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);
|
||||
|
||||
Reference in New Issue
Block a user