Merge the assistant's Files (package D)

# Conflicts:
#	components/email/__tests__/composer-plugin-edit.test.tsx
#	lib/__tests__/plugin-projection.test.ts
This commit is contained in:
Linus Rath
2026-11-03 01:48:30 +01:00
52 changed files with 1675 additions and 89 deletions
@@ -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 });
}
});
});
@@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import type { FileNode } from '@/lib/jmap/types';
import { useToastStore } from '@/stores/toast-store';
import { fileHooks } from '@/lib/plugin-hooks';
import { SaveToFilesDialog } from '../save-to-files-dialog';
// "Save to Files" files an attachment in a folder of the Files app by its
@@ -62,4 +63,21 @@ describe('SaveToFilesDialog', () => {
fireEvent.click(saveHere);
await waitFor(() => expect(createFileNode).toHaveBeenCalledWith('invoice.pdf', 'blob-1', 'application/pdf', 1234, null));
});
// The dialog used to file the blob without the upload hooks, so a plugin
// that checks what goes into Files never heard about it.
it('asks plugins first, as an upload into Files does, and saves nothing when one says no', async () => {
const createFileNode = vi.fn().mockResolvedValue({});
const veto = vi.fn(() => false);
const reg = fileHooks.onBeforeFileUpload.register('dlp', veto);
const client = { listAllFileNodes: vi.fn().mockResolvedValue(nodes), createFileNode } as unknown as IJMAPClient;
render(<SaveToFilesDialog attachment={attachment} client={client} onClose={vi.fn()} />);
fireEvent.click(await screen.findByText('Documents'));
fireEvent.click(screen.getByRole('button', { name: 'save_here' }));
await waitFor(() => expect(veto).toHaveBeenCalledWith({ name: 'invoice.pdf', size: 1234, type: 'application/pdf', path: '/Documents' }));
expect(createFileNode).not.toHaveBeenCalled();
reg.dispose();
});
});
+7 -3
View File
@@ -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<boolean> => {
// `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<boolean> => {
// 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);
+18 -10
View File
@@ -6,6 +6,7 @@ import { FolderPickerDialog } from "@/components/files/folder-picker-dialog";
import { toast } from "@/stores/toast-store";
import { fileNodeKind } from "@/lib/jmap/filenode-kind";
import { TRASH_FOLDER_NAME } from "@/lib/files-trash";
import { saveBlobsToFiles } from "@/lib/files-save";
import type { IJMAPClient } from "@/lib/jmap/client-interface";
import type { FileNode } from "@/lib/jmap/types";
@@ -34,6 +35,18 @@ interface SaveToFilesDialogProps {
// Nothing is "where the items already are": every folder, My files too, can be picked.
const NO_CURRENT_FOLDER = "#none";
/** The path of a folder from the picker's list ('/' for My files), for plugins. */
function folderPathOf(folders: { id: string; name: string; parentId: string | null }[], id: string | null): string {
const byId = new Map(folders.map((f) => [f.id, f]));
const names: string[] = [];
const seen = new Set<string>();
for (let at = id ? byId.get(id) : undefined; at && !seen.has(at.id); at = at.parentId ? byId.get(at.parentId) : undefined) {
seen.add(at.id);
names.unshift(at.name);
}
return `/${names.join("/")}`;
}
/**
* "Save to Files" for an attachment: pick a folder with the Files app's
* folder picker. The attachment's blob is already on the server, so it is
@@ -75,17 +88,12 @@ export function SaveToFilesDialog({ attachment, client, blobAccountId, onClose }
if (!attachment?.blobId) return;
const folderName = parentId ? (folders.find((f) => f.id === parentId)?.name ?? "") : tFiles("my_files");
try {
const filesAccountId = blobAccountId ? client.getFilesAccountId() : undefined;
const blobId = blobAccountId && filesAccountId && blobAccountId !== filesAccountId
? (await client.copyBlobs(blobAccountId, [attachment.blobId], filesAccountId))[attachment.blobId]
: attachment.blobId;
await client.createFileNode(
attachment.name || "attachment",
blobId,
attachment.type || "application/octet-stream",
attachment.size,
parentId,
const { saved } = await saveBlobsToFiles(
[{ blobId: attachment.blobId, name: attachment.name, type: attachment.type, size: attachment.size }],
{ client, parentId, path: folderPathOf(folders, parentId), blobAccountId },
);
// A plugin stopped it (onBeforeFileUpload); it says why itself.
if (saved.length === 0) return;
toast.success(t("saved"), t("saved_to", { folder: folderName }));
} catch (error) {
console.error("Failed to save the attachment to Files:", error);
+66
View File
@@ -0,0 +1,66 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { IJMAPClient } from '../jmap/client-interface';
import { saveBlobsToFiles } from '../files-save';
import { fileHooks } from '../plugin-hooks';
// Saving attachments to Files (the reader's Save to Files and
// api.files.saveAttachments): filed by blob, copied over from another
// account first, and announced to plugins like an upload in Files.
function makeClient() {
let n = 0;
return {
getFilesAccountId: vi.fn(() => 'me'),
copyBlobs: vi.fn(async (_from: string, ids: string[]) => Object.fromEntries(ids.map((id) => [id, `${id}-mine`]))),
createFileNode: vi.fn(async (name: string, blobId: string, type: string, _size: number, parentId: string | null) => ({ id: `n${++n}`, name, blobId, type, parentId, size: 0, created: '', modified: '' })),
};
}
const blobs = [
{ blobId: 'b1', name: 'invoice.pdf', type: 'application/pdf', size: 1234 },
{ blobId: 'b2', name: null, type: null, size: 9 },
];
afterEach(() => {
fileHooks.onBeforeFileUpload.clear();
fileHooks.onAfterFileUpload.clear();
});
describe('saveBlobsToFiles', () => {
it('files each blob in the folder and tells plugins as for an upload', async () => {
const client = makeClient();
const before = vi.fn(() => true);
const after = vi.fn();
fileHooks.onBeforeFileUpload.register('p', before);
fileHooks.onAfterFileUpload.register('p', after);
const { saved, stopped } = await saveBlobsToFiles(blobs, { client: client as unknown as IJMAPClient, parentId: 'docs', path: '/Documents' });
expect(client.createFileNode.mock.calls).toEqual([
['invoice.pdf', 'b1', 'application/pdf', 1234, 'docs'],
['attachment', 'b2', 'application/octet-stream', 9, 'docs'],
]);
expect(client.copyBlobs).not.toHaveBeenCalled();
expect(before.mock.calls).toEqual([
[{ name: 'invoice.pdf', size: 1234, type: 'application/pdf', path: '/Documents' }],
[{ name: 'attachment', size: 9, type: 'application/octet-stream', path: '/Documents' }],
]);
await vi.waitFor(() => expect(after).toHaveBeenCalledTimes(2));
expect(after.mock.calls[0][0]).toMatchObject({ name: 'invoice.pdf', type: 'file', path: '/Documents' });
expect(saved.map((n) => [n.name, n.size])).toEqual([['invoice.pdf', 1234], ['attachment', 9]]);
expect(stopped).toEqual([]);
});
it('copies blobs of another account of the login into the Files account first', async () => {
const client = makeClient();
await saveBlobsToFiles([blobs[0]], { client: client as unknown as IJMAPClient, parentId: null, path: '/', blobAccountId: 'team' });
expect(client.copyBlobs).toHaveBeenCalledWith('team', ['b1'], 'me');
expect(client.createFileNode).toHaveBeenCalledWith('invoice.pdf', 'b1-mine', 'application/pdf', 1234, null);
});
it('skips a file another plugin stops, and saves the rest', async () => {
const client = makeClient();
fileHooks.onBeforeFileUpload.register('dlp', (info: { name: string }) => info.name !== 'invoice.pdf');
const { saved, stopped } = await saveBlobsToFiles(blobs, { client: client as unknown as IJMAPClient, parentId: null, path: '/' });
expect(stopped).toEqual(['invoice.pdf']);
expect(saved.map((n) => n.name)).toEqual(['attachment']);
expect(client.createFileNode).toHaveBeenCalledTimes(1);
});
});
@@ -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.');
});
});
+457
View File
@@ -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<typeof import('@/lib/image-shrink')>()), shrinkImage: shrink }));
vi.mock('@/lib/exif', () => ({ readPhotoExif: exif }));
vi.mock('../plugin-sandbox/host-api-compose', async (importOriginal) => ({
...(await importOriginal<typeof import('../plugin-sandbox/host-api-compose')>()),
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<FileNode> & { 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<FileNode> = {}) => node({ id, name, parentId, ...extra });
const file = (id: string, name: string, parentId: string | null, type: string, modified: string, extra: Partial<FileNode> = {}) =>
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<string, ReturnType<typeof vi.fn>>;
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<string, unknown> }, 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<string, unknown>) => 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]);
});
});
@@ -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',
+28 -2
View File
@@ -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 <b> 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<FileNode>) => 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');
});
});
+7
View File
@@ -1702,6 +1702,13 @@ export class DemoJMAPClient implements IJMAPClient {
if (node) Object.assign(node, updates);
}
async replaceFileNodeBlob(id: string, blobId: string): Promise<void> {
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<string, Partial<Pick<FileNode, 'name' | 'parentId'>>>): Promise<{ updated: string[]; notUpdated: Record<string, string> }> {
const updated: string[] = [];
for (const [id, patch] of Object.entries(updates)) {
+71
View File
@@ -0,0 +1,71 @@
// Save attachments of a message to Files: the blobs are already on the
// server, so each is filed by blobId with no download or upload. From
// another account of the login (a shared or group mailbox) the blob is
// copied into the Files account first (Blob/copy, RFC 8620 §6.3).
//
// Used by the reader's Save to Files dialog and by the plugin host
// (api.files.saveAttachments). Plugins hear about each file as about an
// upload in Files: onBeforeFileUpload may stop it, onAfterFileUpload
// reports it.
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import type { FileNode } from '@/lib/jmap/types';
import { fileHooks } from '@/lib/plugin-hooks';
import { fileToView } from '@/lib/plugin-projection';
/** What saving needs of an attachment: its blob, name, type and size. */
export interface SaveableBlob {
blobId: string;
name?: string | null;
type?: string | null;
size: number;
}
export interface SaveToFilesOptions {
/** The mail login's client; its Files account takes the files. */
client: IJMAPClient;
/** The folder to save into; null is the top of the user's own drive. */
parentId: string | null;
/** The folder's path, for plugins ('/' for the top). */
path: string;
/**
* The JMAP account the blobs are in, when not the Files account (a
* shared or group mailbox of the same login).
*/
blobAccountId?: string;
}
export interface SaveToFilesResult {
/** The files created, with the names the server gave them. */
saved: FileNode[];
/** Names a plugin stopped (onBeforeFileUpload). */
stopped: string[];
}
/**
* Files each blob into `parentId`. A name that is taken becomes
* "name (2).ext" on the server. Stops at the first error and passes it on;
* files saved before it stay.
*/
export async function saveBlobsToFiles(blobs: SaveableBlob[], { client, parentId, path, blobAccountId }: SaveToFilesOptions): Promise<SaveToFilesResult> {
const saved: FileNode[] = [];
const stopped: string[] = [];
const filesAccountId = blobAccountId ? client.getFilesAccountId() : undefined;
const copy = !!blobAccountId && !!filesAccountId && blobAccountId !== filesAccountId;
for (const blob of blobs) {
const name = blob.name || 'attachment';
const type = blob.type || 'application/octet-stream';
if (!(await fileHooks.onBeforeFileUpload.intercept({ name, size: blob.size, type, path }))) {
stopped.push(name);
continue;
}
const blobId = copy
? (await client.copyBlobs(blobAccountId!, [blob.blobId], filesAccountId!))[blob.blobId]
: blob.blobId;
const node = await client.createFileNode(name, blobId, type, blob.size, parentId);
// The server's answer to a create may leave the size out.
saved.push(node.size ? node : { ...node, size: blob.size });
void fileHooks.onAfterFileUpload.emit(fileToView(node, path));
}
return { saved, stopped };
}
@@ -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([]);
});
});
+4 -1
View File
@@ -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;
+6
View File
@@ -700,6 +700,12 @@ export interface IJMAPClient {
*/
copyBlobs(fromAccountId: string, blobIds: string[], toAccountId: string): Promise<Record<string, string>>;
updateFileNode(id: string, updates: Partial<Pick<FileNode, 'name' | 'parentId'>>): Promise<void>;
/**
* 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<void>;
updateFileNodes(updates: Record<string, Partial<Pick<FileNode, 'name' | 'parentId'>>>): Promise<{ updated: string[]; notUpdated: Record<string, string> }>;
destroyFileNodes(ids: string[]): Promise<{ destroyed: string[]; notDestroyed: string[] }>;
copyFileNode(id: string, newName: string, parentId: string | null, listings?: FileNodeListings): Promise<FileNode>;
+23
View File
@@ -10592,6 +10592,29 @@ export class JMAPClient implements IJMAPClient {
}
}
async replaceFileNodeBlob(id: string, blobId: string): Promise<void> {
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<T>(entries: Array<[string, T]>): Map<string, Array<{ id: string; rawId: string; value: T }>> {
const groups = new Map<string, Array<{ id: string; rawId: string; value: T }>>();
+6 -1
View File
@@ -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,
+55 -2
View File
@@ -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<FileInfo[]>,
/** 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<FileInfo[]>,
/** 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<AttachmentTextView>,
/** A picture file, shrunk as email.getAttachmentImage does (files:read, 30 a minute). */
getImage: (fileId: string, options?: Omit<AttachmentImageOptions, 'accountId'>) =>
callApi('files.getImage', [fileId, options], NETWORK_API_TIMEOUT_MS) as Promise<AttachmentImageView>,
/**
* 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<FileAttachResult>,
/**
* 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<FileCreateResult>,
/** 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<FileInfo>,
/** 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 }>,
},
};
}
+686 -7
View File
@@ -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<Record<string, Permission | null>> = {};
/** Method → permission, joined into PERM_PER_METHOD. Some also need a second one, checked in the method. */
export const PERMS: Readonly<Record<string, Permission | null>> = {
'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<FileNode[]> } | 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<FileNode[]> {
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<string, string[]> = {
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<FileFindQuery['kind']>): 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<FileInfo[]> {
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<string> | 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<FileInfo[]> {
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<ArrayBuffer | null> {
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<AttachmentTextView> {
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<AttachmentImageView> {
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<FileAttachResult> {
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<string, FileNode>;
trashed: Set<string>;
}
async function ownDrive(method: string): Promise<OwnDrive> {
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<string>();
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<FileCreateResult> {
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<FileInfo> {
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<unknown> {
return NOT_MINE;
export async function dispatch(plugin: InstalledPlugin, method: string, args: unknown[]): Promise<unknown> {
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() };
}
+86 -3
View File
@@ -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[];
}
+5
View File
@@ -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<boolean>;
/**
* 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<boolean>;
}
const handles = new Map<string, ComposerHandle>();
+3
View File
@@ -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(); });
}
+2 -2
View File
@@ -5872,11 +5872,11 @@
},
"files_read": {
"title": "قراءة ملفاتك",
"body": "تصفّح الملفات المخزّنة في حسابك وفتحها."
"body": "البحث عن الملفات في حسابك وفتحها وقراءتها."
},
"files_write": {
"title": "تغيير ملفاتك",
"body": "رفع الملفات في حسابك وإعادة تسميتها ونقلها وحذفها."
"body": "حفظ الملفات في حسابك وإنشاؤها وإعادة تسميتها ونقلها، ومنع حذف ملف."
},
"identity_read": {
"title": "عرض عناوين المرسل",
+2 -2
View File
@@ -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",
+2 -2
View File
@@ -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",
+2 -2
View File
@@ -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",
+2 -2
View File
@@ -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",
+2 -2
View File
@@ -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",
+2 -2
View File
@@ -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",
+2 -2
View File
@@ -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",
+2 -2
View File
@@ -5872,11 +5872,11 @@
},
"files_read": {
"title": "خواندن فایل‌های شما",
"body": "مرور و باز کردن فایل‌های ذخیره‌شده در حساب شما."
"body": "یافتن، باز کردن و خواندن فایل‌های حساب شما."
},
"files_write": {
"title": "تغییر فایل‌های شما",
"body": "بارگذاری، تغییر نام، جابه‌جایی و حذف فایل‌ها در حساب شما."
"body": "ذخیره، ایجاد، تغییر نام و جابه‌جایی فایل‌ها در حساب شما، و جلوگیری از حذف یک فایل."
},
"identity_read": {
"title": "دیدن آدرس‌های فرستنده شما",
+2 -2
View File
@@ -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",
+2 -2
View File
@@ -5873,11 +5873,11 @@
},
"files_read": {
"title": "קריאת הקבצים שלכם",
"body": "עיון בקבצים השמורים בחשבון שלכם ופתיחתם."
"body": "למצוא, לפתוח ולקרוא את הקבצים בחשבון שלך."
},
"files_write": {
"title": "שינוי הקבצים שלכם",
"body": "העלאה, שינוי שם, העברה ומחיקה של קבצים בחשבון שלכם."
"body": "לשמור, ליצור, לשנות שם ולהעביר קבצים בחשבון שלך, ולמנוע מחיקה של קובץ."
},
"identity_read": {
"title": "צפייה בכתובות השולח שלכם",
+2 -2
View File
@@ -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",
+2 -2
View File
@@ -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",
+2 -2
View File
@@ -5872,11 +5872,11 @@
},
"files_read": {
"title": "ファイルを読む",
"body": "アカウントに保存されたファイルを閲覧して開くことができます。"
"body": "アカウント内のファイルを検索して開き、内容を読み取れます。"
},
"files_write": {
"title": "ファイルを変更",
"body": "アカウント内のファイルをアップロード、名前変更、移動、削除できます。"
"body": "アカウント内のファイルを保存、作成、名前変更、移動でき、ファイルの削除を止めることもできます。"
},
"identity_read": {
"title": "送信者情報を見る",
+2 -2
View File
@@ -5872,11 +5872,11 @@
},
"files_read": {
"title": "파일 읽기",
"body": "계정에 저장된 파일을 둘러보고 열 수 있어요."
"body": "계정의 파일을 찾고, 열고, 읽을 수 있습니다."
},
"files_write": {
"title": "파일 변경",
"body": "계정의 파일을 업로드하고, 이름을 바꾸고, 이동하고, 삭제할 수 있어요."
"body": "계정의 파일을 저장, 생성, 이름 변경, 이동하고 파일 삭제를 막을 수 있습니다."
},
"identity_read": {
"title": "보내는 사람 주소 보기",
+2 -2
View File
@@ -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",
+2 -2
View File
@@ -5872,11 +5872,11 @@
},
"files_read": {
"title": "Таны файлуудыг унших",
"body": "Таны бүртгэлд хадгалсан файлуудыг үзэх, нээх."
"body": "Таны бүртгэл дэх файлуудыг хайх, нээх, унших."
},
"files_write": {
"title": "Таны файлуудыг өөрчлөх",
"body": "Таны бүртгэл дэх файлуудыг байршуулах, нэрийг өөрчлөх, зөөх, устгах."
"body": "Таны бүртгэл дэх файлуудыг хадгалах, үүсгэх, нэрийг өөрчлөх, зөөх, мөн файл устгахыг зогсоох."
},
"identity_read": {
"title": "Таны илгээгчийн хаягуудыг харах",
+2 -2
View File
@@ -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",
+2 -2
View File
@@ -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",
+2 -2
View File
@@ -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",
+2 -2
View File
@@ -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",
+2 -2
View File
@@ -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",
+2 -2
View File
@@ -5872,11 +5872,11 @@
},
"files_read": {
"title": "Читать ваши файлы",
"body": "Просматривать и открывать файлы, хранящиеся в вашем аккаунте."
"body": "Находить, открывать и читать файлы в вашем аккаунте."
},
"files_write": {
"title": "Менять ваши файлы",
"body": "Загружать, переименовывать, перемещать и удалять файлы в вашем аккаунте."
"body": "Сохранять, создавать, переименовывать и перемещать файлы в вашем аккаунте, а также запрещать удаление файла."
},
"identity_read": {
"title": "Видеть ваши адреса отправителя",
+2 -2
View File
@@ -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",
+2 -2
View File
@@ -2798,11 +2798,11 @@
},
"files_read": {
"title": "อ่านไฟล์ของคุณ",
"body": "เรียกดูและเปิดไฟล์ที่จัดเก็บไว้ในบัญชีของคุณ"
"body": "ค้นหา เปิด และอ่านไฟล์ในบัญชีของคุณ"
},
"files_write": {
"title": "จัดการไฟล์ของคุณ",
"body": "อัปโหลด เปลี่ยนชื่อ ย้าย และลบไฟล์ในบัญชีของคุณ"
"body": "บันทึก สร้าง เปลี่ยนชื่อ และย้ายไฟล์ในบัญชีของคุณ และป้องกันไม่ให้ลบไฟล์"
},
"filters_read": {
"title": "อ่านตัวกรองอีเมลของคุณ",
+2 -2
View File
@@ -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",
+2 -2
View File
@@ -5872,11 +5872,11 @@
},
"files_read": {
"title": "Читати ваші файли",
"body": "Переглядати й відкривати файли, що зберігаються у вашому обліковому записі."
"body": "Знаходити, відкривати й читати файли у вашому обліковому записі."
},
"files_write": {
"title": "Змінювати ваші файли",
"body": "Завантажувати, перейменовувати, переміщувати й видаляти файли у вашому обліковому записі."
"body": "Зберігати, створювати, перейменовувати й переміщувати файли у вашому обліковому записі, а також забороняти видалення файлу."
},
"identity_read": {
"title": "Бачити ваші адреси відправника",
+2 -2
View File
@@ -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",
+2 -2
View File
@@ -5872,11 +5872,11 @@
},
"files_read": {
"title": "讀取您的檔案",
"body": "瀏覽和開啟您帳號中儲存的檔案。"
"body": "尋找、開啟並讀取您帳戶中的檔案。"
},
"files_write": {
"title": "變更您的檔案",
"body": "上傳、重新命名、移動和刪除您帳號中的檔案。"
"body": "在您的帳戶中儲存、建立、重新命名及移動檔案,並可阻止刪除檔案。"
},
"identity_read": {
"title": "查看您的寄件身分",
+2 -2
View File
@@ -5872,11 +5872,11 @@
},
"files_read": {
"title": "读取您的文件",
"body": "浏览和打开您账户中存储的文件。"
"body": "查找、打开并读取您账户中的文件。"
},
"files_write": {
"title": "更改您的文件",
"body": "上传、重命名、移动和删除您账户中的文件。"
"body": "在您的账户中保存、创建、重命名和移动文件,并可阻止删除文件。"
},
"identity_read": {
"title": "查看您的发件身份",
+2
View File
@@ -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,