feat: enhance TNEF parsing with detailed debug logging for better traceability
This commit is contained in:
@@ -89,6 +89,7 @@ import { smimeVerify } from "@/lib/smime/smime-verify";
|
|||||||
import { useSmimeStore } from "@/stores/smime-store";
|
import { useSmimeStore } from "@/stores/smime-store";
|
||||||
import type { SmimeStatus } from "@/lib/smime/types";
|
import type { SmimeStatus } from "@/lib/smime/types";
|
||||||
import { parseTnef, isTnefAttachment } from "@/lib/tnef";
|
import { parseTnef, isTnefAttachment } from "@/lib/tnef";
|
||||||
|
import { debug } from "@/lib/debug";
|
||||||
import type { TnefAttachment } from "@/lib/tnef";
|
import type { TnefAttachment } from "@/lib/tnef";
|
||||||
|
|
||||||
interface EmailViewerProps {
|
interface EmailViewerProps {
|
||||||
@@ -1617,26 +1618,59 @@ export function EmailViewer({
|
|||||||
if (!email?.attachments || !client) return;
|
if (!email?.attachments || !client) return;
|
||||||
|
|
||||||
const tnefAtt = email.attachments.find(att => isTnefAttachment(att.name, att.type));
|
const tnefAtt = email.attachments.find(att => isTnefAttachment(att.name, att.type));
|
||||||
if (!tnefAtt?.blobId) return;
|
if (!tnefAtt?.blobId) {
|
||||||
|
debug.log('TNEF: No winmail.dat attachment found in email', email?.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
debug.group('TNEF Processing');
|
||||||
|
debug.log('Found TNEF attachment:', tnefAtt.name, 'type:', tnefAtt.type, 'blobId:', tnefAtt.blobId, 'size:', tnefAtt.size);
|
||||||
|
|
||||||
// Only process if the email has no usable HTML body
|
// Only process if the email has no usable HTML body
|
||||||
const hasHtmlBody = !!(
|
const hasHtmlBody = !!(
|
||||||
email.htmlBody?.[0]?.partId &&
|
email.htmlBody?.[0]?.partId &&
|
||||||
email.bodyValues?.[email.htmlBody[0].partId]?.value?.trim()
|
email.bodyValues?.[email.htmlBody[0].partId]?.value?.trim()
|
||||||
);
|
);
|
||||||
if (hasHtmlBody) return;
|
if (hasHtmlBody) {
|
||||||
|
debug.log('TNEF: Email already has HTML body, skipping TNEF extraction');
|
||||||
|
debug.log(' HTML partId:', email.htmlBody?.[0]?.partId, 'body length:', email.bodyValues?.[email.htmlBody![0].partId]?.value?.length);
|
||||||
|
debug.groupEnd();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
debug.log('TNEF: Email has no HTML body, proceeding with TNEF extraction');
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
async function processTnef() {
|
async function processTnef() {
|
||||||
try {
|
try {
|
||||||
|
debug.time('TNEF fetch blob');
|
||||||
const blobBytes = await client!.fetchBlobArrayBuffer(tnefAtt!.blobId!);
|
const blobBytes = await client!.fetchBlobArrayBuffer(tnefAtt!.blobId!);
|
||||||
if (cancelled || blobBytes.byteLength === 0) return;
|
debug.timeEnd('TNEF fetch blob');
|
||||||
|
debug.log('TNEF: Fetched blob, size:', blobBytes.byteLength, 'bytes');
|
||||||
|
|
||||||
|
if (cancelled) {
|
||||||
|
debug.log('TNEF: Processing cancelled after fetch');
|
||||||
|
debug.groupEnd();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (blobBytes.byteLength === 0) {
|
||||||
|
debug.warn('TNEF: Fetched blob is empty (0 bytes)');
|
||||||
|
debug.groupEnd();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const tnefData = new Uint8Array(blobBytes);
|
const tnefData = new Uint8Array(blobBytes);
|
||||||
|
debug.time('TNEF parse');
|
||||||
const parsed = parseTnef(tnefData);
|
const parsed = parseTnef(tnefData);
|
||||||
|
debug.timeEnd('TNEF parse');
|
||||||
|
|
||||||
if (cancelled) return;
|
if (cancelled) {
|
||||||
|
debug.log('TNEF: Processing cancelled after parse');
|
||||||
|
debug.groupEnd();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
if (parsed.htmlBody) {
|
||||||
setTnefHtml(parsed.htmlBody);
|
setTnefHtml(parsed.htmlBody);
|
||||||
@@ -1646,9 +1680,17 @@ export function EmailViewer({
|
|||||||
}
|
}
|
||||||
if (parsed.attachments.length > 0) {
|
if (parsed.attachments.length > 0) {
|
||||||
setTnefAttachments(parsed.attachments);
|
setTnefAttachments(parsed.attachments);
|
||||||
|
debug.log('TNEF extracted attachments:', parsed.attachments.map(a => a.name + ' (' + a.mimeType + ', ' + a.data.byteLength + ' bytes)').join(', '));
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
// TNEF parsing failed — fall through to plain text display
|
if (!parsed.htmlBody && !parsed.body && parsed.attachments.length === 0) {
|
||||||
|
debug.warn('TNEF: Parsing succeeded but no content was extracted — the winmail.dat may use an unsupported format');
|
||||||
|
}
|
||||||
|
|
||||||
|
debug.groupEnd();
|
||||||
|
} catch (err) {
|
||||||
|
debug.error('TNEF processing failed for email', email?.id, err);
|
||||||
|
debug.groupEnd();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+53
-2
@@ -7,6 +7,8 @@
|
|||||||
* Reference: MS-OXTNEF / MS-TNEF specification.
|
* Reference: MS-OXTNEF / MS-TNEF specification.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { debug } from '@/lib/debug';
|
||||||
|
|
||||||
// TNEF signature
|
// TNEF signature
|
||||||
const TNEF_SIGNATURE = 0x223E9F78;
|
const TNEF_SIGNATURE = 0x223E9F78;
|
||||||
|
|
||||||
@@ -234,35 +236,58 @@ export function parseTnef(data: Uint8Array): TnefResult {
|
|||||||
attachments: [],
|
attachments: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
if (data.byteLength < 6) return result;
|
debug.group('TNEF Parser');
|
||||||
|
debug.log('Input data size:', data.byteLength, 'bytes');
|
||||||
|
|
||||||
|
if (data.byteLength < 6) {
|
||||||
|
debug.warn('TNEF data too small (< 6 bytes), skipping');
|
||||||
|
debug.groupEnd();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
const r = new BinaryReader(data);
|
const r = new BinaryReader(data);
|
||||||
|
|
||||||
const signature = r.readUint32LE();
|
const signature = r.readUint32LE();
|
||||||
if (signature !== TNEF_SIGNATURE) {
|
if (signature !== TNEF_SIGNATURE) {
|
||||||
|
debug.warn('Invalid TNEF signature:', '0x' + signature.toString(16).toUpperCase(), '(expected 0x223E9F78)');
|
||||||
|
debug.groupEnd();
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
debug.log('TNEF signature valid');
|
||||||
|
|
||||||
r.skip(2); // legacy key
|
r.skip(2); // legacy key
|
||||||
|
|
||||||
// Current attachment being assembled
|
// Current attachment being assembled
|
||||||
let curAttach: { name: string; mimeType: string; data: Uint8Array | null } | null = null;
|
let curAttach: { name: string; mimeType: string; data: Uint8Array | null } | null = null;
|
||||||
|
let attrCount = 0;
|
||||||
|
|
||||||
while (r.remaining >= 11) {
|
while (r.remaining >= 11) {
|
||||||
const level = r.readUint8();
|
const level = r.readUint8();
|
||||||
const attrID = r.readUint32LE();
|
const attrID = r.readUint32LE();
|
||||||
const attrLen = r.readUint32LE();
|
const attrLen = r.readUint32LE();
|
||||||
|
attrCount++;
|
||||||
|
|
||||||
if (attrLen > r.remaining - 2) break; // not enough data for payload + checksum
|
if (attrLen > r.remaining - 2) {
|
||||||
|
debug.warn('Attribute #' + attrCount + ': truncated data — need', attrLen, 'bytes but only', r.remaining - 2, 'available');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
const attrData = r.readBytes(attrLen);
|
const attrData = r.readBytes(attrLen);
|
||||||
r.skip(2); // checksum
|
r.skip(2); // checksum
|
||||||
|
|
||||||
|
const levelName = level === LVL_MESSAGE ? 'MESSAGE' : level === LVL_ATTACHMENT ? 'ATTACHMENT' : 'UNKNOWN(' + level + ')';
|
||||||
|
debug.log('Attribute #' + attrCount + ':', levelName, 'id=0x' + attrID.toString(16).toUpperCase(), 'len=' + attrLen);
|
||||||
|
|
||||||
if (level === LVL_MESSAGE) {
|
if (level === LVL_MESSAGE) {
|
||||||
if (attrID === attBody) {
|
if (attrID === attBody) {
|
||||||
result.body = new TextDecoder('utf-8').decode(attrData);
|
result.body = new TextDecoder('utf-8').decode(attrData);
|
||||||
|
debug.log(' → Extracted plain text body (' + result.body.length + ' chars)');
|
||||||
} else if (attrID === attMAPIProps) {
|
} else if (attrID === attMAPIProps) {
|
||||||
const props = parseMAPIProps(attrData);
|
const props = parseMAPIProps(attrData);
|
||||||
|
debug.log(' → Parsed', props.size, 'MAPI properties from message');
|
||||||
|
props.forEach((val, propID) => {
|
||||||
|
debug.log(' MAPI prop 0x' + propID.toString(16).toUpperCase(), 'type=0x' + val.type.toString(16), 'value=' + (val.value instanceof Uint8Array ? val.value.byteLength + ' bytes' : val.value));
|
||||||
|
});
|
||||||
|
|
||||||
// HTML body
|
// HTML body
|
||||||
const htmlProp = props.get(PR_BODY_HTML);
|
const htmlProp = props.get(PR_BODY_HTML);
|
||||||
@@ -273,6 +298,9 @@ export function parseTnef(data: Uint8Array): TnefResult {
|
|||||||
} else {
|
} else {
|
||||||
result.htmlBody = new TextDecoder('utf-8').decode(htmlProp.value);
|
result.htmlBody = new TextDecoder('utf-8').decode(htmlProp.value);
|
||||||
}
|
}
|
||||||
|
debug.log(' → Extracted HTML body (' + result.htmlBody.length + ' chars)');
|
||||||
|
} else {
|
||||||
|
debug.log(' → No HTML body property (PR_BODY_HTML 0x1013) found in MAPI props');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Plain text body from MAPI props (fallback)
|
// Plain text body from MAPI props (fallback)
|
||||||
@@ -280,6 +308,9 @@ export function parseTnef(data: Uint8Array): TnefResult {
|
|||||||
const bodyProp = props.get(PR_BODY);
|
const bodyProp = props.get(PR_BODY);
|
||||||
if (bodyProp?.value instanceof Uint8Array) {
|
if (bodyProp?.value instanceof Uint8Array) {
|
||||||
result.body = decodeMAPIString(bodyProp.value, bodyProp.type & 0x0FFF);
|
result.body = decodeMAPIString(bodyProp.value, bodyProp.type & 0x0FFF);
|
||||||
|
debug.log(' → Extracted plain text body from MAPI props (' + result.body.length + ' chars)');
|
||||||
|
} else {
|
||||||
|
debug.log(' → No plain text body property (PR_BODY 0x1000) found in MAPI props');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -287,6 +318,7 @@ export function parseTnef(data: Uint8Array): TnefResult {
|
|||||||
if (attrID === attAttachRenddata) {
|
if (attrID === attAttachRenddata) {
|
||||||
// Start of a new attachment — flush previous
|
// Start of a new attachment — flush previous
|
||||||
if (curAttach?.data) {
|
if (curAttach?.data) {
|
||||||
|
debug.log(' → Flushing previous attachment:', curAttach.name, '(' + curAttach.mimeType + ',', curAttach.data.byteLength, 'bytes)');
|
||||||
result.attachments.push({
|
result.attachments.push({
|
||||||
name: curAttach.name,
|
name: curAttach.name,
|
||||||
mimeType: curAttach.mimeType,
|
mimeType: curAttach.mimeType,
|
||||||
@@ -294,28 +326,40 @@ export function parseTnef(data: Uint8Array): TnefResult {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
curAttach = { name: 'attachment', mimeType: 'application/octet-stream', data: null };
|
curAttach = { name: 'attachment', mimeType: 'application/octet-stream', data: null };
|
||||||
|
debug.log(' → New attachment started');
|
||||||
} else if (attrID === attAttachTitle && curAttach) {
|
} else if (attrID === attAttachTitle && curAttach) {
|
||||||
let len = attrData.byteLength;
|
let len = attrData.byteLength;
|
||||||
if (len > 0 && attrData[len - 1] === 0) len--;
|
if (len > 0 && attrData[len - 1] === 0) len--;
|
||||||
curAttach.name = new TextDecoder('utf-8').decode(attrData.subarray(0, len));
|
curAttach.name = new TextDecoder('utf-8').decode(attrData.subarray(0, len));
|
||||||
|
debug.log(' → Attachment short name:', curAttach.name);
|
||||||
} else if (attrID === attAttachData && curAttach) {
|
} else if (attrID === attAttachData && curAttach) {
|
||||||
curAttach.data = attrData;
|
curAttach.data = attrData;
|
||||||
|
debug.log(' → Attachment data (attAttachData):', attrData.byteLength, 'bytes');
|
||||||
} else if (attrID === attAttachment && curAttach) {
|
} else if (attrID === attAttachment && curAttach) {
|
||||||
const props = parseMAPIProps(attrData);
|
const props = parseMAPIProps(attrData);
|
||||||
|
debug.log(' → Parsed', props.size, 'MAPI properties from attachment');
|
||||||
|
props.forEach((val, propID) => {
|
||||||
|
debug.log(' MAPI prop 0x' + propID.toString(16).toUpperCase(), 'type=0x' + val.type.toString(16), 'value=' + (val.value instanceof Uint8Array ? val.value.byteLength + ' bytes' : val.value));
|
||||||
|
});
|
||||||
|
|
||||||
const longName = props.get(PR_ATTACH_LONG_FILENAME);
|
const longName = props.get(PR_ATTACH_LONG_FILENAME);
|
||||||
if (longName?.value instanceof Uint8Array) {
|
if (longName?.value instanceof Uint8Array) {
|
||||||
curAttach.name = decodeMAPIString(longName.value, longName.type & 0x0FFF);
|
curAttach.name = decodeMAPIString(longName.value, longName.type & 0x0FFF);
|
||||||
|
debug.log(' → Attachment long filename:', curAttach.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
const mimeTag = props.get(PR_ATTACH_MIME_TAG);
|
const mimeTag = props.get(PR_ATTACH_MIME_TAG);
|
||||||
if (mimeTag?.value instanceof Uint8Array) {
|
if (mimeTag?.value instanceof Uint8Array) {
|
||||||
curAttach.mimeType = decodeMAPIString(mimeTag.value, mimeTag.type & 0x0FFF);
|
curAttach.mimeType = decodeMAPIString(mimeTag.value, mimeTag.type & 0x0FFF);
|
||||||
|
debug.log(' → Attachment MIME type:', curAttach.mimeType);
|
||||||
}
|
}
|
||||||
|
|
||||||
const attachData = props.get(PR_ATTACH_DATA_BIN);
|
const attachData = props.get(PR_ATTACH_DATA_BIN);
|
||||||
if (attachData?.value instanceof Uint8Array) {
|
if (attachData?.value instanceof Uint8Array) {
|
||||||
curAttach.data = attachData.value;
|
curAttach.data = attachData.value;
|
||||||
|
debug.log(' → Attachment data (PR_ATTACH_DATA_BIN):', attachData.value.byteLength, 'bytes');
|
||||||
|
} else {
|
||||||
|
debug.log(' → No PR_ATTACH_DATA_BIN found in attachment MAPI props');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -323,6 +367,7 @@ export function parseTnef(data: Uint8Array): TnefResult {
|
|||||||
|
|
||||||
// Flush last attachment
|
// Flush last attachment
|
||||||
if (curAttach?.data) {
|
if (curAttach?.data) {
|
||||||
|
debug.log('Flushing final attachment:', curAttach.name, '(' + curAttach.mimeType + ',', curAttach.data.byteLength, 'bytes)');
|
||||||
result.attachments.push({
|
result.attachments.push({
|
||||||
name: curAttach.name,
|
name: curAttach.name,
|
||||||
mimeType: curAttach.mimeType,
|
mimeType: curAttach.mimeType,
|
||||||
@@ -330,6 +375,12 @@ export function parseTnef(data: Uint8Array): TnefResult {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
debug.log('TNEF parsing complete — body:', !!result.body, ', htmlBody:', !!result.htmlBody, ', attachments:', result.attachments.length);
|
||||||
|
if (result.attachments.length > 0) {
|
||||||
|
debug.table(result.attachments.map(a => ({ name: a.name, mimeType: a.mimeType, size: a.data.byteLength })));
|
||||||
|
}
|
||||||
|
debug.groupEnd();
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user