mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-01 00:13:37 +00:00
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:
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user