mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
A MAPI property in a TNEF attachment declares how many values follow. The parser looped that many times even when a value could not be read and nothing was consumed, so a 31-byte winmail.dat claiming 0xFFFFFFFF values froze the tab for about 13 seconds on every open, longer with more such properties. The count is now clamped to what the block can hold and the loop stops as soon as a value consumes no bytes.
414 lines
14 KiB
TypeScript
414 lines
14 KiB
TypeScript
/**
|
|
* 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));
|
|
}
|
|
|
|
/**
|
|
* Read a multi-value count. Every value takes at least four bytes, so a count
|
|
* larger than what is left of the block is a lie; clamp it rather than loop
|
|
* up to 2^32 times on the UI thread.
|
|
*/
|
|
function readValueCount(r: BinaryReader): number {
|
|
return Math.min(r.readUint32LE(), Math.floor(r.remaining / 4));
|
|
}
|
|
|
|
/** Parse MAPI properties from a raw attribute data block */
|
|
function parseMAPIProps(data: Uint8Array): Map<number, { type: number; value: Uint8Array | number | null }> {
|
|
const props = new Map<number, { type: number; value: Uint8Array | number | null }>();
|
|
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 = readValueCount(r);
|
|
let lastValue: Uint8Array | null = null;
|
|
for (let j = 0; j < valueCount && r.remaining > 0; j++) {
|
|
const value = readMAPIVarValue(r);
|
|
// A truncated value consumes nothing; retrying it would spin on the
|
|
// same bytes for as many rounds as the sender's count asks for.
|
|
if (value === null) break;
|
|
lastValue = value;
|
|
}
|
|
if (!isMultiValue && lastValue) {
|
|
props.set(propID, { type: propType, value: lastValue });
|
|
}
|
|
} else if (isMultiValue) {
|
|
if (r.remaining < 4) break;
|
|
const valueCount = readValueCount(r);
|
|
for (let j = 0; j < valueCount && r.remaining > 0; j++) {
|
|
const before = r.remaining;
|
|
readMAPIFixedValue(r, baseType);
|
|
if (r.remaining >= before) break;
|
|
}
|
|
} 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', 'email');
|
|
debug.log('email', 'Input data size:', data.byteLength, 'bytes');
|
|
|
|
if (data.byteLength < 6) {
|
|
debug.warn('email', '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('email', 'Invalid TNEF signature:', '0x' + signature.toString(16).toUpperCase(), '(expected 0x223E9F78)');
|
|
debug.groupEnd();
|
|
return result;
|
|
}
|
|
debug.log('email', '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('email', '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('email', '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('email', ' → Extracted plain text body (' + result.body.length + ' chars)');
|
|
} else if (attrID === attMAPIProps) {
|
|
const props = parseMAPIProps(attrData);
|
|
debug.log('email', ' → Parsed', props.size, 'MAPI properties from message');
|
|
props.forEach((val, propID) => {
|
|
debug.log('email', ' 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('email', ' → Extracted HTML body (' + result.htmlBody.length + ' chars)');
|
|
} else {
|
|
debug.log('email', ' → 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('email', ' → Extracted plain text body from MAPI props (' + result.body.length + ' chars)');
|
|
} else {
|
|
debug.log('email', ' → 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('email', ' → 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('email', ' → 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('email', ' → Attachment short name:', curAttach.name);
|
|
} else if (attrID === attAttachData && curAttach) {
|
|
curAttach.data = attrData;
|
|
debug.log('email', ' → Attachment data (attAttachData):', attrData.byteLength, 'bytes');
|
|
} else if (attrID === attAttachment && curAttach) {
|
|
const props = parseMAPIProps(attrData);
|
|
debug.log('email', ' → Parsed', props.size, 'MAPI properties from attachment');
|
|
props.forEach((val, propID) => {
|
|
debug.log('email', ' 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('email', ' → 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('email', ' → Attachment MIME type:', curAttach.mimeType);
|
|
}
|
|
|
|
const attachData = props.get(PR_ATTACH_DATA_BIN);
|
|
if (attachData?.value instanceof Uint8Array) {
|
|
curAttach.data = attachData.value;
|
|
debug.log('email', ' → Attachment data (PR_ATTACH_DATA_BIN):', attachData.value.byteLength, 'bytes');
|
|
} else {
|
|
debug.log('email', ' → No PR_ATTACH_DATA_BIN found in attachment MAPI props');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Flush last attachment
|
|
if (curAttach?.data) {
|
|
debug.log('email', '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('email', '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 })), 'email');
|
|
}
|
|
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'
|
|
);
|
|
}
|