feat: expose gateway keywords to extensions

This commit is contained in:
Ctaoist
2026-08-12 21:40:02 +08:00
parent ddfd9e43e6
commit cf89eaad27
8 changed files with 232 additions and 23 deletions
+10 -9
View File
@@ -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
+71 -4
View File
@@ -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<string, number> = {}) {
function makeSession(core: Record<string, number> = {}, 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<string, number>): Promise<JMAPClient> {
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, unknown>, string]>, index: number) => unknown) {
const sent: Array<Array<[string, Record<string, unknown>, 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<string, unknown> | 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 });
});
});
+23
View File
@@ -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"/);
+21 -1
View File
@@ -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<KeywordDiscoveryResult> {
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<string, unknown> | null }>, _accountId?: string): Promise<Record<string, number>> {
const inBox = this.data.emails.filter(e => e.mailboxIds[mailboxId] && !e.keywords.$seen);
const result: Record<string, number> = {};
+27
View File
@@ -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<string, number>;
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<Email | null>;
getSomeEmails(emailsId: string[], accountId?: string): Promise<Email[]>
getTagCounts(tagIds: string[]): Promise<Record<string, { total: number; unread: number }>>;
/**
* 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<KeywordDiscoveryResult>;
/**
* 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
+55 -1
View File
@@ -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<string, any>;
@@ -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<KeywordDiscoveryResult> {
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
+13 -5
View File
@@ -937,18 +937,26 @@ function doKeywordsGetCounts(value?: unknown): Record<string, { total: number; u
return selected;
}
async function doKeywordsDiscover(value?: unknown) {
function keywordDiscoveryOptions(value: unknown, method: string): { limit: number } | undefined {
if (value !== undefined && !isPlainObject(value)) {
throw new Error('keywords.discover: options must be an object');
throw new Error(`${method}: 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}`);
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<Record<string, { total: number; unread: number }>> {
@@ -1104,7 +1112,7 @@ export async function dispatchApiCall(
args[1] as string[],
args[2] as { keywords?: Record<string, boolean>; 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');
+12 -3
View File
@@ -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;