+16
@@ -298,6 +298,22 @@ same query string — so what it builds can be read, edited and learned from.
|
||||
- **Attachments** listed with type and size: download, open in a new tab, and an
|
||||
inline preview for images and PDFs.
|
||||
- **Show original**, **Show headers**, **Download (.eml)** and **Print**.
|
||||
- **`winmail.dat` opens.** Outlook sending in Rich Text packs every attachment
|
||||
into one TNEF blob that most clients cannot read, so the files inside are
|
||||
gone as far as the reader is concerned. A banner offers to open it and the
|
||||
contents appear as ordinary attachments, named and typed. The long filename
|
||||
is read out of the MAPI stream where there is one, so a file that arrived as
|
||||
`Quarterly Report Final.docx` is not called `QUARTE~1.DOC`.
|
||||
|
||||
It is decoded **in the browser, on request**: the server never sees the
|
||||
contents and has nowhere to keep a decoded copy, and doing the work on sight
|
||||
would spend the bandwidth whether or not anyone wanted what is inside. A blob
|
||||
that goes wrong part-way through keeps the files read before that point —
|
||||
half of them beats none — and 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.
|
||||
- **Forward as attachment** sends the message itself rather than a quotation of
|
||||
it — headers, structure and every attachment intact, which is what a bounce
|
||||
or a phishing report needs and what quoting destroys. It costs **no upload at
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isTnef, parseTnef } from "@/lib/tnef";
|
||||
|
||||
/**
|
||||
* The blobs below are **built from the format description, not captured from
|
||||
* Outlook**. That is worth saying plainly: they prove the parser reads what the
|
||||
* spec says, and they cannot prove it reads what Outlook actually emits. The
|
||||
* cases most likely to differ in the wild are the MAPI property stream, where
|
||||
* real messages carry many more properties than these do, and named properties
|
||||
* (id >= 0x8000), which the parser stops at rather than guessing past.
|
||||
*/
|
||||
|
||||
const SIGNATURE = 0x223e9f78;
|
||||
|
||||
const ATT = {
|
||||
attachRenddata: 0x00069002,
|
||||
attachTitle: 0x00018010,
|
||||
attachData: 0x0006800f,
|
||||
attachment: 0x00069005,
|
||||
tnefVersion: 0x00089006,
|
||||
} as const;
|
||||
|
||||
const sum16 = (b: number[]) => b.reduce((a, x) => (a + x) & 0xffff, 0);
|
||||
const u16 = (v: number) => [v & 0xff, (v >> 8) & 0xff];
|
||||
const u32 = (v: number) => [v & 0xff, (v >> 8) & 0xff, (v >> 16) & 0xff, (v >>> 24) & 0xff];
|
||||
const ascii = (s: string) => [...s].map((c) => c.charCodeAt(0));
|
||||
const utf16 = (s: string) => [...s].flatMap((c) => u16(c.charCodeAt(0)));
|
||||
|
||||
interface Attr {
|
||||
level?: number;
|
||||
id: number;
|
||||
data: number[];
|
||||
/** Deliberately wrong, for the desync case. */
|
||||
badChecksum?: boolean;
|
||||
}
|
||||
|
||||
function tnef(attrs: Attr[], opts: { signature?: number } = {}): Uint8Array {
|
||||
const out: number[] = [...u32(opts.signature ?? SIGNATURE), ...u16(0x1234)];
|
||||
for (const a of attrs) {
|
||||
out.push(a.level ?? 2, ...u32(a.id), ...u32(a.data.length), ...a.data, ...u16(a.badChecksum ? (sum16(a.data) + 1) & 0xffff : sum16(a.data)));
|
||||
}
|
||||
return new Uint8Array(out);
|
||||
}
|
||||
|
||||
/** A MAPI property stream carrying the given string properties. */
|
||||
function mapi(props: Array<{ id: number; type: number; value: string }>): number[] {
|
||||
const out: number[] = [...u32(props.length)];
|
||||
for (const p of props) {
|
||||
out.push(...u32(((p.id & 0xffff) << 16) | (p.type & 0xffff)));
|
||||
const bytes = p.type === 0x001f ? [...utf16(p.value), 0, 0] : [...ascii(p.value), 0];
|
||||
out.push(...u32(bytes.length), ...bytes);
|
||||
const pad = (4 - (bytes.length % 4)) % 4;
|
||||
for (let i = 0; i < pad; i++) out.push(0);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const file = (name: string, body: string, extra: Attr[] = []): Attr[] => [
|
||||
{ id: ATT.attachRenddata, data: new Array(14).fill(0) },
|
||||
{ id: ATT.attachTitle, data: [...ascii(name), 0] },
|
||||
...extra,
|
||||
{ id: ATT.attachData, data: ascii(body) },
|
||||
];
|
||||
|
||||
const text = (a: Uint8Array) => new TextDecoder().decode(a);
|
||||
|
||||
describe("isTnef", () => {
|
||||
it("recognises the types and the filename", () => {
|
||||
expect(isTnef("application/ms-tnef", null)).toBe(true);
|
||||
expect(isTnef("application/vnd.ms-tnef; name=winmail.dat", null)).toBe(true);
|
||||
expect(isTnef("application/octet-stream", "winmail.dat")).toBe(true);
|
||||
expect(isTnef("application/octet-stream", "WINMAIL.DAT")).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves everything else alone", () => {
|
||||
expect(isTnef("application/pdf", "report.pdf")).toBe(false);
|
||||
expect(isTnef(null, null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseTnef", () => {
|
||||
it("pulls one attachment out, with its name and bytes", () => {
|
||||
const out = parseTnef(tnef(file("report.pdf", "hello")));
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0]!.name).toBe("report.pdf");
|
||||
expect(text(out[0]!.data)).toBe("hello");
|
||||
expect(out[0]!.size).toBe(5);
|
||||
// Guessed from the extension, since this blob names no type of its own.
|
||||
expect(out[0]!.type).toBe("application/pdf");
|
||||
});
|
||||
|
||||
it("pulls several out, in order", () => {
|
||||
const out = parseTnef(tnef([...file("a.txt", "one"), ...file("b.png", "two"), ...file("c.zip", "three")]));
|
||||
expect(out.map((a) => a.name)).toEqual(["a.txt", "b.png", "c.zip"]);
|
||||
expect(out.map((a) => text(a.data))).toEqual(["one", "two", "three"]);
|
||||
expect(out.map((a) => a.type)).toEqual(["text/plain", "image/png", "application/zip"]);
|
||||
});
|
||||
|
||||
it("prefers the long filename over the 8.3 one", () => {
|
||||
// The whole reason for reading the MAPI stream at all.
|
||||
const attrs = file("QUARTE~1.DOC", "body", [
|
||||
{ id: ATT.attachment, data: mapi([{ id: 0x3707, type: 0x001e, value: "Quarterly Report Final.docx" }]) },
|
||||
]);
|
||||
expect(parseTnef(tnef(attrs))[0]!.name).toBe("Quarterly Report Final.docx");
|
||||
});
|
||||
|
||||
it("reads a unicode long filename", () => {
|
||||
const attrs = file("SHORT~1.DOC", "body", [
|
||||
{ id: ATT.attachment, data: mapi([{ id: 0x3707, type: 0x001f, value: "四半期報告.docx" }]) },
|
||||
]);
|
||||
expect(parseTnef(tnef(attrs))[0]!.name).toBe("四半期報告.docx");
|
||||
});
|
||||
|
||||
it("takes the MIME type the blob states over one guessed from the name", () => {
|
||||
const attrs = file("data.bin", "body", [
|
||||
{ id: ATT.attachment, data: mapi([{ id: 0x370e, type: 0x001e, value: "image/webp" }]) },
|
||||
]);
|
||||
expect(parseTnef(tnef(attrs))[0]!.type).toBe("image/webp");
|
||||
});
|
||||
|
||||
it("falls back to octet-stream for a name that says nothing", () => {
|
||||
expect(parseTnef(tnef(file("mystery", "x")))[0]!.type).toBe("application/octet-stream");
|
||||
});
|
||||
|
||||
it("names an attachment that carries no title at all", () => {
|
||||
const out = parseTnef(tnef([{ id: ATT.attachRenddata, data: new Array(14).fill(0) }, { id: ATT.attachData, data: ascii("x") }]));
|
||||
expect(out[0]!.name).toBe("attachment");
|
||||
});
|
||||
|
||||
it("ignores attributes it has no use for", () => {
|
||||
const out = parseTnef(tnef([{ level: 1, id: ATT.tnefVersion, data: u32(0x00010000) }, ...file("a.txt", "one")]));
|
||||
expect(out.map((a) => a.name)).toEqual(["a.txt"]);
|
||||
});
|
||||
|
||||
it("is not TNEF, and says so quietly", () => {
|
||||
// The caller gets here by guessing from a filename, so this is an ordinary
|
||||
// answer rather than an error worth showing anybody.
|
||||
expect(parseTnef(new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]))).toEqual([]);
|
||||
expect(parseTnef(tnef(file("a.txt", "x"), { signature: 0xdeadbeef }))).toEqual([]);
|
||||
expect(parseTnef(new Uint8Array([]))).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps what it read when the stream goes out of step", () => {
|
||||
// Half the attachments beats none: the alternative is a reader who can see
|
||||
// the file is there and cannot have it.
|
||||
const bad = tnef([...file("good.txt", "kept"), { id: ATT.attachRenddata, data: new Array(14).fill(0), badChecksum: true }, ...file("lost.txt", "gone")]);
|
||||
const out = parseTnef(bad);
|
||||
expect(out.map((a) => a.name)).toEqual(["good.txt"]);
|
||||
});
|
||||
|
||||
it("keeps what it read when the blob is truncated mid-attribute", () => {
|
||||
const full = tnef([...file("good.txt", "kept"), ...file("cut.txt", "partial")]);
|
||||
const out = parseTnef(full.slice(0, full.length - 12));
|
||||
expect(out.map((a) => a.name)).toEqual(["good.txt"]);
|
||||
});
|
||||
|
||||
it("stops at a named property rather than guessing past it", () => {
|
||||
// A named property carries a GUID before its value; the stream cannot be
|
||||
// trusted to stay aligned past one, so the long name is simply not found.
|
||||
const attrs = file("SHORT~1.DOC", "body", [
|
||||
{ id: ATT.attachment, data: mapi([{ id: 0x8001, type: 0x001e, value: "whatever" }, { id: 0x3707, type: 0x001e, value: "Long Name.docx" }]) },
|
||||
]);
|
||||
expect(parseTnef(tnef(attrs))[0]!.name).toBe("SHORT~1.DOC");
|
||||
});
|
||||
|
||||
it("survives a MAPI stream that is nonsense, keeping the attachment", () => {
|
||||
const attrs = file("keep.txt", "body", [{ id: ATT.attachment, data: [...u32(0xffff), 1, 2, 3] }]);
|
||||
const out = parseTnef(tnef(attrs));
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0]!.name).toBe("keep.txt");
|
||||
});
|
||||
|
||||
it("drops an attachment that has a name but no data", () => {
|
||||
const out = parseTnef(tnef([{ id: ATT.attachRenddata, data: new Array(14).fill(0) }, { id: ATT.attachTitle, data: [...ascii("empty.txt"), 0] }]));
|
||||
expect(out).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* `winmail.dat`, opened.
|
||||
*
|
||||
* Outlook sending in "Rich Text" wraps every attachment into a single
|
||||
* TNEF blob. Every other client shows one unopenable `winmail.dat` and the
|
||||
* files inside it are simply gone as far as the reader is concerned — which is
|
||||
* the whole problem, and it is a decoding problem rather than a mail one.
|
||||
*
|
||||
* Written from the published format (MS-OXTNEF): 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 16-bit checksum. Attachments are delimited
|
||||
* by `attAttachRenddata`, which is why the parse is a small state machine
|
||||
* rather than a lookup.
|
||||
*
|
||||
* **What this does not do: the message body.** A TNEF blob can also carry the
|
||||
* message as compressed RTF, and decoding that is a second format again
|
||||
* (MS-OXRTFCP) for a body the reader already has in plain text or HTML nine
|
||||
* times in ten. The attachments are the part that is otherwise unreachable, so
|
||||
* they are the part that is decoded.
|
||||
*/
|
||||
|
||||
/** Little-endian, and every offset is checked before it is read. */
|
||||
const SIGNATURE = 0x223e9f78;
|
||||
|
||||
// Attribute ids, as the 32-bit values they appear as on the wire.
|
||||
const ATT_ATTACH_RENDDATA = 0x00069002;
|
||||
const ATT_ATTACH_TITLE = 0x00018010;
|
||||
const ATT_ATTACH_DATA = 0x0006800f;
|
||||
const ATT_ATTACHMENT = 0x00069005;
|
||||
|
||||
// MAPI property tags worth reading out of attAttachment.
|
||||
const PID_ATTACH_LONG_FILENAME = 0x3707;
|
||||
const PID_ATTACH_MIME_TAG = 0x370e;
|
||||
|
||||
// MAPI property types.
|
||||
const PT_STRING8 = 0x001e;
|
||||
const PT_UNICODE = 0x001f;
|
||||
const PT_BINARY = 0x0102;
|
||||
const MV_FLAG = 0x1000;
|
||||
|
||||
export interface TnefAttachment {
|
||||
name: string;
|
||||
/** From the blob's own MIME tag where it carries one, else guessed from the name. */
|
||||
type: string;
|
||||
size: number;
|
||||
data: Uint8Array;
|
||||
}
|
||||
|
||||
/** Whether an attachment is worth trying to open as TNEF. */
|
||||
export function isTnef(type: string | null | undefined, name: string | null | undefined): boolean {
|
||||
const t = (type ?? "").split(";")[0]!.trim().toLowerCase();
|
||||
if (t === "application/ms-tnef" || t === "application/vnd.ms-tnef") return true;
|
||||
return (name ?? "").trim().toLowerCase() === "winmail.dat";
|
||||
}
|
||||
|
||||
class Reader {
|
||||
constructor(
|
||||
private readonly view: DataView,
|
||||
public offset = 0,
|
||||
) {}
|
||||
get remaining(): number {
|
||||
return this.view.byteLength - this.offset;
|
||||
}
|
||||
u8(): number {
|
||||
this.need(1);
|
||||
return this.view.getUint8(this.offset++);
|
||||
}
|
||||
u16(): number {
|
||||
this.need(2);
|
||||
const v = this.view.getUint16(this.offset, true);
|
||||
this.offset += 2;
|
||||
return v;
|
||||
}
|
||||
u32(): number {
|
||||
this.need(4);
|
||||
const v = this.view.getUint32(this.offset, true);
|
||||
this.offset += 4;
|
||||
return v;
|
||||
}
|
||||
bytes(length: number): Uint8Array {
|
||||
this.need(length);
|
||||
const out = new Uint8Array(this.view.buffer, this.view.byteOffset + this.offset, length);
|
||||
this.offset += length;
|
||||
// Copied, because the slice would otherwise keep the whole blob alive and
|
||||
// move underneath anyone who held it.
|
||||
return new Uint8Array(out);
|
||||
}
|
||||
private need(n: number) {
|
||||
if (n < 0 || this.offset + n > this.view.byteLength) throw new RangeError("truncated");
|
||||
}
|
||||
}
|
||||
|
||||
/** Sum of the bytes, low 16 bits. The format's own, and it is only a checksum. */
|
||||
function checksum(data: Uint8Array): number {
|
||||
let sum = 0;
|
||||
for (const b of data) sum = (sum + b) & 0xffff;
|
||||
return sum;
|
||||
}
|
||||
|
||||
const decodeAscii = (b: Uint8Array) => new TextDecoder("windows-1252").decode(b).replace(/\0+$/, "");
|
||||
const decodeUtf16 = (b: Uint8Array) => new TextDecoder("utf-16le").decode(b).replace(/\0+$/, "");
|
||||
|
||||
/**
|
||||
* The MAPI property stream inside `attAttachment`, read only for the two
|
||||
* properties worth having: 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.
|
||||
*/
|
||||
function readMapiProps(data: Uint8Array): { name?: string; type?: string } {
|
||||
const out: { name?: string; type?: string } = {};
|
||||
try {
|
||||
const r = new Reader(new DataView(data.buffer, data.byteOffset, data.byteLength));
|
||||
const count = r.u32();
|
||||
// A count larger than the bytes could describe means this is not the
|
||||
// stream we think it is; give up rather than walking off into it.
|
||||
if (count > data.byteLength) return out;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const tag = r.u32();
|
||||
const type = tag & 0xffff;
|
||||
const id = (tag >>> 16) & 0xffff;
|
||||
// A named property carries a GUID and either an id or a name before its
|
||||
// value. Nothing wanted here is one, so the stream cannot be trusted to
|
||||
// stay aligned past it.
|
||||
if (id >= 0x8000) return out;
|
||||
const multi = (type & MV_FLAG) !== 0;
|
||||
const base = type & ~MV_FLAG;
|
||||
const values = multi ? r.u32() : 1;
|
||||
if (base === PT_STRING8 || base === PT_UNICODE || base === PT_BINARY) {
|
||||
let first: Uint8Array | null = null;
|
||||
for (let v = 0; v < values; v++) {
|
||||
const length = r.u32();
|
||||
const raw = r.bytes(length);
|
||||
if (v === 0) first = raw;
|
||||
// Values are padded to a four-byte boundary.
|
||||
const pad = (4 - (length % 4)) % 4;
|
||||
r.offset += pad;
|
||||
}
|
||||
if (first) {
|
||||
if (id === PID_ATTACH_LONG_FILENAME) out.name = base === PT_UNICODE ? decodeUtf16(first) : decodeAscii(first);
|
||||
if (id === PID_ATTACH_MIME_TAG) out.type = (base === PT_UNICODE ? decodeUtf16(first) : decodeAscii(first)).trim();
|
||||
}
|
||||
} else if (base === 0x0002) {
|
||||
r.offset += 2 * values + 2; // PT_SHORT is padded to four bytes
|
||||
} else if (base === 0x0003 || base === 0x000a || base === 0x000b || base === 0x0004) {
|
||||
r.offset += 4 * values;
|
||||
} else if (base === 0x0005 || base === 0x0006 || base === 0x0007 || base === 0x0014 || base === 0x0040) {
|
||||
r.offset += 8 * values;
|
||||
} else if (base === 0x0048) {
|
||||
r.offset += 16 * values;
|
||||
} else {
|
||||
// An unknown type has an unknown width, so the rest cannot be walked.
|
||||
return out;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Truncated or misaligned: keep whatever was read before it went wrong.
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A last resort when the blob names no type of its own. */
|
||||
function guessType(name: string): string {
|
||||
const ext = name.toLowerCase().match(/\.([a-z0-9]+)$/)?.[1] ?? "";
|
||||
const map: Record<string, string> = {
|
||||
pdf: "application/pdf",
|
||||
png: "image/png",
|
||||
jpg: "image/jpeg",
|
||||
jpeg: "image/jpeg",
|
||||
gif: "image/gif",
|
||||
txt: "text/plain",
|
||||
csv: "text/csv",
|
||||
html: "text/html",
|
||||
htm: "text/html",
|
||||
rtf: "application/rtf",
|
||||
zip: "application/zip",
|
||||
doc: "application/msword",
|
||||
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
xls: "application/vnd.ms-excel",
|
||||
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
ppt: "application/vnd.ms-powerpoint",
|
||||
pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
};
|
||||
return map[ext] ?? "application/octet-stream";
|
||||
}
|
||||
|
||||
/**
|
||||
* Every file inside a TNEF blob.
|
||||
*
|
||||
* Returns an empty list rather than throwing for anything that is simply not
|
||||
* TNEF — the caller reaches this by guessing from a filename, so "not that
|
||||
* after all" is an ordinary answer and not an error worth showing anybody.
|
||||
*
|
||||
* A blob that *is* TNEF but goes wrong part of the way through keeps what was
|
||||
* read before that point. Half the attachments is better than none, and the
|
||||
* alternative is a reader who can see the file is there and cannot have it.
|
||||
*/
|
||||
export function parseTnef(input: ArrayBuffer | Uint8Array): TnefAttachment[] {
|
||||
const bytes = input instanceof Uint8Array ? input : new Uint8Array(input);
|
||||
if (bytes.byteLength < 6) return [];
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
if (view.getUint32(0, true) !== SIGNATURE) return [];
|
||||
|
||||
const r = new Reader(view, 6); // signature (4) + key (2)
|
||||
const out: TnefAttachment[] = [];
|
||||
let current: { title?: string; mapiName?: string; mapiType?: string; data?: Uint8Array } | null = null;
|
||||
|
||||
const flush = () => {
|
||||
if (!current?.data) {
|
||||
current = null;
|
||||
return;
|
||||
}
|
||||
const name = (current.mapiName || current.title || "attachment").trim() || "attachment";
|
||||
out.push({
|
||||
name,
|
||||
type: current.mapiType || guessType(name),
|
||||
size: current.data.byteLength,
|
||||
data: current.data,
|
||||
});
|
||||
current = null;
|
||||
};
|
||||
|
||||
try {
|
||||
while (r.remaining > 0) {
|
||||
r.u8(); // level: message or attachment. The attribute id already says which.
|
||||
const id = r.u32();
|
||||
const length = r.u32();
|
||||
const data = r.bytes(length);
|
||||
const stated = r.u16();
|
||||
// A mismatch means the stream has come out of step, and every offset
|
||||
// after it is a guess. Stop, and keep what is already read.
|
||||
if (stated !== checksum(data)) break;
|
||||
|
||||
switch (id) {
|
||||
case ATT_ATTACH_RENDDATA:
|
||||
// Each one opens a new attachment, so it also closes the last.
|
||||
flush();
|
||||
current = {};
|
||||
break;
|
||||
case ATT_ATTACH_TITLE:
|
||||
if (current) current.title = decodeAscii(data);
|
||||
break;
|
||||
case ATT_ATTACHMENT: {
|
||||
if (!current) break;
|
||||
const props = readMapiProps(data);
|
||||
if (props.name) current.mapiName = props.name;
|
||||
if (props.type) current.mapiType = props.type;
|
||||
break;
|
||||
}
|
||||
case ATT_ATTACH_DATA:
|
||||
if (current) current.data = data;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Truncated. Keep what was read.
|
||||
}
|
||||
flush();
|
||||
return out;
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { useCalendar } from "@/store/calendar";
|
||||
import { startAppointment } from "@/lib/appointment";
|
||||
import { client } from "@/jmap/client";
|
||||
import { emlFilename } from "@/lib/emlName";
|
||||
import { isTnef, parseTnef, type TnefAttachment } from "@/lib/tnef";
|
||||
import { internalDomains, isExternalSender, linkVerdict } from "@/lib/warnings";
|
||||
import { spamReport, type SpamReport } from "@/lib/spamScore";
|
||||
import { formatFullDate, formatListDate, formatSize } from "@/lib/format";
|
||||
@@ -708,6 +709,80 @@ export function attachmentIcon(type: string, name?: string | null) {
|
||||
return <File size={18} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* The files inside a `winmail.dat`, once the reader asks for them.
|
||||
*
|
||||
* Opened on request rather than on sight: the blob has to be fetched and
|
||||
* decoded, and doing that to every message carrying one would spend the
|
||||
* bandwidth whether or not anybody wanted what is inside.
|
||||
*
|
||||
* The decode happens here, in the browser. The server never sees the contents
|
||||
* and stores nothing, which is the same bargain as the rest of the app --
|
||||
* there is nowhere for it to put a decoded copy even if it wanted one.
|
||||
*/
|
||||
function TnefContents({ part, accountId }: { part: EmailBodyPart; accountId: Id }) {
|
||||
const [state, setState] = useState<"idle" | "loading" | "done" | "error">("idle");
|
||||
const [files, setFiles] = useState<TnefAttachment[]>([]);
|
||||
const [urls, setUrls] = useState<string[]>([]);
|
||||
|
||||
// Object URLs hold their blob alive until they are revoked, so they are
|
||||
// released when the message closes rather than left to the page's lifetime.
|
||||
useEffect(() => () => urls.forEach((u) => URL.revokeObjectURL(u)), [urls]);
|
||||
|
||||
const open = async () => {
|
||||
if (!part.blobId) return;
|
||||
setState("loading");
|
||||
try {
|
||||
const blob = await client.fetchBlob(accountId, part.blobId, part.type);
|
||||
const found = parseTnef(await blob.arrayBuffer());
|
||||
setFiles(found);
|
||||
setUrls(found.map((f) => URL.createObjectURL(new Blob([f.data as unknown as BlobPart], { type: f.type }))));
|
||||
setState("done");
|
||||
} catch {
|
||||
setState("error");
|
||||
}
|
||||
};
|
||||
|
||||
if (state === "idle") {
|
||||
return (
|
||||
<div className="list-hint" style={{ margin: "0 16px 8px" }}>
|
||||
<span className="grow">{translate("This message packs its attachments into a winmail.dat, which most clients cannot open.")}</span>
|
||||
<button onClick={() => void open()}>{translate("Open it")}</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (state === "loading") return <div className="list-hint" style={{ margin: "0 16px 8px" }}><span className="grow">{translate("Opening…")}</span></div>;
|
||||
if (state === "error") {
|
||||
return (
|
||||
<div className="list-hint" style={{ margin: "0 16px 8px" }}>
|
||||
<span className="grow">{translate("Could not read winmail.dat. The original is still attached below.")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!files.length) {
|
||||
// It decoded, and there was nothing in it. Saying so is better than
|
||||
// leaving the button looking like it did nothing.
|
||||
return (
|
||||
<div className="list-hint" style={{ margin: "0 16px 8px" }}>
|
||||
<span className="grow">{translate("No files inside — it carries only the formatted copy of the message.")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="attachments">
|
||||
{files.map((f, i) => (
|
||||
<a key={`${f.name}-${i}`} className="attachment" href={urls[i]} download={f.name} title={`${f.name} · ${formatSize(f.size)}`}>
|
||||
<span className="att-icon">{attachmentIcon(f.type, f.name)}</span>
|
||||
<span className="att-text">
|
||||
<span className="att-name">{f.name}</span>
|
||||
<span className="att-size">{formatSize(f.size)}</span>
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AttachmentList({ attachments, accountId, email }: { attachments: EmailBodyPart[]; accountId: Id; email: Email }) {
|
||||
const [preview, setPreview] = useState<EmailBodyPart | null>(null);
|
||||
/* Whether we can show it, and whether the server will serve it inline, are
|
||||
@@ -715,6 +790,9 @@ function AttachmentList({ attachments, accountId, email }: { attachments: EmailB
|
||||
const viewable = (a: EmailBodyPart) => Boolean(a.blobId) && previewKind(a.type, a.name) !== null;
|
||||
return (
|
||||
<>
|
||||
{attachments.filter((a) => isTnef(a.type, a.name) && a.blobId).map((a) => (
|
||||
<TnefContents key={`tnef-${a.blobId}`} part={a} accountId={accountId} />
|
||||
))}
|
||||
<div className="attachments">
|
||||
{attachments.map((a, i) => {
|
||||
const url = a.blobId ? client.downloadUrl(accountId, a.blobId, a.name ?? "attachment", a.type) : "#";
|
||||
|
||||
Reference in New Issue
Block a user