From be2ddba8d8b245a5f6b2f7b13ef0eb538c2473a6 Mon Sep 17 00:00:00 2001 From: Phillip Carter Date: Fri, 24 Jul 2026 05:32:01 -0700 Subject: [PATCH] attachments fix --- src/components/AttachmentList.tsx | 9 +--- src/components/ThreadView.tsx | 26 ++++++++-- src/lib/__tests__/attachments.test.ts | 73 +++++++++++++++++++++++++++ src/lib/attachments.ts | 22 ++++++++ 4 files changed, 120 insertions(+), 10 deletions(-) create mode 100644 src/lib/__tests__/attachments.test.ts create mode 100644 src/lib/attachments.ts diff --git a/src/components/AttachmentList.tsx b/src/components/AttachmentList.tsx index 7ec0275..23e3dd5 100644 --- a/src/components/AttachmentList.tsx +++ b/src/components/AttachmentList.tsx @@ -3,6 +3,7 @@ import { useState, useEffect } from "react"; import SpreadsheetViewer from "@/components/SpreadsheetViewer"; import useBodyClass from "@/components/useBodyClass"; +import { visibleAttachments } from "@/lib/attachments"; import { isSpreadsheetAttachment } from "@/lib/spreadsheet"; import { EmailBodyPart } from "@/lib/types"; @@ -109,13 +110,7 @@ export default function AttachmentList({ attachments }: Props) { const [previewPdf, setPreviewPdf] = useState(null); const [previewSpreadsheet, setPreviewSpreadsheet] = useState(null); - const visible = attachments.filter( - (a) => - a.type !== "text/calendar" && - a.blobId && - !a.cid && - a.disposition?.toLowerCase() !== "inline", - ); + const visible = visibleAttachments(attachments); if (visible.length === 0) return null; return ( diff --git a/src/components/ThreadView.tsx b/src/components/ThreadView.tsx index 450e985..d2bb49b 100644 --- a/src/components/ThreadView.tsx +++ b/src/components/ThreadView.tsx @@ -10,6 +10,7 @@ import AttachmentList from "@/components/AttachmentList"; import { Email } from "@/lib/types"; import { formatAddressList, formatFullDate } from "@/lib/format"; import NotSpamButton from "@/components/NotSpamButton"; +import { visibleAttachments } from "@/lib/attachments"; // --------------------------------------------------------------------------- // Body resolution (mirrors email/[id]/page.tsx logic) @@ -64,6 +65,7 @@ function EmailStackItem({ const isUnread = !email.keywords?.["$seen"]; const resolved = resolveBody(email); const isSpam = !!(spamMailboxId && email.mailboxIds[spamMailboxId]); + const downloadableAttachments = visibleAttachments(email.attachments); return (
{formatAddressList(email.from) || "(no sender)"} - + + {downloadableAttachments.length > 0 && ( + + + {downloadableAttachments.length > 1 && + downloadableAttachments.length} + + )} {formatFullDate(email.receivedAt)}
@@ -203,9 +223,9 @@ function EmailStackItem({ )} {/* Attachments */} - {email.attachments?.length > 0 && ( + {downloadableAttachments.length > 0 && (
- +
)} diff --git a/src/lib/__tests__/attachments.test.ts b/src/lib/__tests__/attachments.test.ts new file mode 100644 index 0000000..a0f30b1 --- /dev/null +++ b/src/lib/__tests__/attachments.test.ts @@ -0,0 +1,73 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import AttachmentList from "../../components/AttachmentList"; +import { + isVisibleAttachment, + visibleAttachments, +} from "../attachments"; +import type { EmailBodyPart } from "../types"; + +function part(overrides: Partial = {}): EmailBodyPart { + return { + blobId: "blob-1", + name: "report.xlsx", + size: 1024, + type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ...overrides, + }; +} + +describe("visibleAttachments", () => { + it("keeps an explicit attachment even when the sender assigns a Content-ID", () => { + assert.equal( + isVisibleAttachment( + part({ disposition: "attachment", cid: "historical-file-id" }), + ), + true, + ); + }); + + it("hides explicitly inline MIME parts", () => { + assert.equal( + isVisibleAttachment(part({ disposition: "inline", cid: "logo-id" })), + false, + ); + }); + + it("treats a Content-ID without a disposition as inline", () => { + assert.equal(isVisibleAttachment(part({ cid: "logo-id" })), false); + }); + + it("keeps ordinary parts without a Content-ID", () => { + assert.equal(isVisibleAttachment(part()), true); + }); + + it("omits calendar parts and parts that cannot be downloaded", () => { + assert.deepEqual( + visibleAttachments([ + part({ type: "text/calendar" }), + part({ blobId: undefined }), + ]), + [], + ); + }); + + it("renders a historical spreadsheet that has an attachment disposition and Content-ID", () => { + const html = renderToStaticMarkup( + createElement(AttachmentList, { + attachments: [ + part({ + disposition: "attachment", + cid: "historical-file-id", + name: "March water bills.xlsx", + }), + ], + }), + ); + + assert.match(html, /Attachments/); + assert.match(html, /March water bills\.xlsx/); + }); +}); diff --git a/src/lib/attachments.ts b/src/lib/attachments.ts new file mode 100644 index 0000000..4b7d09e --- /dev/null +++ b/src/lib/attachments.ts @@ -0,0 +1,22 @@ +import type { EmailBodyPart } from "@/lib/types"; + +export function isVisibleAttachment(part: EmailBodyPart): boolean { + if (!part.blobId || part.type === "text/calendar") return false; + + const disposition = part.disposition?.trim().toLowerCase(); + if (disposition === "inline") return false; + + // Some senders assign Content-IDs to ordinary downloadable files. An + // explicit attachment disposition takes precedence over the Content-ID. + if (disposition === "attachment") return true; + + // With no disposition, preserve the conventional CID-as-inline fallback. + return !part.cid; +} + +export function visibleAttachments( + parts: EmailBodyPart[] | null | undefined, +): EmailBodyPart[] { + return (parts ?? []).filter(isVisibleAttachment); +} + -- 2.51.2