diff --git a/components/email/__tests__/attachment-chips.test.tsx b/components/email/__tests__/attachment-chips.test.tsx
index f0d8dab8a..535d3585d 100644
--- a/components/email/__tests__/attachment-chips.test.tsx
+++ b/components/email/__tests__/attachment-chips.test.tsx
@@ -1,6 +1,7 @@
-import { describe, it, expect } from "vitest";
-import { realAttachments } from "../attachment-chips";
-import type { Attachment } from "@/lib/jmap/types";
+import { describe, it, expect, vi } from "vitest";
+import { render, screen, act } from "@testing-library/react";
+import { realAttachments, ListAttachmentChips } from "../attachment-chips";
+import type { Attachment, Email } from "@/lib/jmap/types";
function att(over: Partial): Attachment {
return { partId: "1", blobId: "b1", size: 100, type: "application/pdf", name: "doc.pdf", ...over };
@@ -40,3 +41,44 @@ describe("realAttachments", () => {
expect(realAttachments([a, b])).toEqual([a, b]);
});
});
+
+// #1089: list requests no longer carry `attachments`; rows load their own.
+describe("ListAttachmentChips", () => {
+ const row = (over: Partial) => ({ id: "m1", hasAttachment: true, ...over }) as Email;
+
+ it("uses attachments the email already carries without loading", () => {
+ const load = vi.fn(() => () => {});
+ render();
+ expect(screen.getByTitle("have.pdf")).toBeTruthy();
+ expect(load).not.toHaveBeenCalled();
+ });
+
+ it("does not load for a row without a paperclip", () => {
+ const load = vi.fn(() => () => {});
+ render();
+ expect(load).not.toHaveBeenCalled();
+ });
+
+ it("loads the parts of a row with a paperclip and shows them", () => {
+ let deliver!: (a: Attachment[]) => void;
+ const cancel = vi.fn();
+ const load = vi.fn((_email: Email, onLoad: (a: Attachment[]) => void) => { deliver = onLoad; return cancel; });
+ const { unmount } = render();
+ expect(load).toHaveBeenCalledTimes(1);
+ expect(screen.queryByRole("button")).toBeNull();
+ act(() => deliver([att({ name: "late.pdf" })]));
+ expect(screen.getByTitle("late.pdf")).toBeTruthy();
+ unmount();
+ expect(cancel).toHaveBeenCalled();
+ });
+
+ it("does not show one message's parts on another", () => {
+ let deliver!: (a: Attachment[]) => void;
+ const load = vi.fn((_email: Email, onLoad: (a: Attachment[]) => void) => { deliver = onLoad; return () => {}; });
+ const { rerender } = render();
+ act(() => deliver([att({ name: "first.pdf" })]));
+ rerender();
+ expect(screen.queryByTitle("first.pdf")).toBeNull();
+ expect(load).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/components/email/attachment-chips.tsx b/components/email/attachment-chips.tsx
index 0f4a1c959..c40dda359 100644
--- a/components/email/attachment-chips.tsx
+++ b/components/email/attachment-chips.tsx
@@ -1,8 +1,10 @@
"use client";
+import { useEffect, useState } from "react";
import { FileText, FileSpreadsheet, FileImage, FileArchive, File as FileIcon } from "@/components/icons";
import { cn } from "@/lib/utils";
-import type { Attachment } from "@/lib/jmap/types";
+import type { Attachment, Email } from "@/lib/jmap/types";
+import type { LoadListAttachments } from "@/lib/list-attachments";
/**
* A message can carry a dozen inline images — signature logos, tracking
@@ -83,3 +85,36 @@ export function AttachmentChips({ attachments, onOpen, max = 2, className }: Att
);
}
+
+/**
+ * Attachment parts for a list row. List requests no longer carry
+ * `attachments` (#1089), so a row with a paperclip loads its own once it is
+ * mounted; an email that already has them (thread expansion, demo data) is
+ * used as is.
+ */
+export function useListAttachments(email: Email, load?: LoadListAttachments): Attachment[] | undefined {
+ const [loaded, setLoaded] = useState<{ id: string; attachments: Attachment[] } | null>(null);
+ const needsLoad = !!load && !!email.hasAttachment && !email.attachments;
+
+ useEffect(() => {
+ if (!needsLoad || !load) return;
+ const id = email.id;
+ return load(email, (attachments) => setLoaded({ id, attachments }));
+ // The row's email object is replaced on every keyword change; only a
+ // different message needs a different answer.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [needsLoad, load, email.id]);
+
+ if (email.attachments) return email.attachments;
+ return loaded?.id === email.id ? loaded.attachments : undefined;
+}
+
+interface ListAttachmentChipsProps extends Omit {
+ email: Email;
+ load?: LoadListAttachments;
+}
+
+export function ListAttachmentChips({ email, load, ...chipProps }: ListAttachmentChipsProps) {
+ const attachments = useListAttachments(email, load);
+ return ;
+}
diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx
index 3fd133b7d..87d73e2f0 100644
--- a/components/email/email-list.tsx
+++ b/components/email/email-list.tsx
@@ -3,6 +3,7 @@
import { Email, ThreadGroup } from "@/lib/jmap/types";
import { ThreadListItem } from "./thread-list-item";
import type { Attachment } from "@/lib/jmap/types";
+import type { LoadListAttachments } from "@/lib/list-attachments";
import { EmailContextMenu } from "./email-context-menu";
import { cn } from "@/lib/utils";
import { Trash2, Mail, MailX, MailOpen, Loader2, SearchX, AlertTriangle, CalendarClock, ShieldCheck } from "@/components/icons";
@@ -48,6 +49,7 @@ interface EmailListProps {
onMarkAsSpam?: (email: Email) => void;
onUndoSpam?: (email: Email) => void;
onOpenAttachment?: (email: Email, attachment: Attachment) => void;
+ loadAttachments?: LoadListAttachments;
onEditDraft?: (email: Email) => void;
isScheduledView?: boolean;
onLoadMoreScheduled?: () => void;
@@ -78,6 +80,7 @@ export function EmailList({
onMarkAsSpam,
onUndoSpam,
onOpenAttachment,
+ loadAttachments,
onMoveToMailbox,
onEditDraft,
isScheduledView = false,
@@ -593,6 +596,7 @@ export function EmailList({
onMarkAsSpam={onMarkAsSpam ? (email) => onMarkAsSpam(email) : undefined}
onUndoSpam={onUndoSpam ? (email) => onUndoSpam(email) : undefined}
onOpenAttachment={onOpenAttachment}
+ loadAttachments={loadAttachments}
/>
);
diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx
index b3e045e91..d3fef0ea1 100644
--- a/components/email/thread-list-item.tsx
+++ b/components/email/thread-list-item.tsx
@@ -4,8 +4,9 @@ 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 { ListAttachmentChips } from "./attachment-chips";
import type { Attachment } from "@/lib/jmap/types";
+import type { LoadListAttachments } from "@/lib/list-attachments";
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 "@/components/icons";
import type { AppIcon } from "@/components/icons";
@@ -125,6 +126,7 @@ interface ThreadListItemProps {
onMarkAsSpam?: (email: Email) => void;
onUndoSpam?: (email: Email) => void;
onOpenAttachment?: (email: Email, attachment: Attachment) => void;
+ loadAttachments?: LoadListAttachments;
}
interface SingleEmailItemProps {
@@ -143,6 +145,7 @@ interface SingleEmailItemProps {
onMarkAsSpam?: () => void;
onUndoSpam?: () => void;
onOpenAttachment?: (attachment: Attachment) => void;
+ loadAttachments?: LoadListAttachments;
}
// Visual + behaviour metadata for each mobile swipe action. Keyed by the
@@ -156,7 +159,7 @@ const SWIPE_ACTION_META: Record, { icon: AppIcon; b
};
const SingleEmailItem = React.forwardRef(
- function SingleEmailItem({ email, selected, onClick, onDoubleClick, onContextMenu, showPreview, rowTint, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetTag, onMarkAsSpam, onUndoSpam, onOpenAttachment }, ref) {
+ function SingleEmailItem({ email, selected, onClick, onDoubleClick, onContextMenu, showPreview, rowTint, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetTag, onMarkAsSpam, onUndoSpam, onOpenAttachment, loadAttachments }, ref) {
const t = useTranslations('email_viewer');
const tBatch = useTranslations('email_list.batch_actions');
const tStatus = useTranslations('email_list');
@@ -563,8 +566,9 @@ const SingleEmailItem = React.forwardRef(
)}
{onOpenAttachment && (
-
@@ -598,6 +602,7 @@ const SingleEmailItem = React.forwardRef(
export const ThreadListItem = React.forwardRef(
function ThreadListItem({
onOpenAttachment,
+ loadAttachments,
thread,
isExpanded,
selectedEmailId,
@@ -709,6 +714,7 @@ export const ThreadListItem = React.forwardRef onMarkAsSpam(latestEmail) : undefined}
onUndoSpam={onUndoSpam ? () => onUndoSpam(latestEmail) : undefined}
onOpenAttachment={onOpenAttachment ? (a) => onOpenAttachment(latestEmail, a) : undefined}
+ loadAttachments={loadAttachments}
/>
);
}
@@ -1065,8 +1071,9 @@ export const ThreadListItem = React.forwardRef
)}
{onOpenAttachment && (
- onOpenAttachment(latestEmail, a)}
className="mt-1.5"
/>
diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx
index c7fa32100..056e0c569 100644
--- a/components/mail/mail-app.tsx
+++ b/components/mail/mail-app.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useEffect, useState, useRef, useMemo, useCallback, useId } from "react";
+import { useEffect, useLayoutEffect, useState, useRef, useMemo, useCallback, useId } from "react";
import { usePathname } from "next/navigation";
import { useTranslations } from "next-intl";
import { Sidebar } from "@/components/layout/sidebar";
@@ -72,6 +72,7 @@ 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 { requestListAttachments, type ListAttachmentSource, type LoadListAttachments } from "@/lib/list-attachments";
import { useSearchHistoryStore } from "@/stores/search-history-store";
import { WelcomeBanner } from "@/components/ui/welcome-banner";
import { NavigationRail } from "@/components/layout/navigation-rail";
@@ -3055,6 +3056,39 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
}
}, [resolveBlobSource]);
+ // Where a list row's attachment parts are fetched from: the same routing
+ // the viewer uses to open that row (source stamps first, then the account
+ // being browsed, then a shared folder's owner). Read through a ref so the
+ // loader stays stable and rows do not re-request on every folder count
+ // change. A layout effect, because the rows ask from their own (passive)
+ // effects in the same commit.
+ const listRowSourceRef = useRef<(email: Email) => { source: ListAttachmentSource; accountId?: string } | null>(() => null);
+ useLayoutEffect(() => {
+ listRowSourceRef.current = (email) => {
+ const auth = useAuthStore.getState();
+ if (email.sourceClientAccountId) {
+ const source = auth.getClientForAccount(email.sourceClientAccountId);
+ return source ? { source, accountId: email.sourceAccountId } : null;
+ }
+ const source = (viewingAccountId ? auth.getClientForAccount(viewingAccountId) : undefined) ?? client;
+ if (!source) return null;
+ const accountId = email.sourceAccountId
+ ?? resolveUnstampedEmailAccountId({
+ mailboxes: viewMailboxes,
+ selectedMailbox,
+ searchActive: !!searchQuery || !isFilterEmpty(searchFilters),
+ searchMailboxId,
+ });
+ return { source, accountId };
+ };
+ }, [client, viewingAccountId, viewMailboxes, selectedMailbox, searchQuery, searchFilters, searchMailboxId]);
+
+ const loadListAttachments = useCallback((email, onLoad) => {
+ const target = listRowSourceRef.current(email);
+ if (!target) return () => {};
+ return requestListAttachments(target.source, target.accountId, email.id, onLoad);
+ }, []);
+
const handleDownloadAttachment = async (blobId: string, name: string, type?: string, forceDownload?: boolean) => {
const { blobClient, accountId, clientAccountId } = resolveBlobSource(selectedEmail);
if (!blobClient) return;
@@ -4111,6 +4145,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
await handleUndoSpam(email);
}}
onOpenAttachment={handleOpenListAttachment}
+ loadAttachments={loadListAttachments}
onEditDraft={(email) => {
handleEditDraft(email);
}}
diff --git a/lib/__tests__/list-attachments.test.ts b/lib/__tests__/list-attachments.test.ts
new file mode 100644
index 000000000..0ca055298
--- /dev/null
+++ b/lib/__tests__/list-attachments.test.ts
@@ -0,0 +1,152 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { JMAPClient } from '../jmap/client';
+import { requestListAttachments, type ListAttachmentSource } from '../list-attachments';
+import type { Attachment } from '../jmap/types';
+
+type Call = [string, Record, string];
+
+const pdf = (blobId: string): Attachment => ({ partId: '2', blobId, size: 10, name: `${blobId}.pdf`, type: 'application/pdf' });
+
+function recordingClient(maxObjectsInGet = 50) {
+ const client = new JMAPClient('https://jmap.example.com', 'user@example.com', 'pass');
+ Object.assign(client, { accountId: 'me', capabilities: { 'urn:ietf:params:jmap:core': { maxObjectsInGet } } });
+ const gets: Record[] = [];
+ vi.spyOn(client as unknown as { request: (c: Call[]) => Promise }, 'request')
+ .mockImplementation(async (calls: Call[]) => ({
+ methodResponses: calls.map(([method, args, id]) => {
+ if (method === 'Email/query') return ['Email/query', { ids: ['m1'], total: 1, position: 0 }, id];
+ if (method === 'Email/get') {
+ gets.push(args);
+ const ids = (args.ids as string[] | undefined) ?? ['m1'];
+ return ['Email/get', {
+ list: ids.filter(m => m !== 'gone').map(m => ({ id: m, attachments: [pdf(`b-${m}`)] })),
+ }, id];
+ }
+ return [method, {}, id];
+ }),
+ }));
+ return { client, gets };
+}
+
+// #1089: asking for `attachments` makes Stalwart read every message's raw
+// blob, so a list page must not ask for it.
+describe('list requests', () => {
+ it('do not ask for attachments but keep blobId for drag-out', async () => {
+ const { client, gets } = recordingClient();
+ await client.getSomeEmails(['m1']);
+ await client.getEmails('inbox');
+ expect(gets.length).toBeGreaterThanOrEqual(2);
+ for (const args of gets) {
+ expect(args.properties).not.toContain('attachments');
+ expect(args.properties).toContain('blobId');
+ expect(args.properties).toContain('hasAttachment');
+ }
+ });
+});
+
+describe('getEmailAttachments', () => {
+ it('asks only for attachment parts, in server-sized batches', async () => {
+ const { client, gets } = recordingClient(2);
+ const found = await client.getEmailAttachments(['a', 'b', 'c'], 'owner');
+ expect(gets.map(g => g.ids)).toEqual([['a', 'b'], ['c']]);
+ expect(gets[0]).toMatchObject({ accountId: 'owner', properties: ['id', 'attachments'] });
+ expect(gets[0].bodyProperties).toEqual(expect.arrayContaining(['partId', 'blobId', 'name', 'type', 'cid', 'disposition']));
+ expect(found.get('c')).toEqual([pdf('b-c')]);
+ });
+
+ it('leaves out ids the server did not return', async () => {
+ const { client } = recordingClient();
+ const found = await client.getEmailAttachments(['a', 'gone']);
+ expect([...found.keys()]).toEqual(['a']);
+ });
+});
+
+describe('requestListAttachments', () => {
+ beforeEach(() => { vi.useFakeTimers(); });
+ afterEach(() => { vi.useRealTimers(); });
+
+ function fakeSource(fail = false) {
+ const getEmailAttachments = vi.fn(async (ids: string[]) => {
+ if (fail) throw new Error('boom');
+ return new Map(ids.filter(id => id !== 'gone').map(id => [id, [pdf(id)]]));
+ });
+ return { getEmailAttachments } satisfies ListAttachmentSource;
+ }
+
+ it('coalesces rows that mount together into one request', async () => {
+ const source = fakeSource();
+ const a = vi.fn(), b = vi.fn();
+ requestListAttachments(source, undefined, 'a', a);
+ requestListAttachments(source, undefined, 'b', b);
+ await vi.runAllTimersAsync();
+ expect(source.getEmailAttachments).toHaveBeenCalledTimes(1);
+ expect(source.getEmailAttachments).toHaveBeenCalledWith(['a', 'b'], undefined);
+ expect(a).toHaveBeenCalledWith([pdf('a')]);
+ expect(b).toHaveBeenCalledWith([pdf('b')]);
+ });
+
+ it('answers a second mount from the cache, synchronously', async () => {
+ const source = fakeSource();
+ requestListAttachments(source, undefined, 'a', vi.fn());
+ await vi.runAllTimersAsync();
+ const again = vi.fn();
+ requestListAttachments(source, undefined, 'a', again);
+ expect(again).toHaveBeenCalledWith([pdf('a')]);
+ expect(source.getEmailAttachments).toHaveBeenCalledTimes(1);
+ });
+
+ it('drops a row scrolled away before the request goes out', async () => {
+ const source = fakeSource();
+ const cancel = requestListAttachments(source, undefined, 'a', vi.fn());
+ requestListAttachments(source, undefined, 'b', vi.fn());
+ cancel();
+ await vi.runAllTimersAsync();
+ expect(source.getEmailAttachments).toHaveBeenCalledWith(['b'], undefined);
+ });
+
+ it('joins a request already in flight instead of asking again', async () => {
+ let release!: () => void;
+ const source: ListAttachmentSource & { getEmailAttachments: ReturnType } = {
+ getEmailAttachments: vi.fn(() => new Promise