From c34c0d4173756982ae521d73f677a58f22146207 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 00:17:44 +0100 Subject: [PATCH 1/6] fix: hand Stalwart 1.0 symlinks to plugins as files, not folders --- lib/__tests__/plugin-projection.test.ts | 30 +++++++++++++++++++++++-- lib/plugin-projection.ts | 7 +++++- stores/file-store.ts | 2 ++ 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/lib/__tests__/plugin-projection.test.ts b/lib/__tests__/plugin-projection.test.ts index 7f35f894d..a387a5bcc 100644 --- a/lib/__tests__/plugin-projection.test.ts +++ b/lib/__tests__/plugin-projection.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest'; -import { contactToView, emailSummary, emailToReadView, eventToView, taskToView } from '../plugin-projection'; -import type { CalendarEvent, CalendarTask, ContactCard, Email } from '../jmap/types'; +import { contactToView, emailSummary, emailToReadView, eventToView, fileToView, taskToView } from '../plugin-projection'; +import type { CalendarEvent, CalendarTask, ContactCard, Email, FileNode } from '../jmap/types'; +import { resourceToView, type FileResource } from '@/stores/file-store'; describe('plugin projections', () => { it('writes contact dates without a year as --MM-DD', () => { @@ -61,3 +62,28 @@ describe('emailToReadView attachments', () => { ]); }); }); + +describe('fileToView', () => { + // Folder or file by fileNodeKind: Stalwart 1.0's symlinks have no blob + // but are no folders, and an old blob-backed file marked "d" (#379) is a file. + it('tells folders from files by the node kind', () => { + const base = { parentId: null, size: 3, created: '', modified: '2026-01-01T00:00:00Z' }; + const view = (n: Partial) => fileToView({ ...base, id: 'n', name: 'x', type: 'text/plain', blobId: 'b', ...n } as FileNode, '/'); + expect(view({ nodeType: 'symlink', blobId: null, type: '' }).type).toBe('file'); + expect(view({ type: 'd', blobId: 'b-marker' }).type).toBe('file'); + expect(view({ nodeType: 'directory', blobId: null, type: '' })).toMatchObject({ type: 'directory', mimeType: '' }); + // 0.16 before 0.16.6: no nodeType, no blob, a folder. + expect(view({ blobId: null, type: 'd' }).type).toBe('directory'); + expect(view({}).type).toBe('file'); + }); +}); + +// The Files view's items reach plugins through the same projection: a 1.0 +// symlink there has no blob, and was handed to plugins as a folder. +describe('resourceToView', () => { + it('hands a symlink over as a file, a folder as a folder', () => { + const base = { id: 'x', parentId: null, name: 'x', contentLength: 0, contentType: '', lastModified: '', blobId: null } as unknown as FileResource; + expect(resourceToView({ ...base, isDirectory: false, isSymlink: true }, '/').type).toBe('file'); + expect(resourceToView({ ...base, isDirectory: true }, '/').type).toBe('directory'); + }); +}); diff --git a/lib/plugin-projection.ts b/lib/plugin-projection.ts index 5407d7f4a..a48a861a0 100644 --- a/lib/plugin-projection.ts +++ b/lib/plugin-projection.ts @@ -12,6 +12,7 @@ import type { import type { EmailTemplate } from '@/lib/template-types'; import { generateEmailSource } from '@/lib/email-source'; import { isServerSetKeyword } from '@/lib/jmap/server-keywords'; +import { fileNodeKind } from '@/lib/jmap/filenode-kind'; import { attachmentExtras, cidAltTexts } from '@/lib/plugin-sandbox/assistant/b.projection'; // Resolve a message's plain-text body from its JMAP body parts. Plugins that @@ -198,8 +199,12 @@ export function taskToView(task: CalendarTask): TaskView { }; } +/** + * A FileNode as plugins see it. Folder or file by fileNodeKind (Stalwart + * 1.0's symlinks have no blob either, but hold nothing: they count as files). + */ export function fileToView(node: FileNode, path = ''): FileResourceView { - const isDir = node.type === 'd' || node.blobId === null; + const isDir = fileNodeKind(node) === 'directory'; return { id: node.id, name: node.name, diff --git a/stores/file-store.ts b/stores/file-store.ts index 885d4c1c4..f9ec1a6b5 100644 --- a/stores/file-store.ts +++ b/stores/file-store.ts @@ -555,6 +555,8 @@ export function resourceToView(r: FileResource, path: string): FileResourceView parentId: r.parentId, name: r.name, type: r.isDirectory ? 'd' : r.contentType, + // A symlink has no blob either: the kind says it isn't a folder. + nodeType: r.isDirectory ? 'directory' : r.isSymlink ? 'symlink' : 'file', blobId: r.isDirectory ? null : r.blobId, size: r.contentLength, created: r.lastModified, From 70ffd7f0004c442a6f57d56b924d32385d4b52dc Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 00:17:44 +0100 Subject: [PATCH 2/6] fix: leave the Files trash out of search results --- lib/global-search/__tests__/providers.test.ts | 17 +++++++++++++++++ lib/global-search/providers/files.ts | 5 ++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/lib/global-search/__tests__/providers.test.ts b/lib/global-search/__tests__/providers.test.ts index c24e7dcfa..111bf736c 100644 --- a/lib/global-search/__tests__/providers.test.ts +++ b/lib/global-search/__tests__/providers.test.ts @@ -445,4 +445,21 @@ describe('files provider', () => { expect(hits.map((h) => h.id)).toEqual(['d']); expect(hits[0]).toMatchObject({ isFolder: true }); }); + + // The Files trash is a folder the app keeps (.Trash, one folder per + // delete): neither it nor what was deleted is a hit. + it('leaves the Files trash and everything in it out of the hits', async () => { + const listing = [ + { id: 'zebra-live', parentId: null, name: 'zebra.txt', type: 'text/plain', blobId: 'b1', size: 1, created: '', modified: '' }, + { id: 'trash', parentId: null, name: '.Trash', type: 'd', blobId: null, size: 0, created: '', modified: '' }, + { id: 'batch', parentId: 'trash', name: '1730112000000~zebra', type: 'd', blobId: null, size: 0, created: '', modified: '' }, + { id: 'zebra-gone', parentId: 'batch', name: 'zebra old.txt', type: 'text/plain', blobId: 'b2', size: 1, created: '', modified: '' }, + ]; + invalidateFileSearchCache(); + const acc = account('login-t', { listAllFileNodesAcrossAccounts: vi.fn(async () => listing) }); + const { hits } = await filesProvider.remote(parseSearchQuery('zebra'), acc, { limit: 10, signal }); + expect(hits.map((h) => h.id)).toEqual(['zebra-live']); + const trash = await filesProvider.remote(parseSearchQuery('trash'), acc, { limit: 10, signal }); + expect(trash.hits).toEqual([]); + }); }); diff --git a/lib/global-search/providers/files.ts b/lib/global-search/providers/files.ts index 480345346..7388e666c 100644 --- a/lib/global-search/providers/files.ts +++ b/lib/global-search/providers/files.ts @@ -2,6 +2,7 @@ import { matchesTerms, type ParsedQuery } from '@/lib/global-search/query-parser import type { FileHit, SearchAccount, SearchProvider } from '@/lib/global-search/types'; import type { FileNode } from '@/lib/jmap/types'; import { fileNodeKind } from '@/lib/jmap/filenode-kind'; +import { findTrashFolder, trashSubtreeIds } from '@/lib/files-trash'; import { useFileStore } from '@/stores/file-store'; /** @@ -93,9 +94,11 @@ export function rawFileNodeId(node: FileNode): string { function filterNodes(parsed: ParsedQuery, nodes: FileNode[], account: SearchAccount, limit: number, source: 'local' | 'remote'): FileHit[] { const hits: FileHit[] = []; + // The Files trash (.Trash, one folder per delete, what was deleted) is no hit. + const trashed = trashSubtreeIds(nodes, findTrashFolder(nodes)?.id); for (const node of nodes) { // The owner's folders above a shared node only feed the path below. - if (node.discoverableOnly) continue; + if (node.discoverableOnly || trashed.has(node.id)) continue; const kind = fileNodeKind(node); // A symlink has nothing a hit could open. if (kind === 'symlink') continue; From 6edf7f80a5d9ca3eac603b1baf54feb09768a5f4 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 00:17:45 +0100 Subject: [PATCH 3/6] fix: let plugins stop an attachment saved to Files, as they can stop an upload --- .../__tests__/save-to-files-dialog.test.tsx | 18 +++++ components/email/save-to-files-dialog.tsx | 28 +++++--- lib/__tests__/files-save.test.ts | 66 +++++++++++++++++ lib/files-save.ts | 71 +++++++++++++++++++ 4 files changed, 173 insertions(+), 10 deletions(-) create mode 100644 lib/__tests__/files-save.test.ts create mode 100644 lib/files-save.ts 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 }; +} From 12dc2ce3c86ce4d0620f81956e816606ec23a4af Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 00:17:46 +0100 Subject: [PATCH 4/6] feat: let plugins find, read, attach, save and create files in Files --- .../__tests__/composer-plugin-edit.test.tsx | 23 + components/email/email-composer.tsx | 10 +- lib/__tests__/jmap-filenode-writes.test.ts | 20 + lib/__tests__/plugin-host-files.test.ts | 372 ++++++++++ .../plugin-permission-coverage.test.ts | 7 + lib/demo/demo-client.ts | 7 + lib/jmap/client-interface.ts | 6 + lib/jmap/client.ts | 23 + lib/plugin-sandbox/assistant/d.runtime.ts | 57 +- lib/plugin-sandbox/assistant/d.ts | 645 +++++++++++++++++- lib/plugin-sandbox/assistant/d.types.ts | 89 ++- lib/plugin-sandbox/composer-registry.ts | 5 + lib/plugin-sandbox/host-features.ts | 3 + locales/ar/common.json | 4 +- locales/ca/common.json | 4 +- locales/cs/common.json | 4 +- locales/da/common.json | 4 +- locales/de/common.json | 4 +- locales/en/common.json | 4 +- locales/es/common.json | 4 +- locales/et/common.json | 4 +- locales/fa/common.json | 4 +- locales/fr/common.json | 4 +- locales/he/common.json | 4 +- locales/hu/common.json | 4 +- locales/it/common.json | 4 +- locales/ja/common.json | 4 +- locales/ko/common.json | 4 +- locales/lv/common.json | 4 +- locales/mn/common.json | 4 +- locales/nb/common.json | 4 +- locales/nl/common.json | 4 +- locales/pl/common.json | 4 +- locales/pt/common.json | 4 +- locales/ro/common.json | 4 +- locales/ru/common.json | 4 +- locales/sk/common.json | 4 +- locales/th/common.json | 4 +- locales/tr/common.json | 4 +- locales/uk/common.json | 4 +- locales/vi/common.json | 4 +- locales/zh-TW/common.json | 4 +- locales/zh/common.json | 4 +- 43 files changed, 1312 insertions(+), 75 deletions(-) create mode 100644 lib/__tests__/plugin-host-files.test.ts diff --git a/components/email/__tests__/composer-plugin-edit.test.tsx b/components/email/__tests__/composer-plugin-edit.test.tsx index 950bb5987..57b7ff74b 100644 --- a/components/email/__tests__/composer-plugin-edit.test.tsx +++ b/components/email/__tests__/composer-plugin-edit.test.tsx @@ -405,4 +405,27 @@ describe('plugins editing the message being written', () => { cleanup(); expect(composerHandle(id)).toBeNull(); }); + + // api.files.attach: a file from the user's Files keeps the login that + // holds it, so a draft written from another login copies it over before + // it is sent (rehomeForeignBlobs) and reads go to its owner. + it('attaches a Files file with the login that holds it', async () => { + const fetchB = vi.fn(async () => ({ type: 'text/plain', size: 1, arrayBuffer: async () => new Uint8Array([7]).buffer })); + const previous = useAuthStore.getState(); + const oldGetClient = previous.getClientForAccount; + useAuthStore.setState({ getClientForAccount: (id: string) => (id === 'login-b' ? { fetchBlob: fetchB } : null) as never }); + try { + const { handle } = await renderComposer(); + let ok = false; + await act(async () => { ok = await handle.attachFile!({ blobId: 'F1', name: 'notes.md', type: 'text/markdown', size: 1, sourceAccountId: 'login-b' }); }); + expect(ok).toBe(true); + expect(await screen.findByText('notes.md')).toBeTruthy(); + const [attached] = handle.readMetadata!().attachments; + await handle.readAttachment!(attached.id); + expect(fetchB).toHaveBeenCalledWith('F1', 'notes.md', 'text/markdown'); + } finally { + cleanup(); + useAuthStore.setState({ getClientForAccount: oldGetClient }); + } + }); }); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 76fdaf5b2..6f4d39e37 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -2491,7 +2491,9 @@ export function EmailComposer({ // offers "attach from there". No `file` is set, matching the existing // draft-part hydration path above (a blobId-only attachment is already a // supported shape, just never previously reachable from a plugin). - const handlePluginAttachmentAdd = useCallback(async (raw: PluginAttachmentUpload): Promise => { + // `sourceAccountId` (host only, api.files.attach): the login whose Files + // hold the blob, so a draft sent from another login copies it over first. + const handlePluginAttachmentAdd = useCallback(async (raw: PluginAttachmentUpload, sourceAccountId?: string): Promise => { // The values come from a plugin: check them, and run the same intercept // and size limit every other way of attaching a file goes through. const upload = normalizePluginAttachment(raw); @@ -2509,7 +2511,7 @@ export function EmailComposer({ toast.error(t('attachments_total_too_large', { max: formatFileSize(maxTotal) })); return false; } - setAttachments(prev => [...prev, upload]); + setAttachments(prev => [...prev, sourceAccountId ? { ...upload, sourceAccountId } : upload]); return true; }, [composerClient, t]); // Stable props, so a re-render of the composer doesn't post them to the @@ -3053,6 +3055,7 @@ export function EmailComposer({ return !!editor && replaceEditorSelection(editor, html, { below }); }, attach: (file) => pluginAttachRef.current(file as PluginAttachmentUpload), + attachFile: ({ sourceAccountId, ...file }) => pluginAttachRef.current(file as PluginAttachmentUpload, sourceAccountId), }); setPluginComposerId(id); return unregister; @@ -3174,7 +3177,8 @@ export function EmailComposer({ ); // A cloud picker attaches files, or puts share links in the text. const pluginAttachmentSourceProps = useMemo( - () => ({ onAttach: handlePluginAttachmentAdd, insertText: pluginInsertText, composerId: pluginComposerId }), + // One argument only: the login of a blob is the host's to say (attachFile). + () => ({ onAttach: (raw: PluginAttachmentUpload) => handlePluginAttachmentAdd(raw), insertText: pluginInsertText, composerId: pluginComposerId }), [handlePluginAttachmentAdd, pluginInsertText, pluginComposerId], ); const pluginComposerIdRef = useRef(pluginComposerId); diff --git a/lib/__tests__/jmap-filenode-writes.test.ts b/lib/__tests__/jmap-filenode-writes.test.ts index 9a0fc25b5..c7e58e415 100644 --- a/lib/__tests__/jmap-filenode-writes.test.ts +++ b/lib/__tests__/jmap-filenode-writes.test.ts @@ -466,3 +466,23 @@ describe('FileNode write errors, names and types', () => { expect(sets(calls)).toHaveLength(4); }); }); + +// A file's content is replaced by pointing the node at a new blob: the +// assistant's overwrite (api.files.create with replace). Same request on +// 0.16 and 1.0; `modified: null` has 1.0 stamp the change. +describe('replaceFileNodeBlob', () => { + it('sets the new blob and lets the server stamp the change, in the node\'s own account', async () => { + const { client, calls } = makeClient({}); + await client.replaceFileNodeBlob('n1', 'blob-new'); + await client.replaceFileNodeBlob('grp:n2', 'blob-team'); + expect(sets(calls)).toEqual([ + { accountId: 'me', update: { n1: { blobId: 'blob-new', modified: null } } }, + { accountId: 'grp', update: { n2: { blobId: 'blob-team', modified: null } } }, + ]); + }); + + it('throws when the server refuses it', async () => { + const { client } = makeClient({}, { refuse: (id) => (id === 'n1' ? { type: 'forbidden', description: 'Not allowed.' } : undefined) }); + await expect(client.replaceFileNodeBlob('n1', 'blob-new')).rejects.toThrow('Not allowed.'); + }); +}); diff --git a/lib/__tests__/plugin-host-files.test.ts b/lib/__tests__/plugin-host-files.test.ts new file mode 100644 index 000000000..79776c021 --- /dev/null +++ b/lib/__tests__/plugin-host-files.test.ts @@ -0,0 +1,372 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { InstalledPlugin } from '../plugin-types'; +import type { IJMAPClient } from '../jmap/client-interface'; +import type { Email, FileNode } from '../jmap/types'; +import { useAuthStore } from '@/stores/auth-store'; +import { useEmailStore } from '@/stores/email-store'; +import { useFileStore } from '@/stores/file-store'; +import { usePolicyStore } from '@/stores/policy-store'; +import { DEFAULT_POLICY } from '@/lib/admin/types'; +import { fileHooks } from '@/lib/plugin-hooks'; + +vi.mock('@/stores/toast-store', () => ({ + toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }, +})); +const composeOpen = vi.hoisted(() => vi.fn(() => 'opened')); +vi.mock('../plugin-sandbox/host-api-compose', async (importOriginal) => ({ + ...(await importOriginal()), + doComposeOpen: composeOpen, +})); + +import { dispatchApiCall, PERM_PER_METHOD } from '../plugin-sandbox/host-api'; +import { resetPluginRateLimits } from '../plugin-sandbox/host-api-input'; +import { snapshotHostFeatures } from '../plugin-sandbox/host-features'; +import { withAssistantRuntime } from '../plugin-sandbox/assistant/runtime'; +import { forgetFileListing } from '../plugin-sandbox/assistant/d'; +import { registerComposer, resetComposers } from '../plugin-sandbox/composer-registry'; + +// api.files (area d of the assistant's host APIs): find, read and attach +// the user's files, save attachments, create, move and rename, only in +// the user's own drive and never in a shared folder, with the Files hooks. + +function plugin(perms: string[], id = 'ai-assistant'): InstalledPlugin { + return { + id, name: 'AI Assistant', version: '2.2.0', author: 'test', description: '', + type: 'hook', permissions: perms, grantedPermissions: perms, entrypoint: 'index.js', + enabled: true, status: 'running', settings: {}, + }; +} +const ASSISTANT = plugin(['email:read', 'files:read', 'files:write', 'compose:write', 'compose:open']); +const call = (method: string, ...args: unknown[]) => dispatchApiCall(ASSISTANT, method, args); + +const node = (n: Partial & { id: string; name: string }): FileNode => ({ + parentId: null, type: 'd', blobId: null, size: 0, created: '2026-01-01T00:00:00Z', modified: '2026-01-01T00:00:00Z', ...n, +} as FileNode); +const folder = (id: string, name: string, parentId: string | null = null, extra: Partial = {}) => node({ id, name, parentId, ...extra }); +const file = (id: string, name: string, parentId: string | null, type: string, modified: string, extra: Partial = {}) => + node({ id, name, parentId, type, blobId: `blob-${id}`, size: 100, modified, ...extra }); + +const BOB = { 'bob-principal': { mayRead: true, mayWrite: false } } as unknown as FileNode['shareWith']; + +/** The user's own drive. */ +function ownNodes(): FileNode[] { + return [ + folder('docs', 'Documents'), + file('notes', 'notes.md', 'docs', 'text/markdown', '2026-10-30T10:00:00Z'), + file('budget', 'budget 2026.xlsx', 'docs', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', '2026-10-20T10:00:00Z'), + file('photo', 'whiteboard.jpg', 'docs', 'image/jpeg', '2026-09-01T10:00:00Z'), + file('report', 'report.pdf', null, 'application/pdf', '2026-10-31T10:00:00Z'), + folder('team', 'Team', null, { shareWith: BOB }), + folder('team-sub', 'Plans', 'team'), + file('team-notes', 'team notes.md', 'team-sub', 'text/markdown', '2026-10-01T10:00:00Z'), + node({ id: 'link', name: 'notes link', nodeType: 'symlink', blobId: null, type: '' }), + folder('trash', '.Trash'), + folder('batch', '1730112000000~docs', 'trash'), + file('gone', 'old notes.md', 'batch', 'text/markdown', '2026-10-31T12:00:00Z'), + ]; +} +/** Bob's drive, as the user sees what he shared (listAllFileNodesAcrossAccounts). */ +const bobNodes = (): FileNode[] => [ + folder('bob:root', 'Bob home', null, { isShared: true, accountId: 'bob', accountName: 'Bob', discoverableOnly: true }), + file('bob:plan', 'Bob plan.txt', 'bob:root', 'text/plain', '2026-10-15T10:00:00Z', { isShared: true, accountId: 'bob', accountName: 'Bob' }), +]; + +const RULES = { forbiddenChars: '/<>:"\\|?*', forbiddenNames: ['.', '..', 'CON', 'NUL'], maxNameBytes: 255 }; +const message = { + id: 'm1', threadId: 't1', blobId: 'raw', size: 4, mailboxIds: { inbox: true }, keywords: {}, subject: 'Invoice', + receivedAt: '2026-05-01T10:00:00Z', attachments: [{ partId: '2', blobId: 'blob-inv', name: 'invoice.pdf', type: 'application/pdf', size: 1234 }], +} as unknown as Email; + +let own: FileNode[]; +let client: Record>; +let seq = 0; + +beforeEach(() => { + seq = 0; + resetPluginRateLimits(); + resetComposers(); + forgetFileListing(); + composeOpen.mockClear(); + own = ownNodes(); + client = { + supportsFiles: vi.fn(() => true), + listAllFileNodesAcrossAccounts: vi.fn(async () => [...own, ...bobNodes()]), + listAllFileNodes: vi.fn(async () => own), + fetchBlobArrayBuffer: vi.fn(async () => new TextEncoder().encode('# Notes\n\nLaunch on 12 December.').buffer), + uploadBlob: vi.fn(async (f: File) => ({ blobId: `up-${++seq}`, size: f.size, type: f.type })), + createFileNode: vi.fn(async (name: string, blobId: string, type: string, size: number, parentId: string | null) => ({ id: `new-${++seq}`, name, blobId, type, parentId, size: 0, created: '', modified: '' })), + createFileDirectory: vi.fn(async (name: string, parentId: string | null) => ({ id: `dir-${++seq}`, name, parentId, blobId: null, type: 'd', size: 0, created: '', modified: '' })), + updateFileNode: vi.fn(async () => undefined), + replaceFileNodeBlob: vi.fn(async () => undefined), + getFileNameRules: vi.fn(() => RULES), + getMaxFileNodeDepth: vi.fn(() => 64), + getAccountId: vi.fn(() => 'me'), + getFilesAccountId: vi.fn(() => 'me'), + copyBlobs: vi.fn(), + getEmail: vi.fn(async (id: string) => (id === 'm1' ? message : null)), + getMailboxes: vi.fn(async () => []), + }; + useAuthStore.setState({ client: client as unknown as IJMAPClient, activeAccountId: 'login-a' }); + useEmailStore.setState({ selectedEmail: null, emails: [], mailboxes: [], selectedMailbox: '' }); + usePolicyStore.setState({ policy: DEFAULT_POLICY }); +}); + +afterEach(() => { + useAuthStore.setState({ client: null }); + for (const bus of Object.values(fileHooks)) bus.clear(); + useFileStore.setState({ client: null, hasListed: false }); +}); + +describe('api.files: offered, gated, and nothing that deletes or shares', () => { + it('has exactly these methods, each behind files:read or files:write', () => { + const methods = Object.entries(PERM_PER_METHOD).filter(([m]) => m.startsWith('files.')); + expect(Object.fromEntries(methods)).toEqual({ + 'files.find': 'files:read', 'files.list': 'files:read', 'files.getText': 'files:read', 'files.getImage': 'files:read', + 'files.attach': 'files:read', + 'files.saveAttachments': 'files:write', 'files.create': 'files:write', 'files.createFolder': 'files:write', + 'files.move': 'files:write', 'files.rename': 'files:write', + }); + const api = withAssistantRuntime({} as { files?: Record }, async () => null); + expect(Object.keys(api.files ?? {}).sort()).toEqual(['attach', 'create', 'createFolder', 'find', 'getImage', 'getText', 'list', 'move', 'rename', 'saveAttachments']); + }); + + it('refuses without the permissions', async () => { + await expect(dispatchApiCall(plugin(['email:read']), 'files.find', [{}])).rejects.toThrow(/lacks permission "files:read"/); + await expect(dispatchApiCall(plugin(['files:read']), 'files.create', [{ name: 'a.md', content: 'x', type: 'text/markdown' }])).rejects.toThrow(/lacks permission "files:write"/); + await expect(dispatchApiCall(plugin(['files:read']), 'files.attach', [['notes'], { composerId: 'c1' }])).rejects.toThrow(/lacks permission "compose:write"/); + await expect(dispatchApiCall(plugin(['files:read']), 'files.attach', [['notes'], { newDraft: {} }])).rejects.toThrow(/lacks permission "compose:open"/); + await expect(dispatchApiCall(plugin(['files:write']), 'files.saveAttachments', ['m1', ['blob-inv']])).rejects.toThrow(/lacks permission "email:read"/); + expect(client.uploadBlob).not.toHaveBeenCalled(); + expect(client.createFileNode).not.toHaveBeenCalled(); + }); + + it('says files only where the server has Files and the administrator left it on', async () => { + expect(snapshotHostFeatures()).toMatchObject({ files: true }); + usePolicyStore.setState({ policy: { ...DEFAULT_POLICY, features: { ...DEFAULT_POLICY.features, filesEnabled: false } } }); + expect(snapshotHostFeatures()).toMatchObject({ files: false }); + await expect(call('files.find', {})).rejects.toThrow(/Files is not available/); + usePolicyStore.setState({ policy: DEFAULT_POLICY }); + client.supportsFiles.mockReturnValue(false); + expect(snapshotHostFeatures()).toMatchObject({ files: false }); + await expect(call('files.list')).rejects.toThrow(/Files is not available/); + }); +}); + +describe('files.find and files.list', () => { + it('finds own and shared files, without the trash, symlinks and the folders above a shared file', async () => { + const all = (await call('files.find', { limit: 50 })) as { id: string }[]; + const ids = all.map((f) => f.id); + expect(ids).not.toEqual(expect.arrayContaining(['trash'])); + for (const hidden of ['trash', 'batch', 'gone', 'link', 'bob:root']) expect(ids).not.toContain(hidden); + expect(ids).toEqual(expect.arrayContaining(['notes', 'report', 'bob:plan', 'docs'])); + // Newest first. + expect(ids[0]).toBe('report'); + const plan = all.find((f) => f.id === 'bob:plan'); + expect(plan).toMatchObject({ name: 'Bob plan.txt', kind: 'file', shared: true, owner: 'Bob', path: '/Bob home' }); + expect(all.find((f) => f.id === 'notes')).toEqual({ + id: 'notes', name: 'notes.md', path: '/Documents', parentId: 'docs', kind: 'file', mimeType: 'text/markdown', size: 100, modified: '2026-10-30T10:00:00Z', + }); + }); + + it('filters by words, kind, folder and dates, and keeps the listing for a minute', async () => { + const names = async (q: unknown) => ((await call('files.find', q)) as { name: string }[]).map((f) => f.name); + expect(await names({ text: 'NOTES' })).toEqual(['notes.md', 'team notes.md']); + expect(await names({ text: 'notes', folderId: 'docs' })).toEqual(['notes.md']); + expect(await names({ kind: 'spreadsheet' })).toEqual(['budget 2026.xlsx']); + expect(await names({ kind: 'image' })).toEqual(['whiteboard.jpg']); + expect(await names({ kind: 'folder' })).toEqual(['Documents', 'Team', 'Plans']); + expect(await names({ kind: 'document', modifiedAfter: '2026-10-25' })).toEqual(['report.pdf', 'notes.md']); + expect(await names({ modifiedBefore: '2026-09-02', kind: 'image' })).toEqual(['whiteboard.jpg']); + expect(await names({ limit: 1 })).toEqual(['report.pdf']); + expect(client.listAllFileNodesAcrossAccounts).toHaveBeenCalledTimes(1); + await expect(call('files.find', { kind: 'video' })).rejects.toThrow(/kind must be one of/); + await expect(call('files.find', { folderId: 'trash' })).rejects.toThrow(/no file or folder/); + }); + + it('lists the top of the drive with what others shared, and a folder', async () => { + const top = ((await call('files.list')) as { name: string }[]).map((f) => f.name); + expect(top).toEqual(['Documents', 'Team', 'Bob plan.txt', 'report.pdf']); + const docs = ((await call('files.list', 'docs')) as { name: string }[]).map((f) => f.name); + expect(docs).toEqual(['budget 2026.xlsx', 'notes.md', 'whiteboard.jpg']); + await expect(call('files.list', 'notes')).rejects.toThrow(/not a folder/); + }); +}); + +describe('files.getText', () => { + it('reads a file with the attachment reader, and a shared one from its owner\'s account', async () => { + const out = await call('files.getText', 'notes'); + expect(out).toMatchObject({ name: 'notes.md', format: 'text', status: 'ok', text: '# Notes\n\nLaunch on 12 December.', truncated: false }); + expect(client.fetchBlobArrayBuffer).toHaveBeenLastCalledWith('blob-notes', 'notes.md', 'text/markdown'); + await call('files.getText', 'bob:plan'); + expect(client.fetchBlobArrayBuffer).toHaveBeenLastCalledWith('blob-bob:plan', 'Bob plan.txt', 'text/plain', 'bob'); + await expect(call('files.getText', 'docs')).rejects.toThrow(/is a folder/); + await expect(call('files.getText', 'gone')).rejects.toThrow(/no file or folder/); + }); +}); + +describe('files.attach', () => { + it('attaches to the open draft by blob, with the login that holds the files', async () => { + const attachFile = vi.fn(async () => true); + const { id } = registerComposer({ + readBody: () => ({ html: '', text: '' }), readSelection: () => ({ html: '', text: '', empty: true }), + replaceBody: () => true, replaceSelection: () => true, attach: vi.fn(async () => true), attachFile, + }); + const out = await call('files.attach', ['notes', 'bob:plan'], { composerId: id }); + expect(out).toEqual({ attached: ['notes.md', 'Bob plan.txt'], refused: [] }); + expect(attachFile).toHaveBeenCalledWith({ blobId: 'blob-notes', name: 'notes.md', type: 'text/markdown', size: 100, sourceAccountId: 'login-a' }); + await expect(call('files.attach', ['docs'], { composerId: id })).rejects.toThrow(/is a folder/); + await expect(call('files.attach', ['notes'], { composerId: 'gone' })).rejects.toThrow(/not open any more/); + }); + + it('opens a new draft with the files through compose.open', async () => { + const out = await call('files.attach', ['report'], { newDraft: { to: ['bob@example.org'], subject: 'Report' } }); + expect(out).toEqual({ attached: ['report.pdf'], refused: [] }); + expect(composeOpen).toHaveBeenCalledWith(ASSISTANT, { + to: ['bob@example.org'], subject: 'Report', + attachments: [{ blobId: 'blob-report', name: 'report.pdf', type: 'application/pdf', size: 100 }], + }); + }); +}); + +describe('writes stay in the user\'s own drive', () => { + const create = (input: Record) => call('files.create', { content: '# Minutes', type: 'text/markdown', ...input }); + + it('creates a Markdown file in a folder, with the upload hooks, and lets the server number a taken name', async () => { + const before = vi.fn(() => true); + const after = vi.fn(); + fileHooks.onBeforeFileUpload.register('other', before); + fileHooks.onAfterFileUpload.register('other', after); + const out = (await create({ folderId: 'docs', name: 'notes.md' })) as { file: { name: string; path: string } }; + expect(before).toHaveBeenCalledWith({ name: 'notes.md', size: 9, type: 'text/markdown', path: '/Documents' }); + expect(client.uploadBlob).toHaveBeenCalledTimes(1); + // Never an overwrite: createFileNode sends onExists rename ("notes (2).md"). + expect(client.createFileNode).toHaveBeenCalledWith('notes.md', 'up-1', 'text/markdown', 9, 'docs'); + expect(client.updateFileNode).not.toHaveBeenCalled(); + expect(client.replaceFileNodeBlob).not.toHaveBeenCalled(); + expect(out.file).toMatchObject({ path: '/Documents', parentId: 'docs', kind: 'file', mimeType: 'text/markdown' }); + await vi.waitFor(() => expect(after).toHaveBeenCalledWith(expect.objectContaining({ name: 'notes.md', type: 'file', path: '/Documents' }))); + }); + + it('stops when another plugin vetoes the upload', async () => { + fileHooks.onBeforeFileUpload.register('dlp', () => false); + await expect(create({ name: 'secret.md' })).rejects.toThrow(/another plugin stopped this/); + expect(client.uploadBlob).not.toHaveBeenCalled(); + expect(client.createFileNode).not.toHaveBeenCalled(); + }); + + it('refuses shared folders, other people\'s drives and the trash', async () => { + await expect(create({ folderId: 'team', name: 'a.md' })).rejects.toThrow(/shared with others/); + await expect(create({ folderId: 'team-sub', name: 'a.md' })).rejects.toThrow(/shared with others/); + await expect(create({ folderId: 'bob:root', name: 'a.md' })).rejects.toThrow(/drive someone shared with you/); + await expect(create({ folderId: 'batch', name: 'a.md' })).rejects.toThrow(/in your own files/); + await expect(create({ folderId: 'trash', name: 'a.md' })).rejects.toThrow(/in your own files/); + await expect(call('files.createFolder', { parentId: 'team', name: 'New' })).rejects.toThrow(/shared with others/); + await expect(call('files.move', 'notes', 'team-sub')).rejects.toThrow(/shared with others/); + await expect(call('files.move', 'notes', 'bob:root')).rejects.toThrow(/another person's drive/); + await expect(call('files.move', 'team-notes', 'docs')).rejects.toThrow(/shared with others/); + await expect(call('files.move', 'notes', 'trash')).rejects.toThrow(/in your own files/); + await expect(call('files.rename', 'team-notes', 'x.md')).rejects.toThrow(/shared with others/); + await expect(call('files.rename', 'bob:plan', 'x.txt')).rejects.toThrow(/drive someone shared with you/); + await expect(call('files.saveAttachments', 'm1', ['blob-inv'], 'team')).rejects.toThrow(/shared with others/); + await expect(create({ replace: 'team-notes' })).rejects.toThrow(/shared with others/); + expect(client.uploadBlob).not.toHaveBeenCalled(); + expect(client.createFileNode).not.toHaveBeenCalled(); + expect(client.createFileDirectory).not.toHaveBeenCalled(); + expect(client.updateFileNode).not.toHaveBeenCalled(); + }); + + it('refuses names the server would refuse, and says why', async () => { + await expect(create({ name: 'con.txt' })).rejects.toThrow(/reserved/); + await expect(create({ name: 'a:b.md' })).rejects.toThrow(/may not contain :/); + await expect(create({ name: 'a\u0001b.md' })).rejects.toThrow(/control characters/); + await expect(call('files.rename', 'notes', 'NUL')).rejects.toThrow(/reserved/); + await expect(call('files.rename', 'notes', 'budget 2026.xlsx')).rejects.toThrow(/already something named/); + await expect(call('files.createFolder', { name: '.Trash' })).rejects.toThrow(/trash/); + await expect(create({ name: 'a.md', type: 'application/pdf' })).rejects.toThrow(/type must be/); + expect(client.uploadBlob).not.toHaveBeenCalled(); + }); + + it('replaces a text file and keeps the old version in the Files trash', async () => { + const out = (await create({ replace: 'notes', content: 'New text' })) as { file: { id: string; size: number }; kept: unknown }; + // A trash folder for this change, under the existing .Trash, named like a delete from Documents. + expect(client.createFileDirectory).toHaveBeenCalledTimes(1); + expect(client.createFileDirectory).toHaveBeenCalledWith(expect.stringMatching(/^\d{13}~docs$/), 'trash'); + const batchId = (await client.createFileDirectory.mock.results[0].value).id; + // The old version: a copy pointing at the old blob. + expect(client.createFileNode).toHaveBeenCalledWith('notes.md', 'blob-notes', 'text/markdown', 100, batchId); + expect(client.replaceFileNodeBlob).toHaveBeenCalledWith('notes', 'up-1'); + // The copy is made before the content changes. + expect(client.createFileNode.mock.invocationCallOrder[0]).toBeLessThan(client.replaceFileNodeBlob.mock.invocationCallOrder[0]); + expect(out).toMatchObject({ file: { id: 'notes', size: 8 }, kept: { trashFolder: '.Trash', name: 'notes.md' } }); + }); + + it('makes the trash folder when there is none yet, and only replaces text files', async () => { + own = own.filter((n) => !['trash', 'batch', 'gone'].includes(n.id)); + await create({ replace: 'notes', content: 'New' }); + expect(client.createFileDirectory.mock.calls[0]).toEqual(['.Trash', null]); + await expect(create({ replace: 'budget' })).rejects.toThrow(/only text and Markdown/); + await expect(create({ replace: 'docs' })).rejects.toThrow(/is a folder/); + }); + + it('creates a folder and tells plugins', async () => { + const seen = vi.fn(); + fileHooks.onDirectoryCreate.register('other', seen); + const out = await call('files.createFolder', { parentId: 'docs', name: '2026' }); + expect(client.createFileDirectory).toHaveBeenCalledWith('2026', 'docs'); + expect(out).toMatchObject({ name: '2026', kind: 'folder', path: '/Documents', parentId: 'docs' }); + await vi.waitFor(() => expect(seen).toHaveBeenCalledWith(expect.objectContaining({ name: '2026', type: 'directory', path: '/Documents' }))); + }); + + it('moves within the own drive with the move hooks, and says where it was', async () => { + const moved = vi.fn(); + fileHooks.onFileMove.register('other', moved); + const out = await call('files.move', 'notes', null); + expect(client.updateFileNode).toHaveBeenCalledWith('notes', { parentId: null }); + expect(out).toMatchObject({ file: { id: 'notes', parentId: null, path: '/' }, previousFolderId: 'docs' }); + await vi.waitFor(() => expect(moved).toHaveBeenCalledWith({ files: [expect.objectContaining({ id: 'notes' })], toPath: '/' })); + await expect(call('files.move', 'docs', 'docs')).rejects.toThrow(/into itself/); + await expect(call('files.move', 'budget', 'docs')).rejects.toThrow(/already there/); + }); + + it('stops a move and a rename another plugin vetoes', async () => { + fileHooks.onBeforeFileMove.register('dlp', () => false); + fileHooks.onBeforeFileRename.register('dlp', () => false); + await expect(call('files.move', 'notes', null)).rejects.toThrow(/another plugin stopped this/); + await expect(call('files.rename', 'notes', 'minutes.md')).rejects.toThrow(/another plugin stopped this/); + expect(client.updateFileNode).not.toHaveBeenCalled(); + }); + + it('renames with the rename hooks and says the old name', async () => { + const before = vi.fn(() => true); + fileHooks.onBeforeFileRename.register('other', before); + const out = await call('files.rename', 'notes', 'minutes.md'); + expect(before).toHaveBeenCalledWith({ file: expect.objectContaining({ id: 'notes', name: 'notes.md', path: '/Documents' }), newName: 'minutes.md' }); + expect(client.updateFileNode).toHaveBeenCalledWith('notes', { name: 'minutes.md' }); + expect(out).toMatchObject({ file: { name: 'minutes.md' }, previousName: 'notes.md' }); + }); + + it('saves attachments by blob into an own folder, and another plugin can stop one', async () => { + const out = await call('files.saveAttachments', 'm1', ['blob-inv'], 'docs'); + expect(client.createFileNode).toHaveBeenCalledWith('invoice.pdf', 'blob-inv', 'application/pdf', 1234, 'docs'); + expect(out).toMatchObject({ saved: [{ name: 'invoice.pdf', path: '/Documents', size: 1234 }], stopped: [] }); + await expect(call('files.saveAttachments', 'm1', ['blob-other'], 'docs')).rejects.toThrow(/not an attachment of this message/); + fileHooks.onBeforeFileUpload.register('dlp', () => false); + expect(await call('files.saveAttachments', 'm1', ['blob-inv'], null)).toEqual({ saved: [], stopped: ['invoice.pdf'] }); + expect(client.createFileNode).toHaveBeenCalledTimes(1); + }); + + it('refreshes the open Files view and the listing after a change', async () => { + const refresh = vi.fn(async () => {}); + useFileStore.setState({ client: client as unknown as IJMAPClient, hasListed: true, refresh }); + await call('files.find', {}); + await call('files.rename', 'notes', 'minutes.md'); + expect(refresh).toHaveBeenCalledTimes(1); + await call('files.find', {}); + expect(client.listAllFileNodesAcrossAccounts).toHaveBeenCalledTimes(2); + }); + + it('allows 20 changes a minute', async () => { + for (let i = 0; i < 20; i++) await call('files.createFolder', { name: `F${i}` }); + await expect(call('files.createFolder', { name: 'F20' })).rejects.toThrow(/at most 20 changes/); + }); +}); diff --git a/lib/__tests__/plugin-permission-coverage.test.ts b/lib/__tests__/plugin-permission-coverage.test.ts index 1ac16d526..6b51f4bcc 100644 --- a/lib/__tests__/plugin-permission-coverage.test.ts +++ b/lib/__tests__/plugin-permission-coverage.test.ts @@ -82,6 +82,13 @@ describe('plugin permissions', () => { 'drafts.create': 'email:write', 'email.forward': 'email:forward', 'notifications.show': 'ui:notifications', + 'files.find': 'files:read', + 'files.getText': 'files:read', + 'files.attach': 'files:read', + 'files.create': 'files:write', + 'files.saveAttachments': 'files:write', + 'files.move': 'files:write', + 'files.rename': 'files:write', }); }); }); diff --git a/lib/demo/demo-client.ts b/lib/demo/demo-client.ts index 0eaa08e6b..8a9377ec4 100644 --- a/lib/demo/demo-client.ts +++ b/lib/demo/demo-client.ts @@ -1702,6 +1702,13 @@ export class DemoJMAPClient implements IJMAPClient { if (node) Object.assign(node, updates); } + async replaceFileNodeBlob(id: string, blobId: string): Promise { + const node = this.data.fileNodes.find(n => n.id === id); + if (!node) throw new Error('No such file'); + const blob = this.blobStore.get(blobId); + Object.assign(node, { blobId, ...(blob ? { size: blob.size } : {}), modified: new Date().toISOString() }); + } + async updateFileNodes(updates: Record>>): Promise<{ updated: string[]; notUpdated: Record }> { const updated: string[] = []; for (const [id, patch] of Object.entries(updates)) { diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index c5419f48e..d07c11d25 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -699,6 +699,12 @@ export interface IJMAPClient { */ copyBlobs(fromAccountId: string, blobIds: string[], toAccountId: string): Promise>; updateFileNode(id: string, updates: Partial>): Promise; + /** + * Point a file at new content (a blob already uploaded): same node, same + * name and place, new blob. The server stamps `modified`. Throws when + * refused. + */ + replaceFileNodeBlob(id: string, blobId: string): Promise; updateFileNodes(updates: Record>>): Promise<{ updated: string[]; notUpdated: Record }>; destroyFileNodes(ids: string[]): Promise<{ destroyed: string[]; notDestroyed: string[] }>; copyFileNode(id: string, newName: string, parentId: string | null, listings?: FileNodeListings): Promise; diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 4893af357..bca9cb570 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -10470,6 +10470,29 @@ export class JMAPClient implements IJMAPClient { } } + async replaceFileNodeBlob(id: string, blobId: string): Promise { + const node = this.resolveFileNodeId(id); + const response = await this.request( + [["FileNode/set", { + accountId: node.accountId, + // `modified: null` has the server stamp the change: Stalwart 1.0 + // leaves a client-set `modified` alone on a content change (see + // updateFileNodeBlob in lib/wopi/files.ts); 0.16 bumps it anyway. + update: { [node.id as string]: { blobId, modified: null } }, + }, "fns0"]], + this.fileUsing(), + ); + + const result = response.methodResponses?.[0]; + if (!result || result[0] === "error") { + throw jmapError(result?.[1]?.description || "FileNode/set update failed", result?.[1]); + } + const refused = result[1].notUpdated?.[node.id as string]; + if (refused) { + throw jmapError(refused.description || "Failed to replace the file's content", refused); + } + } + /** Group FileNode ids by the account that owns them. */ private groupFileNodeIds(entries: Array<[string, T]>): Map> { const groups = new Map>(); diff --git a/lib/plugin-sandbox/assistant/d.runtime.ts b/lib/plugin-sandbox/assistant/d.runtime.ts index 0ce8066a4..75cad52f9 100644 --- a/lib/plugin-sandbox/assistant/d.runtime.ts +++ b/lib/plugin-sandbox/assistant/d.runtime.ts @@ -1,8 +1,61 @@ // Area d of the assistant's host APIs, inside the plugin sandbox (see README.md). import type { CallApi, RuntimeAdditions } from './runtime'; +import type { AttachmentImageOptions, AttachmentImageView, AttachmentTextView } from './b.types'; +import type { + FileAttachResult, FileAttachTarget, FileCreateInput, FileCreateResult, FileFindQuery, FileInfo, +} from './d.types'; +import { NETWORK_API_TIMEOUT_MS } from '../api-timeouts'; /** The methods this area adds to the plugin's api, by namespace. */ -export function runtime(_callApi: CallApi): RuntimeAdditions { - return {}; +export function runtime(callApi: CallApi): RuntimeAdditions { + return { + files: { + /** + * The user's files and folders that match, newest first: their own + * drive and drives others shared with them, without the trash + * (files:read). See FileFindQuery. + */ + find: (query?: FileFindQuery) => + callApi('files.find', [query], NETWORK_API_TIMEOUT_MS) as Promise, + /** What a folder holds; without one, the top of the user's drive and what others shared (files:read). */ + list: (folderId?: string | null) => + callApi('files.list', [folderId], NETWORK_API_TIMEOUT_MS) as Promise, + /** The text of a file, as email.getAttachmentText reads attachments (files:read, 30 a minute). */ + getText: (fileId: string, options?: { maxChars?: number }) => + callApi('files.getText', [fileId, options], NETWORK_API_TIMEOUT_MS) as Promise, + /** A picture file, shrunk as email.getAttachmentImage does (files:read, 30 a minute). */ + getImage: (fileId: string, options?: Omit) => + callApi('files.getImage', [fileId, options], NETWORK_API_TIMEOUT_MS) as Promise, + /** + * Attach files to the open draft ({ composerId }, also compose:write) + * or to a new one ({ newDraft: { to, subject, body } }, also + * compose:open). At most 20 files (files:read). + */ + attach: (fileIds: string[], target: FileAttachTarget) => + callApi('files.attach', [fileIds, target], NETWORK_API_TIMEOUT_MS) as Promise, + /** + * Save attachments of a message into a folder of the user's own drive + * (null: the top). files:write and email:read; 20 changes a minute. + */ + saveAttachments: (emailId: string, blobIds: string[], folderId?: string | null, options?: { accountId?: string }) => + callApi('files.saveAttachments', [emailId, blobIds, folderId, options], NETWORK_API_TIMEOUT_MS) as Promise<{ saved: FileInfo[]; stopped: string[] }>, + /** + * A new text or Markdown file in the user's own drive, or new content + * for one (replace: the old version goes into the Files trash). + * files:write; 20 changes a minute. + */ + create: (input: FileCreateInput) => + callApi('files.create', [input], NETWORK_API_TIMEOUT_MS) as Promise, + /** A new folder in the user's own drive (files:write). */ + createFolder: (input: { parentId?: string | null; name: string }) => + callApi('files.createFolder', [input], NETWORK_API_TIMEOUT_MS) as Promise, + /** Move a file or folder within the user's own drive (files:write). */ + move: (fileId: string, folderId: string | null) => + callApi('files.move', [fileId, folderId], NETWORK_API_TIMEOUT_MS) as Promise<{ file: FileInfo; previousFolderId: string | null }>, + /** Rename a file or folder of the user's own drive (files:write). */ + rename: (fileId: string, name: string) => + callApi('files.rename', [fileId, name], NETWORK_API_TIMEOUT_MS) as Promise<{ file: FileInfo; previousName: string }>, + }, + }; } diff --git a/lib/plugin-sandbox/assistant/d.ts b/lib/plugin-sandbox/assistant/d.ts index d6e69eba4..46a3a1704 100644 --- a/lib/plugin-sandbox/assistant/d.ts +++ b/lib/plugin-sandbox/assistant/d.ts @@ -1,18 +1,649 @@ -// Area d of the assistant's host APIs, host side (see README.md). +// Area d of the assistant's host APIs, host side (see README.md): the +// user's Files. Find, list and read files (own drive and drives others +// shared), attach them to a draft, save attachments into Files, create text +// and Markdown files and folders, replace a text file (the old version goes +// into the Files trash), move and rename. +// +// Over the JMAP client and the Files helpers, never the Files store's +// actions: those re-list the open Files view and replace its Undo. The +// plugin hooks the store fires fire here too (onBeforeFileUpload, +// onBeforeFileMove, onBeforeFileRename and the observers), so another +// plugin can still stop a change. Writes go only into the user's own +// drive and never into a folder that is shared (shareWith on it or above +// it). Nothing here deletes, shares, makes a public link or moves a file to +// another account: those calls don't exist. import type { InstalledPlugin, Permission } from '../../plugin-types'; +import type { IJMAPClient } from '../../jmap/client-interface'; +import type { FileNode } from '../../jmap/types'; import { NOT_MINE } from './not-mine'; -import type { AreaHostFeaturesD } from './d.types'; +import type { AttachmentImageView, AttachmentTextView } from './b.types'; +import type { + AreaHostFeaturesD, FileAttachResult, FileCreateResult, FileFindQuery, FileInfo, +} from './d.types'; +import { useAuthStore } from '@/stores/auth-store'; +import { usePolicyStore } from '@/stores/policy-store'; +import { useFileStore } from '@/stores/file-store'; +import { fileNodeKind } from '@/lib/jmap/filenode-kind'; +import { TRASH_FOLDER_NAME, findTrashFolder, trashBatchName, trashSubtreeIds } from '@/lib/files-trash'; +import { fileNameProblem, type FileNameProblem } from '@/lib/file-name-rules'; +import { invalidateFileSearchCache, pathOfNode } from '@/lib/global-search/providers/files'; +import { saveBlobsToFiles } from '@/lib/files-save'; +import { fileHooks } from '@/lib/plugin-hooks'; +import { fileToView } from '@/lib/plugin-projection'; +import { pluginHasPermission } from '../permissions'; +import { readNamedEmail } from '../host-api-mail'; +import { doComposeOpen } from '../host-api-compose'; +import { composerHandle } from '../composer-registry'; +import { + isPlainRecord, readBoolean, readId, readInteger, readObject, readString, sessionClient, withinRate, +} from '../host-api-input'; +// The readers (pdf.js, the Office reader) load only when a file is read. +import { DEFAULT_TEXT_CHARS, MAX_TEXT_CHARS, MAX_TEXT_SOURCE_BYTES } from '@/lib/attachment-text/types'; +import { DEFAULT_IMAGE_SIDE, MAX_IMAGE_SIDE, MIN_IMAGE_SIDE, shrinkImage, toBase64 } from '@/lib/image-shrink'; +import { readPhotoExif } from '@/lib/exif'; -/** Method → permission, joined into PERM_PER_METHOD. */ -export const PERMS: Readonly> = {}; +/** Method → permission, joined into PERM_PER_METHOD. Some also need a second one, checked in the method. */ +export const PERMS: Readonly> = { + 'files.find': 'files:read', + 'files.list': 'files:read', + 'files.getText': 'files:read', + 'files.getImage': 'files:read', + // And compose:write (an open draft) or compose:open (a new one). + 'files.attach': 'files:read', + // And email:read. + 'files.saveAttachments': 'files:write', + 'files.create': 'files:write', + 'files.createFolder': 'files:write', + 'files.move': 'files:write', + 'files.rename': 'files:write', +}; + +/** How long a listing of the user's files serves files.find and files.list. */ +export const FILE_LISTING_TTL_MS = 60_000; +/** Reads of a file's text or picture a plugin may make per minute. */ +export const MAX_FILE_READS_PER_MINUTE = 30; +/** Changes to Files (create, save, move, rename) a plugin may make per minute. */ +export const MAX_FILE_WRITES_PER_MINUTE = 20; +/** Most results of files.find, and its default. */ +export const MAX_FIND_RESULTS = 50; +const DEFAULT_FIND_RESULTS = 20; +/** Most entries files.list returns. */ +export const MAX_LIST_ENTRIES = 200; +/** Most files one files.attach or files.saveAttachments call takes. */ +export const MAX_FILES_AT_ONCE = 20; +/** Longest text files.create writes, in characters. */ +export const MAX_CREATE_CHARS = 512 * 1024; + +const TEXT_TYPES = ['text/markdown', 'text/plain'] as const; + +// ─── Availability and the listing ─── + +/** Files as the app offers it: the server has it for this account and the administrator left it on (as the navigation rail decides). */ +export function filesAvailable(): boolean { + const client = useAuthStore.getState().client; + return !!client?.supportsFiles?.() && usePolicyStore.getState().isFeatureEnabled('filesEnabled'); +} + +let listing: { client: IJMAPClient; at: number; promise: Promise } | null = null; +let watchingStore = false; + +/** Forget the cached listing (after a change, or in tests). */ +export function forgetFileListing(): void { + listing = null; +} + +/** Every node of the user's own drive and of the drives shared with them, cached for a minute. */ +function allNodes(client: IJMAPClient): Promise { + if (!watchingStore) { + watchingStore = true; + // A change in the Files view makes the cached listing stale. + useFileStore.subscribe((state, previous) => { + if (state.lastAction !== previous.lastAction || (previous.isLoading && !state.isLoading)) forgetFileListing(); + }); + } + const now = Date.now(); + if (listing && listing.client === client && now - listing.at < FILE_LISTING_TTL_MS) return listing.promise; + const entry = { client, at: now, promise: client.listAllFileNodesAcrossAccounts() }; + listing = entry; + entry.promise.catch(() => { if (listing === entry) listing = null; }); + return entry.promise; +} + +/** + * What the assistant may see: everything but the Files trash and what is + * in it, 1.0's symlinks (they hold nothing) and the folders of another + * account that are listed only as the way down to something shared. + */ +function visibleNodes(nodes: FileNode[]): FileNode[] { + const trashed = trashSubtreeIds(nodes, findTrashFolder(nodes)?.id); + return nodes.filter((n) => !n.discoverableOnly && !trashed.has(n.id) && fileNodeKind(n) !== 'symlink'); +} + +const isFolder = (node: FileNode) => fileNodeKind(node) === 'directory'; + +function infoOf(node: FileNode, nodes: FileNode[]): FileInfo { + const folder = isFolder(node); + return { + id: node.id, + name: node.name, + path: pathOfNode(nodes, node), + parentId: node.parentId ?? null, + kind: folder ? 'folder' : 'file', + mimeType: folder ? '' : (node.type || 'application/octet-stream'), + size: folder ? 0 : (node.size || 0), + modified: node.modified || node.created || '', + ...(node.isShared ? { shared: true, ...(node.accountName ? { owner: node.accountName } : {}) } : {}), + }; +} + +function findNode(method: string, nodes: FileNode[], id: string): FileNode { + const node = nodes.find((n) => n.id === id); + if (!node) throw new Error(`${method}: no file or folder with id "${id}"`); + return node; +} + +function needFiles(method: string): IJMAPClient { + const client = sessionClient(method); + if (!filesAvailable()) throw new Error(`${method}: Files is not available`); + return client; +} + +function needPermission(plugin: InstalledPlugin, method: string, permission: Permission): void { + if (!pluginHasPermission(plugin, permission)) throw new Error(`Plugin "${plugin.id}" lacks permission "${permission}"`); +} + +function readWrite(method: string, plugin: InstalledPlugin): void { + if (!withinRate('files-write', plugin.id, MAX_FILE_WRITES_PER_MINUTE)) { + throw new Error(`${method}: at most ${MAX_FILE_WRITES_PER_MINUTE} changes to Files a minute`); + } +} + +// ─── Kinds of files ─── + +const EXT = (name: string) => /\.([a-z0-9]+)$/i.exec(name)?.[1]?.toLowerCase() ?? ''; +const KIND_EXTENSIONS: Record = { + pdf: ['pdf'], + spreadsheet: ['xlsx', 'xlsm', 'xls', 'ods', 'csv', 'tsv', 'numbers'], + presentation: ['pptx', 'pptm', 'ppt', 'odp', 'key'], + text: ['txt', 'md', 'markdown', 'log', 'rtf'], + word: ['docx', 'docm', 'doc', 'odt', 'pages'], + image: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'heic', 'heif', 'avif', 'tif', 'tiff', 'svg'], +}; + +function matchesKind(node: FileNode, kind: NonNullable): boolean { + if (kind === 'folder') return isFolder(node); + if (isFolder(node)) return false; + const ext = EXT(node.name); + const type = (node.type || '').toLowerCase(); + const is = (k: string) => KIND_EXTENSIONS[k].includes(ext); + switch (kind) { + case 'image': return type.startsWith('image/') || is('image'); + case 'pdf': return type === 'application/pdf' || is('pdf'); + case 'spreadsheet': return is('spreadsheet') || /spreadsheet|excel|text\/csv/.test(type); + case 'presentation': return is('presentation') || /presentation|powerpoint/.test(type); + case 'text': return is('text') || type.startsWith('text/'); + case 'document': + return ['pdf', 'spreadsheet', 'presentation', 'text', 'word'].some(is) + || /pdf|wordprocessing|msword|opendocument|spreadsheet|presentation|^text\//.test(type); + } +} + +const KINDS = ['document', 'image', 'pdf', 'spreadsheet', 'presentation', 'text', 'folder'] as const; + +function readDate(value: unknown, method: string, field: string): number { + const text = readString(value, method, field, 40, { min: 1 }); + const at = Date.parse(text); + if (Number.isNaN(at)) throw new Error(`${method}: ${field} must be a date like 2026-05-01 or 2026-05-01T10:00:00Z`); + return at; +} + +const modifiedAt = (node: FileNode) => Date.parse(node.modified || node.created || '') || 0; + +// ─── Reading ─── + +/** Files and folders that match, newest first (see FileFindQuery). */ +export async function doFilesFind(_plugin: InstalledPlugin, queryArg?: unknown): Promise { + const method = 'files.find'; + const query = readObject(queryArg, method, 'query', ['text', 'folderId', 'kind', 'modifiedAfter', 'modifiedBefore', 'limit'], true); + const words = query.text === undefined ? [] : readString(query.text, method, 'text', 200).toLowerCase().split(/\s+/).filter(Boolean); + const folderId = query.folderId === undefined ? null : readId(query.folderId, method, 'folderId'); + if (query.kind !== undefined && !(KINDS as readonly unknown[]).includes(query.kind)) { + throw new Error(`${method}: kind must be one of ${KINDS.join(', ')}`); + } + const kind = query.kind as FileFindQuery['kind']; + const after = query.modifiedAfter === undefined ? null : readDate(query.modifiedAfter, method, 'modifiedAfter'); + const before = query.modifiedBefore === undefined ? null : readDate(query.modifiedBefore, method, 'modifiedBefore'); + const limit = query.limit === undefined ? DEFAULT_FIND_RESULTS : readInteger(query.limit, method, 'limit', 1, MAX_FIND_RESULTS); + const client = needFiles(method); + const all = await allNodes(client); + const nodes = visibleNodes(all); + let below: Set | null = null; + if (folderId !== null) { + const folder = findNode(method, nodes, folderId); + if (!isFolder(folder)) throw new Error(`${method}: "${folder.name}" is not a folder`); + below = new Set([folder.id]); + for (let grew = true; grew;) { + grew = false; + for (const n of nodes) if (n.parentId && below.has(n.parentId) && !below.has(n.id)) { below.add(n.id); grew = true; } + } + below.delete(folder.id); + } + const hits = nodes.filter((node) => { + if (below && !below.has(node.id)) return false; + if (kind && !matchesKind(node, kind)) return false; + if (after !== null && modifiedAt(node) < after) return false; + if (before !== null && modifiedAt(node) >= before) return false; + if (words.length) { + const hay = `${node.name} ${pathOfNode(all, node)}`.toLowerCase(); + if (!words.every((w) => hay.includes(w))) return false; + } + return true; + }); + hits.sort((a, b) => modifiedAt(b) - modifiedAt(a)); + return hits.slice(0, limit).map((n) => infoOf(n, all)); +} + +/** What a folder holds (folders first, then by name); without a folder, the top of the user's drive and what others shared with them. */ +export async function doFilesList(_plugin: InstalledPlugin, folderArg?: unknown): Promise { + const method = 'files.list'; + const folderId = folderArg === undefined || folderArg === null ? null : readId(folderArg, method, 'folderId'); + const client = needFiles(method); + const all = await allNodes(client); + const nodes = visibleNodes(all); + let entries: FileNode[]; + if (folderId === null) { + const ids = new Set(nodes.map((n) => n.id)); + entries = nodes.filter((n) => (n.isShared + // The top of what is shared: its parent isn't something the user can open. + ? !n.parentId || !ids.has(n.parentId) + : (n.parentId ?? null) === null)); + } else { + const folder = findNode(method, nodes, folderId); + if (!isFolder(folder)) throw new Error(`${method}: "${folder.name}" is not a folder`); + entries = nodes.filter((n) => n.parentId === folder.id); + } + entries.sort((a, b) => Number(isFolder(b)) - Number(isFolder(a)) || a.name.localeCompare(b.name)); + return entries.slice(0, MAX_LIST_ENTRIES).map((n) => infoOf(n, all)); +} + +async function readableFile(method: string, plugin: InstalledPlugin, fileArg: unknown): Promise<{ client: IJMAPClient; node: FileNode }> { + const fileId = readId(fileArg, method, 'fileId'); + const client = needFiles(method); + if (!withinRate(method, plugin.id, MAX_FILE_READS_PER_MINUTE)) throw new Error(`${method}: too many reads, try again in a minute`); + const node = findNode(method, visibleNodes(await allNodes(client)), fileId); + if (isFolder(node) || !node.blobId) throw new Error(`${method}: "${node.name}" is a folder`); + return { client, node }; +} + +function fetchNode(client: IJMAPClient, node: FileNode): Promise { + return client.fetchBlobArrayBuffer(node.blobId as string, node.name || undefined, node.type || undefined, ...(node.isShared && node.accountId ? [node.accountId] : [])); +} + +/** The text of a file, with the attachment reader (see AttachmentTextView). */ +export async function doFilesGetText(plugin: InstalledPlugin, fileArg: unknown, optionsArg?: unknown): Promise { + const method = 'files.getText'; + readId(fileArg, method, 'fileId'); + const opts = readObject(optionsArg, method, 'options', ['maxChars'], true); + const maxChars = opts.maxChars === undefined ? DEFAULT_TEXT_CHARS : readInteger(opts.maxChars, method, 'maxChars', 1, MAX_TEXT_CHARS); + const { client, node } = await readableFile(method, plugin, fileArg); + const name = node.name || 'file'; + const type = node.type || 'application/octet-stream'; + const base = { name, type, size: node.size || 0 }; + const { extractText, textFormatOf } = await import('@/lib/attachment-text'); + if ((node.size || 0) > MAX_TEXT_SOURCE_BYTES) { + return { ...base, format: textFormatOf(name, type), status: 'too_big', text: '', truncated: false }; + } + const data = await fetchNode(client, node); + const result = await extractText(data, { name, type, maxChars }); + return { ...base, size: data.byteLength, ...result }; +} + +const IMAGE_NAME = /\.(jpe?g|png|gif|webp|bmp|avif|heic|heif|tiff?)$/i; + +/** A picture the browser can draw; SVG is left out (it can load remote content). */ +function isRasterImage(name: string, type: string): boolean { + const mediaType = type.split(';')[0].trim().toLowerCase(); + if (mediaType === 'image/svg+xml' || /\.svgz?$/i.test(name)) return false; + return mediaType.startsWith('image/') || IMAGE_NAME.test(name); +} + +/** A picture file, shrunk in the app as api.email.getAttachmentImage does (see AttachmentImageView). */ +export async function doFilesGetImage(plugin: InstalledPlugin, fileArg: unknown, optionsArg?: unknown): Promise { + const method = 'files.getImage'; + readId(fileArg, method, 'fileId'); + const opts = readObject(optionsArg, method, 'options', ['maxSide', 'exif'], true); + const maxSide = opts.maxSide === undefined ? DEFAULT_IMAGE_SIDE : readInteger(opts.maxSide, method, 'maxSide', MIN_IMAGE_SIDE, MAX_IMAGE_SIDE); + const wantExif = opts.exif === undefined ? false : readBoolean(opts.exif, method, 'exif'); + const { client, node } = await readableFile(method, plugin, fileArg); + const name = node.name || 'image'; + const sourceType = node.type || 'application/octet-stream'; + const answer = (status: AttachmentImageView['status'], width = 0, height = 0): AttachmentImageView => ({ name, type: sourceType, width, height, data: '', status }); + if (!isRasterImage(name, sourceType)) return answer('unsupported'); + if ((node.size || 0) > MAX_TEXT_SOURCE_BYTES) return answer('too_big'); + const data = await fetchNode(client, node); + const shrunk = await shrinkImage(new Blob([data], { type: sourceType }), { maxSide }); + if (shrunk.status !== 'ok' || !shrunk.bytes || !shrunk.type) return answer(shrunk.status === 'ok' ? 'failed' : shrunk.status, shrunk.width, shrunk.height); + const exif = wantExif ? readPhotoExif(data) : null; + return { + name, type: shrunk.type, width: shrunk.width ?? 0, height: shrunk.height ?? 0, data: toBase64(shrunk.bytes), status: 'ok', + ...(exif && Object.keys(exif).length ? { exif } : {}), + }; +} + +// ─── Attaching ─── + +const SAFE_TYPE = /^[a-z0-9][a-z0-9!#$&^_.+-]{0,126}\/[a-z0-9][a-z0-9!#$&^_.+-]{0,126}$/i; +const attachType = (type: string) => (SAFE_TYPE.test(type) ? type : 'application/octet-stream'); + +function readFileIds(method: string, value: unknown): string[] { + if (!Array.isArray(value) || value.length === 0) throw new Error(`${method}: fileIds must be a list of file ids`); + if (value.length > MAX_FILES_AT_ONCE) throw new Error(`${method}: at most ${MAX_FILES_AT_ONCE} files at once`); + return [...new Set(value.map((id, i) => readId(id, method, `fileIds[${i}]`)))]; +} + +/** + * Attach files to the open draft (`composerId`, compose:write) or to a new + * one (`newDraft`, compose:open), by their blobs: no download, no upload. + * The composer's own checks run (size limit, onBeforeAttachmentUpload). + */ +export async function doFilesAttach(plugin: InstalledPlugin, idsArg: unknown, targetArg: unknown): Promise { + const method = 'files.attach'; + const ids = readFileIds(method, idsArg); + if (!isPlainRecord(targetArg)) throw new Error(`${method}: say where: { composerId } or { newDraft }`); + const target = readObject(targetArg, method, 'target', ['composerId', 'newDraft']); + if ((target.composerId === undefined) === (target.newDraft === undefined)) throw new Error(`${method}: give either composerId or newDraft`); + needPermission(plugin, method, target.composerId !== undefined ? 'compose:write' : 'compose:open'); + const client = needFiles(method); + if (!withinRate(method, plugin.id, MAX_FILES_AT_ONCE)) throw new Error(`${method}: at most ${MAX_FILES_AT_ONCE} times a minute`); + const nodes = visibleNodes(await allNodes(client)); + const files = ids.map((id) => findNode(method, nodes, id)); + const folder = files.find((n) => isFolder(n) || !n.blobId); + if (folder) throw new Error(`${method}: "${folder.name}" is a folder; only files can be attached`); + const refs = files.map((n) => ({ blobId: n.blobId as string, name: n.name || 'file', type: attachType(n.type || ''), size: n.size || 0 })); + + if (target.composerId !== undefined) { + const handle = composerHandle(readId(target.composerId, method, 'composerId')); + if (!handle) throw new Error(`${method}: that composer is not open any more`); + // The login whose Files hold the blobs: a draft sent from another login copies them first. + const sourceAccountId = useAuthStore.getState().activeAccountId ?? undefined; + const attached: string[] = []; + const refused: string[] = []; + for (const ref of refs) { + const ok = handle.attachFile ? await handle.attachFile({ ...ref, sourceAccountId }) : await handle.attach(ref); + (ok ? attached : refused).push(ref.name); + } + return { attached, refused }; + } + + const draft = readObject(target.newDraft, method, 'newDraft', ['to', 'subject', 'body']); + // compose.open checks the addresses, the subject and the body, and asks before it replaces an open draft. + doComposeOpen(plugin, { ...draft, attachments: refs }); + return { attached: refs.map((r) => r.name), refused: [] }; +} + +// ─── Writing: only the user's own drive, never a shared folder ─── + +interface OwnDrive { + client: IJMAPClient; + /** The user's own drive, read fresh (with shareWith). */ + nodes: FileNode[]; + byId: Map; + trashed: Set; +} + +async function ownDrive(method: string): Promise { + const client = needFiles(method); + const nodes = (await client.listAllFileNodes()).filter((n) => !n.isShared); + return { client, nodes, byId: new Map(nodes.map((n) => [n.id, n])), trashed: trashSubtreeIds(nodes, findTrashFolder(nodes)?.id) }; +} + +const sharedOut = (node: FileNode) => !!node.shareWith && Object.keys(node.shareWith).length > 0; + +/** Whether `node` or a folder above it is shared with someone. */ +function inSharedPlace(drive: OwnDrive, node: FileNode | null): boolean { + const seen = new Set(); + for (let at = node; at && !seen.has(at.id); at = at.parentId ? drive.byId.get(at.parentId) ?? null : null) { + seen.add(at.id); + if (sharedOut(at)) return true; + } + return false; +} + +/** A node of the user's own drive, outside the trash, not in a shared folder. */ +function ownNode(method: string, drive: OwnDrive, id: string): FileNode { + if (id.includes(':')) throw new Error(`${method}: that file is in a drive someone shared with you; the assistant only changes your own files`); + const node = drive.byId.get(id); + if (!node || drive.trashed.has(node.id) || fileNodeKind(node) === 'symlink') throw new Error(`${method}: no file or folder with id "${id}" in your own files`); + if (inSharedPlace(drive, node)) throw new Error(`${method}: "${node.name}" is shared with others; the assistant doesn't change shared files`); + return node; +} + +/** A folder of the user's own drive to write into (null: the top), outside the trash, not shared. */ +function ownFolder(method: string, drive: OwnDrive, id: string | null): FileNode | null { + if (id === null) return null; + const folder = ownNode(method, drive, id); + if (!isFolder(folder)) throw new Error(`${method}: "${folder.name}" is not a folder`); + return folder; +} + +const pathOf = (drive: OwnDrive, folder: FileNode | null) => (folder ? (pathOfNode(drive.nodes, folder) === '/' ? `/${folder.name}` : `${pathOfNode(drive.nodes, folder)}/${folder.name}`) : '/'); + +function readFolderId(method: string, value: unknown, field = 'folderId'): string | null { + return value === undefined || value === null ? null : readId(value, method, field); +} + +function problemText(problem: FileNameProblem): string { + switch (problem.kind) { + case 'chars': return `the name may not contain ${problem.chars}`; + case 'control': return 'the name may not contain control characters'; + case 'tooLong': return 'the name is too long'; + case 'reserved': return 'that name is reserved by the server'; + } +} + +/** A new name the server takes: refused with the reason, never changed behind the user's back. */ +function readNewName(method: string, client: IJMAPClient, value: unknown): string { + const raw = readString(value, method, 'name', 255, { min: 1 }); + // eslint-disable-next-line no-control-regex -- refused, as Stalwart 1.0 does, rather than changed + if (/[\u0000-\u001f\u007f-\u009f]/.test(raw)) throw new Error(`${method}: the name may not contain control characters`); + const name = raw.trim(); + if (!name) throw new Error(`${method}: the name is empty`); + if (name === '.' || name === '..' || name.includes('/')) throw new Error(`${method}: "${name}" can't be a file name`); + if (name === TRASH_FOLDER_NAME) throw new Error(`${method}: "${name}" is the Files trash's name`); + const problem = fileNameProblem(name, client.getFileNameRules?.() ?? null); + if (problem) throw new Error(`${method}: ${problemText(problem)}`); + return name; +} + +/** After a change: fresh listings for the assistant and search, and the Files view if it is open. */ +function afterWrite(): void { + forgetFileListing(); + invalidateFileSearchCache(); + const files = useFileStore.getState(); + if (files.client && files.hasListed) void files.refresh().catch(() => {}); +} + +const STOPPED = 'another plugin stopped this'; + +/** + * Save attachments of a message into a folder of the user's own drive, by + * their blobs (lib/files-save.ts, as the reader's Save to Files). Needs + * email:read too. A name that is taken becomes "name (2).ext". + */ +export async function doFilesSaveAttachments(plugin: InstalledPlugin, emailArg: unknown, blobsArg: unknown, folderArg?: unknown, optionsArg?: unknown): Promise<{ saved: FileInfo[]; stopped: string[] }> { + const method = 'files.saveAttachments'; + needPermission(plugin, method, 'email:read'); + const emailId = readId(emailArg, method, 'emailId'); + if (!Array.isArray(blobsArg) || blobsArg.length === 0) throw new Error(`${method}: blobIds must be a list of attachment blob ids`); + if (blobsArg.length > MAX_FILES_AT_ONCE) throw new Error(`${method}: at most ${MAX_FILES_AT_ONCE} attachments at once`); + const blobIds = [...new Set(blobsArg.map((b, i) => readId(b, method, `blobIds[${i}]`)))]; + const folderId = readFolderId(method, folderArg); + const opts = readObject(optionsArg, method, 'options', ['accountId'], true); + const drive = await ownDrive(method); + const folder = ownFolder(method, drive, folderId); + readWrite(method, plugin); + const found = await readNamedEmail(drive.client, emailId, method, opts.accountId); + if (!found) throw new Error(`${method}: no message with id "${emailId}"`); + const parts = blobIds.map((blobId) => { + const part = (found.email.attachments ?? []).find((a) => a.blobId === blobId); + if (!part) throw new Error(`${method}: that blob is not an attachment of this message`); + return { blobId, name: part.name, type: part.type, size: part.size || 0 }; + }); + const path = pathOf(drive, folder); + const { saved, stopped } = await saveBlobsToFiles(parts, { + client: drive.client, parentId: folder?.id ?? null, path, blobAccountId: found.accountId ?? drive.client.getAccountId(), + }); + if (saved.length) afterWrite(); + return { + saved: saved.map((node) => ({ ...infoOf(node, drive.nodes), path, parentId: folder?.id ?? null, size: node.size || 0 })), + stopped, + }; +} + +const isTextFile = (node: FileNode) => /\.(md|markdown|txt|text)$/i.test(node.name) || /^text\/(plain|markdown)\b/i.test(node.type || ''); + +/** + * A new text or Markdown file, or new content for one (`replace`): the old + * version is kept as a copy in the Files trash first, where Restore brings + * it back next to the new one. The copy points at the old blob, so it + * costs no space. + */ +export async function doFilesCreate(plugin: InstalledPlugin, inputArg: unknown): Promise { + const method = 'files.create'; + const input = readObject(inputArg, method, 'input', ['folderId', 'name', 'content', 'type', 'replace']); + const content = readString(input.content, method, 'content', MAX_CREATE_CHARS); + if (!(TEXT_TYPES as readonly unknown[]).includes(input.type)) throw new Error(`${method}: type must be text/markdown or text/plain`); + const type = input.type as typeof TEXT_TYPES[number]; + const replaceId = input.replace === undefined ? null : readId(input.replace, method, 'replace'); + const drive = await ownDrive(method); + const { client } = drive; + + if (replaceId !== null) { + const node = ownNode(method, drive, replaceId); + if (isFolder(node) || !node.blobId) throw new Error(`${method}: "${node.name}" is a folder`); + if (!isTextFile(node)) throw new Error(`${method}: only text and Markdown files can be replaced; "${node.name}" is not one`); + readWrite(method, plugin); + const parent = node.parentId ? drive.byId.get(node.parentId) ?? null : null; + const path = pathOf(drive, parent); + const file = new File([content], node.name, { type: node.type || type }); + if (!(await fileHooks.onBeforeFileUpload.intercept({ name: node.name, size: file.size, type: file.type, path }))) throw new Error(`${method}: ${STOPPED}`); + const uploaded = await client.uploadBlob(file); + // The old version first: a copy of the node, pointing at the old blob, in a trash folder of its own. + const trash = findTrashFolder(drive.nodes) ?? await client.createFileDirectory(TRASH_FOLDER_NAME, null); + const batch = await client.createFileDirectory(trashBatchName(node.parentId ?? null), trash.id); + const kept = await client.createFileNode(node.name, node.blobId, node.type || type, node.size || 0, batch.id); + await client.replaceFileNodeBlob(node.id, uploaded.blobId); + const now = new Date().toISOString(); + const replaced: FileNode = { ...node, blobId: uploaded.blobId, size: uploaded.size ?? file.size, modified: now }; + void fileHooks.onAfterFileUpload.emit(fileToView(replaced, path)); + afterWrite(); + return { file: infoOf(replaced, drive.nodes), kept: { trashFolder: TRASH_FOLDER_NAME, name: kept.name } }; + } + + const name = readNewName(method, client, input.name); + const folder = ownFolder(method, drive, readFolderId(method, input.folderId)); + readWrite(method, plugin); + const path = pathOf(drive, folder); + const file = new File([content], name, { type }); + if (!(await fileHooks.onBeforeFileUpload.intercept({ name, size: file.size, type, path }))) throw new Error(`${method}: ${STOPPED}`); + const uploaded = await client.uploadBlob(file); + // A name that is taken becomes "name (2).md" on the server: nothing is overwritten. + const node = await client.createFileNode(name, uploaded.blobId, type, uploaded.size ?? file.size, folder?.id ?? null); + void fileHooks.onAfterFileUpload.emit(fileToView(node, path)); + afterWrite(); + return { file: { ...infoOf(node, drive.nodes), path, parentId: folder?.id ?? null, size: node.size || uploaded.size || file.size, mimeType: type } }; +} + +/** A new folder in the user's own drive; a name that is taken becomes "name (2)". */ +export async function doFilesCreateFolder(plugin: InstalledPlugin, inputArg: unknown): Promise { + const method = 'files.createFolder'; + const input = readObject(inputArg, method, 'input', ['parentId', 'name']); + const drive = await ownDrive(method); + const name = readNewName(method, drive.client, input.name); + const parent = ownFolder(method, drive, readFolderId(method, input.parentId, 'parentId')); + const maxDepth = drive.client.getMaxFileNodeDepth?.() ?? null; + if (maxDepth !== null) { + let depth = 1; + for (let at = parent; at; at = at.parentId ? drive.byId.get(at.parentId) ?? null : null) depth++; + if (depth > maxDepth) throw new Error(`${method}: folders can't be nested deeper than ${maxDepth} levels`); + } + readWrite(method, plugin); + const path = pathOf(drive, parent); + const created = await drive.client.createFileDirectory(name, parent?.id ?? null); + void fileHooks.onDirectoryCreate.emit(fileToView(created, path)); + afterWrite(); + return { ...infoOf(created, drive.nodes), path, parentId: parent?.id ?? null, kind: 'folder', mimeType: '', size: 0 }; +} + +/** + * Move a file or folder within the user's own drive (null: to the top). + * Never into or out of a shared folder, the trash or another account. + * `previousFolderId` is where it was, for an Undo that moves it back. + */ +export async function doFilesMove(plugin: InstalledPlugin, fileArg: unknown, folderArg: unknown): Promise<{ file: FileInfo; previousFolderId: string | null }> { + const method = 'files.move'; + const fileId = readId(fileArg, method, 'fileId'); + const folderId = readFolderId(method, folderArg); + if (folderId !== null && folderId.includes(':')) throw new Error(`${method}: files can't be moved to another person's drive`); + const drive = await ownDrive(method); + const node = ownNode(method, drive, fileId); + const target = ownFolder(method, drive, folderId); + const from = node.parentId ?? null; + if ((target?.id ?? null) === from) throw new Error(`${method}: "${node.name}" is already there`); + for (let at = target; at; at = at.parentId ? drive.byId.get(at.parentId) ?? null : null) { + if (at.id === node.id) throw new Error(`${method}: a folder can't be moved into itself`); + } + const clash = drive.nodes.find((n) => (n.parentId ?? null) === (target?.id ?? null) && n.name === node.name && n.id !== node.id); + if (clash) throw new Error(`${method}: there is already something named "${node.name}" there`); + readWrite(method, plugin); + const fromPath = pathOf(drive, from ? drive.byId.get(from) ?? null : null); + const toPath = pathOf(drive, target); + if (!(await fileHooks.onBeforeFileMove.intercept({ files: [fileToView(node, fromPath)], toPath }))) throw new Error(`${method}: ${STOPPED}`); + await drive.client.updateFileNode(node.id, { parentId: target?.id ?? null }); + void fileHooks.onFileMove.emit({ files: [fileToView({ ...node, parentId: target?.id ?? null }, toPath)], toPath }); + afterWrite(); + return { file: { ...infoOf(node, drive.nodes), path: toPath, parentId: target?.id ?? null }, previousFolderId: from }; +} + +/** Rename a file or folder of the user's own drive. `previousName` is for an Undo. */ +export async function doFilesRename(plugin: InstalledPlugin, fileArg: unknown, nameArg: unknown): Promise<{ file: FileInfo; previousName: string }> { + const method = 'files.rename'; + const fileId = readId(fileArg, method, 'fileId'); + const drive = await ownDrive(method); + const name = readNewName(method, drive.client, nameArg); + const node = ownNode(method, drive, fileId); + if (name === node.name) throw new Error(`${method}: it is already called "${name}"`); + const clash = drive.nodes.find((n) => (n.parentId ?? null) === (node.parentId ?? null) && n.name === name && n.id !== node.id); + if (clash) throw new Error(`${method}: there is already something named "${name}" in that folder`); + readWrite(method, plugin); + const path = pathOf(drive, node.parentId ? drive.byId.get(node.parentId) ?? null : null); + const view = fileToView(node, path); + if (!(await fileHooks.onBeforeFileRename.intercept({ file: view, newName: name }))) throw new Error(`${method}: ${STOPPED}`); + await drive.client.updateFileNode(node.id, { name }); + void fileHooks.onFileRename.emit({ file: { ...view, name }, previousName: node.name }); + afterWrite(); + return { file: { ...infoOf({ ...node, name }, drive.nodes), path }, previousName: node.name }; +} /** Runs a method of this area; NOT_MINE for any other. */ -export async function dispatch(_plugin: InstalledPlugin, _method: string, _args: unknown[]): Promise { - return NOT_MINE; +export async function dispatch(plugin: InstalledPlugin, method: string, args: unknown[]): Promise { + switch (method) { + case 'files.find': return doFilesFind(plugin, args[0]); + case 'files.list': return doFilesList(plugin, args[0]); + case 'files.getText': return doFilesGetText(plugin, args[0], args[1]); + case 'files.getImage': return doFilesGetImage(plugin, args[0], args[1]); + case 'files.attach': return doFilesAttach(plugin, args[0], args[1]); + case 'files.saveAttachments': return doFilesSaveAttachments(plugin, args[0], args[1], args[2], args[3]); + case 'files.create': return doFilesCreate(plugin, args[0]); + case 'files.createFolder': return doFilesCreateFolder(plugin, args[0]); + case 'files.move': return doFilesMove(plugin, args[0], args[1]); + case 'files.rename': return doFilesRename(plugin, args[0], args[1]); + default: return NOT_MINE; + } } /** This area's flags in api.host.features, right now. */ export function features(): AreaHostFeaturesD { - return {}; + return { files: filesAvailable() }; } diff --git a/lib/plugin-sandbox/assistant/d.types.ts b/lib/plugin-sandbox/assistant/d.types.ts index 962c354c3..708ffa9e1 100644 --- a/lib/plugin-sandbox/assistant/d.types.ts +++ b/lib/plugin-sandbox/assistant/d.types.ts @@ -1,8 +1,91 @@ // Area d of the assistant's host APIs: its permissions and its flags in // api.host.features (see README.md). Pure: no imports from the app. +// +// api.files: find, read and attach the user's files, save attachments to +// Files, create text and Markdown files and folders, move and rename. Writes +// go only into the user's own drive, never into a folder shared with +// others. There is no call that deletes, shares, makes a public link or +// moves a file to another account. -/** Permissions this area adds to ALL_PERMISSIONS. */ +/** Permissions this area adds to ALL_PERMISSIONS (it uses files:read and files:write). */ export const PERMISSIONS = [] as const; -/** Flags this area adds to api.host.features. */ -export interface AreaHostFeaturesD {} +/** Flags this area adds to api.host.features (missing on older webmails: read them as `=== true`). */ +export interface AreaHostFeaturesD { + /** api.files: the server has Files and the administrator left it on. */ + files?: boolean; +} + +/** A file or folder as api.files gives it. */ +export interface FileInfo { + /** Its id for the other api.files calls. A file in a drive someone shared has an id of the form "account:node". */ + id: string; + name: string; + /** The folder it is in, from the top of its drive: "/" or "/Documents/2026". */ + path: string; + /** The folder it is in; null at the top of the drive. */ + parentId: string | null; + kind: 'file' | 'folder'; + /** The media type of a file; '' for a folder. */ + mimeType: string; + /** Bytes; 0 for a folder. */ + size: number; + /** When its content last changed (ISO 8601), '' when the server doesn't say. */ + modified: string; + /** In a drive another person shared with the user: read and attach only. */ + shared?: boolean; + /** Whose drive a shared file is in. */ + owner?: string; +} + +/** What api.files.find looks for. Every field is optional; all given ones must match. */ +export interface FileFindQuery { + /** Words of the name; each must appear (any case). */ + text?: string; + /** Only below this folder (any depth). */ + folderId?: string; + /** document: PDF, Office and text files; also image, pdf, spreadsheet, presentation, text, folder. */ + kind?: 'document' | 'image' | 'pdf' | 'spreadsheet' | 'presentation' | 'text' | 'folder'; + /** Changed on or after this date or time (ISO 8601). */ + modifiedAfter?: string; + /** Changed before this date or time (ISO 8601). */ + modifiedBefore?: string; + /** Most results, 1 to 50 (default 20). Newest first. */ + limit?: number; +} + +/** What api.files.create writes. */ +export interface FileCreateInput { + /** The folder; null or left out: the top of the user's drive. Ignored with `replace`. */ + folderId?: string | null; + /** The file name; with `replace` the file keeps its own. */ + name?: string; + /** The text, at most 512 KB. */ + content: string; + type: 'text/markdown' | 'text/plain'; + /** + * Replace this file's content. Only text and Markdown files of the user's + * own drive. The old version goes into the Files trash first, from where + * Restore brings it back. + */ + replace?: string; +} + +export interface FileCreateResult { + /** The file as it is now (a name that was taken comes back as "name (2).md"). */ + file: FileInfo; + /** With `replace`: where the old version is, by name, in the Files trash. */ + kept?: { trashFolder: string; name: string }; +} + +/** Where api.files.attach puts the files. */ +export type FileAttachTarget = + | { composerId: string } + | { newDraft: { to?: string[]; subject?: string; body?: string } }; + +export interface FileAttachResult { + /** Names of the files attached. */ + attached: string[]; + /** Names the composer turned down (too big, or a plugin stopped them). */ + refused: string[]; +} diff --git a/lib/plugin-sandbox/composer-registry.ts b/lib/plugin-sandbox/composer-registry.ts index 7acac39ef..385f0f950 100644 --- a/lib/plugin-sandbox/composer-registry.ts +++ b/lib/plugin-sandbox/composer-registry.ts @@ -46,6 +46,11 @@ export interface ComposerHandle { replaceSelection(html: string, text: string, opts: { below: boolean }): boolean; /** Attach a blob the plugin uploaded, through the composer's usual checks. */ attach(file: ComposerAttachment): Promise; + /** + * Attach a file from the user's Files (host only, api.files.attach), with + * the login whose Files hold it, through the same checks. + */ + attachFile?(file: ComposerAttachment & { sourceAccountId?: string }): Promise; } const handles = new Map(); diff --git a/lib/plugin-sandbox/host-features.ts b/lib/plugin-sandbox/host-features.ts index 0d82ac618..77d67eff1 100644 --- a/lib/plugin-sandbox/host-features.ts +++ b/lib/plugin-sandbox/host-features.ts @@ -9,6 +9,7 @@ // plugin id, one switch at a time. import { usePolicyStore } from '@/stores/policy-store'; +import { useAuthStore } from '@/stores/auth-store'; import { noThirdPartyTraffic, subscribeThirdPartyMode } from '@/lib/third-party-mode'; import type { HostFeaturesView } from '../plugin-types'; import type { OrganisationPolicy } from '@/lib/admin/organisation-policy'; @@ -79,4 +80,6 @@ export function watchHostFeatures(onChange: (features: HostFeaturesView) => void usePolicyStore.subscribe(check); // The mode is known once the config has been read, and an admin may turn it on later. subscribeThirdPartyMode(check); + // Flags that depend on the server (Files) change with the signed-in session. + useAuthStore.subscribe((state, previous) => { if (state.client !== previous.client) check(); }); } diff --git a/locales/ar/common.json b/locales/ar/common.json index 143549ae7..7b0b1cf96 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -5859,11 +5859,11 @@ }, "files_read": { "title": "قراءة ملفاتك", - "body": "تصفّح الملفات المخزّنة في حسابك وفتحها." + "body": "البحث عن الملفات في حسابك وفتحها وقراءتها." }, "files_write": { "title": "تغيير ملفاتك", - "body": "رفع الملفات في حسابك وإعادة تسميتها ونقلها وحذفها." + "body": "حفظ الملفات في حسابك وإنشاؤها وإعادة تسميتها ونقلها، ومنع حذف ملف." }, "identity_read": { "title": "عرض عناوين المرسل", diff --git a/locales/ca/common.json b/locales/ca/common.json index 2dfd09d16..b0cd27039 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -5859,11 +5859,11 @@ }, "files_read": { "title": "Llegir els vostres fitxers", - "body": "Navegar pels fitxers desats al vostre compte i obrir-los." + "body": "Cercar, obrir i llegir els fitxers del vostre compte." }, "files_write": { "title": "Canviar els vostres fitxers", - "body": "Pujar, canviar el nom, moure i suprimir fitxers del vostre compte." + "body": "Desar, crear, canviar el nom i moure fitxers del vostre compte, i impedir que se suprimeixi un fitxer." }, "identity_read": { "title": "Veure les vostres identitats d'enviament", diff --git a/locales/cs/common.json b/locales/cs/common.json index 9e9d2bdb0..26d3e450f 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -5859,11 +5859,11 @@ }, "files_read": { "title": "Číst vaše soubory", - "body": "Prochází a otevírá soubory uložené ve vašem účtu." + "body": "Hledat, otevírat a číst soubory ve vašem účtu." }, "files_write": { "title": "Měnit vaše soubory", - "body": "Nahrává, přejmenovává, přesouvá a maže soubory ve vašem účtu." + "body": "Ukládat, vytvářet, přejmenovávat a přesouvat soubory ve vašem účtu a zabránit smazání souboru." }, "identity_read": { "title": "Vidět vaše identity odesílatele", diff --git a/locales/da/common.json b/locales/da/common.json index abe4a2d69..1db06f99d 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -5859,11 +5859,11 @@ }, "files_read": { "title": "Læse dine filer", - "body": "Gennemse og åbne de filer, der er gemt på din konto." + "body": "Finde, åbne og læse filerne på din konto." }, "files_write": { "title": "Ændre dine filer", - "body": "Uploade, omdøbe, flytte og slette filer på din konto." + "body": "Gemme, oprette, omdøbe og flytte filer på din konto og forhindre, at en fil bliver slettet." }, "identity_read": { "title": "Se dine afsenderidentiteter", diff --git a/locales/de/common.json b/locales/de/common.json index 9a0ba5b6f..527f54407 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -5859,11 +5859,11 @@ }, "files_read": { "title": "Ihre Dateien lesen", - "body": "Die in Ihrem Konto gespeicherten Dateien durchsuchen und öffnen." + "body": "Die Dateien in Ihrem Konto finden, öffnen und lesen." }, "files_write": { "title": "Ihre Dateien ändern", - "body": "Dateien in Ihrem Konto hochladen, umbenennen, verschieben und löschen." + "body": "Dateien in Ihrem Konto speichern, erstellen, umbenennen und verschieben und das Löschen einer Datei verhindern." }, "identity_read": { "title": "Ihre Absenderadressen sehen", diff --git a/locales/en/common.json b/locales/en/common.json index 52387484f..1b7fd654c 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -5859,11 +5859,11 @@ }, "files_read": { "title": "Read your files", - "body": "Browse and open the files stored in your account." + "body": "Find, open and read the files in your account." }, "files_write": { "title": "Change your files", - "body": "Upload, rename, move and delete files in your account." + "body": "Save, create, rename and move files in your account, and stop a file from being deleted." }, "identity_read": { "title": "See your sender addresses", diff --git a/locales/es/common.json b/locales/es/common.json index b0e901c68..7252868bd 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -5859,11 +5859,11 @@ }, "files_read": { "title": "Leer tus archivos", - "body": "Explorar y abrir los archivos guardados en tu cuenta." + "body": "Buscar, abrir y leer los archivos de tu cuenta." }, "files_write": { "title": "Cambiar tus archivos", - "body": "Subir, renombrar, mover y eliminar archivos de tu cuenta." + "body": "Guardar, crear, renombrar y mover archivos de tu cuenta, e impedir que se elimine un archivo." }, "identity_read": { "title": "Ver tus direcciones de remitente", diff --git a/locales/et/common.json b/locales/et/common.json index 057b2b4ce..c7aa3629e 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -5859,11 +5859,11 @@ }, "files_read": { "title": "Loe sinu faile", - "body": "Sirvida ja avada sinu kontol hoitavaid faile." + "body": "Leida, avada ja lugeda sinu konto faile." }, "files_write": { "title": "Muuda sinu faile", - "body": "Laadida üles, ümber nimetada, liigutada ja kustutada faile sinu kontol." + "body": "Salvestada, luua, ümber nimetada ja liigutada faile sinu kontol ning takistada faili kustutamist." }, "identity_read": { "title": "Vaata sinu saatjaaadresse", diff --git a/locales/fa/common.json b/locales/fa/common.json index a993ab5d9..14c0988ec 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -5859,11 +5859,11 @@ }, "files_read": { "title": "خواندن فایل‌های شما", - "body": "مرور و باز کردن فایل‌های ذخیره‌شده در حساب شما." + "body": "یافتن، باز کردن و خواندن فایل‌های حساب شما." }, "files_write": { "title": "تغییر فایل‌های شما", - "body": "بارگذاری، تغییر نام، جابه‌جایی و حذف فایل‌ها در حساب شما." + "body": "ذخیره، ایجاد، تغییر نام و جابه‌جایی فایل‌ها در حساب شما، و جلوگیری از حذف یک فایل." }, "identity_read": { "title": "دیدن آدرس‌های فرستنده شما", diff --git a/locales/fr/common.json b/locales/fr/common.json index 47740b31a..88ee4d184 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -5859,11 +5859,11 @@ }, "files_read": { "title": "Lire vos fichiers", - "body": "Parcourir et ouvrir les fichiers stockés dans votre compte." + "body": "Rechercher, ouvrir et lire les fichiers de votre compte." }, "files_write": { "title": "Modifier vos fichiers", - "body": "Téléverser, renommer, déplacer et supprimer des fichiers dans votre compte." + "body": "Enregistrer, créer, renommer et déplacer des fichiers dans votre compte, et empêcher la suppression d'un fichier." }, "identity_read": { "title": "Voir vos adresses d'expéditeur", diff --git a/locales/he/common.json b/locales/he/common.json index 2d139125d..416cefdfc 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -5860,11 +5860,11 @@ }, "files_read": { "title": "קריאת הקבצים שלכם", - "body": "עיון בקבצים השמורים בחשבון שלכם ופתיחתם." + "body": "למצוא, לפתוח ולקרוא את הקבצים בחשבון שלך." }, "files_write": { "title": "שינוי הקבצים שלכם", - "body": "העלאה, שינוי שם, העברה ומחיקה של קבצים בחשבון שלכם." + "body": "לשמור, ליצור, לשנות שם ולהעביר קבצים בחשבון שלך, ולמנוע מחיקה של קובץ." }, "identity_read": { "title": "צפייה בכתובות השולח שלכם", diff --git a/locales/hu/common.json b/locales/hu/common.json index b60dffc22..1dd398c44 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -5859,11 +5859,11 @@ }, "files_read": { "title": "Fájljaid olvasása", - "body": "Böngészi és megnyitja a fiókodban tárolt fájlokat." + "body": "Megkeresi, megnyitja és elolvassa a fiókodban lévő fájlokat." }, "files_write": { "title": "Fájljaid módosítása", - "body": "Fájlokat tölt fel, nevez át, helyez át és töröl a fiókodban." + "body": "Fájlokat ment, hoz létre, nevez át és helyez át a fiókodban, és megakadályozhatja egy fájl törlését." }, "identity_read": { "title": "Küldő azonosságaid megtekintése", diff --git a/locales/it/common.json b/locales/it/common.json index 6ae3ea17c..91899254f 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -5859,11 +5859,11 @@ }, "files_read": { "title": "Leggere i tuoi file", - "body": "Sfogliare e aprire i file salvati nel tuo account." + "body": "Cercare, aprire e leggere i file del tuo account." }, "files_write": { "title": "Modificare i tuoi file", - "body": "Caricare, rinominare, spostare ed eliminare file nel tuo account." + "body": "Salvare, creare, rinominare e spostare file nel tuo account e impedire che un file venga eliminato." }, "identity_read": { "title": "Vedere i tuoi indirizzi mittente", diff --git a/locales/ja/common.json b/locales/ja/common.json index 6777f5da1..680c10ee1 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -5859,11 +5859,11 @@ }, "files_read": { "title": "ファイルを読む", - "body": "アカウントに保存されたファイルを閲覧して開くことができます。" + "body": "アカウント内のファイルを検索して開き、内容を読み取れます。" }, "files_write": { "title": "ファイルを変更", - "body": "アカウント内のファイルをアップロード、名前変更、移動、削除できます。" + "body": "アカウント内のファイルを保存、作成、名前変更、移動でき、ファイルの削除を止めることもできます。" }, "identity_read": { "title": "送信者情報を見る", diff --git a/locales/ko/common.json b/locales/ko/common.json index 211b3af77..37f5bc0f8 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -5859,11 +5859,11 @@ }, "files_read": { "title": "파일 읽기", - "body": "계정에 저장된 파일을 둘러보고 열 수 있어요." + "body": "계정의 파일을 찾고, 열고, 읽을 수 있습니다." }, "files_write": { "title": "파일 변경", - "body": "계정의 파일을 업로드하고, 이름을 바꾸고, 이동하고, 삭제할 수 있어요." + "body": "계정의 파일을 저장, 생성, 이름 변경, 이동하고 파일 삭제를 막을 수 있습니다." }, "identity_read": { "title": "보내는 사람 주소 보기", diff --git a/locales/lv/common.json b/locales/lv/common.json index 8425d62dd..69b447a4b 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -5859,11 +5859,11 @@ }, "files_read": { "title": "Lasīt jūsu failus", - "body": "Pārlūkot un atvērt jūsu kontā glabātos failus." + "body": "Atrast, atvērt un lasīt jūsu konta failus." }, "files_write": { "title": "Mainīt jūsu failus", - "body": "Augšupielādēt, pārdēvēt, pārvietot un dzēst failus jūsu kontā." + "body": "Saglabāt, izveidot, pārdēvēt un pārvietot failus jūsu kontā un neļaut dzēst failu." }, "identity_read": { "title": "Redzēt jūsu sūtītāja adreses", diff --git a/locales/mn/common.json b/locales/mn/common.json index c6449e95b..905a7beb3 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -5859,11 +5859,11 @@ }, "files_read": { "title": "Таны файлуудыг унших", - "body": "Таны бүртгэлд хадгалсан файлуудыг үзэх, нээх." + "body": "Таны бүртгэл дэх файлуудыг хайх, нээх, унших." }, "files_write": { "title": "Таны файлуудыг өөрчлөх", - "body": "Таны бүртгэл дэх файлуудыг байршуулах, нэрийг өөрчлөх, зөөх, устгах." + "body": "Таны бүртгэл дэх файлуудыг хадгалах, үүсгэх, нэрийг өөрчлөх, зөөх, мөн файл устгахыг зогсоох." }, "identity_read": { "title": "Таны илгээгчийн хаягуудыг харах", diff --git a/locales/nb/common.json b/locales/nb/common.json index f2508b4db..ede976fa9 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -5859,11 +5859,11 @@ }, "files_read": { "title": "Lese filene dine", - "body": "Bla gjennom og åpne filene som er lagret på kontoen din." + "body": "Finne, åpne og lese filene på kontoen din." }, "files_write": { "title": "Endre filene dine", - "body": "Laste opp, gi nytt navn til, flytte og slette filer på kontoen din." + "body": "Lagre, opprette, gi nytt navn til og flytte filer på kontoen din, og hindre at en fil blir slettet." }, "identity_read": { "title": "Se avsenderidentitetene dine", diff --git a/locales/nl/common.json b/locales/nl/common.json index c45e1e39d..c91b9fbec 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -5859,11 +5859,11 @@ }, "files_read": { "title": "Je bestanden lezen", - "body": "De bestanden in je account bekijken en openen." + "body": "De bestanden in je account zoeken, openen en lezen." }, "files_write": { "title": "Je bestanden wijzigen", - "body": "Bestanden in je account uploaden, hernoemen, verplaatsen en verwijderen." + "body": "Bestanden in je account opslaan, maken, hernoemen en verplaatsen, en voorkomen dat een bestand wordt verwijderd." }, "identity_read": { "title": "Je afzenderadressen zien", diff --git a/locales/pl/common.json b/locales/pl/common.json index 2ef455e6b..95d66a6ad 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -5859,11 +5859,11 @@ }, "files_read": { "title": "Odczyt plików", - "body": "Przegląda i otwiera pliki zapisane na Twoim koncie." + "body": "Wyszukiwanie, otwieranie i czytanie plików na Twoim koncie." }, "files_write": { "title": "Zmiana plików", - "body": "Przesyła, zmienia nazwy, przenosi i usuwa pliki na Twoim koncie." + "body": "Zapisywanie, tworzenie, zmiana nazw i przenoszenie plików na Twoim koncie oraz blokowanie usunięcia pliku." }, "identity_read": { "title": "Podgląd tożsamości nadawcy", diff --git a/locales/pt/common.json b/locales/pt/common.json index b9bd05e8d..7dba05efb 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -5859,11 +5859,11 @@ }, "files_read": { "title": "Ler seus arquivos", - "body": "Navegar e abrir os arquivos armazenados na sua conta." + "body": "Encontrar, abrir e ler os arquivos da sua conta." }, "files_write": { "title": "Alterar seus arquivos", - "body": "Enviar, renomear, mover e excluir arquivos na sua conta." + "body": "Salvar, criar, renomear e mover arquivos na sua conta, e impedir que um arquivo seja excluído." }, "identity_read": { "title": "Ver suas identidades de envio", diff --git a/locales/ro/common.json b/locales/ro/common.json index b8202b50a..6f05b8c9d 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -5859,11 +5859,11 @@ }, "files_read": { "title": "Citirea fișierelor", - "body": "Răsfoiește și deschide fișierele stocate în contul dvs." + "body": "Găsește, deschide și citește fișierele din contul dvs." }, "files_write": { "title": "Modificarea fișierelor", - "body": "Încarcă, redenumește, mută și șterge fișiere din contul dvs." + "body": "Salvează, creează, redenumește și mută fișiere în contul dvs. și poate împiedica ștergerea unui fișier." }, "identity_read": { "title": "Vizualizarea identităților de expediere", diff --git a/locales/ru/common.json b/locales/ru/common.json index dc35ef7bf..fde51c728 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -5859,11 +5859,11 @@ }, "files_read": { "title": "Читать ваши файлы", - "body": "Просматривать и открывать файлы, хранящиеся в вашем аккаунте." + "body": "Находить, открывать и читать файлы в вашем аккаунте." }, "files_write": { "title": "Менять ваши файлы", - "body": "Загружать, переименовывать, перемещать и удалять файлы в вашем аккаунте." + "body": "Сохранять, создавать, переименовывать и перемещать файлы в вашем аккаунте, а также запрещать удаление файла." }, "identity_read": { "title": "Видеть ваши адреса отправителя", diff --git a/locales/sk/common.json b/locales/sk/common.json index 498e73262..8c4ee73f2 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -5859,11 +5859,11 @@ }, "files_read": { "title": "Čítať vaše súbory", - "body": "Prechádza a otvára súbory uložené vo vašom účte." + "body": "Vyhľadávať, otvárať a čítať súbory vo vašom účte." }, "files_write": { "title": "Meniť vaše súbory", - "body": "Nahráva, premenúva, presúva a odstraňuje súbory vo vašom účte." + "body": "Ukladať, vytvárať, premenovávať a presúvať súbory vo vašom účte a zabrániť odstráneniu súboru." }, "identity_read": { "title": "Vidieť vaše identity odosielateľa", diff --git a/locales/th/common.json b/locales/th/common.json index 5763442b1..e3624e689 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -2797,11 +2797,11 @@ }, "files_read": { "title": "อ่านไฟล์ของคุณ", - "body": "เรียกดูและเปิดไฟล์ที่จัดเก็บไว้ในบัญชีของคุณ" + "body": "ค้นหา เปิด และอ่านไฟล์ในบัญชีของคุณ" }, "files_write": { "title": "จัดการไฟล์ของคุณ", - "body": "อัปโหลด เปลี่ยนชื่อ ย้าย และลบไฟล์ในบัญชีของคุณ" + "body": "บันทึก สร้าง เปลี่ยนชื่อ และย้ายไฟล์ในบัญชีของคุณ และป้องกันไม่ให้ลบไฟล์" }, "filters_read": { "title": "อ่านตัวกรองอีเมลของคุณ", diff --git a/locales/tr/common.json b/locales/tr/common.json index 6d75b1c5b..c9e467884 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -5859,11 +5859,11 @@ }, "files_read": { "title": "Dosyalarınızı okuma", - "body": "Hesabınızda saklanan dosyalara göz atma ve onları açma." + "body": "Hesabınızdaki dosyaları bulma, açma ve okuma." }, "files_write": { "title": "Dosyalarınızı değiştirme", - "body": "Hesabınızdaki dosyaları yükleme, yeniden adlandırma, taşıma ve silme." + "body": "Hesabınızdaki dosyaları kaydetme, oluşturma, yeniden adlandırma ve taşıma; bir dosyanın silinmesini engelleme." }, "identity_read": { "title": "Gönderen adreslerinizi görme", diff --git a/locales/uk/common.json b/locales/uk/common.json index c2f4842c0..744a99b76 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -5859,11 +5859,11 @@ }, "files_read": { "title": "Читати ваші файли", - "body": "Переглядати й відкривати файли, що зберігаються у вашому обліковому записі." + "body": "Знаходити, відкривати й читати файли у вашому обліковому записі." }, "files_write": { "title": "Змінювати ваші файли", - "body": "Завантажувати, перейменовувати, переміщувати й видаляти файли у вашому обліковому записі." + "body": "Зберігати, створювати, перейменовувати й переміщувати файли у вашому обліковому записі, а також забороняти видалення файлу." }, "identity_read": { "title": "Бачити ваші адреси відправника", diff --git a/locales/vi/common.json b/locales/vi/common.json index ea4b099be..8ac7a69ff 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -5871,11 +5871,11 @@ }, "files_read": { "title": "Đọc tệp của bạn", - "body": "Duyệt và mở các tệp được lưu trữ trong tài khoản của bạn." + "body": "Tìm, mở và đọc các tệp trong tài khoản của bạn." }, "files_write": { "title": "Thay đổi tệp của bạn", - "body": "Tải lên, đổi tên, di chuyển và xóa các tệp trong tài khoản của bạn." + "body": "Lưu, tạo, đổi tên và di chuyển tệp trong tài khoản của bạn, đồng thời ngăn xóa một tệp." }, "identity_read": { "title": "Xem địa chỉ người gửi của bạn", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 197ce96a8..86e315825 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -5859,11 +5859,11 @@ }, "files_read": { "title": "讀取您的檔案", - "body": "瀏覽和開啟您帳號中儲存的檔案。" + "body": "尋找、開啟並讀取您帳戶中的檔案。" }, "files_write": { "title": "變更您的檔案", - "body": "上傳、重新命名、移動和刪除您帳號中的檔案。" + "body": "在您的帳戶中儲存、建立、重新命名及移動檔案,並可阻止刪除檔案。" }, "identity_read": { "title": "查看您的寄件身分", diff --git a/locales/zh/common.json b/locales/zh/common.json index 841c94c6e..b6234d821 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -5859,11 +5859,11 @@ }, "files_read": { "title": "读取您的文件", - "body": "浏览和打开您账户中存储的文件。" + "body": "查找、打开并读取您账户中的文件。" }, "files_write": { "title": "更改您的文件", - "body": "上传、重命名、移动和删除您账户中的文件。" + "body": "在您的账户中保存、创建、重命名和移动文件,并可阻止删除文件。" }, "identity_read": { "title": "查看您的发件身份", From 64aad7943f78745d63de50f90997850e1810e2b5 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 01:31:05 +0100 Subject: [PATCH 5/6] test: give the composer test's settings mock the calendar sync bridge the calendar store registers --- components/email/__tests__/composer-plugin-edit.test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/components/email/__tests__/composer-plugin-edit.test.tsx b/components/email/__tests__/composer-plugin-edit.test.tsx index 57b7ff74b..225f19440 100644 --- a/components/email/__tests__/composer-plugin-edit.test.tsx +++ b/components/email/__tests__/composer-plugin-edit.test.tsx @@ -112,7 +112,8 @@ vi.mock('@/stores/settings-store', () => { typeof sel === 'function' ? sel(state) : state; hook.getState = () => state; hook.setState = (p: Partial) => Object.assign(state, p); - return { useSettingsStore: hook }; + // The calendar store (reached through the composer) registers itself here. + return { useSettingsStore: hook, registerCalendarSubscriptionSyncBridge: () => {} }; }); vi.mock('@/stores/contact-store', () => { From cf512f0a4e44eeb3537956d5ab3b558f74c41a9f Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 01:31:05 +0100 Subject: [PATCH 6/6] fix: check attachments, shared folders and limits in the assistant's Files calls Fixes from the review of api.files: - `files.attach` to a new draft runs `onBeforeAttachmentUpload` for each file and the server's total size limit before `compose.open`, which runs neither; refused files come back in `refused` - a write is refused when a folder above the target is missing from the listing, instead of counting it as not shared - `onFileMove` reports the item as it was, from where it was, like the Files app's own move - writes are counted against the 20-a-minute limit before the drive is listed, so refused writes count and cost no listing - reads stop the download past 20 MB (`fetchBlobArrayBuffer` with a limit) and answer `too_big` when the listed size was wrong - when the server refuses the new content of a replaced file, the old version's copy is taken out of the trash again The two getText assertions on `fetchBlobArrayBuffer` now expect the account (undefined for the own drive) and the byte limit, since the call passes both. --- lib/__tests__/plugin-host-files.test.ts | 91 ++++++++++++++++++++++++- lib/plugin-sandbox/assistant/d.ts | 90 ++++++++++++++++++------ 2 files changed, 157 insertions(+), 24 deletions(-) diff --git a/lib/__tests__/plugin-host-files.test.ts b/lib/__tests__/plugin-host-files.test.ts index 79776c021..c9a6cb9ca 100644 --- a/lib/__tests__/plugin-host-files.test.ts +++ b/lib/__tests__/plugin-host-files.test.ts @@ -7,12 +7,17 @@ import { useEmailStore } from '@/stores/email-store'; import { useFileStore } from '@/stores/file-store'; import { usePolicyStore } from '@/stores/policy-store'; import { DEFAULT_POLICY } from '@/lib/admin/types'; -import { fileHooks } from '@/lib/plugin-hooks'; +import { emailHooks, fileHooks } from '@/lib/plugin-hooks'; +import { MAX_TEXT_SOURCE_BYTES } from '@/lib/attachment-text/types'; vi.mock('@/stores/toast-store', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }, })); const composeOpen = vi.hoisted(() => vi.fn(() => 'opened')); +const shrink = vi.hoisted(() => vi.fn(async () => ({ status: 'ok', type: 'image/jpeg', width: 800, height: 600, bytes: new Uint8Array([1, 2, 3]) }))); +const exif = vi.hoisted(() => vi.fn(() => ({ taken: '2026-10-01T10:00:00', lat: 52.5, lon: 13.4 }))); +vi.mock('@/lib/image-shrink', async (importOriginal) => ({ ...(await importOriginal()), shrinkImage: shrink })); +vi.mock('@/lib/exif', () => ({ readPhotoExif: exif })); vi.mock('../plugin-sandbox/host-api-compose', async (importOriginal) => ({ ...(await importOriginal()), doComposeOpen: composeOpen, @@ -87,6 +92,8 @@ beforeEach(() => { resetComposers(); forgetFileListing(); composeOpen.mockClear(); + shrink.mockClear(); + exif.mockClear(); own = ownNodes(); client = { supportsFiles: vi.fn(() => true), @@ -98,6 +105,7 @@ beforeEach(() => { createFileDirectory: vi.fn(async (name: string, parentId: string | null) => ({ id: `dir-${++seq}`, name, parentId, blobId: null, type: 'd', size: 0, created: '', modified: '' })), updateFileNode: vi.fn(async () => undefined), replaceFileNodeBlob: vi.fn(async () => undefined), + destroyFileNodes: vi.fn(async (ids: string[]) => ({ destroyed: ids, notDestroyed: [] })), getFileNameRules: vi.fn(() => RULES), getMaxFileNodeDepth: vi.fn(() => 64), getAccountId: vi.fn(() => 'me'), @@ -114,6 +122,7 @@ beforeEach(() => { afterEach(() => { useAuthStore.setState({ client: null }); for (const bus of Object.values(fileHooks)) bus.clear(); + emailHooks.onBeforeAttachmentUpload.clear(); useFileStore.setState({ client: null, hasListed: false }); }); @@ -196,9 +205,9 @@ describe('files.getText', () => { it('reads a file with the attachment reader, and a shared one from its owner\'s account', async () => { const out = await call('files.getText', 'notes'); expect(out).toMatchObject({ name: 'notes.md', format: 'text', status: 'ok', text: '# Notes\n\nLaunch on 12 December.', truncated: false }); - expect(client.fetchBlobArrayBuffer).toHaveBeenLastCalledWith('blob-notes', 'notes.md', 'text/markdown'); + expect(client.fetchBlobArrayBuffer).toHaveBeenLastCalledWith('blob-notes', 'notes.md', 'text/markdown', undefined, MAX_TEXT_SOURCE_BYTES + 1); await call('files.getText', 'bob:plan'); - expect(client.fetchBlobArrayBuffer).toHaveBeenLastCalledWith('blob-bob:plan', 'Bob plan.txt', 'text/plain', 'bob'); + expect(client.fetchBlobArrayBuffer).toHaveBeenLastCalledWith('blob-bob:plan', 'Bob plan.txt', 'text/plain', 'bob', MAX_TEXT_SOURCE_BYTES + 1); await expect(call('files.getText', 'docs')).rejects.toThrow(/is a folder/); await expect(call('files.getText', 'gone')).rejects.toThrow(/no file or folder/); }); @@ -370,3 +379,79 @@ describe('writes stay in the user\'s own drive', () => { await expect(call('files.createFolder', { name: 'F20' })).rejects.toThrow(/at most 20 changes/); }); }); + + +describe('files.getImage', () => { + it('shrinks a picture, reads the photo\'s date and place only when asked, and refuses SVG', async () => { + own.push(file('drawing', 'drawing.svg', 'docs', 'image/svg+xml', '2026-01-01T00:00:00Z')); + const plain = await call('files.getImage', 'photo'); + expect(plain).toEqual({ name: 'whiteboard.jpg', type: 'image/jpeg', width: 800, height: 600, data: 'AQID', status: 'ok' }); + expect(exif).not.toHaveBeenCalled(); + const withPlace = await call('files.getImage', 'photo', { exif: true }); + expect(withPlace).toMatchObject({ exif: { taken: '2026-10-01T10:00:00', lat: 52.5, lon: 13.4 } }); + const fetches = client.fetchBlobArrayBuffer.mock.calls.length; + expect(await call('files.getImage', 'drawing')).toMatchObject({ status: 'unsupported', data: '' }); + // SVG can load remote content: it is never fetched. + expect(client.fetchBlobArrayBuffer.mock.calls.length).toBe(fetches); + }); + + it('allows 30 reads a minute', async () => { + for (let i = 0; i < 30; i++) await call('files.getImage', 'photo'); + await expect(call('files.getImage', 'photo')).rejects.toThrow(/too many reads/); + }); + + // The size in the listing may be wrong: the download stops past the limit. + it('stops a download past 20 MB and answers too_big', async () => { + client.fetchBlobArrayBuffer.mockResolvedValueOnce(new ArrayBuffer(MAX_TEXT_SOURCE_BYTES + 1)); + expect(await call('files.getText', 'notes')).toMatchObject({ status: 'too_big', text: '' }); + expect(client.fetchBlobArrayBuffer).toHaveBeenLastCalledWith('blob-notes', 'notes.md', 'text/markdown', undefined, MAX_TEXT_SOURCE_BYTES + 1); + client.fetchBlobArrayBuffer.mockResolvedValueOnce(new ArrayBuffer(MAX_TEXT_SOURCE_BYTES + 1)); + expect(await call('files.getImage', 'photo')).toMatchObject({ status: 'too_big' }); + expect(shrink).not.toHaveBeenCalled(); + }); +}); + +describe('the review fixes', () => { + // compose.open itself runs no attachment checks: files.attach runs them for a new draft. + it('runs the attachment intercept and the size limit for a new draft, and leaves vetoed files out', async () => { + emailHooks.onBeforeAttachmentUpload.register('dlp', (info: { name: string }) => info.name !== 'report.pdf'); + const vetoed = await call('files.attach', ['report'], { newDraft: { to: ['bob@example.org'] } }); + expect(vetoed).toEqual({ attached: [], refused: ['report.pdf'] }); + expect(composeOpen).not.toHaveBeenCalled(); + const some = await call('files.attach', ['report', 'notes'], { newDraft: {} }); + expect(some).toEqual({ attached: ['notes.md'], refused: ['report.pdf'] }); + expect(composeOpen).toHaveBeenCalledWith(ASSISTANT, { attachments: [{ blobId: 'blob-notes', name: 'notes.md', type: 'text/markdown', size: 100 }] }); + emailHooks.onBeforeAttachmentUpload.clear(); + client.getMaxSizeAttachmentsPerEmail = vi.fn(() => 150); + const fit = await call('files.attach', ['notes', 'budget'], { newDraft: {} }); + expect(fit).toEqual({ attached: ['notes.md'], refused: ['budget 2026.xlsx'] }); + }); + + it('refuses a write when a folder above is missing from the listing', async () => { + own.push(folder('orphan', 'Orphan', 'gone-parent'), file('lost', 'lost.md', 'orphan', 'text/markdown', '2026-01-01T00:00:00Z')); + await expect(call('files.create', { folderId: 'orphan', name: 'a.md', content: 'x', type: 'text/markdown' })).rejects.toThrow(/couldn't be read/); + await expect(call('files.rename', 'lost', 'found.md')).rejects.toThrow(/couldn't be read/); + expect(client.uploadBlob).not.toHaveBeenCalled(); + expect(client.updateFileNode).not.toHaveBeenCalled(); + }); + + it('tells plugins about a move with the item as it was, like the Files app', async () => { + const moved = vi.fn(); + fileHooks.onFileMove.register('other', moved); + await call('files.move', 'notes', null); + await vi.waitFor(() => expect(moved).toHaveBeenCalledWith({ files: [expect.objectContaining({ id: 'notes', path: '/Documents' })], toPath: '/' })); + }); + + it('counts refused writes, before reading the drive', async () => { + for (let i = 0; i < 20; i++) await expect(call('files.createFolder', { parentId: 'team', name: `F${i}` })).rejects.toThrow(/shared with others/); + await expect(call('files.createFolder', { name: 'F20' })).rejects.toThrow(/at most 20 changes/); + expect(client.listAllFileNodes).toHaveBeenCalledTimes(20); + }); + + it('takes the old version out of the trash again when the server refuses the new content', async () => { + client.replaceFileNodeBlob.mockRejectedValueOnce(new Error('modified: invalid')); + await expect(call('files.create', { replace: 'notes', content: 'New', type: 'text/markdown' })).rejects.toThrow('modified: invalid'); + const batchId = (await client.createFileDirectory.mock.results[0].value).id; + expect(client.destroyFileNodes).toHaveBeenCalledWith([batchId]); + }); +}); diff --git a/lib/plugin-sandbox/assistant/d.ts b/lib/plugin-sandbox/assistant/d.ts index 46a3a1704..375738f87 100644 --- a/lib/plugin-sandbox/assistant/d.ts +++ b/lib/plugin-sandbox/assistant/d.ts @@ -29,7 +29,7 @@ import { TRASH_FOLDER_NAME, findTrashFolder, trashBatchName, trashSubtreeIds } f import { fileNameProblem, type FileNameProblem } from '@/lib/file-name-rules'; import { invalidateFileSearchCache, pathOfNode } from '@/lib/global-search/providers/files'; import { saveBlobsToFiles } from '@/lib/files-save'; -import { fileHooks } from '@/lib/plugin-hooks'; +import { emailHooks, fileHooks } from '@/lib/plugin-hooks'; import { fileToView } from '@/lib/plugin-projection'; import { pluginHasPermission } from '../permissions'; import { readNamedEmail } from '../host-api-mail'; @@ -153,7 +153,8 @@ function needPermission(plugin: InstalledPlugin, method: string, permission: Per if (!pluginHasPermission(plugin, permission)) throw new Error(`Plugin "${plugin.id}" lacks permission "${permission}"`); } -function readWrite(method: string, plugin: InstalledPlugin): void { +/** Counts a change to Files against the plugin's limit, refusing past it. */ +function countWrite(method: string, plugin: InstalledPlugin): void { if (!withinRate('files-write', plugin.id, MAX_FILE_WRITES_PER_MINUTE)) { throw new Error(`${method}: at most ${MAX_FILE_WRITES_PER_MINUTE} changes to Files a minute`); } @@ -276,8 +277,15 @@ async function readableFile(method: string, plugin: InstalledPlugin, fileArg: un return { client, node }; } -function fetchNode(client: IJMAPClient, node: FileNode): Promise { - return client.fetchBlobArrayBuffer(node.blobId as string, node.name || undefined, node.type || undefined, ...(node.isShared && node.accountId ? [node.accountId] : [])); +/** + * A file's bytes, read no further than MAX_TEXT_SOURCE_BYTES + 1: the size + * the listing gives may be wrong, so the download stops itself. Null when + * the file is bigger than that. + */ +async function fetchNode(client: IJMAPClient, node: FileNode): Promise { + const accountId = node.isShared && node.accountId ? node.accountId : undefined; + const data = await client.fetchBlobArrayBuffer(node.blobId as string, node.name || undefined, node.type || undefined, accountId, MAX_TEXT_SOURCE_BYTES + 1); + return data.byteLength > MAX_TEXT_SOURCE_BYTES ? null : data; } /** The text of a file, with the attachment reader (see AttachmentTextView). */ @@ -295,6 +303,7 @@ export async function doFilesGetText(plugin: InstalledPlugin, fileArg: unknown, return { ...base, format: textFormatOf(name, type), status: 'too_big', text: '', truncated: false }; } const data = await fetchNode(client, node); + if (!data) return { ...base, format: textFormatOf(name, type), status: 'too_big', text: '', truncated: false }; const result = await extractText(data, { name, type, maxChars }); return { ...base, size: data.byteLength, ...result }; } @@ -322,6 +331,7 @@ export async function doFilesGetImage(plugin: InstalledPlugin, fileArg: unknown, if (!isRasterImage(name, sourceType)) return answer('unsupported'); if ((node.size || 0) > MAX_TEXT_SOURCE_BYTES) return answer('too_big'); const data = await fetchNode(client, node); + if (!data) return answer('too_big'); const shrunk = await shrinkImage(new Blob([data], { type: sourceType }), { maxSide }); if (shrunk.status !== 'ok' || !shrunk.bytes || !shrunk.type) return answer(shrunk.status === 'ok' ? 'failed' : shrunk.status, shrunk.width, shrunk.height); const exif = wantExif ? readPhotoExif(data) : null; @@ -345,7 +355,9 @@ function readFileIds(method: string, value: unknown): string[] { /** * Attach files to the open draft (`composerId`, compose:write) or to a new * one (`newDraft`, compose:open), by their blobs: no download, no upload. - * The composer's own checks run (size limit, onBeforeAttachmentUpload). + * Either way each file passes onBeforeAttachmentUpload and the server's + * limit for all attachments of a message: the open composer checks them + * itself; for a new draft they are checked here, before compose.open. */ export async function doFilesAttach(plugin: InstalledPlugin, idsArg: unknown, targetArg: unknown): Promise { const method = 'files.attach'; @@ -377,9 +389,23 @@ export async function doFilesAttach(plugin: InstalledPlugin, idsArg: unknown, ta } const draft = readObject(target.newDraft, method, 'newDraft', ['to', 'subject', 'body']); + const refused: string[] = []; + const passed: typeof refs = []; + for (const ref of refs) { + if (await emailHooks.onBeforeAttachmentUpload.intercept({ name: ref.name, type: ref.type, size: ref.size })) passed.push(ref); + else refused.push(ref.name); + } + const maxTotal = client.getMaxSizeAttachmentsPerEmail?.() ?? 0; + let total = 0; + const fitting = passed.filter((ref) => { + if (maxTotal > 0 && total + ref.size > maxTotal) { refused.push(ref.name); return false; } + total += ref.size; + return true; + }); + if (!fitting.length) return { attached: [], refused }; // compose.open checks the addresses, the subject and the body, and asks before it replaces an open draft. - doComposeOpen(plugin, { ...draft, attachments: refs }); - return { attached: refs.map((r) => r.name), refused: [] }; + doComposeOpen(plugin, { ...draft, attachments: fitting }); + return { attached: fitting.map((r) => r.name), refused }; } // ─── Writing: only the user's own drive, never a shared folder ─── @@ -400,14 +426,23 @@ async function ownDrive(method: string): Promise { const sharedOut = (node: FileNode) => !!node.shareWith && Object.keys(node.shareWith).length > 0; -/** Whether `node` or a folder above it is shared with someone. */ -function inSharedPlace(drive: OwnDrive, node: FileNode | null): boolean { +/** + * Whether `node` or a folder above it is shared with someone: 'shared', + * 'own', or 'unknown' when a folder on the way up is not in the listing + * (a listing cut short, or a node that moved meanwhile), which counts as + * shared. + */ +function placeOf(drive: OwnDrive, node: FileNode): 'own' | 'shared' | 'unknown' { const seen = new Set(); - for (let at = node; at && !seen.has(at.id); at = at.parentId ? drive.byId.get(at.parentId) ?? null : null) { + for (let at: FileNode | undefined = node; at; ) { + if (seen.has(at.id)) return 'unknown'; seen.add(at.id); - if (sharedOut(at)) return true; + if (sharedOut(at)) return 'shared'; + if (at.parentId == null) return 'own'; + at = drive.byId.get(at.parentId); + if (!at) return 'unknown'; } - return false; + return 'unknown'; } /** A node of the user's own drive, outside the trash, not in a shared folder. */ @@ -415,7 +450,9 @@ function ownNode(method: string, drive: OwnDrive, id: string): FileNode { if (id.includes(':')) throw new Error(`${method}: that file is in a drive someone shared with you; the assistant only changes your own files`); const node = drive.byId.get(id); if (!node || drive.trashed.has(node.id) || fileNodeKind(node) === 'symlink') throw new Error(`${method}: no file or folder with id "${id}" in your own files`); - if (inSharedPlace(drive, node)) throw new Error(`${method}: "${node.name}" is shared with others; the assistant doesn't change shared files`); + const place = placeOf(drive, node); + if (place === 'shared') throw new Error(`${method}: "${node.name}" is shared with others; the assistant doesn't change shared files`); + if (place === 'unknown') throw new Error(`${method}: the folders above "${node.name}" couldn't be read, so it can't tell whether they are shared; try again`); return node; } @@ -480,9 +517,10 @@ export async function doFilesSaveAttachments(plugin: InstalledPlugin, emailArg: const blobIds = [...new Set(blobsArg.map((b, i) => readId(b, method, `blobIds[${i}]`)))]; const folderId = readFolderId(method, folderArg); const opts = readObject(optionsArg, method, 'options', ['accountId'], true); + // Counted before anything is read: a refused write counts too, and costs no listing. + countWrite(method, plugin); const drive = await ownDrive(method); const folder = ownFolder(method, drive, folderId); - readWrite(method, plugin); const found = await readNamedEmail(drive.client, emailId, method, opts.accountId); if (!found) throw new Error(`${method}: no message with id "${emailId}"`); const parts = blobIds.map((blobId) => { @@ -516,6 +554,8 @@ export async function doFilesCreate(plugin: InstalledPlugin, inputArg: unknown): if (!(TEXT_TYPES as readonly unknown[]).includes(input.type)) throw new Error(`${method}: type must be text/markdown or text/plain`); const type = input.type as typeof TEXT_TYPES[number]; const replaceId = input.replace === undefined ? null : readId(input.replace, method, 'replace'); + // Counted before anything is read: a refused write counts too, and costs no listing. + countWrite(method, plugin); const drive = await ownDrive(method); const { client } = drive; @@ -523,7 +563,6 @@ export async function doFilesCreate(plugin: InstalledPlugin, inputArg: unknown): const node = ownNode(method, drive, replaceId); if (isFolder(node) || !node.blobId) throw new Error(`${method}: "${node.name}" is a folder`); if (!isTextFile(node)) throw new Error(`${method}: only text and Markdown files can be replaced; "${node.name}" is not one`); - readWrite(method, plugin); const parent = node.parentId ? drive.byId.get(node.parentId) ?? null : null; const path = pathOf(drive, parent); const file = new File([content], node.name, { type: node.type || type }); @@ -533,7 +572,13 @@ export async function doFilesCreate(plugin: InstalledPlugin, inputArg: unknown): const trash = findTrashFolder(drive.nodes) ?? await client.createFileDirectory(TRASH_FOLDER_NAME, null); const batch = await client.createFileDirectory(trashBatchName(node.parentId ?? null), trash.id); const kept = await client.createFileNode(node.name, node.blobId, node.type || type, node.size || 0, batch.id); - await client.replaceFileNodeBlob(node.id, uploaded.blobId); + try { + await client.replaceFileNodeBlob(node.id, uploaded.blobId); + } catch (error) { + // Nothing changed: take the copy out of the trash again, so no stray old version stays there. + await client.destroyFileNodes([batch.id]).catch(() => {}); + throw error; + } const now = new Date().toISOString(); const replaced: FileNode = { ...node, blobId: uploaded.blobId, size: uploaded.size ?? file.size, modified: now }; void fileHooks.onAfterFileUpload.emit(fileToView(replaced, path)); @@ -543,7 +588,6 @@ export async function doFilesCreate(plugin: InstalledPlugin, inputArg: unknown): const name = readNewName(method, client, input.name); const folder = ownFolder(method, drive, readFolderId(method, input.folderId)); - readWrite(method, plugin); const path = pathOf(drive, folder); const file = new File([content], name, { type }); if (!(await fileHooks.onBeforeFileUpload.intercept({ name, size: file.size, type, path }))) throw new Error(`${method}: ${STOPPED}`); @@ -559,6 +603,8 @@ export async function doFilesCreate(plugin: InstalledPlugin, inputArg: unknown): export async function doFilesCreateFolder(plugin: InstalledPlugin, inputArg: unknown): Promise { const method = 'files.createFolder'; const input = readObject(inputArg, method, 'input', ['parentId', 'name']); + // Counted before anything is read: a refused write counts too, and costs no listing. + countWrite(method, plugin); const drive = await ownDrive(method); const name = readNewName(method, drive.client, input.name); const parent = ownFolder(method, drive, readFolderId(method, input.parentId, 'parentId')); @@ -568,7 +614,6 @@ export async function doFilesCreateFolder(plugin: InstalledPlugin, inputArg: unk for (let at = parent; at; at = at.parentId ? drive.byId.get(at.parentId) ?? null : null) depth++; if (depth > maxDepth) throw new Error(`${method}: folders can't be nested deeper than ${maxDepth} levels`); } - readWrite(method, plugin); const path = pathOf(drive, parent); const created = await drive.client.createFileDirectory(name, parent?.id ?? null); void fileHooks.onDirectoryCreate.emit(fileToView(created, path)); @@ -586,6 +631,8 @@ export async function doFilesMove(plugin: InstalledPlugin, fileArg: unknown, fol const fileId = readId(fileArg, method, 'fileId'); const folderId = readFolderId(method, folderArg); if (folderId !== null && folderId.includes(':')) throw new Error(`${method}: files can't be moved to another person's drive`); + // Counted before anything is read: a refused write counts too, and costs no listing. + countWrite(method, plugin); const drive = await ownDrive(method); const node = ownNode(method, drive, fileId); const target = ownFolder(method, drive, folderId); @@ -596,12 +643,12 @@ export async function doFilesMove(plugin: InstalledPlugin, fileArg: unknown, fol } const clash = drive.nodes.find((n) => (n.parentId ?? null) === (target?.id ?? null) && n.name === node.name && n.id !== node.id); if (clash) throw new Error(`${method}: there is already something named "${node.name}" there`); - readWrite(method, plugin); const fromPath = pathOf(drive, from ? drive.byId.get(from) ?? null : null); const toPath = pathOf(drive, target); if (!(await fileHooks.onBeforeFileMove.intercept({ files: [fileToView(node, fromPath)], toPath }))) throw new Error(`${method}: ${STOPPED}`); await drive.client.updateFileNode(node.id, { parentId: target?.id ?? null }); - void fileHooks.onFileMove.emit({ files: [fileToView({ ...node, parentId: target?.id ?? null }, toPath)], toPath }); + // As the Files store reports a move: the item as it was, from where it was. + void fileHooks.onFileMove.emit({ files: [fileToView(node, fromPath)], toPath }); afterWrite(); return { file: { ...infoOf(node, drive.nodes), path: toPath, parentId: target?.id ?? null }, previousFolderId: from }; } @@ -610,13 +657,14 @@ export async function doFilesMove(plugin: InstalledPlugin, fileArg: unknown, fol export async function doFilesRename(plugin: InstalledPlugin, fileArg: unknown, nameArg: unknown): Promise<{ file: FileInfo; previousName: string }> { const method = 'files.rename'; const fileId = readId(fileArg, method, 'fileId'); + // Counted before anything is read: a refused write counts too, and costs no listing. + countWrite(method, plugin); const drive = await ownDrive(method); const name = readNewName(method, drive.client, nameArg); const node = ownNode(method, drive, fileId); if (name === node.name) throw new Error(`${method}: it is already called "${name}"`); const clash = drive.nodes.find((n) => (n.parentId ?? null) === (node.parentId ?? null) && n.name === name && n.id !== node.id); if (clash) throw new Error(`${method}: there is already something named "${name}" in that folder`); - readWrite(method, plugin); const path = pathOf(drive, node.parentId ? drive.byId.get(node.parentId) ?? null : null); const view = fileToView(node, path); if (!(await fileHooks.onBeforeFileRename.intercept({ file: view, newName: name }))) throw new Error(`${method}: ${STOPPED}`);