feat: expose label settings to extensions

This commit is contained in:
Ctaoist
2026-08-12 15:38:05 +08:00
parent 5e73633a80
commit f2c379b929
5 changed files with 323 additions and 4 deletions
+27
View File
@@ -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.
</details>
<details>
+69 -1
View File
@@ -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 } });
});
});
+184 -1
View File
@@ -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<string, Permission | null> = {
// 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<KeywordVisibility>(['show', 'hide', 'unread']);
type PluginKeywordDefinitionInput = Omit<KeywordDefinition, 'color'> & { color?: string };
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
/** RFC 8621 keyword syntax, applied to the complete `$label:<id>` 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<string, { total: number; unread: number }> {
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<string, { total: number; unread: number }> = {};
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<Record<string, { total: number; unread: number }>> {
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';
export { cancelForPlugin as cancelPluginDialogs } from './host-dialog';
+3 -1
View File
@@ -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];
export type ApiMethod = (typeof API_METHODS)[number];
+40 -1
View File
@@ -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<PluginKeywordDefinition, 'color'> & { 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<void>,
},
// 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<PluginKeywordDefinition[]>,
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<string, number>;
scanned: number;
total: number;
complete: boolean;
}>,
getCounts: (ids?: string[]) =>
callApi('keywords.getCounts', [ids]) as Promise<Record<string, PluginKeywordCounts>>,
refreshCounts: () =>
callApi('keywords.refreshCounts', []) as Promise<Record<string, PluginKeywordCounts>>,
},
// 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;
void slotName;