feat(list): show attachments on list rows and open them from there

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.
This commit is contained in:
Shuki Vaknin
2026-09-01 18:51:51 +03:00
parent a210b22e49
commit 0e1c34cdec
6 changed files with 177 additions and 1 deletions
@@ -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>): 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]);
});
});
+85
View File
@@ -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 (
<div className={cn("flex items-center gap-1.5 flex-wrap", className)}>
{shown.map((a) => {
const { icon: Icon, className: iconClass } = iconFor(a.type, a.name ?? "");
return (
<button
key={a.blobId + a.partId}
type="button"
// The row itself opens the message; a chip must not do both.
onClick={(e) => { e.stopPropagation(); onOpen(a); }}
onDoubleClick={(e) => e.stopPropagation()}
title={a.name}
className="inline-flex max-w-[12rem] items-center gap-1.5 rounded-md border border-border bg-background/60 px-2 py-0.5 text-xs text-foreground/80 hover:bg-muted hover:text-foreground"
>
<Icon className={cn("h-3.5 w-3.5 flex-shrink-0", iconClass)} />
<span className="truncate">{shortName(a.name ?? "")}</span>
</button>
);
})}
{overflow > 0 && (
<span className="rounded-md border border-border px-1.5 py-0.5 text-xs text-muted-foreground tabular-nums">
+{overflow}
</span>
)}
</div>
);
}
+4
View File
@@ -2,6 +2,7 @@
import { Email, ThreadGroup } from "@/lib/jmap/types";
import { ThreadListItem } from "./thread-list-item";
import type { Attachment } from "@/lib/jmap/types";
import { EmailContextMenu } from "./email-context-menu";
import { cn } from "@/lib/utils";
import { Trash2, Mail, MailX, MailOpen, Loader2, SearchX, AlertTriangle, CalendarClock, ShieldCheck } from "lucide-react";
@@ -44,6 +45,7 @@ interface EmailListProps {
onMoveToMailbox?: (emailId: string, mailboxId: string) => void;
onMarkAsSpam?: (email: Email) => void;
onUndoSpam?: (email: Email) => void;
onOpenAttachment?: (email: Email, attachment: Attachment) => void;
onEditDraft?: (email: Email) => void;
isScheduledView?: boolean;
onLoadMoreScheduled?: () => void;
@@ -73,6 +75,7 @@ export function EmailList({
onSetTag,
onMarkAsSpam,
onUndoSpam,
onOpenAttachment,
onMoveToMailbox,
onEditDraft,
isScheduledView = false,
@@ -585,6 +588,7 @@ export function EmailList({
onSetTag={onSetTag}
onMarkAsSpam={onMarkAsSpam ? (email) => onMarkAsSpam(email) : undefined}
onUndoSpam={onUndoSpam ? (email) => onUndoSpam(email) : undefined}
onOpenAttachment={onOpenAttachment}
/>
</div>
);
+21 -1
View File
@@ -4,6 +4,8 @@ import React, { useCallback } from "react";
import { formatDate, formatDateTime, stripInvisibleLeading } from "@/lib/utils";
import { Email, ThreadGroup } from "@/lib/jmap/types";
import { cn } from "@/lib/utils";
import { AttachmentChips } from "./attachment-chips";
import type { Attachment } from "@/lib/jmap/types";
import { SelectableAvatar } from "@/components/email/selectable-avatar";
import { Paperclip, Star, Pin, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare, CheckSquare, Square, Reply, Forward, CalendarClock, Folder, Archive, Trash2, MailOpen, ShieldAlert } from "lucide-react";
import type { LucideIcon } from "lucide-react";
@@ -82,6 +84,7 @@ interface ThreadListItemProps {
onSetTag?: (emailId: string, tagId: string | null) => void;
onMarkAsSpam?: (email: Email) => void;
onUndoSpam?: (email: Email) => void;
onOpenAttachment?: (email: Email, attachment: Attachment) => void;
}
interface SingleEmailItemProps {
@@ -99,6 +102,7 @@ interface SingleEmailItemProps {
onSetTag?: (tagId: string | null) => void;
onMarkAsSpam?: () => void;
onUndoSpam?: () => void;
onOpenAttachment?: (attachment: Attachment) => void;
}
// Visual + behaviour metadata for each mobile swipe action. Keyed by the
@@ -112,7 +116,7 @@ const SWIPE_ACTION_META: Record<Exclude<SwipeAction, 'none'>, { icon: LucideIcon
};
const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
function SingleEmailItem({ email, selected, onClick, onDoubleClick, onContextMenu, showPreview, rowTint, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetTag, onMarkAsSpam, onUndoSpam }, ref) {
function SingleEmailItem({ email, selected, onClick, onDoubleClick, onContextMenu, showPreview, rowTint, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetTag, onMarkAsSpam, onUndoSpam, onOpenAttachment }, ref) {
const t = useTranslations('email_viewer');
const tBatch = useTranslations('email_list.batch_actions');
const isUnread = !email.keywords?.$seen;
@@ -509,6 +513,13 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
{previewSnippet ? <SearchSnippetText snippet={previewSnippet} /> : (trimmedPreview || t('no_preview_available'))}
</p>
)}
{onOpenAttachment && (
<AttachmentChips
attachments={email.attachments}
onOpen={onOpenAttachment}
className="mt-1.5"
/>
)}
</>
)}
</div>
@@ -537,6 +548,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemProps>(
function ThreadListItem({
onOpenAttachment,
thread,
isExpanded,
selectedEmailId,
@@ -644,6 +656,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
onSetTag={onSetTag ? (color) => onSetTag(latestEmail.id, color) : undefined}
onMarkAsSpam={onMarkAsSpam ? () => onMarkAsSpam(latestEmail) : undefined}
onUndoSpam={onUndoSpam ? () => onUndoSpam(latestEmail) : undefined}
onOpenAttachment={onOpenAttachment ? (a) => onOpenAttachment(latestEmail, a) : undefined}
/>
);
}
@@ -993,6 +1006,13 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
{previewSnippet ? <SearchSnippetText snippet={previewSnippet} /> : (trimmedPreview || tEmailViewer('no_preview_available'))}
</p>
)}
{onOpenAttachment && (
<AttachmentChips
attachments={latestEmail.attachments}
onOpen={(a) => onOpenAttachment(latestEmail, a)}
className="mt-1.5"
/>
)}
</>
)}
</div>
+22
View File
@@ -68,6 +68,7 @@ import { DragDropProvider } from "@/contexts/drag-drop-context";
import { isFilterEmpty, activeFilterCount } from "@/lib/jmap/search-utils";
import { SearchBox, type ContactSearchField } from "@/components/search/search-box";
import type { ContactSuggestion } from "@/lib/search-suggestions";
import type { Attachment } from "@/lib/jmap/types";
import { useSearchHistoryStore } from "@/stores/search-history-store";
import { WelcomeBanner } from "@/components/ui/welcome-banner";
import { NavigationRail } from "@/components/layout/navigation-rail";
@@ -2914,6 +2915,26 @@ export function MailApp({ linkSegments }: MailAppProps = {}) {
return { blobClient, accountId, clientAccountId };
}, [isUnifiedView, client]);
// Opening an attachment straight from a list row. Deliberately resolves
// the blob source from that row's own email rather than the selected one:
// in the unified inbox each row can belong to a different account, and
// the selected message may be from another one entirely — or none.
const handleOpenListAttachment = useCallback(async (email: Email, attachment: Attachment) => {
const { blobClient, accountId, clientAccountId } = resolveBlobSource(email);
const name = attachment.name;
if (!blobClient || !name) return;
try {
const { mailAttachmentAction } = useSettingsStore.getState();
if (mailAttachmentAction === 'preview' && isFilePreviewable(name, attachment.type)) {
setPreviewAttachment({ blobId: attachment.blobId, name, type: attachment.type, accountId, clientAccountId });
return;
}
await blobClient.downloadBlob(attachment.blobId, name, attachment.type, accountId);
} catch (error) {
console.error("Failed to open attachment from the list:", error);
}
}, [resolveBlobSource]);
const handleDownloadAttachment = async (blobId: string, name: string, type?: string, forceDownload?: boolean) => {
const { blobClient, accountId, clientAccountId } = resolveBlobSource(selectedEmail);
if (!blobClient) return;
@@ -3915,6 +3936,7 @@ export function MailApp({ linkSegments }: MailAppProps = {}) {
onUndoSpam={async (email) => {
await handleUndoSpam(email);
}}
onOpenAttachment={handleOpenListAttachment}
onEditDraft={(email) => {
handleEditDraft(email);
}}
+3
View File
@@ -192,6 +192,9 @@ const EMAIL_LIST_PROPERTIES = [
"subject",
"preview",
"hasAttachment",
// Attachment metadata (name / type / blobId) so list rows can offer the
// files directly. hasAttachment alone only supports a paperclip icon.
"attachments",
// Needed so list rows can serve drag-out to the file system as .eml.
"blobId",
] as const;