diff --git a/components/email/__tests__/save-to-files-dialog.test.tsx b/components/email/__tests__/save-to-files-dialog.test.tsx index ab2461046..5d36a3c17 100644 --- a/components/email/__tests__/save-to-files-dialog.test.tsx +++ b/components/email/__tests__/save-to-files-dialog.test.tsx @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest'; import type { IJMAPClient } from '@/lib/jmap/client-interface'; import type { FileNode } from '@/lib/jmap/types'; import { useToastStore } from '@/stores/toast-store'; +import { fileHooks } from '@/lib/plugin-hooks'; import { SaveToFilesDialog } from '../save-to-files-dialog'; // "Save to Files" files an attachment in a folder of the Files app by its @@ -62,4 +63,21 @@ describe('SaveToFilesDialog', () => { fireEvent.click(saveHere); await waitFor(() => expect(createFileNode).toHaveBeenCalledWith('invoice.pdf', 'blob-1', 'application/pdf', 1234, null)); }); + + // The dialog used to file the blob without the upload hooks, so a plugin + // that checks what goes into Files never heard about it. + it('asks plugins first, as an upload into Files does, and saves nothing when one says no', async () => { + const createFileNode = vi.fn().mockResolvedValue({}); + const veto = vi.fn(() => false); + const reg = fileHooks.onBeforeFileUpload.register('dlp', veto); + const client = { listAllFileNodes: vi.fn().mockResolvedValue(nodes), createFileNode } as unknown as IJMAPClient; + render(); + + fireEvent.click(await screen.findByText('Documents')); + fireEvent.click(screen.getByRole('button', { name: 'save_here' })); + + await waitFor(() => expect(veto).toHaveBeenCalledWith({ name: 'invoice.pdf', size: 1234, type: 'application/pdf', path: '/Documents' })); + expect(createFileNode).not.toHaveBeenCalled(); + reg.dispose(); + }); }); diff --git a/components/email/save-to-files-dialog.tsx b/components/email/save-to-files-dialog.tsx index 6c3517698..641eb77b7 100644 --- a/components/email/save-to-files-dialog.tsx +++ b/components/email/save-to-files-dialog.tsx @@ -6,6 +6,7 @@ import { FolderPickerDialog } from "@/components/files/folder-picker-dialog"; import { toast } from "@/stores/toast-store"; import { fileNodeKind } from "@/lib/jmap/filenode-kind"; import { TRASH_FOLDER_NAME } from "@/lib/files-trash"; +import { saveBlobsToFiles } from "@/lib/files-save"; import type { IJMAPClient } from "@/lib/jmap/client-interface"; import type { FileNode } from "@/lib/jmap/types"; @@ -34,6 +35,18 @@ interface SaveToFilesDialogProps { // Nothing is "where the items already are": every folder, My files too, can be picked. const NO_CURRENT_FOLDER = "#none"; +/** The path of a folder from the picker's list ('/' for My files), for plugins. */ +function folderPathOf(folders: { id: string; name: string; parentId: string | null }[], id: string | null): string { + const byId = new Map(folders.map((f) => [f.id, f])); + const names: string[] = []; + const seen = new Set(); + for (let at = id ? byId.get(id) : undefined; at && !seen.has(at.id); at = at.parentId ? byId.get(at.parentId) : undefined) { + seen.add(at.id); + names.unshift(at.name); + } + return `/${names.join("/")}`; +} + /** * "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, so it is @@ -75,17 +88,12 @@ export function SaveToFilesDialog({ attachment, client, blobAccountId, onClose } 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", - blobId, - attachment.type || "application/octet-stream", - attachment.size, - parentId, + const { saved } = await saveBlobsToFiles( + [{ blobId: attachment.blobId, name: attachment.name, type: attachment.type, size: attachment.size }], + { client, parentId, path: folderPathOf(folders, parentId), blobAccountId }, ); + // A plugin stopped it (onBeforeFileUpload); it says why itself. + if (saved.length === 0) return; toast.success(t("saved"), t("saved_to", { folder: folderName })); } catch (error) { console.error("Failed to save the attachment to Files:", error); diff --git a/lib/__tests__/files-save.test.ts b/lib/__tests__/files-save.test.ts new file mode 100644 index 000000000..b9b801563 --- /dev/null +++ b/lib/__tests__/files-save.test.ts @@ -0,0 +1,66 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { IJMAPClient } from '../jmap/client-interface'; +import { saveBlobsToFiles } from '../files-save'; +import { fileHooks } from '../plugin-hooks'; + +// Saving attachments to Files (the reader's Save to Files and +// api.files.saveAttachments): filed by blob, copied over from another +// account first, and announced to plugins like an upload in Files. + +function makeClient() { + let n = 0; + return { + getFilesAccountId: vi.fn(() => 'me'), + copyBlobs: vi.fn(async (_from: string, ids: string[]) => Object.fromEntries(ids.map((id) => [id, `${id}-mine`]))), + createFileNode: vi.fn(async (name: string, blobId: string, type: string, _size: number, parentId: string | null) => ({ id: `n${++n}`, name, blobId, type, parentId, size: 0, created: '', modified: '' })), + }; +} +const blobs = [ + { blobId: 'b1', name: 'invoice.pdf', type: 'application/pdf', size: 1234 }, + { blobId: 'b2', name: null, type: null, size: 9 }, +]; + +afterEach(() => { + fileHooks.onBeforeFileUpload.clear(); + fileHooks.onAfterFileUpload.clear(); +}); + +describe('saveBlobsToFiles', () => { + it('files each blob in the folder and tells plugins as for an upload', async () => { + const client = makeClient(); + const before = vi.fn(() => true); + const after = vi.fn(); + fileHooks.onBeforeFileUpload.register('p', before); + fileHooks.onAfterFileUpload.register('p', after); + const { saved, stopped } = await saveBlobsToFiles(blobs, { client: client as unknown as IJMAPClient, parentId: 'docs', path: '/Documents' }); + expect(client.createFileNode.mock.calls).toEqual([ + ['invoice.pdf', 'b1', 'application/pdf', 1234, 'docs'], + ['attachment', 'b2', 'application/octet-stream', 9, 'docs'], + ]); + expect(client.copyBlobs).not.toHaveBeenCalled(); + expect(before.mock.calls).toEqual([ + [{ name: 'invoice.pdf', size: 1234, type: 'application/pdf', path: '/Documents' }], + [{ name: 'attachment', size: 9, type: 'application/octet-stream', path: '/Documents' }], + ]); + await vi.waitFor(() => expect(after).toHaveBeenCalledTimes(2)); + expect(after.mock.calls[0][0]).toMatchObject({ name: 'invoice.pdf', type: 'file', path: '/Documents' }); + expect(saved.map((n) => [n.name, n.size])).toEqual([['invoice.pdf', 1234], ['attachment', 9]]); + expect(stopped).toEqual([]); + }); + + it('copies blobs of another account of the login into the Files account first', async () => { + const client = makeClient(); + await saveBlobsToFiles([blobs[0]], { client: client as unknown as IJMAPClient, parentId: null, path: '/', blobAccountId: 'team' }); + expect(client.copyBlobs).toHaveBeenCalledWith('team', ['b1'], 'me'); + expect(client.createFileNode).toHaveBeenCalledWith('invoice.pdf', 'b1-mine', 'application/pdf', 1234, null); + }); + + it('skips a file another plugin stops, and saves the rest', async () => { + const client = makeClient(); + fileHooks.onBeforeFileUpload.register('dlp', (info: { name: string }) => info.name !== 'invoice.pdf'); + const { saved, stopped } = await saveBlobsToFiles(blobs, { client: client as unknown as IJMAPClient, parentId: null, path: '/' }); + expect(stopped).toEqual(['invoice.pdf']); + expect(saved.map((n) => n.name)).toEqual(['attachment']); + expect(client.createFileNode).toHaveBeenCalledTimes(1); + }); +}); diff --git a/lib/files-save.ts b/lib/files-save.ts new file mode 100644 index 000000000..1a793bebf --- /dev/null +++ b/lib/files-save.ts @@ -0,0 +1,71 @@ +// Save attachments of a message to Files: the blobs are already on the +// server, so each is filed by blobId with no download or upload. From +// another account of the login (a shared or group mailbox) the blob is +// copied into the Files account first (Blob/copy, RFC 8620 ยง6.3). +// +// Used by the reader's Save to Files dialog and by the plugin host +// (api.files.saveAttachments). Plugins hear about each file as about an +// upload in Files: onBeforeFileUpload may stop it, onAfterFileUpload +// reports it. + +import type { IJMAPClient } from '@/lib/jmap/client-interface'; +import type { FileNode } from '@/lib/jmap/types'; +import { fileHooks } from '@/lib/plugin-hooks'; +import { fileToView } from '@/lib/plugin-projection'; + +/** What saving needs of an attachment: its blob, name, type and size. */ +export interface SaveableBlob { + blobId: string; + name?: string | null; + type?: string | null; + size: number; +} + +export interface SaveToFilesOptions { + /** The mail login's client; its Files account takes the files. */ + client: IJMAPClient; + /** The folder to save into; null is the top of the user's own drive. */ + parentId: string | null; + /** The folder's path, for plugins ('/' for the top). */ + path: string; + /** + * The JMAP account the blobs are in, when not the Files account (a + * shared or group mailbox of the same login). + */ + blobAccountId?: string; +} + +export interface SaveToFilesResult { + /** The files created, with the names the server gave them. */ + saved: FileNode[]; + /** Names a plugin stopped (onBeforeFileUpload). */ + stopped: string[]; +} + +/** + * Files each blob into `parentId`. A name that is taken becomes + * "name (2).ext" on the server. Stops at the first error and passes it on; + * files saved before it stay. + */ +export async function saveBlobsToFiles(blobs: SaveableBlob[], { client, parentId, path, blobAccountId }: SaveToFilesOptions): Promise { + const saved: FileNode[] = []; + const stopped: string[] = []; + const filesAccountId = blobAccountId ? client.getFilesAccountId() : undefined; + const copy = !!blobAccountId && !!filesAccountId && blobAccountId !== filesAccountId; + for (const blob of blobs) { + const name = blob.name || 'attachment'; + const type = blob.type || 'application/octet-stream'; + if (!(await fileHooks.onBeforeFileUpload.intercept({ name, size: blob.size, type, path }))) { + stopped.push(name); + continue; + } + const blobId = copy + ? (await client.copyBlobs(blobAccountId!, [blob.blobId], filesAccountId!))[blob.blobId] + : blob.blobId; + const node = await client.createFileNode(name, blobId, type, blob.size, parentId); + // The server's answer to a create may leave the size out. + saved.push(node.size ? node : { ...node, size: blob.size }); + void fileHooks.onAfterFileUpload.emit(fileToView(node, path)); + } + return { saved, stopped }; +}