diff --git a/lib/__tests__/jmap-filenode-client.test.ts b/lib/__tests__/jmap-filenode-client.test.ts index 5fe347a59..867a6a409 100644 --- a/lib/__tests__/jmap-filenode-client.test.ts +++ b/lib/__tests__/jmap-filenode-client.test.ts @@ -189,3 +189,64 @@ describe('JMAPClient FileNode name decoding (#869)', () => { expect(nodes[0]).toMatchObject({ id: 'f1', accountId: 'account-1', isShared: false }); }); }); + +describe('JMAPClient folders listed only on the way to a shared node', () => { + const NO_RIGHTS = { mayRead: false, mayAddChildren: false, mayRename: false, mayDelete: false, mayModifyContent: false, mayShare: false }; + const READ = { ...NO_RIGHTS, mayRead: true }; + const T = '2026-09-01T08:00:00Z'; + // What Stalwart 1.0 returns for an ancestor the user may not read + // (`discoverable_only`): name and place in the tree, nothing else. + const placeholder = { blobId: null, type: null, size: null, created: null, modified: null, shareWith: null, myRights: NO_RIGHTS }; + + beforeEach(() => { + vi.restoreAllMocks(); + }); + + /** The user's own drive plus another user's, answered per account. */ + function sharingClient(drives: Record): JMAPClient { + const client = createClient(); + const fileCap = { 'urn:ietf:params:jmap:filenode': {} }; + Object.assign(client, { + accounts: { + 'account-1': { name: 'user', isPersonal: true, isReadOnly: false, accountCapabilities: fileCap }, + owner: { name: 'owner@example.com', isPersonal: false, isReadOnly: false, accountCapabilities: fileCap }, + }, + }); + vi.spyOn(globalThis, 'fetch').mockImplementation((async (_url: string, init: RequestInit) => { + const { methodCalls } = JSON.parse(init.body as string) as { methodCalls: MethodCall[] }; + const methodResponses = methodCalls.map(([method, args, callId]) => + [method, { list: drives[args.accountId as string] ?? [], notFound: [] }, callId]); + const body = JSON.stringify({ methodResponses }); + return { ok: true, status: 200, text: () => Promise.resolve(body) } as Response; + }) as never); + return client; + } + + it('marks the owner\'s folders above a shared folder on Stalwart 1.0', async () => { + const client = sharingClient({ + // Not a real shape for an own node; only other accounts are marked. + 'account-1': [{ id: 'mine', parentId: null, name: 'Mine', ...placeholder }], + owner: [ + { id: 'a', parentId: null, name: 'Documents', ...placeholder }, + { id: 'b', parentId: 'a', name: 'Projects', ...placeholder }, + { id: 'c', parentId: 'b', name: 'Project X', blobId: null, type: null, size: null, created: T, modified: T, shareWith: null, myRights: READ }, + ], + }); + + const nodes = await client.listAllFileNodesAcrossAccounts(); + expect(nodes.filter(n => n.discoverableOnly).map(n => n.id)).toEqual(['owner:a', 'owner:b']); + }); + + it('marks nothing on 0.16, where a child the share predates has no rights but has dates', async () => { + const client = sharingClient({ + owner: [ + { id: 'f', parentId: 'hidden', name: 'Shared', blobId: null, type: null, size: null, created: T, modified: T, myRights: READ }, + { id: 'g', parentId: 'f', name: 'old.txt', blobId: 'b1', type: 'text/plain', size: 3, created: T, modified: T, myRights: NO_RIGHTS }, + ], + }); + + const nodes = await client.listAllFileNodesAcrossAccounts(); + expect(nodes).toHaveLength(2); + expect(nodes.some(n => n.discoverableOnly)).toBe(false); + }); +}); diff --git a/lib/global-search/__tests__/providers.test.ts b/lib/global-search/__tests__/providers.test.ts index caac560eb..57431aed1 100644 --- a/lib/global-search/__tests__/providers.test.ts +++ b/lib/global-search/__tests__/providers.test.ts @@ -343,4 +343,17 @@ describe('files provider', () => { const retry = await filesProvider.remote(parseSearchQuery('zebra'), acc, { limit: 10, signal }); expect(retry.hits).toHaveLength(2); }); + + it('leaves the owner\'s folders above a shared node out of the hits but not out of its path on Stalwart 1.0', async () => { + const shared = [ + // An ancestor listed only as the way down (no dates, no rights). + { id: 'owner:a', parentId: null, name: 'zebra archive', type: null, blobId: null, size: null, created: null, modified: null, accountId: 'owner', isShared: true, discoverableOnly: true }, + { id: 'owner:x', parentId: 'owner:a', name: 'zebra project', type: null, blobId: null, size: null, created: '2026-01-01T00:00:00Z', modified: '2026-01-01T00:00:00Z', accountId: 'owner', isShared: true }, + ]; + invalidateFileSearchCache(); + const acc = account('login-s', { listAllFileNodesAcrossAccounts: vi.fn(async () => shared) }); + const { hits } = await filesProvider.remote(parseSearchQuery('zebra'), acc, { limit: 10, signal }); + expect(hits.map((h) => h.id)).toEqual(['x']); + expect(hits[0]).toMatchObject({ folderPath: '/zebra archive', isFolder: true, jmapAccountId: 'owner' }); + }); }); diff --git a/lib/global-search/providers/files.ts b/lib/global-search/providers/files.ts index f54aa3db8..337c110f1 100644 --- a/lib/global-search/providers/files.ts +++ b/lib/global-search/providers/files.ts @@ -97,6 +97,8 @@ export function rawFileNodeId(node: FileNode): string { function filterNodes(parsed: ParsedQuery, nodes: FileNode[], account: SearchAccount, limit: number, source: 'local' | 'remote'): FileHit[] { const hits: FileHit[] = []; for (const node of nodes) { + // The owner's folders above a shared node only feed the path below. + if (node.discoverableOnly) continue; if (!matchesTerms(parsed.terms, [node.name, isFolderNode(node) ? 'folder' : node.type])) continue; const folderPath = pathOfNode(nodes, node); hits.push({ diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index fc0bd55e0..93dd37c26 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -81,6 +81,18 @@ function fromWireFileNode(node: FileNode): FileNode { }; } +/** + * A folder Stalwart 1.0 lists only as the way down to something shared + * with the user: the ancestors of a shared node come back with a name and + * parent but no dates and every right false. A node the user may read always + * has both dates, and 0.16 never leaves `modified` out. + */ +function isDiscoverableOnly(node: FileNode): boolean { + const rights = node.myRights; + return node.modified == null && node.created == null + && !!rights && Object.values(rights).every(granted => !granted); +} + /** * RFC 7617 Basic credentials, UTF-8 encoded (what Stalwart decodes). `btoa` * alone is Latin-1: it garbles `ΓΌ` and throws on anything past U+00FF, such @@ -8032,7 +8044,8 @@ export class JMAPClient implements IJMAPClient { * shared accounts. Nodes owned by another principal (shared with the user) * are tagged with `isShared: true` and the owning `accountId`/`accountName`, * and their ids are namespaced `accountId:nodeId` so they don't collide with - * the primary account's ids. Mirrors getAllCalendars(). + * the primary account's ids. Folders listed only as the way down to a shared + * node are marked `discoverableOnly`. Mirrors getAllCalendars(). */ async listAllFileNodesAcrossAccounts(): Promise { const primaryId = this.getFilesAccountId(); @@ -8045,8 +8058,9 @@ export class JMAPClient implements IJMAPClient { try { const nodes = await this.fetchAllFileNodes(accountId); for (const node of nodes) { + const wire = fromWireFileNode(node); all.push({ - ...fromWireFileNode(node), + ...wire, id: isPrimary ? node.id : `${accountId}:${node.id}`, parentId: node.parentId == null ? null @@ -8054,6 +8068,7 @@ export class JMAPClient implements IJMAPClient { accountId, accountName: account?.name || (isPrimary ? this.username : accountId), isShared: !isPrimary, + ...(!isPrimary && isDiscoverableOnly(wire) ? { discoverableOnly: true } : {}), }); } } catch (error) { diff --git a/lib/jmap/types.ts b/lib/jmap/types.ts index a4bfd92ce..73fef7533 100644 --- a/lib/jmap/types.ts +++ b/lib/jmap/types.ts @@ -1019,6 +1019,10 @@ export interface FileNode { // True when this node was fetched from another principal's account that was // shared with the logged-in user (mirrors Calendar.isShared / AddressBook.isShared). isShared?: boolean; + // A folder of another account that is listed only because something below + // it was shared with the user (Stalwart 1.0): name and place in the tree, + // no dates, no rights. It leads to the shared node but is not shown itself. + discoverableOnly?: boolean; // Owning account's JMAP id and display name, set when aggregating nodes // across connected/shared accounts so mutations route to the right account. accountId?: string; diff --git a/stores/__tests__/file-store.test.ts b/stores/__tests__/file-store.test.ts index ac4661b8e..65f0a8d4f 100644 --- a/stores/__tests__/file-store.test.ts +++ b/stores/__tests__/file-store.test.ts @@ -412,6 +412,72 @@ describe('file-store modification date (issue #700)', () => { }); }); +describe('file-store "Shared with me"', () => { + const NO_RIGHTS = { mayRead: false, mayAddChildren: false, mayRename: false, mayDelete: false, mayModifyContent: false, mayShare: false }; + const READ = { ...NO_RIGHTS, mayRead: true }; + const T = '2026-09-01T08:00:00Z'; + + // Another user's nodes as listAllFileNodesAcrossAccounts hands them over. + const shared = (id: string, name: string, parentId: string | null, blobId: string | null = null): FileNode => ({ + id: `owner:${id}`, parentId: parentId && `owner:${parentId}`, name, type: blobId ? 'text/plain' : '', blobId, size: blobId ? 3 : 0, + created: T, modified: T, myRights: READ, isShared: true, accountId: 'owner', accountName: 'Owner', + }); + // Stalwart 1.0's ancestors of a shared node: no dates, no rights. + const placeholder = (id: string, name: string, parentId: string | null): FileNode => ({ + ...shared(id, name, parentId), created: null as unknown as string, modified: null as unknown as string, + myRights: NO_RIGHTS, discoverableOnly: true, + }); + + function withSharedDrive(nodes: FileNode[]) { + const client = makeMockClient([]); + Object.assign(client, { listAllFileNodesAcrossAccounts: async () => nodes.map(n => ({ ...n })) }); + return client; + } + + beforeEach(() => { + useFileStore.setState({ + client: null, + currentParentId: null, + currentPath: '/', + pathStack: [{ id: null, name: '' }], + resources: [], + sharedRoots: [], + }); + }); + + it('shows the shared folder as the root, not the owner\'s folders above it, on Stalwart 1.0', async () => { + useFileStore.getState().initClient(withSharedDrive([ + placeholder('docs', 'Documents', null), + placeholder('projects', 'Projects', 'docs'), + shared('x', 'Project X', 'projects'), + shared('report', 'report.txt', 'x', 'b1'), + // A subfolder the owner took back, with something below it still shared. + placeholder('private', 'Private', 'x'), + shared('deep', 'Deep', 'private'), + ])); + + await useFileStore.getState().loadSharedRoots(); + expect(useFileStore.getState().sharedRoots.map(r => r.name)).toEqual(['Deep', 'Project X']); + + await useFileStore.getState().navigate('owner:x', 'Project X'); + expect(useFileStore.getState().resources.map(r => r.name)).toEqual(['report.txt']); + }); + + it('keeps a child that reports no rights but has dates, as 0.16 lists one the share predates', async () => { + useFileStore.getState().initClient(withSharedDrive([ + // 0.16 leaves the owner's folders above a share out of the listing. + shared('f', 'Shared', 'unlisted'), + { ...shared('old', 'old.txt', 'f', 'b1'), myRights: NO_RIGHTS }, + ])); + + await useFileStore.getState().loadSharedRoots(); + expect(useFileStore.getState().sharedRoots.map(r => r.name)).toEqual(['Shared']); + + await useFileStore.getState().navigate('owner:f', 'Shared'); + expect(useFileStore.getState().resources.map(r => r.name)).toEqual(['old.txt']); + }); +}); + describe('resourceServerRef', () => { it('splits a shared drive\'s namespaced id into the owner account and the node id', () => { expect(resourceServerRef({ id: 'owner1:n5', isShared: true, ownerAccountId: 'owner1' })) diff --git a/stores/file-store.ts b/stores/file-store.ts index 6a7ba6545..be2a4b49c 100644 --- a/stores/file-store.ts +++ b/stores/file-store.ts @@ -528,7 +528,11 @@ export const useFileStore = create((set, get) => ({ const allNodes = isCrossAccountId(parentId) ? await client.listAllFileNodesAcrossAccounts() : await client.listAllFileNodes(); - const resources = sortResources(childrenOf(allNodes, parentId).map(nodeToResource)); + // The owner's folders on the way down to a shared node aren't shown + // (see loadSharedRoots); the user can do nothing in them. + const resources = sortResources( + childrenOf(allNodes, parentId).filter(n => !n.discoverableOnly).map(nodeToResource), + ); // Prune recent files whose backing node no longer exists on the server const { recentFiles } = get(); @@ -1048,7 +1052,10 @@ export const useFileStore = create((set, get) => ({ return; } try { - const all = await client.listAllFileNodesAcrossAccounts(); + // Stalwart 1.0 also lists the owner's folders above a shared node + // (discoverableOnly); left in, the owner's top folder became the root + // and the shared folder sat somewhere below it. + const all = (await client.listAllFileNodesAcrossAccounts()).filter(n => !n.discoverableOnly); const idSet = new Set(all.map(n => n.id)); // A shared node is a "root" of the shared-with-me tree when its parent is // not among the nodes the user can see (either null, or owned by a