diff --git a/README.md b/README.md index 5905e6c3f..135909323 100644 --- a/README.md +++ b/README.md @@ -264,13 +264,12 @@ const api = require('@plugin-host'); const known = await api.keywords.list(); // settings:read const scan = await api.jmap.getKeywords(); // email:read -const id = Object.keys(scan.keywords) - .find((keyword) => keyword.startsWith('$label:')) - ?.slice('$label:'.length); -if (id) { +const providerLabel = scan.labels + .find((label) => label.id.startsWith('$label:')); +if (providerLabel) { await api.keywords.add([{ // settings:write - id, - label: 'Provider label', + id: providerLabel.id.slice('$label:'.length), + label: providerLabel.name, // color is optional; Bulwark picks a palette colour when omitted visibility: 'show', }]); @@ -287,9 +286,11 @@ 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. +request API. When the server advertises `urn:mail-gateway:keywords`, it returns +all cached keywords with exact total/unread counts and provider-label metadata, +including empty Gmail labels. On ordinary JMAP servers it falls back to a +bounded scan of message keywords. `keywords.discover()` retains its original +message-scan response for compatibility. `jmap.setKeywords()` replaces one message's complete keyword map via `Email/set`. Omitted keywords are removed, so extensions should use the existing diff --git a/lib/__tests__/jmap-keyword-discovery.test.ts b/lib/__tests__/jmap-keyword-discovery.test.ts index 08047c5a5..3a8b96e1e 100644 --- a/lib/__tests__/jmap-keyword-discovery.test.ts +++ b/lib/__tests__/jmap-keyword-discovery.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { JMAPClient } from '../jmap/client'; +import { GATEWAY_KEYWORDS_CAPABILITY, JMAPClient } from '../jmap/client'; // JMAP has no way to ask which keywords an account uses, so `discoverKeywords` // finds them by walking the message list: an Email/query for the next slice of @@ -8,10 +8,19 @@ import { JMAPClient } from '../jmap/client'; // admits when it stopped early rather than passing a partial scan off as the // whole account. -function makeSession(core: Record = {}) { +function makeSession(core: Record = {}, gatewayKeywords = false) { return { - capabilities: { 'urn:ietf:params:jmap:core': core }, - accounts: { 'acct-1': { name: 'test', isPersonal: true, accountCapabilities: {} } }, + capabilities: { + 'urn:ietf:params:jmap:core': core, + ...(gatewayKeywords ? { [GATEWAY_KEYWORDS_CAPABILITY]: { supportsCounts: true } } : {}), + }, + accounts: { + 'acct-1': { + name: 'test', + isPersonal: true, + accountCapabilities: gatewayKeywords ? { [GATEWAY_KEYWORDS_CAPABILITY]: {} } : {}, + }, + }, primaryAccounts: { 'urn:ietf:params:jmap:mail': 'acct-1' }, apiUrl: 'https://mail.example.com/jmap/api', downloadUrl: 'https://mail.example.com/jmap/download/{accountId}/{blobId}/{name}', @@ -58,6 +67,14 @@ describe('JMAPClient.discoverKeywords', () => { return client; } + async function connectedGatewayClient(core?: Record): Promise { + fetchSpy.mockResolvedValueOnce(jsonResponse(makeSession(core, true))); + const client = JMAPClient.withBearer('https://mail.example.com', 'token123', 'user@test.com'); + await client.connect(); + fetchSpy.mockReset(); + return client; + } + /** Records the method calls of every request the client makes. */ function recordRequests(reply: (methodCalls: Array<[string, Record, string]>, index: number) => unknown) { const sent: Array, string]>> = []; @@ -206,4 +223,54 @@ describe('JMAPClient.discoverKeywords', () => { expect(result).toMatchObject({ keywords: {}, scanned: 0, total: 0, complete: true }); }); + + it('uses Gateway Keyword/get and preserves provider-label metadata', async () => { + const client = await connectedGatewayClient(); + let requestBody: Record | undefined; + fetchSpy.mockImplementationOnce((async (_url: string, init: RequestInit) => { + requestBody = JSON.parse(init.body as string); + return jsonResponse({ + methodResponses: [['Keyword/get', { + accountId: 'acct-1', + totalEmails: 42, + list: [ + { id: '$flagged', name: '$flagged', color: null, total: 8, unread: 3, isProviderLabel: false, source: 'message' }, + { id: '$label:GoogleVoice', name: 'Google Voice', color: null, total: 0, unread: 0, isProviderLabel: true, source: 'provider' }, + ], + notFound: [], + }, '0']], + }); + }) as never); + + const result = await client.getKeywords(); + + expect(requestBody).toMatchObject({ + using: ['urn:ietf:params:jmap:core', GATEWAY_KEYWORDS_CAPABILITY], + methodCalls: [['Keyword/get', { accountId: 'acct-1' }, '0']], + }); + expect(result).toEqual({ + keywords: { $flagged: 8, '$label:GoogleVoice': 0 }, + labels: [ + { id: '$flagged', name: '$flagged', color: null, total: 8, unread: 3, isProviderLabel: false, source: 'message' }, + { id: '$label:GoogleVoice', name: 'Google Voice', color: null, total: 0, unread: 0, isProviderLabel: true, source: 'provider' }, + ], + scanned: 42, + total: 42, + complete: true, + }); + }); + + it('falls back to scanning on ordinary JMAP servers', async () => { + const client = await connectedClient(); + recordRequests(() => page(['m1'], [{ '$label:work': true, $seen: true }], 1)); + + const result = await client.getKeywords(); + + expect(result.keywords).toEqual({ '$label:work': 1, $seen: 1 }); + expect(result.labels).toEqual([ + expect.objectContaining({ id: '$label:work', name: 'work', total: 1, source: 'message' }), + expect.objectContaining({ id: '$seen', name: '$seen', total: 1, source: 'message' }), + ]); + expect(result).toMatchObject({ scanned: 1, total: 1, complete: true }); + }); }); diff --git a/lib/__tests__/plugin-api-gates.test.ts b/lib/__tests__/plugin-api-gates.test.ts index 585f04978..d66c92f7e 100644 --- a/lib/__tests__/plugin-api-gates.test.ts +++ b/lib/__tests__/plugin-api-gates.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, it, expect, vi } from 'vitest'; import type { InstalledPlugin } from '../plugin-types'; +import { useAuthStore } from '@/stores/auth-store'; import { useEmailStore } from '@/stores/email-store'; import { DEFAULT_KEYWORDS, useSettingsStore } from '@/stores/settings-store'; @@ -98,6 +99,7 @@ describe('method-name typos', () => { describe('native keyword extension API', () => { afterEach(() => { + useAuthStore.setState({ client: null }); useSettingsStore.setState({ emailKeywords: [...DEFAULT_KEYWORDS] }); useEmailStore.setState({ tagCounts: {} }); }); @@ -169,6 +171,27 @@ describe('native keyword extension API', () => { expect(error).toMatch(/No active session/); }); + it('routes jmap.getKeywords through the client implementation', async () => { + const result = { + keywords: { '$label:work': 2 }, + labels: [{ + id: '$label:work', name: 'Work', color: null, total: 2, unread: 1, + isProviderLabel: true, source: 'provider' as const, + }], + scanned: 8, + total: 8, + complete: true, + }; + const getKeywords = vi.fn().mockResolvedValue(result); + const discoverKeywords = vi.fn(); + useAuthStore.setState({ client: { getKeywords, discoverKeywords } as never }); + const reader = plugin({ permissions: ['email:read'], grantedPermissions: ['email:read'] }); + + await expect(dispatchApiCall(reader, 'jmap.getKeywords', [{ limit: 100 }])).resolves.toEqual(result); + expect(getKeywords).toHaveBeenCalledWith({ limit: 100 }); + expect(discoverKeywords).not.toHaveBeenCalled(); + }); + 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"/); diff --git a/lib/demo/demo-client.ts b/lib/demo/demo-client.ts index 89605c1f0..9db6ef0d2 100644 --- a/lib/demo/demo-client.ts +++ b/lib/demo/demo-client.ts @@ -1,4 +1,4 @@ -import type { IJMAPClient } from '@/lib/jmap/client-interface'; +import type { IJMAPClient, KeywordDiscoveryResult } from '@/lib/jmap/client-interface'; import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, ScheduledEmail, SendEmailResult, SharedAccount } from '@/lib/jmap/types'; import type { SieveScript, SieveCapabilities } from '@/lib/jmap/sieve-types'; import { getDemoData, type DemoData } from './demo-data'; @@ -249,6 +249,26 @@ export class DemoJMAPClient implements IJMAPClient { return { keywords, scanned, total, complete: scanned >= total }; } + async getKeywords(options?: { + limit?: number; + onProgress?: (scanned: number, total: number) => void; + signal?: AbortSignal; + }): Promise { + const scan = await this.discoverKeywords(options); + return { + ...scan, + labels: Object.entries(scan.keywords).map(([id, total]) => ({ + id, + name: id.startsWith('$label:') ? id.slice('$label:'.length) : id, + color: null, + total, + unread: 0, + isProviderLabel: false, + source: 'message' as const, + })), + }; + } + async getCategoryUnreadCounts(mailboxId: string, tabs: Array<{ id: string; filter: Record | null }>, _accountId?: string): Promise> { const inBox = this.data.emails.filter(e => e.mailboxIds[mailboxId] && !e.keywords.$seen); const result: Record = {}; diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index beb227de4..5925a3a2a 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -1,6 +1,24 @@ import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeRights, Principal, PushSubscription, ScheduledEmail, SendEmailResult, SharedAccount } from "./types"; import type { SieveScript, SieveCapabilities } from "./sieve-types"; +export interface KeywordInfo { + id: string; + name: string; + color: string | null; + total: number; + unread: number; + isProviderLabel: boolean; + source: 'provider' | 'message'; +} + +export interface KeywordDiscoveryResult { + keywords: Record; + labels: KeywordInfo[]; + scanned: number; + total: number; + complete: boolean; +} + /** * Interface defining the public JMAP client contract. * @@ -88,6 +106,15 @@ export interface IJMAPClient { getEmail(emailId: string, accountId?: string): Promise; getSomeEmails(emailsId: string[], accountId?: string): Promise getTagCounts(tagIds: string[]): Promise>; + /** + * Enumerate account keywords for extensions. Mail Gateway can return exact + * counts and provider-label metadata; other servers use the bounded scan. + */ + getKeywords(options?: { + limit?: number; + onProgress?: (scanned: number, total: number) => void; + signal?: AbortSignal; + }): Promise; /** * Every keyword currently set on the account's messages and how many of the * walked messages carry it, found by walking the message list - JMAP offers no diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index f77da7982..e9cf94010 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -1,6 +1,6 @@ import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeFilter, FileNodeRights, Principal, PushSubscription, EmailSubmission, ScheduledEmail, SendEmailResult, SharedAccount } from "./types"; import type { SieveScript, SieveCapabilities } from "./sieve-types"; -import type { IJMAPClient } from "./client-interface"; +import type { IJMAPClient, KeywordDiscoveryResult, KeywordInfo } from "./client-interface"; import { toWildcardQuery } from "./search-utils"; import { batched, itemsPerRequest } from "./request-limits"; import { debug } from "@/lib/debug"; @@ -139,6 +139,8 @@ const SUBMISSION_USING = [ 'urn:ietf:params:jmap:submission', ] as const; +export const GATEWAY_KEYWORDS_CAPABILITY = 'urn:mail-gateway:keywords'; + // eslint-disable-next-line @typescript-eslint/no-explicit-any type JMAPResponseResult = Record; @@ -1502,6 +1504,58 @@ export class JMAPClient implements IJMAPClient { return { keywords, scanned, total: Math.max(total, scanned), complete }; } + /** + * Extension-facing keyword enumeration. + * + * Mail Gateway advertises a narrow capability that can enumerate all cached + * keywords in one request, including provider labels with no messages. A + * normal JMAP server has no equivalent method, so retain the existing + * message walk as a transparent fallback. + */ + async getKeywords(options?: { + limit?: number; + onProgress?: (scanned: number, total: number) => void; + signal?: AbortSignal; + }): Promise { + const supportsGatewayKeywords = + this.hasCapability(GATEWAY_KEYWORDS_CAPABILITY) && + this.hasAccountCapability(GATEWAY_KEYWORDS_CAPABILITY); + + if (!supportsGatewayKeywords) { + const scan = await this.discoverKeywords(options); + const labels: KeywordInfo[] = Object.entries(scan.keywords).map(([id, total]) => ({ + id, + name: id.startsWith('$label:') ? id.slice('$label:'.length) : id, + color: null, + total, + unread: 0, + isProviderLabel: false, + source: 'message', + })); + return { ...scan, labels }; + } + + if (options?.signal?.aborted) { + return { keywords: {}, labels: [], scanned: 0, total: 0, complete: false }; + } + + const response = await this.request( + [["Keyword/get", { accountId: this.accountId }, "0"]], + ["urn:ietf:params:jmap:core", GATEWAY_KEYWORDS_CAPABILITY], + ); + const [method, result] = response.methodResponses?.[0] ?? []; + if (method !== 'Keyword/get' || !Array.isArray(result?.list)) { + const description = typeof result?.description === 'string' ? `: ${result.description}` : ''; + throw new Error(`Mail Gateway Keyword/get failed${description}`); + } + + const labels = (result.list as KeywordInfo[]).map((item) => ({ ...item })); + const keywords = Object.fromEntries(labels.map((item) => [item.id, item.total])); + const total = typeof result.totalEmails === 'number' ? result.totalEmails : 0; + options?.onProgress?.(total, total); + return { keywords, labels, scanned: total, total, complete: true }; + } + /** * Per-tab unread counts for message-list category tabs. One Email/query * (limit 0, calculateTotal) per tab, batched into as few requests as the diff --git a/lib/plugin-sandbox/host-api.ts b/lib/plugin-sandbox/host-api.ts index 7891260e9..c5d9cfe42 100644 --- a/lib/plugin-sandbox/host-api.ts +++ b/lib/plugin-sandbox/host-api.ts @@ -937,18 +937,26 @@ function doKeywordsGetCounts(value?: unknown): Record DEFAULT_KEYWORD_SCAN_LIMIT) ) { - throw new Error(`keywords.discover: limit must be an integer from 1 to ${DEFAULT_KEYWORD_SCAN_LIMIT}`); + throw new Error(`${method}: limit must be an integer from 1 to ${DEFAULT_KEYWORD_SCAN_LIMIT}`); } - return requireClient().discoverKeywords(rawLimit === undefined ? undefined : { limit: rawLimit }); + return rawLimit === undefined ? undefined : { limit: rawLimit }; +} + +async function doJmapGetKeywords(value?: unknown) { + return requireClient().getKeywords(keywordDiscoveryOptions(value, 'jmap.getKeywords')); +} + +async function doKeywordsDiscover(value?: unknown) { + return requireClient().discoverKeywords(keywordDiscoveryOptions(value, 'keywords.discover')); } async function doKeywordsRefreshCounts(): Promise> { @@ -1104,7 +1112,7 @@ 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.getKeywords': return doJmapGetKeywords(args[0]); case 'jmap.setKeywords': { const emailId = assertEmailId(args[0], 'jmap.setKeywords'); const accountId = assertOptionalAccountId(args[2], 'jmap.setKeywords'); diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx index 2278f8155..4199f64a8 100644 --- a/lib/plugin-sandbox/runtime.tsx +++ b/lib/plugin-sandbox/runtime.tsx @@ -220,9 +220,9 @@ function buildPluginApi(manifest: PluginManifest) { // 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. + * Enumerate keywords in the active account. Mail Gateway supplies exact + * counts and provider-label metadata; ordinary JMAP servers fall back to + * scanning message keywords. */ getKeywords: (options?: { limit?: number }) => callApi('jmap.getKeywords', [options]) as Promise<{ @@ -230,6 +230,15 @@ function buildPluginApi(manifest: PluginManifest) { scanned: number; total: number; complete: boolean; + labels: Array<{ + id: string; + name: string; + color: string | null; + total: number; + unread: number; + isProviderLabel: boolean; + source: 'provider' | 'message'; + }>; }>, /** * Replace an email's complete keyword map. Omitted keywords are removed;