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;