feat: expose JMAP keyword helpers to extensions

This commit is contained in:
Ctaoist
2026-08-12 20:43:55 +08:00
parent f2c379b929
commit ddfd9e43e6
5 changed files with 158 additions and 7 deletions
+22 -3
View File
@@ -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.
</details>
+42 -1
View File
@@ -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/);
}
});
});
+64
View File
@@ -81,6 +81,14 @@ const PERM_PER_METHOD: Record<string, Permission | null> = {
'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<string, true> {
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<string, true> = {};
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<string, boolean>; 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);
+3
View File
@@ -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',
+27 -3
View File
@@ -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<string, number>;
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<string, true>, accountId?: string) =>
callApi('jmap.setKeywords', [emailId, keywords, accountId]) as Promise<void>,
/** 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<void>,
/** 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<void>,
/** 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<Uint8Array>,