mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
feat: expose label settings to extensions
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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 } });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user