diff --git a/README.md b/README.md index 9cc0a86eb..e095fdec2 100644 --- a/README.md +++ b/README.md @@ -274,6 +274,10 @@ if (providerLabel) { visibility: 'show', }]); } +const current = await api.keywords.list(); // settings:read +await api.keywords.reorder(current.map(({ id }) => id), { // settings:write + caseSensitive: false, // default +}); const counts = await api.keywords.refreshCounts(); // email:read // Complete replacement: keywords omitted here are removed from the message. @@ -301,8 +305,11 @@ compatibility aliases. `keywords.add()` is append-only and case-insensitive by id: it returns added and skipped definitions without overwriting the user's existing label name, -colour, visibility, or order. Keyword discovery reports whether its bounded -scan was complete. +colour, visibility, or order. `keywords.reorder()` accepts a complete +permutation of the existing label ids and changes only their order; missing, +unknown, or duplicate ids are rejected without changing settings. Matching is +case-insensitive by default; pass `{ caseSensitive: true }` to require exact id +casing. Keyword discovery reports whether its bounded scan was complete. diff --git a/lib/__tests__/plugin-api-gates.test.ts b/lib/__tests__/plugin-api-gates.test.ts index d66c92f7e..84abd3f8a 100644 --- a/lib/__tests__/plugin-api-gates.test.ts +++ b/lib/__tests__/plugin-api-gates.test.ts @@ -109,6 +109,8 @@ describe('native keyword extension API', () => { .toMatch(/lacks permission "settings:read"/); expect(await gateError(plugin(), 'keywords.add', [[]])) .toMatch(/lacks permission "settings:write"/); + expect(await gateError(plugin(), 'keywords.reorder', [[]])) + .toMatch(/lacks permission "settings:write"/); const reader = plugin({ permissions: ['settings:read'], grantedPermissions: ['settings:read'] }); expect(await dispatchApiCall(reader, 'keywords.list', [])) @@ -152,6 +154,81 @@ describe('native keyword extension API', () => { expect(useSettingsStore.getState().emailKeywords).toEqual(before); }); + it('reorders complete label sets without changing user metadata', async () => { + useSettingsStore.setState({ + emailKeywords: [ + { id: 'work', label: 'My Work', color: 'purple', visibility: 'hide' }, + { id: 'shopping', label: 'Shopping', color: 'green', visibility: 'unread' }, + { id: 'travel', label: 'Travel', color: 'blue' }, + ], + }); + const writer = plugin({ permissions: ['settings:write'], grantedPermissions: ['settings:write'] }); + + const result = await dispatchApiCall(writer, 'keywords.reorder', [[ + 'TRAVEL', 'work', 'shopping', + ]]); + + expect(result).toEqual([ + { id: 'travel', label: 'Travel', color: 'blue' }, + { id: 'work', label: 'My Work', color: 'purple', visibility: 'hide' }, + { id: 'shopping', label: 'Shopping', color: 'green', visibility: 'unread' }, + ]); + expect(useSettingsStore.getState().emailKeywords).toEqual(result); + }); + + it('supports optional case-sensitive label id matching', async () => { + const original = [ + { id: 'Work', label: 'Work', color: 'purple' }, + { id: 'shopping', label: 'Shopping', color: 'green' }, + ]; + useSettingsStore.setState({ emailKeywords: original }); + const writer = plugin({ permissions: ['settings:write'], grantedPermissions: ['settings:write'] }); + + await expect(dispatchApiCall(writer, 'keywords.reorder', [ + ['SHOPPING', 'work'], + { caseSensitive: true }, + ])).rejects.toThrow(/unknown label id/); + expect(useSettingsStore.getState().emailKeywords).toEqual(original); + + await expect(dispatchApiCall(writer, 'keywords.reorder', [ + ['shopping', 'Work'], + { caseSensitive: true }, + ])).resolves.toEqual([original[1], original[0]]); + }); + + it('rejects invalid reorder options without changing settings', async () => { + const original = [{ id: 'work', label: 'Work', color: 'purple' }]; + useSettingsStore.setState({ emailKeywords: original }); + const writer = plugin({ permissions: ['settings:write'], grantedPermissions: ['settings:write'] }); + + for (const options of [true, { caseSensitive: 'yes' }, { unknown: true }]) { + await expect(dispatchApiCall(writer, 'keywords.reorder', [ + ['work'], options, + ])).rejects.toThrow(/options/); + expect(useSettingsStore.getState().emailKeywords).toEqual(original); + } + }); + + it('rejects incomplete, duplicate, and unknown reorder ids atomically', async () => { + const original = [ + { id: 'work', label: 'Work', color: 'purple' }, + { id: 'shopping', label: 'Shopping', color: 'green' }, + ]; + useSettingsStore.setState({ emailKeywords: original }); + const writer = plugin({ permissions: ['settings:write'], grantedPermissions: ['settings:write'] }); + + for (const ids of [ + ['work'], + ['work', 'WORK'], + ['work', 'missing'], + ]) { + await expect(dispatchApiCall(writer, 'keywords.reorder', [ids])).rejects.toThrow( + /every existing label|duplicate label id|unknown label id/, + ); + expect(useSettingsStore.getState().emailKeywords).toEqual(original); + } + }); + it('gates discovery and counts as email metadata', async () => { for (const method of ['jmap.getKeywords', 'keywords.discover', 'keywords.getCounts', 'keywords.refreshCounts']) { expect(await gateError(plugin(), method, [])).toMatch(/lacks permission "email:read"/); diff --git a/lib/plugin-sandbox/host-api.ts b/lib/plugin-sandbox/host-api.ts index c5d9cfe42..e8c5d7c36 100644 --- a/lib/plugin-sandbox/host-api.ts +++ b/lib/plugin-sandbox/host-api.ts @@ -128,11 +128,13 @@ const PERM_PER_METHOD: Record = { 'email.setKeyword': 'email:write', 'email.removeKeyword': 'email:write', // Native keyword definitions are a deliberately narrow settings API: a - // plugin can read definitions or append missing ones, but cannot overwrite - // or remove user-managed tags. Discovery/counts reveal mail metadata and - // therefore use email:read rather than a settings permission. + // plugin can read definitions, append missing ones, or reorder the complete + // existing set, but cannot overwrite or remove user-managed tags. + // Discovery/counts reveal mail metadata and therefore use email:read rather + // than a settings permission. 'keywords.list': 'settings:read', 'keywords.add': 'settings:write', + 'keywords.reorder': 'settings:write', 'keywords.discover': 'email:read', 'keywords.getCounts': 'email:read', 'keywords.refreshCounts': 'email:read', @@ -909,6 +911,57 @@ function doKeywordsAdd(value: unknown): { added: KeywordDefinition[]; skipped: s return { added: added.map((keyword) => ({ ...keyword })), skipped }; } +function doKeywordsReorder(value: unknown, rawOptions?: unknown): KeywordDefinition[] { + if (!Array.isArray(value)) { + throw new Error('keywords.reorder: ids must be an array'); + } + if (value.some((id) => typeof id !== 'string')) { + throw new Error('keywords.reorder: ids must contain only strings'); + } + if (rawOptions !== undefined && !isPlainObject(rawOptions)) { + throw new Error('keywords.reorder: options must be an object'); + } + const options = rawOptions as Record | undefined; + if (options && Object.keys(options).some((key) => key !== 'caseSensitive')) { + throw new Error('keywords.reorder: options contains an unknown property'); + } + if (options?.caseSensitive !== undefined && typeof options.caseSensitive !== 'boolean') { + throw new Error('keywords.reorder: options.caseSensitive must be a boolean'); + } + const caseSensitive = options?.caseSensitive === true; + const normalizeId = (id: string) => caseSensitive ? id : id.toLowerCase(); + + const existing = useSettingsStore.getState().emailKeywords; + if (value.length !== existing.length) { + throw new Error('keywords.reorder: ids must contain every existing label exactly once'); + } + + const byId = new Map(existing.map((keyword) => [normalizeId(keyword.id), keyword])); + if (byId.size !== existing.length) { + throw new Error('keywords.reorder: existing label ids are not unique'); + } + + const seen = new Set(); + const reordered: KeywordDefinition[] = []; + for (const id of value as string[]) { + const normalizedId = normalizeId(id); + if (seen.has(normalizedId)) { + throw new Error(`keywords.reorder: duplicate label id: ${id}`); + } + const keyword = byId.get(normalizedId); + if (!keyword) { + throw new Error(`keywords.reorder: unknown label id: ${id}`); + } + seen.add(normalizedId); + reordered.push(keyword); + } + + // Reuse the existing definitions verbatim so ordering cannot change a + // label's name, colour, visibility, id casing, or any future metadata. + useSettingsStore.setState({ emailKeywords: reordered }); + return reordered.map((keyword) => ({ ...keyword })); +} + function assertKeywordIds(value: unknown): string[] | undefined { if (value === undefined || value === null) return undefined; if (!Array.isArray(value) || value.length > MAX_PLUGIN_KEYWORD_DEFINITIONS) { @@ -1250,6 +1303,7 @@ export async function dispatchApiCall( case 'keywords.list': return doKeywordsList(); case 'keywords.add': return doKeywordsAdd(args[0]); + case 'keywords.reorder': return doKeywordsReorder(args[0], args[1]); case 'keywords.discover': return doKeywordsDiscover(args[0]); case 'keywords.getCounts': return doKeywordsGetCounts(args[0]); case 'keywords.refreshCounts': return doKeywordsRefreshCounts(); diff --git a/lib/plugin-sandbox/protocol.ts b/lib/plugin-sandbox/protocol.ts index 36aa0998b..56546ecc5 100644 --- a/lib/plugin-sandbox/protocol.ts +++ b/lib/plugin-sandbox/protocol.ts @@ -254,7 +254,7 @@ export const API_METHODS = [ // Email keyword mutations (JMAP Email/set keyword patches). 'email.setKeyword', 'email.removeKeyword', // Native tag definitions, discovery, and sidebar counts. - 'keywords.list', 'keywords.add', 'keywords.discover', 'keywords.getCounts', 'keywords.refreshCounts', + 'keywords.list', 'keywords.add', 'keywords.reorder', 'keywords.discover', 'keywords.getCounts', 'keywords.refreshCounts', // Message-list category tabs (Gmail-style inbox tabs). 'tabs.set', 'tabs.clear', 'tabs.getState', 'tabs.categorize', 'tabs.refreshCounts', // Sieve integration for delivery-time classification plugins. diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx index 978cc7722..12d7be36b 100644 --- a/lib/plugin-sandbox/runtime.tsx +++ b/lib/plugin-sandbox/runtime.tsx @@ -340,8 +340,8 @@ function buildPluginApi(manifest: PluginManifest) { }, // Native sidebar tag definitions. Definition reads/writes use the existing // settings permissions; server discovery and message counts use email:read. - // add() is intentionally append-only: it never overwrites or removes tags - // the user has already named, coloured, hidden, or reordered. + // add() is intentionally append-only. reorder() requires a complete + // permutation of existing ids. Neither method overwrites or removes tags. keywords: { list: () => callApi('keywords.list', []) as Promise, add: (definitions: PluginKeywordDefinitionInput[]) => @@ -349,6 +349,8 @@ function buildPluginApi(manifest: PluginManifest) { added: PluginKeywordDefinition[]; skipped: string[]; }>, + reorder: (ids: string[], options?: { caseSensitive?: boolean }) => + callApi('keywords.reorder', [ids, options]) as Promise, discover: (options?: { limit?: number }) => callApi('keywords.discover', [options]) as Promise<{ keywords: Record;