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 (
+
+ {shown.map((a) => {
+ const { icon: Icon, className: iconClass } = iconFor(a.type, a.name ?? "");
+ return (
+
+ );
+ })}
+ {overflow > 0 && (
+
+ +{overflow}
+
+ )}
+
+ );
+}
diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx
index a3c12c4d9..eac131f4e 100644
--- a/components/email/email-list.tsx
+++ b/components/email/email-list.tsx
@@ -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}
/>
);
diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx
index 0272fd13c..93cc20a57 100644
--- a/components/email/thread-list-item.tsx
+++ b/components/email/thread-list-item.tsx
@@ -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, { icon: LucideIcon
};
const SingleEmailItem = React.forwardRef(
- 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(
{previewSnippet ? : (trimmedPreview || t('no_preview_available'))}
)}
+ {onOpenAttachment && (
+
+ )}
>
)}
@@ -537,6 +548,7 @@ const SingleEmailItem = React.forwardRef(
export const ThreadListItem = React.forwardRef(
function ThreadListItem({
+ onOpenAttachment,
thread,
isExpanded,
selectedEmailId,
@@ -644,6 +656,7 @@ export const ThreadListItem = React.forwardRef 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 : (trimmedPreview || tEmailViewer('no_preview_available'))}
)}
+ {onOpenAttachment && (
+ onOpenAttachment(latestEmail, a)}
+ className="mt-1.5"
+ />
+ )}
>
)}
diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx
index 2f77adda3..5484a6854 100644
--- a/components/mail/mail-app.tsx
+++ b/components/mail/mail-app.tsx
@@ -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);
}}
diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts
index d01e35e8b..35206d139 100644
--- a/lib/jmap/client.ts
+++ b/lib/jmap/client.ts
@@ -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;