feat: expose label reordering to extensions

This commit is contained in:
Ctaoist
2026-08-13 10:59:45 +08:00
parent 70383dea18
commit 6e792f8834
5 changed files with 148 additions and 8 deletions
+9 -2
View File
@@ -274,6 +274,10 @@ if (providerLabel) {
visibility: 'show',
}]);
}
const current = await api.keywords.list(); // settings:read
await api.keywords.reorder(current.map(({ id }) => id), { // settings:write
caseSensitive: false, // default
});
const counts = await api.keywords.refreshCounts(); // email:read
// Complete replacement: keywords omitted here are removed from the message.
@@ -301,8 +305,11 @@ 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. Keyword discovery reports whether its bounded
scan was complete.
colour, visibility, or order. `keywords.reorder()` accepts a complete
permutation of the existing label ids and changes only their order; missing,
unknown, or duplicate ids are rejected without changing settings. Matching is
case-insensitive by default; pass `{ caseSensitive: true }` to require exact id
casing. Keyword discovery reports whether its bounded scan was complete.
</details>
+77
View File
@@ -109,6 +109,8 @@ describe('native keyword extension API', () => {
.toMatch(/lacks permission "settings:read"/);
expect(await gateError(plugin(), 'keywords.add', [[]]))
.toMatch(/lacks permission "settings:write"/);
expect(await gateError(plugin(), 'keywords.reorder', [[]]))
.toMatch(/lacks permission "settings:write"/);
const reader = plugin({ permissions: ['settings:read'], grantedPermissions: ['settings:read'] });
expect(await dispatchApiCall(reader, 'keywords.list', []))
@@ -152,6 +154,81 @@ describe('native keyword extension API', () => {
expect(useSettingsStore.getState().emailKeywords).toEqual(before);
});
it('reorders complete label sets without changing user metadata', async () => {
useSettingsStore.setState({
emailKeywords: [
{ id: 'work', label: 'My Work', color: 'purple', visibility: 'hide' },
{ id: 'shopping', label: 'Shopping', color: 'green', visibility: 'unread' },
{ id: 'travel', label: 'Travel', color: 'blue' },
],
});
const writer = plugin({ permissions: ['settings:write'], grantedPermissions: ['settings:write'] });
const result = await dispatchApiCall(writer, 'keywords.reorder', [[
'TRAVEL', 'work', 'shopping',
]]);
expect(result).toEqual([
{ id: 'travel', label: 'Travel', color: 'blue' },
{ id: 'work', label: 'My Work', color: 'purple', visibility: 'hide' },
{ id: 'shopping', label: 'Shopping', color: 'green', visibility: 'unread' },
]);
expect(useSettingsStore.getState().emailKeywords).toEqual(result);
});
it('supports optional case-sensitive label id matching', async () => {
const original = [
{ id: 'Work', label: 'Work', color: 'purple' },
{ id: 'shopping', label: 'Shopping', color: 'green' },
];
useSettingsStore.setState({ emailKeywords: original });
const writer = plugin({ permissions: ['settings:write'], grantedPermissions: ['settings:write'] });
await expect(dispatchApiCall(writer, 'keywords.reorder', [
['SHOPPING', 'work'],
{ caseSensitive: true },
])).rejects.toThrow(/unknown label id/);
expect(useSettingsStore.getState().emailKeywords).toEqual(original);
await expect(dispatchApiCall(writer, 'keywords.reorder', [
['shopping', 'Work'],
{ caseSensitive: true },
])).resolves.toEqual([original[1], original[0]]);
});
it('rejects invalid reorder options without changing settings', async () => {
const original = [{ id: 'work', label: 'Work', color: 'purple' }];
useSettingsStore.setState({ emailKeywords: original });
const writer = plugin({ permissions: ['settings:write'], grantedPermissions: ['settings:write'] });
for (const options of [true, { caseSensitive: 'yes' }, { unknown: true }]) {
await expect(dispatchApiCall(writer, 'keywords.reorder', [
['work'], options,
])).rejects.toThrow(/options/);
expect(useSettingsStore.getState().emailKeywords).toEqual(original);
}
});
it('rejects incomplete, duplicate, and unknown reorder ids atomically', async () => {
const original = [
{ id: 'work', label: 'Work', color: 'purple' },
{ id: 'shopping', label: 'Shopping', color: 'green' },
];
useSettingsStore.setState({ emailKeywords: original });
const writer = plugin({ permissions: ['settings:write'], grantedPermissions: ['settings:write'] });
for (const ids of [
['work'],
['work', 'WORK'],
['work', 'missing'],
]) {
await expect(dispatchApiCall(writer, 'keywords.reorder', [ids])).rejects.toThrow(
/every existing label|duplicate label id|unknown label id/,
);
expect(useSettingsStore.getState().emailKeywords).toEqual(original);
}
});
it('gates discovery and counts as email metadata', async () => {
for (const method of ['jmap.getKeywords', 'keywords.discover', 'keywords.getCounts', 'keywords.refreshCounts']) {
expect(await gateError(plugin(), method, [])).toMatch(/lacks permission "email:read"/);
+57 -3
View File
@@ -128,11 +128,13 @@ const PERM_PER_METHOD: Record<string, Permission | null> = {
'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.
// plugin can read definitions, append missing ones, or reorder the complete
// existing set, 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.reorder': 'settings:write',
'keywords.discover': 'email:read',
'keywords.getCounts': 'email:read',
'keywords.refreshCounts': 'email:read',
@@ -909,6 +911,57 @@ function doKeywordsAdd(value: unknown): { added: KeywordDefinition[]; skipped: s
return { added: added.map((keyword) => ({ ...keyword })), skipped };
}
function doKeywordsReorder(value: unknown, rawOptions?: unknown): KeywordDefinition[] {
if (!Array.isArray(value)) {
throw new Error('keywords.reorder: ids must be an array');
}
if (value.some((id) => typeof id !== 'string')) {
throw new Error('keywords.reorder: ids must contain only strings');
}
if (rawOptions !== undefined && !isPlainObject(rawOptions)) {
throw new Error('keywords.reorder: options must be an object');
}
const options = rawOptions as Record<string, unknown> | undefined;
if (options && Object.keys(options).some((key) => key !== 'caseSensitive')) {
throw new Error('keywords.reorder: options contains an unknown property');
}
if (options?.caseSensitive !== undefined && typeof options.caseSensitive !== 'boolean') {
throw new Error('keywords.reorder: options.caseSensitive must be a boolean');
}
const caseSensitive = options?.caseSensitive === true;
const normalizeId = (id: string) => caseSensitive ? id : id.toLowerCase();
const existing = useSettingsStore.getState().emailKeywords;
if (value.length !== existing.length) {
throw new Error('keywords.reorder: ids must contain every existing label exactly once');
}
const byId = new Map(existing.map((keyword) => [normalizeId(keyword.id), keyword]));
if (byId.size !== existing.length) {
throw new Error('keywords.reorder: existing label ids are not unique');
}
const seen = new Set<string>();
const reordered: KeywordDefinition[] = [];
for (const id of value as string[]) {
const normalizedId = normalizeId(id);
if (seen.has(normalizedId)) {
throw new Error(`keywords.reorder: duplicate label id: ${id}`);
}
const keyword = byId.get(normalizedId);
if (!keyword) {
throw new Error(`keywords.reorder: unknown label id: ${id}`);
}
seen.add(normalizedId);
reordered.push(keyword);
}
// Reuse the existing definitions verbatim so ordering cannot change a
// label's name, colour, visibility, id casing, or any future metadata.
useSettingsStore.setState({ emailKeywords: reordered });
return reordered.map((keyword) => ({ ...keyword }));
}
function assertKeywordIds(value: unknown): string[] | undefined {
if (value === undefined || value === null) return undefined;
if (!Array.isArray(value) || value.length > MAX_PLUGIN_KEYWORD_DEFINITIONS) {
@@ -1250,6 +1303,7 @@ export async function dispatchApiCall(
case 'keywords.list': return doKeywordsList();
case 'keywords.add': return doKeywordsAdd(args[0]);
case 'keywords.reorder': return doKeywordsReorder(args[0], args[1]);
case 'keywords.discover': return doKeywordsDiscover(args[0]);
case 'keywords.getCounts': return doKeywordsGetCounts(args[0]);
case 'keywords.refreshCounts': return doKeywordsRefreshCounts();
+1 -1
View File
@@ -254,7 +254,7 @@ export const API_METHODS = [
// 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',
'keywords.list', 'keywords.add', 'keywords.reorder', '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.
+4 -2
View File
@@ -340,8 +340,8 @@ function buildPluginApi(manifest: PluginManifest) {
},
// 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.
// add() is intentionally append-only. reorder() requires a complete
// permutation of existing ids. Neither method overwrites or removes tags.
keywords: {
list: () => callApi('keywords.list', []) as Promise<PluginKeywordDefinition[]>,
add: (definitions: PluginKeywordDefinitionInput[]) =>
@@ -349,6 +349,8 @@ function buildPluginApi(manifest: PluginManifest) {
added: PluginKeywordDefinition[];
skipped: string[];
}>,
reorder: (ids: string[], options?: { caseSensitive?: boolean }) =>
callApi('keywords.reorder', [ids, options]) as Promise<PluginKeywordDefinition[]>,
discover: (options?: { limit?: number }) =>
callApi('keywords.discover', [options]) as Promise<{
keywords: Record<string, number>;