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:
@@ -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