Open winmail.dat
Outlook sending in Rich Text packs every attachment into one TNEF blob. Every other client shows a single unopenable winmail.dat, and the files inside it are gone as far as the reader is concerned -- which is a decoding problem rather than a mail one. Written from the published format: a signature, a key, then a flat run of attributes, each one a level byte, a 32-bit id carrying its own type, a length, the data and a checksum. Attachments are delimited by attAttachRenddata rather than named, which is why the parse is a small state machine. The MAPI property stream inside attAttachment is read for two properties: the long filename and the MIME type. attAttachTitle carries an 8.3 name, so a file that arrived as "Quarterly Report Final.docx" is QUARTE~1.DOC there and correct here. The stream stops at a named property (id >= 0x8000) rather than guessing past it, since those carry a GUID before their value and nothing after one can be trusted to stay aligned. Decoded in the browser, on request. The server never sees the contents and has nowhere to keep a decoded copy; doing the work on sight would spend the bandwidth whether or not anybody wanted what is inside. A blob that goes wrong part-way through keeps what was read before that point, whether it ran out or the checksum stopped matching. Half the attachments beats none: the alternative is a reader who can see the file is there and cannot have it. The original stays attached either way. The message body is deliberately not decoded. TNEF can also carry it as compressed RTF, which is a second format again for a body the reader already has in plain text or HTML nine times in ten. The mock now sends one, built by its own encoder rather than by the parser's fixtures, so the two are independent implementations of the same description.
This commit is contained in:
@@ -100,7 +100,43 @@ const subjects = [
|
||||
];
|
||||
const emails: Obj[] = [];
|
||||
let counter = 1;
|
||||
function addEmail(o: { from: [string, string]; to?: string; subject: string; daysAgo: number; mailbox: string; threadId?: string; unread?: boolean; flagged?: boolean; html?: boolean; attach?: boolean; inReplyTo?: string }) {
|
||||
/**
|
||||
* A real TNEF blob, built to the format description, so the winmail.dat
|
||||
* decoder has something to open that is not a hand-made fixture in its own
|
||||
* test file. Two files inside, one of them carrying a long name in the MAPI
|
||||
* stream behind an 8.3 title -- which is the case the decoder exists for.
|
||||
*/
|
||||
function winmailDat(): Buffer {
|
||||
const u16 = (v: number) => Buffer.from([v & 0xff, (v >> 8) & 0xff]);
|
||||
const u32 = (v: number) => Buffer.from([v & 0xff, (v >> 8) & 0xff, (v >> 16) & 0xff, (v >>> 24) & 0xff]);
|
||||
const sum = (b: Buffer) => { let n = 0; for (const x of b) n = (n + x) & 0xffff; return n; };
|
||||
const attr = (level: number, id: number, data: Buffer) => Buffer.concat([Buffer.from([level]), u32(id), u32(data.length), data, u16(sum(data))]);
|
||||
const asciiProp = (id: number, value: string) => {
|
||||
const bytes = Buffer.concat([Buffer.from(value, "latin1"), Buffer.from([0])]);
|
||||
const pad = Buffer.alloc((4 - (bytes.length % 4)) % 4);
|
||||
return Buffer.concat([u32(((id & 0xffff) << 16) | 0x001e), u32(bytes.length), bytes, pad]);
|
||||
};
|
||||
const mapi = (props: Buffer[]) => Buffer.concat([u32(props.length), ...props]);
|
||||
|
||||
const renddata = Buffer.alloc(14);
|
||||
const title = (n: string) => Buffer.concat([Buffer.from(n, "latin1"), Buffer.from([0])]);
|
||||
const notes = Buffer.from("Numbers pulled from the mock, not from anywhere real.\n", "latin1");
|
||||
const csv = Buffer.from("quarter,revenue\nQ1,120\nQ2,145\n", "latin1");
|
||||
|
||||
return Buffer.concat([
|
||||
u32(0x223e9f78), u16(0x1234),
|
||||
attr(1, 0x00089006, u32(0x00010000)), // attTnefVersion
|
||||
attr(2, 0x00069002, renddata),
|
||||
attr(2, 0x00018010, title("QUARTE~1.CSV")),
|
||||
attr(2, 0x00069005, mapi([asciiProp(0x3707, "Quarterly Revenue Final.csv"), asciiProp(0x370e, "text/csv")])),
|
||||
attr(2, 0x0006800f, csv),
|
||||
attr(2, 0x00069002, renddata),
|
||||
attr(2, 0x00018010, title("notes.txt")),
|
||||
attr(2, 0x0006800f, notes),
|
||||
]);
|
||||
}
|
||||
|
||||
function addEmail(o: { from: [string, string]; to?: string; subject: string; daysAgo: number; mailbox: string; threadId?: string; unread?: boolean; flagged?: boolean; html?: boolean; attach?: boolean; winmail?: boolean; inReplyTo?: string }) {
|
||||
const id = `e${counter++}`;
|
||||
const received = new Date(Date.now() - o.daysAgo * 86400_000 - Math.random() * 3600_000 * 5).toISOString().replace(/\.\d{3}Z$/, "Z");
|
||||
const text = `Hi,\n\nThis is a sample message about "${o.subject}". It was generated by the ihasmail mock server so you can try the interface without a real mailbox.\n\nSome highlights:\n- Keyboard shortcuts (press ? )\n- Conversation view\n- Drag & drop to folders\n\nCheers,\n${o.from[0]}\n\n> On Monday, someone wrote:\n> This is the quoted part of an earlier message.\n> It should be collapsed by default.`;
|
||||
@@ -112,6 +148,10 @@ function addEmail(o: { from: [string, string]; to?: string; subject: string; day
|
||||
attachments.push({ partId: "3", blobId: putBlob("%PDF-1.4 mock", "application/pdf"), size: 48213, name: "contract-v3.pdf", type: "application/pdf", charset: null, disposition: "attachment", cid: null });
|
||||
attachments.push({ partId: "4", blobId: putBlob(Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", "base64"), "image/png"), size: 68, name: "pixel.png", type: "image/png", charset: null, disposition: "attachment", cid: null });
|
||||
}
|
||||
if (o.winmail) {
|
||||
const dat = winmailDat();
|
||||
attachments.push({ partId: "6", blobId: putBlob(dat, "application/ms-tnef"), size: dat.length, name: "winmail.dat", type: "application/ms-tnef", charset: null, disposition: "attachment", cid: null });
|
||||
}
|
||||
if (o.html) attachments.push({ partId: "5", blobId: putBlob(Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP4z8DwHwAFAAH/q842iQAAAABJRU5ErkJggg==", "base64"), "image/png"), size: 68, name: "logo.png", type: "image/png", charset: null, disposition: "inline", cid: "logo@mock" });
|
||||
const e: Obj = {
|
||||
id, blobId: putBlob(`From: ${o.from[0]} <${o.from[1]}>\r\nTo: ${USER}\r\nSubject: ${o.subject}\r\nDate: ${received}\r\nMessage-ID: <${id}@mock>\r\n\r\n${text}`, "message/rfc822"),
|
||||
@@ -153,6 +193,7 @@ for (let i = 0; i < 45; i++) {
|
||||
}
|
||||
addEmail({ from: ["Demo User", USER], to: "[email protected]", subject: "Draft: ideas for the retreat", daysAgo: 0.1, mailbox: "drafts", html: true }).keywords = { $draft: true, $seen: true };
|
||||
addEmail({ from: ["Spammy", "[email protected]"], subject: "You have WON!!!", daysAgo: 2, mailbox: "junk", unread: true });
|
||||
addEmail({ from: ["Outlook User", "[email protected]"], subject: "Q3 figures (sent from Outlook)", daysAgo: 1, mailbox: "inbox", unread: true, winmail: true });
|
||||
addEmail({ from: ["Finance Team", "[email protected]"], subject: "Invoice 2201 approved", daysAgo: 1, mailbox: "work-inv", unread: true });
|
||||
addEmail({ from: ["Finance Team", "[email protected]"], subject: "Invoice 2202 pending", daysAgo: 2, mailbox: "work-inv", unread: true });
|
||||
// A thread whose unread message is not the last one: someone's server queued
|
||||
|
||||
Reference in New Issue
Block a user