From 42b694fb2c26b1eacd4c7887425f53a85697bade Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 08:16:47 +0100 Subject: [PATCH] feat: save attachments of shared and group mailboxes to Files by copying the blob on the server --- .../__tests__/save-to-files-dialog.test.tsx | 20 +++++ .../__tests__/viewer-save-to-files.test.tsx | 73 +++++++++++++++++++ components/email/email-viewer.tsx | 8 +- components/email/save-to-files-dialog.tsx | 21 ++++-- lib/__tests__/jmap-blob-copy.test.ts | 55 ++++++++++++++ lib/demo/demo-client.ts | 5 ++ lib/jmap/client-interface.ts | 6 ++ lib/jmap/client.ts | 20 +++++ 8 files changed, 200 insertions(+), 8 deletions(-) create mode 100644 components/email/__tests__/viewer-save-to-files.test.tsx create mode 100644 lib/__tests__/jmap-blob-copy.test.ts diff --git a/components/email/__tests__/save-to-files-dialog.test.tsx b/components/email/__tests__/save-to-files-dialog.test.tsx index 0af4e64bf..87b3ea588 100644 --- a/components/email/__tests__/save-to-files-dialog.test.tsx +++ b/components/email/__tests__/save-to-files-dialog.test.tsx @@ -28,4 +28,24 @@ describe('SaveToFilesDialog', () => { await waitFor(() => expect(onClose).toHaveBeenCalled()); expect(useToastStore.getState().toasts.some((toast) => toast.title === 'saved')).toBe(true); }); + + // A shared mailbox's attachment: the blob is in the team's account, the + // file goes into the user's own Files account. + it('copies the blob of a shared account into the Files account first', async () => { + const createFileNode = vi.fn().mockResolvedValue({}); + const copyBlobs = vi.fn().mockResolvedValue({ 'blob-1': 'blob-mine' }); + const client = { + listAllFileNodes: vi.fn().mockResolvedValue(nodes), + getFilesAccountId: () => 'me', + copyBlobs, + createFileNode, + } as unknown as IJMAPClient; + render(); + + fireEvent.click(await screen.findByText('Documents')); + fireEvent.click(screen.getByRole('button', { name: 'save_here' })); + + await waitFor(() => expect(createFileNode).toHaveBeenCalledWith('invoice.pdf', 'blob-mine', 'application/pdf', 1234, 'docs')); + expect(copyBlobs).toHaveBeenCalledWith('team', ['blob-1'], 'me'); + }); }); diff --git a/components/email/__tests__/viewer-save-to-files.test.tsx b/components/email/__tests__/viewer-save-to-files.test.tsx new file mode 100644 index 000000000..0d0a39b2d --- /dev/null +++ b/components/email/__tests__/viewer-save-to-files.test.tsx @@ -0,0 +1,73 @@ +import { render, screen } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { Email } from '@/lib/jmap/types'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; + +// Save to Files for an attachment of a shared or group mailbox of the same +// login: its blob is copied into the Files account (Blob/copy). Another +// login's mail can't reach this login's Files, so it isn't offered there. + +vi.mock('@/hooks/use-media-query', () => ({ + useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }), + useMediaQuery: () => false, +})); +vi.mock('@/components/tour/tour-provider', () => ({ + useTour: () => ({ activeTour: null, startTour: vi.fn(), endTour: vi.fn(), registerStep: vi.fn() }), +})); +vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); +vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] })); + +class NoopResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} +vi.stubGlobal('ResizeObserver', NoopResizeObserver); + +const { EmailViewer } = await import('../email-viewer'); +const { useAuthStore } = await import('@/stores/auth-store'); +const { usePolicyStore } = await import('@/stores/policy-store'); + +const email = { + id: 'e1', + blobId: 'b1', + threadId: 't1', + mailboxIds: { inbox: true }, + keywords: { $seen: true }, + from: [{ name: 'Alice', email: 'alice@example.com' }], + to: [{ name: 'Bob', email: 'bob@example.com' }], + subject: 'Invoice', + receivedAt: '2026-01-01T00:00:00Z', + preview: 'See attached', + hasAttachment: true, + size: 100, + textBody: [], + htmlBody: [], + attachments: [{ partId: '2', blobId: 'blob-pdf', name: 'invoice.pdf', type: 'application/pdf', size: 1234, disposition: 'attachment' }], + bodyValues: {}, +} as unknown as Email; + +function makeClient(accountId: string) { + return new Proxy({ getAccountId: () => accountId, getFilesAccountId: () => accountId, supportsFiles: () => true } as Record, { + get: (target, prop) => (prop in target ? target[prop as string] : prop === 'then' ? undefined : vi.fn().mockResolvedValue(null)), + }) as unknown as IJMAPClient; +} + +describe('Save to Files in the reader', () => { + const own = makeClient('me'); + + beforeEach(() => { + useAuthStore.setState({ client: own } as never); + usePolicyStore.setState({ isFeatureEnabled: () => true } as never); + }); + + it("is offered for a shared mailbox's attachment of the same login", () => { + render(); + expect(screen.getAllByRole('button', { name: 'save_to_files.title' }).length).toBeGreaterThan(0); + }); + + it("isn't offered for another login's mail", () => { + render(); + expect(screen.queryByRole('button', { name: 'save_to_files.title' })).toBeNull(); + }); +}); diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index d9a474015..a2453d2e7 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -1470,8 +1470,10 @@ export function EmailViewer({ const blobClient = blobSource ? blobSource.client : legacyBlobClient; const blobAccountId = blobSource ? blobSource.accountId : legacyBlobAccountId; const filesFeatureEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled')); - const canSaveToFiles = filesFeatureEnabled && !!client && blobClient === client - && !!client.supportsFiles?.() && (!blobAccountId || blobAccountId === client.getAccountId()); + // The blob must be reachable from the Files account's session: the + // login's own mail, or a shared or group account of the same login, whose + // blob is copied over (Blob/copy). Another login's mail can't be. + const canSaveToFiles = filesFeatureEnabled && !!client && blobClient === client && !!client.supportsFiles?.(); // List-Unsubscribe mailto: send the message ourselves - this is a webmail // client, handing a mailto: URL to the OS mail handler goes nowhere for @@ -6258,7 +6260,7 @@ export function EmailViewer({ )} {canSaveToFiles && client && saveToFilesTarget && ( - setSaveToFilesTarget(null)} /> + setSaveToFilesTarget(null)} /> )} {/* View source: the raw message the server holds. */} diff --git a/components/email/save-to-files-dialog.tsx b/components/email/save-to-files-dialog.tsx index a33528888..907075174 100644 --- a/components/email/save-to-files-dialog.tsx +++ b/components/email/save-to-files-dialog.tsx @@ -20,8 +20,14 @@ export interface SaveableAttachment { interface SaveToFilesDialogProps { /** The attachment to save; the dialog is open while it is set. */ attachment: SaveableAttachment | null; - /** The mail login's client: the attachment's blob is in its own account, as are its files. */ + /** The mail login's client; its Files account takes the file. */ client: IJMAPClient; + /** + * The JMAP account the attachment's blob is in, when not the Files + * account (a shared or group mailbox of the same login): the blob is + * copied over first with Blob/copy. + */ + blobAccountId?: string; onClose: () => void; } @@ -30,10 +36,11 @@ const NO_CURRENT_FOLDER = "#none"; /** * "Save to Files" for an attachment: pick a folder with the Files app's - * folder picker. The attachment's blob is already on the server, in the same - * account as the files, so it is filed by blobId with no download or upload. + * folder picker. The attachment's blob is already on the server, so it is + * filed by blobId with no download or upload; from another account of the + * login it is copied into the Files account first (Blob/copy, RFC 8620 §6.3). */ -export function SaveToFilesDialog({ attachment, client, onClose }: SaveToFilesDialogProps) { +export function SaveToFilesDialog({ attachment, client, blobAccountId, onClose }: SaveToFilesDialogProps) { const t = useTranslations("email_viewer.save_to_files"); const tFiles = useTranslations("files"); const [nodes, setNodes] = useState([]); @@ -68,9 +75,13 @@ export function SaveToFilesDialog({ attachment, client, onClose }: SaveToFilesDi if (!attachment?.blobId) return; const folderName = parentId ? (folders.find((f) => f.id === parentId)?.name ?? "") : tFiles("my_files"); try { + const filesAccountId = blobAccountId ? client.getFilesAccountId() : undefined; + const blobId = blobAccountId && filesAccountId && blobAccountId !== filesAccountId + ? (await client.copyBlobs(blobAccountId, [attachment.blobId], filesAccountId))[attachment.blobId] + : attachment.blobId; await client.createFileNode( attachment.name || "attachment", - attachment.blobId, + blobId, attachment.type || "application/octet-stream", attachment.size, parentId, diff --git a/lib/__tests__/jmap-blob-copy.test.ts b/lib/__tests__/jmap-blob-copy.test.ts new file mode 100644 index 000000000..916ba40cb --- /dev/null +++ b/lib/__tests__/jmap-blob-copy.test.ts @@ -0,0 +1,55 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { JMAPClient } from '../jmap/client'; + +// Blob/copy (RFC 8620 §6.3): Save to Files puts an attachment of a shared +// mailbox into the user's own Files account without a download. + +function createClient(): JMAPClient { + const client = new JMAPClient('https://jmap.example.com', 'user', 'pass'); + Object.assign(client, { + apiUrl: 'https://jmap.example.com/jmap/', + accountId: 'a', + session: { + primaryAccounts: { 'urn:ietf:params:jmap:mail': 'a' }, + accounts: { a: { name: 'me', isPersonal: true, accountCapabilities: {} }, b: { name: 'team', isPersonal: false, accountCapabilities: {} } }, + }, + }); + return client; +} + +function answer(result: object) { + const calls: unknown[] = []; + vi.spyOn(globalThis, 'fetch').mockImplementation(async (_input, init) => { + calls.push(JSON.parse(String(init?.body)).methodCalls[0]); + return { + ok: true, + status: 200, + headers: new Headers(), + text: () => Promise.resolve(JSON.stringify({ methodResponses: [['Blob/copy', result, 'bc0']] })), + json: () => Promise.resolve({ methodResponses: [['Blob/copy', result, 'bc0']] }), + } as Response; + }); + return calls; +} + +afterEach(() => vi.restoreAllMocks()); + +describe('copyBlobs', () => { + it('asks the server to copy the blob into the other account and returns its new id', async () => { + const calls = answer({ fromAccountId: 'b', accountId: 'a', copied: { Gteam1: 'Gmine1' }, notCopied: null }); + const copied = await createClient().copyBlobs('b', ['Gteam1'], 'a'); + expect(calls).toEqual([['Blob/copy', { fromAccountId: 'b', accountId: 'a', blobIds: ['Gteam1'] }, 'bc0']]); + expect(copied).toEqual({ Gteam1: 'Gmine1' }); + }); + + it('fails when the server did not copy it', async () => { + answer({ fromAccountId: 'b', accountId: 'a', copied: null, notCopied: { Gteam1: { type: 'blobNotFound' } } }); + await expect(createClient().copyBlobs('b', ['Gteam1'], 'a')).rejects.toThrow('blobNotFound'); + }); + + it('copies nothing within one account', async () => { + const calls = answer({}); + expect(await createClient().copyBlobs('a', ['G1'], 'a')).toEqual({ G1: 'G1' }); + expect(calls).toEqual([]); + }); +}); diff --git a/lib/demo/demo-client.ts b/lib/demo/demo-client.ts index aac590b2a..44bb9e3d7 100644 --- a/lib/demo/demo-client.ts +++ b/lib/demo/demo-client.ts @@ -1393,6 +1393,11 @@ export class DemoJMAPClient implements IJMAPClient { return node; } + async copyBlobs(_fromAccountId: string, blobIds: string[], _toAccountId: string): Promise> { + // One demo store holds every account's blobs. + return Object.fromEntries(blobIds.map((id) => [id, id])); + } + async createFileNode(name: string, blobId: string, type: string, size: number, parentId: string | null): Promise { const node: FileNode = { id: generateDemoId('file'), diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index 55a13299e..549d06060 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -644,6 +644,12 @@ export interface IJMAPClient { getFileNodes(ids: string[] | null, properties?: string[]): Promise; createFileDirectory(name: string, parentId: string | null): Promise; createFileNode(name: string, blobId: string, type: string, size: number, parentId: string | null): Promise; + /** + * Blob/copy (RFC 8620 §6.3): copies blobs from one account of this session + * into another, on the server. Returns the new id of each copied blob; + * throws when one was not copied. + */ + copyBlobs(fromAccountId: string, blobIds: string[], toAccountId: string): Promise>; updateFileNode(id: string, updates: Partial>): Promise; updateFileNodes(updates: Record>>): Promise<{ updated: string[]; notUpdated: Record }>; destroyFileNodes(ids: string[]): Promise<{ destroyed: string[]; notDestroyed: string[] }>; diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 0ec6b2ad7..d611a5a7d 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -9601,6 +9601,26 @@ export class JMAPClient implements IJMAPClient { * `size` is not sent: every server works it out from the blob, and * Stalwart 1.0 refuses a create whose size differs from the blob's. */ + async copyBlobs(fromAccountId: string, blobIds: string[], toAccountId: string): Promise> { + if (blobIds.length === 0 || fromAccountId === toAccountId) { + return Object.fromEntries(blobIds.map((id) => [id, id])); + } + const response = await this.request([ + ["Blob/copy", { fromAccountId, accountId: toAccountId, blobIds }, "bc0"], + ]); + const [name, result] = response.methodResponses?.[0] ?? []; + if (name !== "Blob/copy" || !result) { + throw jmapError(result?.description || result?.type || "Blob/copy failed", result); + } + const copied = (result.copied ?? {}) as Record; + const missing = blobIds.filter((id) => !copied[id]); + if (missing.length > 0) { + const error = (result.notCopied ?? {})[missing[0]]; + throw jmapError(error?.description || error?.type || "Blob/copy did not copy the blob", error); + } + return copied; + } + async createFileNode(name: string, blobId: string, type: string, _size: number, parentId: string | null): Promise { const parent = this.resolveFileNodeId(parentId);