mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-30 16:16:19 +00:00
feat: expose label reordering to extensions
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
@@ -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"/);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user