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 } {
|
function extractMimePartContent(rawText: string, depth = 0): { html: string | null; text: string | null } {
|
||||||
if (depth > 6) {
|
if (depth > 6) {
|
||||||
const trimmed = rawText.trim();
|
const trimmed = rawText.trim();
|
||||||
@@ -871,6 +886,12 @@ export function EmailViewer({
|
|||||||
const [tnefText, setTnefText] = useState<string | null>(null);
|
const [tnefText, setTnefText] = useState<string | null>(null);
|
||||||
const [tnefAttachments, setTnefAttachments] = useState<TnefAttachment[]>([]);
|
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
|
// Ensure S/MIME key records are loaded from IndexedDB
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
smimeStore.load();
|
smimeStore.load();
|
||||||
@@ -1088,6 +1109,10 @@ export function EmailViewer({
|
|||||||
setTnefHtml(null);
|
setTnefHtml(null);
|
||||||
setTnefText(null);
|
setTnefText(null);
|
||||||
setTnefAttachments([]);
|
setTnefAttachments([]);
|
||||||
|
setEmbeddedEmailHtml(null);
|
||||||
|
setEmbeddedEmailText(null);
|
||||||
|
setEmbeddedEmailAttachments([]);
|
||||||
|
setEmbeddedEmailUnwrapped(false);
|
||||||
}, [email?.id, externalContentPolicy]);
|
}, [email?.id, externalContentPolicy]);
|
||||||
|
|
||||||
const prepareSmimeUnlock = useCallback((keyRecordId: string) => {
|
const prepareSmimeUnlock = useCallback((keyRecordId: string) => {
|
||||||
@@ -1675,15 +1700,20 @@ export function EmailViewer({
|
|||||||
debug.group('TNEF Processing');
|
debug.group('TNEF Processing');
|
||||||
debug.log('Found TNEF attachment:', tnefAtt.name, 'type:', tnefAtt.type, 'blobId:', tnefAtt.blobId, 'size:', tnefAtt.size);
|
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
|
// Check if the email already has a usable HTML body with real content
|
||||||
const hasHtmlBody = !!(
|
// Outlook often forwards TNEF emails with an HTML body that's just Word
|
||||||
email.htmlBody?.[0]?.partId &&
|
// boilerplate (CSS + ) — treat these as effectively empty.
|
||||||
email.bodyValues?.[email.htmlBody[0].partId]?.value?.trim()
|
const htmlPartId = email.htmlBody?.[0]?.partId;
|
||||||
);
|
const htmlValue = htmlPartId ? email.bodyValues?.[htmlPartId]?.value?.trim() : '';
|
||||||
if (hasHtmlBody) {
|
let hasRealHtmlBody = !!htmlValue;
|
||||||
debug.log('TNEF: Email already has HTML body, will extract attachments only');
|
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 {
|
} 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;
|
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);
|
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);
|
setTnefHtml(parsed.htmlBody);
|
||||||
}
|
}
|
||||||
if (parsed.body && !hasHtmlBody) {
|
if (parsed.body && !hasRealHtmlBody) {
|
||||||
setTnefText(parsed.body);
|
setTnefText(parsed.body);
|
||||||
}
|
}
|
||||||
if (parsed.attachments.length > 0) {
|
if (parsed.attachments.length > 0) {
|
||||||
@@ -1746,6 +1776,83 @@ export function EmailViewer({
|
|||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [email, client]);
|
}, [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
|
// Fetch inline CID images with authentication to prevent browser auth dialogs
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -1829,6 +1936,8 @@ export function EmailViewer({
|
|||||||
const jmapAttachments = (email?.attachments ?? [])
|
const jmapAttachments = (email?.attachments ?? [])
|
||||||
// Hide winmail.dat when we have successfully extracted TNEF content or 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))
|
.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) => ({
|
.map((attachment, index) => ({
|
||||||
id: attachment.blobId || `${attachment.name || 'attachment'}-${index}`,
|
id: attachment.blobId || `${attachment.name || 'attachment'}-${index}`,
|
||||||
name: attachment.name || null,
|
name: attachment.name || null,
|
||||||
@@ -1847,8 +1956,19 @@ export function EmailViewer({
|
|||||||
tnefData: att.data,
|
tnefData: att.data,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return [...jmapAttachments, ...tnefExtracted];
|
// Append attachments extracted from embedded message/rfc822
|
||||||
}, [email?.attachments, smimeDecryptedAttachments, tnefHtml, tnefText, tnefAttachments]);
|
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
|
// Generate email source for viewing
|
||||||
const generateEmailSource = (email: Email): string => {
|
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>');
|
.replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>');
|
||||||
return { html: htmlFromText, isHtml: false };
|
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;
|
return emailContent;
|
||||||
}, [cidBlobUrls, emailContent, smimeDecryptedHtml, smimeDecryptedText, tnefHtml, tnefText]);
|
}, [cidBlobUrls, emailContent, smimeDecryptedHtml, smimeDecryptedText, tnefHtml, tnefText, embeddedEmailHtml, embeddedEmailText]);
|
||||||
|
|
||||||
const handleEffectiveAttachmentOpen = useCallback((attachment: EffectiveAttachment) => {
|
const handleEffectiveAttachmentOpen = useCallback((attachment: EffectiveAttachment) => {
|
||||||
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
|
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
|
||||||
|
|||||||
Reference in New Issue
Block a user