mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-03 16:16:59 +00:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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 }>>();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }>,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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() };
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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(); });
|
||||
}
|
||||
|
||||
@@ -5872,11 +5872,11 @@
|
||||
},
|
||||
"files_read": {
|
||||
"title": "قراءة ملفاتك",
|
||||
"body": "تصفّح الملفات المخزّنة في حسابك وفتحها."
|
||||
"body": "البحث عن الملفات في حسابك وفتحها وقراءتها."
|
||||
},
|
||||
"files_write": {
|
||||
"title": "تغيير ملفاتك",
|
||||
"body": "رفع الملفات في حسابك وإعادة تسميتها ونقلها وحذفها."
|
||||
"body": "حفظ الملفات في حسابك وإنشاؤها وإعادة تسميتها ونقلها، ومنع حذف ملف."
|
||||
},
|
||||
"identity_read": {
|
||||
"title": "عرض عناوين المرسل",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -5872,11 +5872,11 @@
|
||||
},
|
||||
"files_read": {
|
||||
"title": "خواندن فایلهای شما",
|
||||
"body": "مرور و باز کردن فایلهای ذخیرهشده در حساب شما."
|
||||
"body": "یافتن، باز کردن و خواندن فایلهای حساب شما."
|
||||
},
|
||||
"files_write": {
|
||||
"title": "تغییر فایلهای شما",
|
||||
"body": "بارگذاری، تغییر نام، جابهجایی و حذف فایلها در حساب شما."
|
||||
"body": "ذخیره، ایجاد، تغییر نام و جابهجایی فایلها در حساب شما، و جلوگیری از حذف یک فایل."
|
||||
},
|
||||
"identity_read": {
|
||||
"title": "دیدن آدرسهای فرستنده شما",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -5873,11 +5873,11 @@
|
||||
},
|
||||
"files_read": {
|
||||
"title": "קריאת הקבצים שלכם",
|
||||
"body": "עיון בקבצים השמורים בחשבון שלכם ופתיחתם."
|
||||
"body": "למצוא, לפתוח ולקרוא את הקבצים בחשבון שלך."
|
||||
},
|
||||
"files_write": {
|
||||
"title": "שינוי הקבצים שלכם",
|
||||
"body": "העלאה, שינוי שם, העברה ומחיקה של קבצים בחשבון שלכם."
|
||||
"body": "לשמור, ליצור, לשנות שם ולהעביר קבצים בחשבון שלך, ולמנוע מחיקה של קובץ."
|
||||
},
|
||||
"identity_read": {
|
||||
"title": "צפייה בכתובות השולח שלכם",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -5872,11 +5872,11 @@
|
||||
},
|
||||
"files_read": {
|
||||
"title": "ファイルを読む",
|
||||
"body": "アカウントに保存されたファイルを閲覧して開くことができます。"
|
||||
"body": "アカウント内のファイルを検索して開き、内容を読み取れます。"
|
||||
},
|
||||
"files_write": {
|
||||
"title": "ファイルを変更",
|
||||
"body": "アカウント内のファイルをアップロード、名前変更、移動、削除できます。"
|
||||
"body": "アカウント内のファイルを保存、作成、名前変更、移動でき、ファイルの削除を止めることもできます。"
|
||||
},
|
||||
"identity_read": {
|
||||
"title": "送信者情報を見る",
|
||||
|
||||
@@ -5872,11 +5872,11 @@
|
||||
},
|
||||
"files_read": {
|
||||
"title": "파일 읽기",
|
||||
"body": "계정에 저장된 파일을 둘러보고 열 수 있어요."
|
||||
"body": "계정의 파일을 찾고, 열고, 읽을 수 있습니다."
|
||||
},
|
||||
"files_write": {
|
||||
"title": "파일 변경",
|
||||
"body": "계정의 파일을 업로드하고, 이름을 바꾸고, 이동하고, 삭제할 수 있어요."
|
||||
"body": "계정의 파일을 저장, 생성, 이름 변경, 이동하고 파일 삭제를 막을 수 있습니다."
|
||||
},
|
||||
"identity_read": {
|
||||
"title": "보내는 사람 주소 보기",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -5872,11 +5872,11 @@
|
||||
},
|
||||
"files_read": {
|
||||
"title": "Таны файлуудыг унших",
|
||||
"body": "Таны бүртгэлд хадгалсан файлуудыг үзэх, нээх."
|
||||
"body": "Таны бүртгэл дэх файлуудыг хайх, нээх, унших."
|
||||
},
|
||||
"files_write": {
|
||||
"title": "Таны файлуудыг өөрчлөх",
|
||||
"body": "Таны бүртгэл дэх файлуудыг байршуулах, нэрийг өөрчлөх, зөөх, устгах."
|
||||
"body": "Таны бүртгэл дэх файлуудыг хадгалах, үүсгэх, нэрийг өөрчлөх, зөөх, мөн файл устгахыг зогсоох."
|
||||
},
|
||||
"identity_read": {
|
||||
"title": "Таны илгээгчийн хаягуудыг харах",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -5872,11 +5872,11 @@
|
||||
},
|
||||
"files_read": {
|
||||
"title": "Читать ваши файлы",
|
||||
"body": "Просматривать и открывать файлы, хранящиеся в вашем аккаунте."
|
||||
"body": "Находить, открывать и читать файлы в вашем аккаунте."
|
||||
},
|
||||
"files_write": {
|
||||
"title": "Менять ваши файлы",
|
||||
"body": "Загружать, переименовывать, перемещать и удалять файлы в вашем аккаунте."
|
||||
"body": "Сохранять, создавать, переименовывать и перемещать файлы в вашем аккаунте, а также запрещать удаление файла."
|
||||
},
|
||||
"identity_read": {
|
||||
"title": "Видеть ваши адреса отправителя",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2798,11 +2798,11 @@
|
||||
},
|
||||
"files_read": {
|
||||
"title": "อ่านไฟล์ของคุณ",
|
||||
"body": "เรียกดูและเปิดไฟล์ที่จัดเก็บไว้ในบัญชีของคุณ"
|
||||
"body": "ค้นหา เปิด และอ่านไฟล์ในบัญชีของคุณ"
|
||||
},
|
||||
"files_write": {
|
||||
"title": "จัดการไฟล์ของคุณ",
|
||||
"body": "อัปโหลด เปลี่ยนชื่อ ย้าย และลบไฟล์ในบัญชีของคุณ"
|
||||
"body": "บันทึก สร้าง เปลี่ยนชื่อ และย้ายไฟล์ในบัญชีของคุณ และป้องกันไม่ให้ลบไฟล์"
|
||||
},
|
||||
"filters_read": {
|
||||
"title": "อ่านตัวกรองอีเมลของคุณ",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -5872,11 +5872,11 @@
|
||||
},
|
||||
"files_read": {
|
||||
"title": "Читати ваші файли",
|
||||
"body": "Переглядати й відкривати файли, що зберігаються у вашому обліковому записі."
|
||||
"body": "Знаходити, відкривати й читати файли у вашому обліковому записі."
|
||||
},
|
||||
"files_write": {
|
||||
"title": "Змінювати ваші файли",
|
||||
"body": "Завантажувати, перейменовувати, переміщувати й видаляти файли у вашому обліковому записі."
|
||||
"body": "Зберігати, створювати, перейменовувати й переміщувати файли у вашому обліковому записі, а також забороняти видалення файлу."
|
||||
},
|
||||
"identity_read": {
|
||||
"title": "Бачити ваші адреси відправника",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -5872,11 +5872,11 @@
|
||||
},
|
||||
"files_read": {
|
||||
"title": "讀取您的檔案",
|
||||
"body": "瀏覽和開啟您帳號中儲存的檔案。"
|
||||
"body": "尋找、開啟並讀取您帳戶中的檔案。"
|
||||
},
|
||||
"files_write": {
|
||||
"title": "變更您的檔案",
|
||||
"body": "上傳、重新命名、移動和刪除您帳號中的檔案。"
|
||||
"body": "在您的帳戶中儲存、建立、重新命名及移動檔案,並可阻止刪除檔案。"
|
||||
},
|
||||
"identity_read": {
|
||||
"title": "查看您的寄件身分",
|
||||
|
||||
@@ -5872,11 +5872,11 @@
|
||||
},
|
||||
"files_read": {
|
||||
"title": "读取您的文件",
|
||||
"body": "浏览和打开您账户中存储的文件。"
|
||||
"body": "查找、打开并读取您账户中的文件。"
|
||||
},
|
||||
"files_write": {
|
||||
"title": "更改您的文件",
|
||||
"body": "上传、重命名、移动和删除您账户中的文件。"
|
||||
"body": "在您的账户中保存、创建、重命名和移动文件,并可阻止删除文件。"
|
||||
},
|
||||
"identity_read": {
|
||||
"title": "查看您的发件身份",
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user