diff --git a/components/email/__tests__/composer-plugin-edit.test.tsx b/components/email/__tests__/composer-plugin-edit.test.tsx
index a1af71131..85e47c0bb 100644
--- a/components/email/__tests__/composer-plugin-edit.test.tsx
+++ b/components/email/__tests__/composer-plugin-edit.test.tsx
@@ -406,4 +406,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/__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/email-composer.tsx b/components/email/email-composer.tsx
index 20f04d211..77163d04b 100644
--- a/components/email/email-composer.tsx
+++ b/components/email/email-composer.tsx
@@ -2525,7 +2525,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);
@@ -2543,7 +2545,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
@@ -3085,6 +3087,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;
@@ -3221,7 +3224,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/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/__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..c9a6cb9ca
--- /dev/null
+++ b/lib/__tests__/plugin-host-files.test.ts
@@ -0,0 +1,457 @@
+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 { 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,
+}));
+
+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();
+ shrink.mockClear();
+ exif.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),
+ destroyFileNodes: vi.fn(async (ids: string[]) => ({ destroyed: ids, notDestroyed: [] })),
+ 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();
+ emailHooks.onBeforeAttachmentUpload.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', 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', 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/);
+ });
+});
+
+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/);
+ });
+});
+
+
+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/__tests__/plugin-permission-coverage.test.ts b/lib/__tests__/plugin-permission-coverage.test.ts
index 6de9c8bb3..b05e7d376 100644
--- a/lib/__tests__/plugin-permission-coverage.test.ts
+++ b/lib/__tests__/plugin-permission-coverage.test.ts
@@ -86,6 +86,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',
// The AI assistant's actions (lib/plugin-sandbox/assistant/c.ts).
'calendar.respond': 'calendar:respond',
'calendar.undoDelete': 'calendar:write',
diff --git a/lib/__tests__/plugin-projection.test.ts b/lib/__tests__/plugin-projection.test.ts
index 7b391f3aa..255d764e6 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', () => {
@@ -91,3 +92,28 @@ describe('emailToReadView text', () => {
expect(view.text).toBe('a is literal\nline two');
});
});
+
+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/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/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 };
+}
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;
diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts
index aafab104e..377e61525 100644
--- a/lib/jmap/client-interface.ts
+++ b/lib/jmap/client-interface.ts
@@ -700,6 +700,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 d511b3f01..6d35dcdfa 100644
--- a/lib/jmap/client.ts
+++ b/lib/jmap/client.ts
@@ -10592,6 +10592,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-projection.ts b/lib/plugin-projection.ts
index fe7ecc309..ba8b16126 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';
import { htmlToPlainText } from '@/lib/html-to-text';
@@ -191,8 +192,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/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..375738f87 100644
--- a/lib/plugin-sandbox/assistant/d.ts
+++ b/lib/plugin-sandbox/assistant/d.ts
@@ -1,18 +1,697 @@
-// 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 { emailHooks, 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}"`);
+}
+
+/** 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`);
+ }
+}
+
+// ─── 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 };
+}
+
+/**
+ * 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). */
+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);
+ 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 };
+}
+
+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);
+ 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;
+ 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.
+ * 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';
+ 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']);
+ 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: fitting });
+ return { attached: fitting.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: '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: FileNode | undefined = node; at; ) {
+ if (seen.has(at.id)) return 'unknown';
+ seen.add(at.id);
+ if (sharedOut(at)) return 'shared';
+ if (at.parentId == null) return 'own';
+ at = drive.byId.get(at.parentId);
+ if (!at) return 'unknown';
+ }
+ return 'unknown';
+}
+
+/** 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`);
+ 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;
+}
+
+/** 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);
+ // 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);
+ 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');
+ // 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;
+
+ 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`);
+ 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);
+ 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));
+ 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));
+ 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']);
+ // 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'));
+ 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`);
+ }
+ 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`);
+ // 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);
+ 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`);
+ 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 });
+ // 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 };
+}
+
+/** 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');
+ // 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`);
+ 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 0b679d28c..d8c6ba58c 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, InstalledPlugin } 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 116d43e67..cc8420d14 100644
--- a/locales/ar/common.json
+++ b/locales/ar/common.json
@@ -5872,11 +5872,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 c86d831cd..6e15f53fc 100644
--- a/locales/ca/common.json
+++ b/locales/ca/common.json
@@ -5872,11 +5872,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 7a043c034..d8426dce2 100644
--- a/locales/cs/common.json
+++ b/locales/cs/common.json
@@ -5872,11 +5872,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 f4b3c4137..cf66a7294 100644
--- a/locales/da/common.json
+++ b/locales/da/common.json
@@ -5872,11 +5872,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 e50737b1b..3772dbab8 100644
--- a/locales/de/common.json
+++ b/locales/de/common.json
@@ -5872,11 +5872,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 9b4212a19..57a58dab4 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -5872,11 +5872,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 8b32d2c55..cc786a268 100644
--- a/locales/es/common.json
+++ b/locales/es/common.json
@@ -5872,11 +5872,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 c45021947..2743f7f83 100644
--- a/locales/et/common.json
+++ b/locales/et/common.json
@@ -5872,11 +5872,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 cd2c56850..539f81c1a 100644
--- a/locales/fa/common.json
+++ b/locales/fa/common.json
@@ -5872,11 +5872,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 7027a3d55..fb7d148ec 100644
--- a/locales/fr/common.json
+++ b/locales/fr/common.json
@@ -5872,11 +5872,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 2d25d10ce..73d75426a 100644
--- a/locales/he/common.json
+++ b/locales/he/common.json
@@ -5873,11 +5873,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 7f4aaa53e..b45fe495e 100644
--- a/locales/hu/common.json
+++ b/locales/hu/common.json
@@ -5872,11 +5872,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 bb88418f0..a021b7397 100644
--- a/locales/it/common.json
+++ b/locales/it/common.json
@@ -5872,11 +5872,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 77c5de600..f2ea41909 100644
--- a/locales/ja/common.json
+++ b/locales/ja/common.json
@@ -5872,11 +5872,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 bdabdc7a7..cbde91d32 100644
--- a/locales/ko/common.json
+++ b/locales/ko/common.json
@@ -5872,11 +5872,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 a91d611d5..02d30c2f9 100644
--- a/locales/lv/common.json
+++ b/locales/lv/common.json
@@ -5872,11 +5872,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 7172b4826..4dd86dea2 100644
--- a/locales/mn/common.json
+++ b/locales/mn/common.json
@@ -5872,11 +5872,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 c412f9a40..d98bd1756 100644
--- a/locales/nb/common.json
+++ b/locales/nb/common.json
@@ -5872,11 +5872,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 7fbe32e59..4d541f358 100644
--- a/locales/nl/common.json
+++ b/locales/nl/common.json
@@ -5872,11 +5872,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 2c47f5b76..a6a7cd520 100644
--- a/locales/pl/common.json
+++ b/locales/pl/common.json
@@ -5872,11 +5872,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 a430848b1..685a98aea 100644
--- a/locales/pt/common.json
+++ b/locales/pt/common.json
@@ -5872,11 +5872,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 d7e8ca517..45c3fefed 100644
--- a/locales/ro/common.json
+++ b/locales/ro/common.json
@@ -5872,11 +5872,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 23e45e4b2..b40069b61 100644
--- a/locales/ru/common.json
+++ b/locales/ru/common.json
@@ -5872,11 +5872,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 8904ec99e..aa0304452 100644
--- a/locales/sk/common.json
+++ b/locales/sk/common.json
@@ -5872,11 +5872,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 0cc2efb2d..4d2c52754 100644
--- a/locales/th/common.json
+++ b/locales/th/common.json
@@ -2798,11 +2798,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 6f112968c..4f25997fa 100644
--- a/locales/tr/common.json
+++ b/locales/tr/common.json
@@ -5872,11 +5872,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 a220f0d47..408e920da 100644
--- a/locales/uk/common.json
+++ b/locales/uk/common.json
@@ -5872,11 +5872,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 2e534dc08..6952aec2e 100644
--- a/locales/vi/common.json
+++ b/locales/vi/common.json
@@ -5884,11 +5884,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 b05486150..1236173cf 100644
--- a/locales/zh-TW/common.json
+++ b/locales/zh-TW/common.json
@@ -5872,11 +5872,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 69e0232b3..f51d10c28 100644
--- a/locales/zh/common.json
+++ b/locales/zh/common.json
@@ -5872,11 +5872,11 @@
},
"files_read": {
"title": "读取您的文件",
- "body": "浏览和打开您账户中存储的文件。"
+ "body": "查找、打开并读取您账户中的文件。"
},
"files_write": {
"title": "更改您的文件",
- "body": "上传、重命名、移动和删除您账户中的文件。"
+ "body": "在您的账户中保存、创建、重命名和移动文件,并可阻止删除文件。"
},
"identity_read": {
"title": "查看您的发件身份",
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,