diff --git a/README.md b/README.md index 6135fe781..5905e6c3f 100644 --- a/README.md +++ b/README.md @@ -263,7 +263,7 @@ Sandboxed plugins that integrate provider-side labels can use the native const api = require('@plugin-host'); const known = await api.keywords.list(); // settings:read -const scan = await api.keywords.discover(); // email:read +const scan = await api.jmap.getKeywords(); // email:read const id = Object.keys(scan.keywords) .find((keyword) => keyword.startsWith('$label:')) ?.slice('$label:'.length); @@ -276,12 +276,31 @@ if (id) { }]); } const counts = await api.keywords.refreshCounts(); // email:read + +// Complete replacement: keywords omitted here are removed from the message. +await api.jmap.setKeywords('email-id', { // email:write + '$seen': true, + '$label:provider-label-id': true, +}); +await api.jmap.setKeyword('email-id', '$label:work'); // email:write +await api.jmap.removeKeyword('email-id', '$label:work'); // email:write ``` +`jmap.getKeywords()` is a narrow read-only facade rather than an arbitrary JMAP +request API. It currently discovers keywords used by messages in the active +account; labels with no messages cannot be discovered through standard JMAP. +`keywords.discover()` remains available as a compatibility alias. + +`jmap.setKeywords()` replaces one message's complete keyword map via +`Email/set`. Omitted keywords are removed, so extensions should use the existing +`jmap.setKeyword()` and `jmap.removeKeyword()` methods for incremental edits. +The existing `email.setKeyword()` and `email.removeKeyword()` names remain as +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. `keywords.discover()` scans the active account's -JMAP message keywords and reports whether its bounded scan was complete. +colour, visibility, or order. 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 7bb0a7925..585f04978 100644 --- a/lib/__tests__/plugin-api-gates.test.ts +++ b/lib/__tests__/plugin-api-gates.test.ts @@ -151,7 +151,7 @@ describe('native keyword extension API', () => { }); it('gates discovery and counts as email metadata', async () => { - for (const method of ['keywords.discover', 'keywords.getCounts', 'keywords.refreshCounts']) { + for (const method of ['jmap.getKeywords', 'keywords.discover', 'keywords.getCounts', 'keywords.refreshCounts']) { expect(await gateError(plugin(), method, [])).toMatch(/lacks permission "email:read"/); } @@ -160,4 +160,45 @@ describe('native keyword extension API', () => { expect(await dispatchApiCall(reader, 'keywords.getCounts', [['shopping']])) .toEqual({ shopping: { total: 9, unread: 2 } }); }); + + it('keeps jmap.getKeywords available to untrusted plugins with email:read', async () => { + const reader = plugin({ permissions: ['email:read'], grantedPermissions: ['email:read'] }); + const error = await gateError(reader, 'jmap.getKeywords', []); + + expect(error).not.toMatch(GATE_FAILURE); + expect(error).toMatch(/No active session/); + }); + + it('gates jmap.setKeywords with email:write and validates complete maps', async () => { + expect(await gateError(plugin(), 'jmap.setKeywords', ['email-1', { '$seen': true }])) + .toMatch(/lacks permission "email:write"/); + + const writer = plugin({ permissions: ['email:write'], grantedPermissions: ['email:write'] }); + expect(await gateError(writer, 'jmap.setKeywords', ['email-1', { '$seen': false }])) + .toMatch(/must be true; omit it to remove it/); + expect(await gateError(writer, 'jmap.setKeywords', ['email-1', { 'invalid keyword': true }])) + .toMatch(/Invalid JMAP keyword/); + + const error = await gateError(writer, 'jmap.setKeywords', ['email-1', { '$seen': true }]); + expect(error).not.toMatch(GATE_FAILURE); + expect(error).toMatch(/No active session/); + }); + + it('exposes incremental JMAP keyword aliases with email:write', async () => { + for (const method of ['jmap.setKeyword', 'jmap.removeKeyword']) { + expect(await gateError(plugin(), method, ['email-1', '$label:work'])) + .toMatch(/lacks permission "email:write"/); + } + + const writer = plugin({ permissions: ['email:write'], grantedPermissions: ['email:write'] }); + for (const method of ['jmap.setKeyword', 'jmap.removeKeyword']) { + expect(await gateError(writer, method, ['', '$label:work'])) + .toMatch(/emailId is required/); + expect(await gateError(writer, method, ['email-1', 'invalid keyword'])) + .toMatch(/Invalid JMAP keyword/); + const error = await gateError(writer, method, ['email-1', '$label:work']); + expect(error).not.toMatch(GATE_FAILURE); + expect(error).toMatch(/No active session/); + } + }); }); diff --git a/lib/plugin-sandbox/host-api.ts b/lib/plugin-sandbox/host-api.ts index f499aacaf..7891260e9 100644 --- a/lib/plugin-sandbox/host-api.ts +++ b/lib/plugin-sandbox/host-api.ts @@ -81,6 +81,14 @@ const PERM_PER_METHOD: Record = { 'jmap.sendRaw': 'email:raw-send', 'jmap.submitRaw': 'email:raw-send', 'jmap.importRaw': 'email:raw-send', + // Narrow read-only JMAP facade. This intentionally does not expose an + // arbitrary request primitive that could turn email:read into Email/set. + 'jmap.getKeywords': 'email:read', + // Replace one message's complete keyword map. Kept separate from the raw + // request surface so email:write authorizes exactly this mutation. + 'jmap.setKeywords': 'email:write', + 'jmap.setKeyword': 'email:write', + 'jmap.removeKeyword': 'email:write', // uploaded files : // upfiles.get reads back a just-attached file (see onBeforeBlobUpload) and // is a read - it sits behind email:blob-read. To read a stored message @@ -750,6 +758,40 @@ function assertPluginKeyword(keyword: unknown): string { return keyword; } +function assertPluginKeywords(value: unknown): Record { + if (!isPlainObject(value)) { + throw new Error('jmap.setKeywords: keywords must be an object'); + } + const entries = Object.entries(value); + if (entries.length > MAX_PLUGIN_KEYWORD_DEFINITIONS) { + throw new Error(`jmap.setKeywords: at most ${MAX_PLUGIN_KEYWORD_DEFINITIONS} keywords are allowed`); + } + const keywords: Record = {}; + for (const [keyword, enabled] of entries) { + assertPluginKeyword(keyword); + if (enabled !== true) { + throw new Error(`jmap.setKeywords: keyword "${keyword}" must be true; omit it to remove it`); + } + keywords[keyword] = true; + } + return keywords; +} + +function assertEmailId(value: unknown, method: string): string { + if (typeof value !== 'string' || value.length === 0) { + throw new Error(`${method}: emailId is required`); + } + return value; +} + +function assertOptionalAccountId(value: unknown, method: string): string | undefined { + if (value === undefined) return undefined; + if (typeof value !== 'string' || value.length === 0) { + throw new Error(`${method}: accountId must be a non-empty string`); + } + return value; +} + function requireClient() { const { client } = useAuthStore.getState(); if (!client) throw new Error('No active session'); @@ -1062,6 +1104,28 @@ export async function dispatchApiCall( args[1] as string[], args[2] as { keywords?: Record; accountId?: string } | undefined, ); + case 'jmap.getKeywords': return doKeywordsDiscover(args[0]); + case 'jmap.setKeywords': { + const emailId = assertEmailId(args[0], 'jmap.setKeywords'); + const accountId = assertOptionalAccountId(args[2], 'jmap.setKeywords'); + const keywords = assertPluginKeywords(args[1]); + await requireClient().updateEmailKeywords(emailId, keywords, accountId); + return undefined; + } + case 'jmap.setKeyword': { + const emailId = assertEmailId(args[0], 'jmap.setKeyword'); + const keyword = assertPluginKeyword(args[1]); + const accountId = assertOptionalAccountId(args[2], 'jmap.setKeyword'); + await requireClient().setKeyword(emailId, keyword, accountId); + return undefined; + } + case 'jmap.removeKeyword': { + const emailId = assertEmailId(args[0], 'jmap.removeKeyword'); + const keyword = assertPluginKeyword(args[1]); + const accountId = assertOptionalAccountId(args[2], 'jmap.removeKeyword'); + await requireClient().removeKeyword(emailId, keyword, accountId); + return undefined; + } case 'upfiles.get' : return getFile(args[0] as string); case 'upfiles.save' : return saveFile(args[0] as string, args[1] as File); diff --git a/lib/plugin-sandbox/protocol.ts b/lib/plugin-sandbox/protocol.ts index ebc55f9a6..36aa0998b 100644 --- a/lib/plugin-sandbox/protocol.ts +++ b/lib/plugin-sandbox/protocol.ts @@ -243,6 +243,9 @@ export const API_METHODS = [ 'http.post', 'http.fetch', 'crypto.getOrCreateWebAuthn', 'crypto.getPublicKeys', 'crypto.createPublicKey', 'crypto.removePublicKey', 'crypto.getEncryptionAtRest', 'crypto.setEncryptionAtRest', 'jmap.fetchBlob', 'jmap.uploadBlob', 'jmap.sendRaw', + // Narrow keyword helpers. Unlike the raw-blob methods, these are available + // to untrusted plugins with email:read/email:write as appropriate. + 'jmap.getKeywords', 'jmap.setKeywords', 'jmap.setKeyword', 'jmap.removeKeyword', 'upfiles.get', 'upfiles.save', 'contact.get', 'contact.update', 'contact.create', 'contact.search', 'admin.getConfig', 'admin.getAllConfig', 'admin.setConfig', 'admin.deleteConfig', diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx index c1ddd9de3..2278f8155 100644 --- a/lib/plugin-sandbox/runtime.tsx +++ b/lib/plugin-sandbox/runtime.tsx @@ -215,10 +215,34 @@ function buildPluginApi(manifest: PluginManifest) { callApi('http.post', [path, body, options], NETWORK_API_TIMEOUT_MS), fetch: (url: string, init?: unknown) => callApi('http.fetch', [url, init], NETWORK_API_TIMEOUT_MS), }, - // Privileged-tier only (same-origin plugins). Calls throw for untrusted - // plugins (the host refuses the method) — these power crypto plugins that - // need raw message bytes and raw submission. + // Safe keyword methods are available to untrusted plugins with the + // matching email permission. Raw blob/submission methods remain restricted + // to the privileged tier for crypto plugins. jmap: { + /** + * Discover keywords used by messages in the active account. This is a + * safe read-only facade, available to untrusted plugins with email:read; + * it does not expose arbitrary JMAP method calls. + */ + getKeywords: (options?: { limit?: number }) => + callApi('jmap.getKeywords', [options]) as Promise<{ + keywords: Record; + scanned: number; + total: number; + complete: boolean; + }>, + /** + * Replace an email's complete keyword map. Omitted keywords are removed; + * use api.email.setKeyword/removeKeyword for an incremental mutation. + */ + setKeywords: (emailId: string, keywords: Record, accountId?: string) => + callApi('jmap.setKeywords', [emailId, keywords, accountId]) as Promise, + /** Add one keyword without changing the message's other keywords. */ + setKeyword: (emailId: string, keyword: string, accountId?: string) => + callApi('jmap.setKeyword', [emailId, keyword, accountId]) as Promise, + /** Remove one keyword without changing the message's other keywords. */ + removeKeyword: (emailId: string, keyword: string, accountId?: string) => + callApi('jmap.removeKeyword', [emailId, keyword, accountId]) as Promise, /** Fetch a blob's raw bytes by id. Resolves to a Uint8Array. */ fetchBlob: (blobId: string, opts?: { name?: string; type?: string }) => callApi('jmap.fetchBlob', [blobId, opts]) as Promise,