From 0e1c34cdec8e9c21b74e01ebfd9d4cee35bfb1a4 Mon Sep 17 00:00:00 2001
From: Shuki Vaknin
Date: Tue, 1 Sep 2026 18:50:22 +0300
Subject: [PATCH] feat(list): show attachments on list rows and open them from
there
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A message with attachments showed a paperclip and nothing else, so the
only way to reach a file was to open the message, scroll to the bottom
and find it. The list rows never had the information to do better:
EMAIL_LIST_PROPERTIES asked for hasAttachment, a boolean.
Request attachments alongside it and render the files as chips under the
preview, each one opening that file directly. Chips honour the existing
mailAttachmentAction setting, so they preview or download exactly as the
viewer's attachment list does.
Inline parts are filtered out. A single Outlook message routinely carries
half a dozen cid: signature spacers of under 200 bytes; chipping those
would bury the one file the reader actually wants. Only parts that are
neither inline nor cid-referenced, and that carry a filename, are shown —
two at a time, with the remainder collapsed into a count.
Downloads resolve the blob source from the row's own message rather than
the selected one, so they stay correct in the unified inbox where each
row can belong to a different account.
---
.../email/__tests__/attachment-chips.test.tsx | 42 +++++++++
components/email/attachment-chips.tsx | 85 +++++++++++++++++++
components/email/email-list.tsx | 4 +
components/email/thread-list-item.tsx | 22 ++++-
components/mail/mail-app.tsx | 22 +++++
lib/jmap/client.ts | 3 +
6 files changed, 177 insertions(+), 1 deletion(-)
create mode 100644 components/email/__tests__/attachment-chips.test.tsx
create mode 100644 components/email/attachment-chips.tsx
diff --git a/components/email/__tests__/attachment-chips.test.tsx b/components/email/__tests__/attachment-chips.test.tsx
new file mode 100644
index 000000000..f0d8dab8a
--- /dev/null
+++ b/components/email/__tests__/attachment-chips.test.tsx
@@ -0,0 +1,42 @@
+import { describe, it, expect } from "vitest";
+import { realAttachments } from "../attachment-chips";
+import type { Attachment } from "@/lib/jmap/types";
+
+function att(over: Partial): Attachment {
+ return { partId: "1", blobId: "b1", size: 100, type: "application/pdf", name: "doc.pdf", ...over };
+}
+
+describe("realAttachments", () => {
+ it("returns nothing when the message has no attachments", () => {
+ expect(realAttachments(undefined)).toEqual([]);
+ expect(realAttachments([])).toEqual([]);
+ });
+
+ it("drops parts marked inline", () => {
+ const kept = att({ name: "invoice.pdf" });
+ const out = realAttachments([
+ kept,
+ att({ partId: "2", blobId: "b2", name: "image001.png", type: "image/png", disposition: "inline" }),
+ ]);
+ expect(out).toEqual([kept]);
+ });
+
+ it("drops parts referenced by Content-ID even without a disposition", () => {
+ // Outlook signature images arrive as cid: references; six of them on one
+ // message would otherwise fill the row with 180-byte spacers.
+ const out = realAttachments([
+ att({ partId: "2", blobId: "b2", name: "image001.png", type: "image/png", cid: "image001.png@01DD" }),
+ ]);
+ expect(out).toEqual([]);
+ });
+
+ it("drops parts with no filename, which cannot be labelled or saved", () => {
+ expect(realAttachments([att({ name: undefined })])).toEqual([]);
+ });
+
+ it("keeps genuine attachments in order", () => {
+ const a = att({ partId: "1", blobId: "b1", name: "a.pdf" });
+ const b = att({ partId: "2", blobId: "b2", name: "b.xlsx", type: "application/vnd.ms-excel" });
+ expect(realAttachments([a, b])).toEqual([a, b]);
+ });
+});
diff --git a/components/email/attachment-chips.tsx b/components/email/attachment-chips.tsx
new file mode 100644
index 000000000..bb39f9ee4
--- /dev/null
+++ b/components/email/attachment-chips.tsx
@@ -0,0 +1,85 @@
+"use client";
+
+import { FileText, FileSpreadsheet, FileImage, FileArchive, File as FileIcon } from "lucide-react";
+import { cn } from "@/lib/utils";
+import type { Attachment } from "@/lib/jmap/types";
+
+/**
+ * A message can carry a dozen inline images — signature logos, tracking
+ * pixels, spacers a couple of hundred bytes each — none of which anyone
+ * wants to download. Only parts the sender actually attached belong in
+ * the list, so anything marked inline or referenced by a Content-ID is
+ * dropped.
+ */
+export function realAttachments(attachments?: Attachment[]): Attachment[] {
+ if (!attachments?.length) return [];
+ return attachments.filter(
+ (a) => a.disposition !== "inline" && !a.cid && !!a.name,
+ );
+}
+
+const ICON_BY_TYPE: { match: RegExp; icon: typeof FileIcon; className: string }[] = [
+ { match: /^image\//, icon: FileImage, className: "text-violet-600 dark:text-violet-400" },
+ { match: /pdf/, icon: FileText, className: "text-red-600 dark:text-red-400" },
+ { match: /sheet|excel|csv/, icon: FileSpreadsheet, className: "text-emerald-600 dark:text-emerald-400" },
+ { match: /zip|compress|tar|rar|7z/, icon: FileArchive, className: "text-amber-600 dark:text-amber-400" },
+ { match: /word|document|rtf|text\//, icon: FileText, className: "text-sky-600 dark:text-sky-400" },
+];
+
+function iconFor(type: string, name: string) {
+ const probe = `${type || ""} ${name || ""}`.toLowerCase();
+ return ICON_BY_TYPE.find((e) => e.match.test(probe)) ?? { icon: FileIcon, className: "text-muted-foreground" };
+}
+
+/** Long names are unreadable truncated at the tail — the extension is the
+ * most identifying part, so keep it and elide the middle. */
+function shortName(name: string, max = 18): string {
+ if (name.length <= max) return name;
+ const dot = name.lastIndexOf(".");
+ const ext = dot > 0 && name.length - dot <= 6 ? name.slice(dot) : "";
+ const head = name.slice(0, Math.max(1, max - ext.length - 1));
+ return `${head}…${ext}`;
+}
+
+interface AttachmentChipsProps {
+ attachments?: Attachment[];
+ onOpen: (attachment: Attachment) => void;
+ /** How many chips to show before collapsing the rest into a count. */
+ max?: number;
+ className?: string;
+}
+
+export function AttachmentChips({ attachments, onOpen, max = 2, className }: AttachmentChipsProps) {
+ const real = realAttachments(attachments);
+ if (!real.length) return null;
+
+ const shown = real.slice(0, max);
+ const overflow = real.length - shown.length;
+
+ return (
+