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 };
+}