/** * Minimal TNEF (Transport Neutral Encapsulation Format) parser. * * Parses winmail.dat files sent by Microsoft Outlook to extract * the HTML body, plain text body, and embedded attachments. * * Reference: MS-OXTNEF / MS-TNEF specification. */ import { debug } from '@/lib/debug'; // TNEF signature const TNEF_SIGNATURE = 0x223E9F78; // Attribute levels const LVL_MESSAGE = 0x01; const LVL_ATTACHMENT = 0x02; // Message-level attribute IDs const attBody = 0x0002800C; const attMAPIProps = 0x00069003; // Attachment-level attribute IDs const attAttachRenddata = 0x00069002; const attAttachData = 0x0006800F; const attAttachTitle = 0x00018010; const attAttachment = 0x00069005; // MAPI props for attachments // MAPI property types const PT_SHORT = 0x0002; const PT_LONG = 0x0003; const PT_BOOLEAN = 0x000B; const PT_STRING8 = 0x001E; const PT_UNICODE = 0x001F; const PT_BINARY = 0x0102; const PT_SYSTIME = 0x0040; const PT_CLSID = 0x0048; const PT_I8 = 0x0014; // Multi-value flag const MV_FLAG = 0x1000; // MAPI property IDs const PR_BODY = 0x1000; const PR_BODY_HTML = 0x1013; const PR_ATTACH_LONG_FILENAME = 0x3707; const PR_ATTACH_MIME_TAG = 0x370E; const PR_ATTACH_DATA_BIN = 0x3701; export interface TnefAttachment { name: string; mimeType: string; data: Uint8Array; } export interface TnefResult { body: string | null; htmlBody: string | null; attachments: TnefAttachment[]; } class BinaryReader { private view: DataView; private offset: number; private bytes: Uint8Array; constructor(data: Uint8Array) { this.bytes = data; this.view = new DataView(data.buffer, data.byteOffset, data.byteLength); this.offset = 0; } readUint8(): number { const val = this.view.getUint8(this.offset); this.offset += 1; return val; } readUint16LE(): number { const val = this.view.getUint16(this.offset, true); this.offset += 2; return val; } readUint32LE(): number { const val = this.view.getUint32(this.offset, true); this.offset += 4; return val; } readBytes(length: number): Uint8Array { const slice = this.bytes.slice(this.offset, this.offset + length); this.offset += length; return slice; } skip(n: number): void { this.offset += n; } get remaining(): number { return this.bytes.byteLength - this.offset; } } /** Padding needed to align to 4-byte boundary */ function pad4(len: number): number { return (4 - (len % 4)) % 4; } /** Read a single MAPI property value (fixed-length types only) */ function readMAPIFixedValue(r: BinaryReader, propType: number): Uint8Array | number | null { switch (propType) { case PT_SHORT: { const val = r.readUint16LE(); r.skip(2); // padded to 4 bytes return val; } case PT_LONG: case PT_BOOLEAN: return r.readUint32LE(); case PT_I8: case PT_SYSTIME: return r.readBytes(8); case PT_CLSID: return r.readBytes(16); default: // Unknown/unsupported type — try to read as fixed 4 bytes if (r.remaining >= 4) { return r.readBytes(4); } return null; } } /** Read a variable-length MAPI value (length-prefixed with padding) */ function readMAPIVarValue(r: BinaryReader): Uint8Array | null { if (r.remaining < 4) return null; const length = r.readUint32LE(); if (length > r.remaining) return null; const data = r.readBytes(length); r.skip(pad4(length)); return data; } /** Check if a base property type is variable-length */ function isVarLengthType(baseType: number): boolean { return baseType === PT_STRING8 || baseType === PT_UNICODE || baseType === PT_BINARY; } /** Decode a MAPI string (PT_STRING8 or PT_UNICODE) from raw bytes */ function decodeMAPIString(data: Uint8Array, propType: number): string { if (propType === PT_UNICODE) { let len = data.byteLength; // Strip null terminator (2 bytes for UTF-16) if (len >= 2 && data[len - 1] === 0 && data[len - 2] === 0) { len -= 2; } return new TextDecoder('utf-16le').decode(data.subarray(0, len)); } let len = data.byteLength; if (len >= 1 && data[len - 1] === 0) { len -= 1; } return new TextDecoder('utf-8').decode(data.subarray(0, len)); } /** Parse MAPI properties from a raw attribute data block */ function parseMAPIProps(data: Uint8Array): Map { const props = new Map(); const r = new BinaryReader(data); if (r.remaining < 4) return props; const count = r.readUint32LE(); for (let i = 0; i < count && r.remaining >= 4; i++) { const propType = r.readUint16LE(); const propID = r.readUint16LE(); // Named properties (ID >= 0x8000) carry extra GUID + name data if (propID >= 0x8000) { if (r.remaining < 20) break; r.skip(16); // GUID const kind = r.readUint32LE(); if (kind === 0) { if (r.remaining < 4) break; r.skip(4); // named-by-ID } else { if (r.remaining < 4) break; const nameLen = r.readUint32LE(); if (nameLen > r.remaining) break; r.skip(nameLen); r.skip(pad4(nameLen)); } } const baseType = propType & 0x0FFF; const isMultiValue = (propType & MV_FLAG) !== 0; if (isVarLengthType(baseType)) { // Variable-length types always have a value count (1 for single-value) if (r.remaining < 4) break; const valueCount = r.readUint32LE(); let lastValue: Uint8Array | null = null; for (let j = 0; j < valueCount && r.remaining > 0; j++) { lastValue = readMAPIVarValue(r); } if (!isMultiValue && lastValue) { props.set(propID, { type: propType, value: lastValue }); } } else if (isMultiValue) { if (r.remaining < 4) break; const valueCount = r.readUint32LE(); for (let j = 0; j < valueCount && r.remaining > 0; j++) { readMAPIFixedValue(r, baseType); } } else { const value = readMAPIFixedValue(r, baseType); props.set(propID, { type: propType, value }); } } return props; } /** * Parse a TNEF (winmail.dat) file and extract the body and attachments. * * @param data - Raw bytes of the TNEF file * @returns Parsed result with body text, HTML body, and attachments */ export function parseTnef(data: Uint8Array): TnefResult { const result: TnefResult = { body: null, htmlBody: null, attachments: [], }; 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 signature = r.readUint32LE(); if (signature !== TNEF_SIGNATURE) { debug.warn('Invalid TNEF signature:', '0x' + signature.toString(16).toUpperCase(), '(expected 0x223E9F78)'); debug.groupEnd(); return result; } debug.log('TNEF signature valid'); r.skip(2); // legacy key // Current attachment being assembled let curAttach: { name: string; mimeType: string; data: Uint8Array | null } | null = null; let attrCount = 0; while (r.remaining >= 11) { const level = r.readUint8(); const attrID = r.readUint32LE(); const attrLen = r.readUint32LE(); attrCount++; 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); 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 (attrID === attBody) { result.body = new TextDecoder('utf-8').decode(attrData); debug.log(' → Extracted plain text body (' + result.body.length + ' chars)'); } else if (attrID === attMAPIProps) { 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 const htmlProp = props.get(PR_BODY_HTML); if (htmlProp?.value instanceof Uint8Array) { const baseType = htmlProp.type & 0x0FFF; if (baseType === PT_STRING8 || baseType === PT_UNICODE) { result.htmlBody = decodeMAPIString(htmlProp.value, baseType); } else { 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) if (!result.body) { const bodyProp = props.get(PR_BODY); if (bodyProp?.value instanceof Uint8Array) { 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'); } } } } else if (level === LVL_ATTACHMENT) { if (attrID === attAttachRenddata) { // Start of a new attachment — flush previous if (curAttach?.data) { debug.log(' → Flushing previous attachment:', curAttach.name, '(' + curAttach.mimeType + ',', curAttach.data.byteLength, 'bytes)'); result.attachments.push({ name: curAttach.name, mimeType: curAttach.mimeType, data: curAttach.data, }); } curAttach = { name: 'attachment', mimeType: 'application/octet-stream', data: null }; debug.log(' → New attachment started'); } else if (attrID === attAttachTitle && curAttach) { let len = attrData.byteLength; if (len > 0 && attrData[len - 1] === 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) { curAttach.data = attrData; debug.log(' → Attachment data (attAttachData):', attrData.byteLength, 'bytes'); } else if (attrID === attAttachment && curAttach) { 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); if (longName?.value instanceof Uint8Array) { curAttach.name = decodeMAPIString(longName.value, longName.type & 0x0FFF); debug.log(' → Attachment long filename:', curAttach.name); } const mimeTag = props.get(PR_ATTACH_MIME_TAG); if (mimeTag?.value instanceof Uint8Array) { curAttach.mimeType = decodeMAPIString(mimeTag.value, mimeTag.type & 0x0FFF); debug.log(' → Attachment MIME type:', curAttach.mimeType); } const attachData = props.get(PR_ATTACH_DATA_BIN); if (attachData?.value instanceof Uint8Array) { 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'); } } } } // Flush last attachment if (curAttach?.data) { debug.log('Flushing final attachment:', curAttach.name, '(' + curAttach.mimeType + ',', curAttach.data.byteLength, 'bytes)'); result.attachments.push({ name: curAttach.name, mimeType: curAttach.mimeType, data: curAttach.data, }); } 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; } /** * Check if a MIME attachment is a TNEF (winmail.dat) file. */ export function isTnefAttachment(name?: string | null, type?: string): boolean { const lowerName = (name || '').toLowerCase(); const lowerType = (type || '').toLowerCase(); return ( lowerName === 'winmail.dat' || lowerType === 'application/ms-tnef' || lowerType === 'application/vnd.ms-tnef' ); }