fix: let plugins stop an attachment saved to Files, as they can stop an upload

This commit is contained in:
Linus Rath
2026-11-03 00:17:45 +01:00
parent 70ffd7f000
commit 6edf7f80a5
4 changed files with 173 additions and 10 deletions
@@ -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(<SaveToFilesDialog attachment={attachment} client={client} onClose={vi.fn()} />);
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();
});
});
+18 -10
View File
@@ -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<string>();
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);
+66
View File
@@ -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);
});
});
+71
View File
@@ -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<SaveToFilesResult> {
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 };
}