diff --git a/README.md b/README.md index a1063b971..6135fe781 100644 --- a/README.md +++ b/README.md @@ -256,6 +256,33 @@ PLUGIN_DEV_DIR=../my-plugins # load plugins from disk instead of ZIPs `EXTENSION_DIRECTORY_URL` enables the admin marketplace for browsing and installing plugins and themes. `PLUGIN_DEV_DIR` is for plugin authors: each immediate subfolder is one plugin with a `manifest.json`, and an entrypoint under `src/` is bundled on demand with esbuild, so editing sources needs only a browser refresh. +Sandboxed plugins that integrate provider-side labels can use the native +`api.keywords` facade exposed by `@plugin-host`: + +```js +const api = require('@plugin-host'); + +const known = await api.keywords.list(); // settings:read +const scan = await api.keywords.discover(); // email:read +const id = Object.keys(scan.keywords) + .find((keyword) => keyword.startsWith('$label:')) + ?.slice('$label:'.length); +if (id) { + await api.keywords.add([{ // settings:write + id, + label: 'Provider label', + // color is optional; Bulwark picks a palette colour when omitted + visibility: 'show', + }]); +} +const counts = await api.keywords.refreshCounts(); // email:read +``` + +`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. `keywords.discover()` scans the active account's +JMAP message keywords and 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 abf0f8232..7bb0a7925 100644 --- a/lib/__tests__/plugin-api-gates.test.ts +++ b/lib/__tests__/plugin-api-gates.test.ts @@ -1,5 +1,7 @@ -import { describe, it, expect, vi } from 'vitest'; +import { afterEach, describe, it, expect, vi } from 'vitest'; import type { InstalledPlugin } from '../plugin-types'; +import { useEmailStore } from '@/stores/email-store'; +import { DEFAULT_KEYWORDS, useSettingsStore } from '@/stores/settings-store'; vi.mock('@/stores/toast-store', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }, @@ -93,3 +95,69 @@ describe('method-name typos', () => { expect(await gateError(plugin(), 'upfiles.set', ['x'])).toMatch(/Unknown API method/); }); }); + +describe('native keyword extension API', () => { + afterEach(() => { + useSettingsStore.setState({ emailKeywords: [...DEFAULT_KEYWORDS] }); + useEmailStore.setState({ tagCounts: {} }); + }); + + it('uses the existing settings permissions for definition reads and writes', async () => { + expect(await gateError(plugin(), 'keywords.list', [])) + .toMatch(/lacks permission "settings:read"/); + expect(await gateError(plugin(), 'keywords.add', [[]])) + .toMatch(/lacks permission "settings:write"/); + + const reader = plugin({ permissions: ['settings:read'], grantedPermissions: ['settings:read'] }); + expect(await dispatchApiCall(reader, 'keywords.list', [])) + .toEqual(useSettingsStore.getState().emailKeywords); + }); + + it('only appends missing definitions and preserves existing user metadata', async () => { + useSettingsStore.setState({ + emailKeywords: [{ id: 'work', label: 'My Work', color: 'purple', visibility: 'hide' }], + }); + const writer = plugin({ permissions: ['settings:write'], grantedPermissions: ['settings:write'] }); + + const result = await dispatchApiCall(writer, 'keywords.add', [[ + { id: 'WORK', label: 'Plugin Work', color: 'red' }, + { id: 'gmail~shopping', label: 'Shopping', visibility: 'unread' }, + ]]) as { added: Array<{ id: string }>; skipped: string[] }; + + expect(result.added.map((keyword) => keyword.id)).toEqual(['gmail~shopping']); + expect(result.skipped).toEqual(['WORK']); + expect(useSettingsStore.getState().emailKeywords).toEqual([ + { id: 'work', label: 'My Work', color: 'purple', visibility: 'hide' }, + expect.objectContaining({ + id: 'gmail~shopping', + label: 'Shopping', + visibility: 'unread', + color: expect.any(String), + }), + ]); + }); + + it('rejects invalid definitions without partially updating settings', async () => { + useSettingsStore.setState({ emailKeywords: [...DEFAULT_KEYWORDS] }); + const before = useSettingsStore.getState().emailKeywords; + const writer = plugin({ permissions: ['settings:write'], grantedPermissions: ['settings:write'] }); + + await expect(dispatchApiCall(writer, 'keywords.add', [[ + { id: 'valid', label: 'Valid', color: 'blue' }, + { id: 'invalid label', label: 'Invalid', color: 'blue' }, + ]])).rejects.toThrow(/not a valid JMAP label id/); + + expect(useSettingsStore.getState().emailKeywords).toEqual(before); + }); + + it('gates discovery and counts as email metadata', async () => { + for (const method of ['keywords.discover', 'keywords.getCounts', 'keywords.refreshCounts']) { + expect(await gateError(plugin(), method, [])).toMatch(/lacks permission "email:read"/); + } + + useEmailStore.setState({ tagCounts: { shopping: { total: 9, unread: 2 } } }); + const reader = plugin({ permissions: ['email:read'], grantedPermissions: ['email:read'] }); + expect(await dispatchApiCall(reader, 'keywords.getCounts', [['shopping']])) + .toEqual({ shopping: { total: 9, unread: 2 } }); + }); +}); diff --git a/lib/plugin-sandbox/host-api.ts b/lib/plugin-sandbox/host-api.ts index 529d28255..f499aacaf 100644 --- a/lib/plugin-sandbox/host-api.ts +++ b/lib/plugin-sandbox/host-api.ts @@ -11,8 +11,18 @@ import { useIdentityStore } from '@/stores/identity-store'; import { useEmailStore } from '@/stores/email-store'; import { useFilterStore } from '@/stores/filter-store'; import { useMessageListTabsStore } from '@/stores/message-list-tabs-store'; +import { + KEYWORD_PALETTE, + useSettingsStore, + type KeywordDefinition, + type KeywordVisibility, +} from '@/stores/settings-store'; import type { MessageListTabsConfig } from '../plugin-types'; import { apiFetch } from '../browser-navigation'; +import { DEFAULT_KEYWORD_SCAN_LIMIT } from '../jmap/client'; +import { suggestKeywordColor } from '../keyword-discovery'; +import { MAX_KEYWORD_LENGTH } from '../keyword-nesting'; +import { KEYWORD_PREFIX } from '../thread-utils'; import { awaitDialog, awaitPrompt, type PromptField } from './host-dialog'; import { fileStorage } from '../plugin-storage'; import { generateUUID } from '../utils'; @@ -109,6 +119,15 @@ const PERM_PER_METHOD: Record = { // email keyword mutations '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. + 'keywords.list': 'settings:read', + 'keywords.add': 'settings:write', + 'keywords.discover': 'email:read', + 'keywords.getCounts': 'email:read', + 'keywords.refreshCounts': 'email:read', // message-list category tabs 'tabs.set': 'ui:message-list-tabs', 'tabs.clear': 'ui:message-list-tabs', @@ -737,6 +756,164 @@ function requireClient() { return client; } +// ─── Native keyword definitions ────────────────────────────── + +const MAX_PLUGIN_KEYWORD_DEFINITIONS = 500; +const MAX_PLUGIN_KEYWORD_LABEL_LENGTH = 255; +const VALID_VISIBILITIES = new Set(['show', 'hide', 'unread']); + +type PluginKeywordDefinitionInput = Omit & { color?: string }; + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** RFC 8621 keyword syntax, applied to the complete `$label:` value. */ +function isValidKeywordDefinitionId(id: string): boolean { + const keyword = KEYWORD_PREFIX + id; + if (id.length === 0 || keyword.length > MAX_KEYWORD_LENGTH) return false; + for (let i = 0; i < keyword.length; i++) { + const code = keyword.charCodeAt(i); + if ( + code < 0x21 || code > 0x7e || + code === 0x28 || code === 0x29 || code === 0x7b || code === 0x7d || + code === 0x5d || code === 0x25 || code === 0x2a || code === 0x22 || code === 0x5c + ) { + return false; + } + } + return true; +} + +function assertKeywordDefinition(value: unknown, index: number): PluginKeywordDefinitionInput { + if (!isPlainObject(value)) { + throw new Error(`keywords.add: definitions[${index}] must be an object`); + } + + const id = value.id; + const label = value.label; + const color = value.color; + const visibility = value.visibility; + + if (typeof id !== 'string' || !isValidKeywordDefinitionId(id)) { + throw new Error(`keywords.add: definitions[${index}].id is not a valid JMAP label id`); + } + if ( + typeof label !== 'string' || label.trim().length === 0 || + label.length > MAX_PLUGIN_KEYWORD_LABEL_LENGTH + ) { + throw new Error( + `keywords.add: definitions[${index}].label must be 1-${MAX_PLUGIN_KEYWORD_LABEL_LENGTH} characters`, + ); + } + if ( + color !== undefined && + (typeof color !== 'string' || !Object.prototype.hasOwnProperty.call(KEYWORD_PALETTE, color)) + ) { + throw new Error(`keywords.add: definitions[${index}].color is not in the keyword palette`); + } + if (visibility !== undefined && !VALID_VISIBILITIES.has(visibility as KeywordVisibility)) { + throw new Error(`keywords.add: definitions[${index}].visibility is invalid`); + } + + return { + id, + label: label.trim(), + ...(color === undefined ? {} : { color }), + ...(visibility === undefined ? {} : { visibility: visibility as KeywordVisibility }), + }; +} + +function assertKeywordDefinitionArray(value: unknown): PluginKeywordDefinitionInput[] { + if (!Array.isArray(value)) throw new Error('keywords.add: definitions must be an array'); + if (value.length > MAX_PLUGIN_KEYWORD_DEFINITIONS) { + throw new Error(`keywords.add: at most ${MAX_PLUGIN_KEYWORD_DEFINITIONS} definitions may be added at once`); + } + return value.map(assertKeywordDefinition); +} + +function doKeywordsList(): KeywordDefinition[] { + return useSettingsStore.getState().emailKeywords.map((keyword) => ({ ...keyword })); +} + +function doKeywordsAdd(value: unknown): { added: KeywordDefinition[]; skipped: string[] } { + const definitions = assertKeywordDefinitionArray(value); + const existing = useSettingsStore.getState().emailKeywords; + const known = new Set(existing.map((keyword) => keyword.id.toLowerCase())); + const takenColors = new Set(existing.map((keyword) => keyword.color)); + const added: KeywordDefinition[] = []; + const skipped: string[] = []; + + for (const definition of definitions) { + const foldedId = definition.id.toLowerCase(); + if (known.has(foldedId)) { + skipped.push(definition.id); + continue; + } + known.add(foldedId); + const color = definition.color ?? suggestKeywordColor(definition.id, takenColors); + takenColors.add(color); + added.push({ ...definition, color }); + } + + if (added.length > 0) { + // One atomic append keeps concurrent plugin calls from partially + // overwriting the list and triggers the normal persist/settings-sync path. + useSettingsStore.setState((state) => ({ + emailKeywords: [...state.emailKeywords, ...added], + })); + } + + return { added: added.map((keyword) => ({ ...keyword })), skipped }; +} + +function assertKeywordIds(value: unknown): string[] | undefined { + if (value === undefined || value === null) return undefined; + if (!Array.isArray(value) || value.length > MAX_PLUGIN_KEYWORD_DEFINITIONS) { + throw new Error(`keywords.getCounts: ids must be an array of at most ${MAX_PLUGIN_KEYWORD_DEFINITIONS} strings`); + } + if (value.some((id) => typeof id !== 'string' || !isValidKeywordDefinitionId(id))) { + throw new Error('keywords.getCounts: ids contains an invalid label id'); + } + return value as string[]; +} + +function doKeywordsGetCounts(value?: unknown): Record { + const ids = assertKeywordIds(value); + const counts = useEmailStore.getState().tagCounts; + if (!ids) { + return Object.fromEntries( + Object.entries(counts).map(([id, count]) => [id, { ...count }]), + ); + } + + const selected: Record = {}; + for (const id of ids) { + const count = counts[id]; + if (count) selected[id] = { ...count }; + } + return selected; +} + +async function doKeywordsDiscover(value?: unknown) { + if (value !== undefined && !isPlainObject(value)) { + throw new Error('keywords.discover: options must be an object'); + } + const rawLimit = value?.limit; + if ( + rawLimit !== undefined && + (typeof rawLimit !== 'number' || !Number.isInteger(rawLimit) || rawLimit < 1 || rawLimit > DEFAULT_KEYWORD_SCAN_LIMIT) + ) { + throw new Error(`keywords.discover: limit must be an integer from 1 to ${DEFAULT_KEYWORD_SCAN_LIMIT}`); + } + return requireClient().discoverKeywords(rawLimit === undefined ? undefined : { limit: rawLimit }); +} + +async function doKeywordsRefreshCounts(): Promise> { + await useEmailStore.getState().fetchTagCounts(requireClient()); + return doKeywordsGetCounts(); +} + // ─── Message-list category tabs ─────────────────────────────── /** @@ -999,6 +1176,12 @@ export async function dispatchApiCall( return undefined; } + case 'keywords.list': return doKeywordsList(); + case 'keywords.add': return doKeywordsAdd(args[0]); + case 'keywords.discover': return doKeywordsDiscover(args[0]); + case 'keywords.getCounts': return doKeywordsGetCounts(args[0]); + case 'keywords.refreshCounts': return doKeywordsRefreshCounts(); + case 'tabs.set': { // validateTabsConfig (inside registerTabs) throws a developer-readable // error that surfaces as the api.tabs.set rejection in the sandbox. @@ -1037,4 +1220,4 @@ export async function dispatchApiCall( // ─── Cleanup hook for unloading plugins ─────────────────────── -export { cancelForPlugin as cancelPluginDialogs } from './host-dialog'; \ No newline at end of file +export { cancelForPlugin as cancelPluginDialogs } from './host-dialog'; diff --git a/lib/plugin-sandbox/protocol.ts b/lib/plugin-sandbox/protocol.ts index 0ab19f001..ebc55f9a6 100644 --- a/lib/plugin-sandbox/protocol.ts +++ b/lib/plugin-sandbox/protocol.ts @@ -250,10 +250,12 @@ export const API_METHODS = [ 'ui.confirm', 'ui.alert', 'ui.prompt', 'ui.rerenderEmail', 'ui.rerenderFetchedEmails', 'ui.openExternalUrl', 'ui.downloadFile', // 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', // 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. 'sieve.isSupported', 'sieve.getActiveScript', 'sieve.validateScript', 'sieve.regenerate', ] as const; -export type ApiMethod = (typeof API_METHODS)[number]; \ No newline at end of file +export type ApiMethod = (typeof API_METHODS)[number]; diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx index 432c2d6d1..c1ddd9de3 100644 --- a/lib/plugin-sandbox/runtime.tsx +++ b/lib/plugin-sandbox/runtime.tsx @@ -165,6 +165,22 @@ function decodeCallbacks(value: unknown, depth = 0): unknown { type PluginManifest = BackgroundInit['manifest']; +type PluginKeywordVisibility = 'show' | 'hide' | 'unread'; + +interface PluginKeywordDefinition { + id: string; + label: string; + color: string; + visibility?: PluginKeywordVisibility; +} + +type PluginKeywordDefinitionInput = Omit & { color?: string }; + +interface PluginKeywordCounts { + total: number; + unread: number; +} + function buildPluginApi(manifest: PluginManifest) { return { plugin: { @@ -289,6 +305,29 @@ function buildPluginApi(manifest: PluginManifest) { removeKeyword: (emailId: string, keyword: string, accountId?: string) => callApi('email.removeKeyword', [emailId, keyword, accountId]) as Promise, }, + // 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. + keywords: { + list: () => callApi('keywords.list', []) as Promise, + add: (definitions: PluginKeywordDefinitionInput[]) => + callApi('keywords.add', [definitions]) as Promise<{ + added: PluginKeywordDefinition[]; + skipped: string[]; + }>, + discover: (options?: { limit?: number }) => + callApi('keywords.discover', [options]) as Promise<{ + keywords: Record; + scanned: number; + total: number; + complete: boolean; + }>, + getCounts: (ids?: string[]) => + callApi('keywords.getCounts', [ids]) as Promise>, + refreshCounts: () => + callApi('keywords.refreshCounts', []) as Promise>, + }, // Message-list category tabs (permission: ui:message-list-tabs; categorize // additionally needs email:write). The host renders the strip natively and // ANDs the active tab's JMAP search filter (`query`) or keyword into the @@ -653,4 +692,4 @@ export function SandboxRuntime(): React.JSX.Element { // Suppress unused-variable warning when `mode` is only read for debugging. void mode; -void slotName; \ No newline at end of file +void slotName;