mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
feat: expose gateway keywords to extensions
This commit is contained in:
@@ -264,13 +264,12 @@ const api = require('@plugin-host');
|
||||
|
||||
const known = await api.keywords.list(); // settings:read
|
||||
const scan = await api.jmap.getKeywords(); // email:read
|
||||
const id = Object.keys(scan.keywords)
|
||||
.find((keyword) => keyword.startsWith('$label:'))
|
||||
?.slice('$label:'.length);
|
||||
if (id) {
|
||||
const providerLabel = scan.labels
|
||||
.find((label) => label.id.startsWith('$label:'));
|
||||
if (providerLabel) {
|
||||
await api.keywords.add([{ // settings:write
|
||||
id,
|
||||
label: 'Provider label',
|
||||
id: providerLabel.id.slice('$label:'.length),
|
||||
label: providerLabel.name,
|
||||
// color is optional; Bulwark picks a palette colour when omitted
|
||||
visibility: 'show',
|
||||
}]);
|
||||
@@ -287,9 +286,11 @@ 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.
|
||||
request API. When the server advertises `urn:mail-gateway:keywords`, it returns
|
||||
all cached keywords with exact total/unread counts and provider-label metadata,
|
||||
including empty Gmail labels. On ordinary JMAP servers it falls back to a
|
||||
bounded scan of message keywords. `keywords.discover()` retains its original
|
||||
message-scan response for compatibility.
|
||||
|
||||
`jmap.setKeywords()` replaces one message's complete keyword map via
|
||||
`Email/set`. Omitted keywords are removed, so extensions should use the existing
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { JMAPClient } from '../jmap/client';
|
||||
import { GATEWAY_KEYWORDS_CAPABILITY, JMAPClient } from '../jmap/client';
|
||||
|
||||
// JMAP has no way to ask which keywords an account uses, so `discoverKeywords`
|
||||
// finds them by walking the message list: an Email/query for the next slice of
|
||||
@@ -8,10 +8,19 @@ import { JMAPClient } from '../jmap/client';
|
||||
// admits when it stopped early rather than passing a partial scan off as the
|
||||
// whole account.
|
||||
|
||||
function makeSession(core: Record<string, number> = {}) {
|
||||
function makeSession(core: Record<string, number> = {}, gatewayKeywords = false) {
|
||||
return {
|
||||
capabilities: { 'urn:ietf:params:jmap:core': core },
|
||||
accounts: { 'acct-1': { name: 'test', isPersonal: true, accountCapabilities: {} } },
|
||||
capabilities: {
|
||||
'urn:ietf:params:jmap:core': core,
|
||||
...(gatewayKeywords ? { [GATEWAY_KEYWORDS_CAPABILITY]: { supportsCounts: true } } : {}),
|
||||
},
|
||||
accounts: {
|
||||
'acct-1': {
|
||||
name: 'test',
|
||||
isPersonal: true,
|
||||
accountCapabilities: gatewayKeywords ? { [GATEWAY_KEYWORDS_CAPABILITY]: {} } : {},
|
||||
},
|
||||
},
|
||||
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'acct-1' },
|
||||
apiUrl: 'https://mail.example.com/jmap/api',
|
||||
downloadUrl: 'https://mail.example.com/jmap/download/{accountId}/{blobId}/{name}',
|
||||
@@ -58,6 +67,14 @@ describe('JMAPClient.discoverKeywords', () => {
|
||||
return client;
|
||||
}
|
||||
|
||||
async function connectedGatewayClient(core?: Record<string, number>): Promise<JMAPClient> {
|
||||
fetchSpy.mockResolvedValueOnce(jsonResponse(makeSession(core, true)));
|
||||
const client = JMAPClient.withBearer('https://mail.example.com', 'token123', 'user@test.com');
|
||||
await client.connect();
|
||||
fetchSpy.mockReset();
|
||||
return client;
|
||||
}
|
||||
|
||||
/** Records the method calls of every request the client makes. */
|
||||
function recordRequests(reply: (methodCalls: Array<[string, Record<string, unknown>, string]>, index: number) => unknown) {
|
||||
const sent: Array<Array<[string, Record<string, unknown>, string]>> = [];
|
||||
@@ -206,4 +223,54 @@ describe('JMAPClient.discoverKeywords', () => {
|
||||
|
||||
expect(result).toMatchObject({ keywords: {}, scanned: 0, total: 0, complete: true });
|
||||
});
|
||||
|
||||
it('uses Gateway Keyword/get and preserves provider-label metadata', async () => {
|
||||
const client = await connectedGatewayClient();
|
||||
let requestBody: Record<string, unknown> | undefined;
|
||||
fetchSpy.mockImplementationOnce((async (_url: string, init: RequestInit) => {
|
||||
requestBody = JSON.parse(init.body as string);
|
||||
return jsonResponse({
|
||||
methodResponses: [['Keyword/get', {
|
||||
accountId: 'acct-1',
|
||||
totalEmails: 42,
|
||||
list: [
|
||||
{ id: '$flagged', name: '$flagged', color: null, total: 8, unread: 3, isProviderLabel: false, source: 'message' },
|
||||
{ id: '$label:GoogleVoice', name: 'Google Voice', color: null, total: 0, unread: 0, isProviderLabel: true, source: 'provider' },
|
||||
],
|
||||
notFound: [],
|
||||
}, '0']],
|
||||
});
|
||||
}) as never);
|
||||
|
||||
const result = await client.getKeywords();
|
||||
|
||||
expect(requestBody).toMatchObject({
|
||||
using: ['urn:ietf:params:jmap:core', GATEWAY_KEYWORDS_CAPABILITY],
|
||||
methodCalls: [['Keyword/get', { accountId: 'acct-1' }, '0']],
|
||||
});
|
||||
expect(result).toEqual({
|
||||
keywords: { $flagged: 8, '$label:GoogleVoice': 0 },
|
||||
labels: [
|
||||
{ id: '$flagged', name: '$flagged', color: null, total: 8, unread: 3, isProviderLabel: false, source: 'message' },
|
||||
{ id: '$label:GoogleVoice', name: 'Google Voice', color: null, total: 0, unread: 0, isProviderLabel: true, source: 'provider' },
|
||||
],
|
||||
scanned: 42,
|
||||
total: 42,
|
||||
complete: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to scanning on ordinary JMAP servers', async () => {
|
||||
const client = await connectedClient();
|
||||
recordRequests(() => page(['m1'], [{ '$label:work': true, $seen: true }], 1));
|
||||
|
||||
const result = await client.getKeywords();
|
||||
|
||||
expect(result.keywords).toEqual({ '$label:work': 1, $seen: 1 });
|
||||
expect(result.labels).toEqual([
|
||||
expect.objectContaining({ id: '$label:work', name: 'work', total: 1, source: 'message' }),
|
||||
expect.objectContaining({ id: '$seen', name: '$seen', total: 1, source: 'message' }),
|
||||
]);
|
||||
expect(result).toMatchObject({ scanned: 1, total: 1, complete: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { afterEach, describe, it, expect, vi } from 'vitest';
|
||||
import type { InstalledPlugin } from '../plugin-types';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import { DEFAULT_KEYWORDS, useSettingsStore } from '@/stores/settings-store';
|
||||
|
||||
@@ -98,6 +99,7 @@ describe('method-name typos', () => {
|
||||
|
||||
describe('native keyword extension API', () => {
|
||||
afterEach(() => {
|
||||
useAuthStore.setState({ client: null });
|
||||
useSettingsStore.setState({ emailKeywords: [...DEFAULT_KEYWORDS] });
|
||||
useEmailStore.setState({ tagCounts: {} });
|
||||
});
|
||||
@@ -169,6 +171,27 @@ describe('native keyword extension API', () => {
|
||||
expect(error).toMatch(/No active session/);
|
||||
});
|
||||
|
||||
it('routes jmap.getKeywords through the client implementation', async () => {
|
||||
const result = {
|
||||
keywords: { '$label:work': 2 },
|
||||
labels: [{
|
||||
id: '$label:work', name: 'Work', color: null, total: 2, unread: 1,
|
||||
isProviderLabel: true, source: 'provider' as const,
|
||||
}],
|
||||
scanned: 8,
|
||||
total: 8,
|
||||
complete: true,
|
||||
};
|
||||
const getKeywords = vi.fn().mockResolvedValue(result);
|
||||
const discoverKeywords = vi.fn();
|
||||
useAuthStore.setState({ client: { getKeywords, discoverKeywords } as never });
|
||||
const reader = plugin({ permissions: ['email:read'], grantedPermissions: ['email:read'] });
|
||||
|
||||
await expect(dispatchApiCall(reader, 'jmap.getKeywords', [{ limit: 100 }])).resolves.toEqual(result);
|
||||
expect(getKeywords).toHaveBeenCalledWith({ limit: 100 });
|
||||
expect(discoverKeywords).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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"/);
|
||||
|
||||
+21
-1
@@ -1,4 +1,4 @@
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
import type { IJMAPClient, KeywordDiscoveryResult } from '@/lib/jmap/client-interface';
|
||||
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, ScheduledEmail, SendEmailResult, SharedAccount } from '@/lib/jmap/types';
|
||||
import type { SieveScript, SieveCapabilities } from '@/lib/jmap/sieve-types';
|
||||
import { getDemoData, type DemoData } from './demo-data';
|
||||
@@ -249,6 +249,26 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
return { keywords, scanned, total, complete: scanned >= total };
|
||||
}
|
||||
|
||||
async getKeywords(options?: {
|
||||
limit?: number;
|
||||
onProgress?: (scanned: number, total: number) => void;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<KeywordDiscoveryResult> {
|
||||
const scan = await this.discoverKeywords(options);
|
||||
return {
|
||||
...scan,
|
||||
labels: Object.entries(scan.keywords).map(([id, total]) => ({
|
||||
id,
|
||||
name: id.startsWith('$label:') ? id.slice('$label:'.length) : id,
|
||||
color: null,
|
||||
total,
|
||||
unread: 0,
|
||||
isProviderLabel: false,
|
||||
source: 'message' as const,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async getCategoryUnreadCounts(mailboxId: string, tabs: Array<{ id: string; filter: Record<string, unknown> | null }>, _accountId?: string): Promise<Record<string, number>> {
|
||||
const inBox = this.data.emails.filter(e => e.mailboxIds[mailboxId] && !e.keywords.$seen);
|
||||
const result: Record<string, number> = {};
|
||||
|
||||
@@ -1,6 +1,24 @@
|
||||
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeRights, Principal, PushSubscription, ScheduledEmail, SendEmailResult, SharedAccount } from "./types";
|
||||
import type { SieveScript, SieveCapabilities } from "./sieve-types";
|
||||
|
||||
export interface KeywordInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string | null;
|
||||
total: number;
|
||||
unread: number;
|
||||
isProviderLabel: boolean;
|
||||
source: 'provider' | 'message';
|
||||
}
|
||||
|
||||
export interface KeywordDiscoveryResult {
|
||||
keywords: Record<string, number>;
|
||||
labels: KeywordInfo[];
|
||||
scanned: number;
|
||||
total: number;
|
||||
complete: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface defining the public JMAP client contract.
|
||||
*
|
||||
@@ -88,6 +106,15 @@ export interface IJMAPClient {
|
||||
getEmail(emailId: string, accountId?: string): Promise<Email | null>;
|
||||
getSomeEmails(emailsId: string[], accountId?: string): Promise<Email[]>
|
||||
getTagCounts(tagIds: string[]): Promise<Record<string, { total: number; unread: number }>>;
|
||||
/**
|
||||
* Enumerate account keywords for extensions. Mail Gateway can return exact
|
||||
* counts and provider-label metadata; other servers use the bounded scan.
|
||||
*/
|
||||
getKeywords(options?: {
|
||||
limit?: number;
|
||||
onProgress?: (scanned: number, total: number) => void;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<KeywordDiscoveryResult>;
|
||||
/**
|
||||
* Every keyword currently set on the account's messages and how many of the
|
||||
* walked messages carry it, found by walking the message list - JMAP offers no
|
||||
|
||||
+55
-1
@@ -1,6 +1,6 @@
|
||||
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeFilter, FileNodeRights, Principal, PushSubscription, EmailSubmission, ScheduledEmail, SendEmailResult, SharedAccount } from "./types";
|
||||
import type { SieveScript, SieveCapabilities } from "./sieve-types";
|
||||
import type { IJMAPClient } from "./client-interface";
|
||||
import type { IJMAPClient, KeywordDiscoveryResult, KeywordInfo } from "./client-interface";
|
||||
import { toWildcardQuery } from "./search-utils";
|
||||
import { batched, itemsPerRequest } from "./request-limits";
|
||||
import { debug } from "@/lib/debug";
|
||||
@@ -139,6 +139,8 @@ const SUBMISSION_USING = [
|
||||
'urn:ietf:params:jmap:submission',
|
||||
] as const;
|
||||
|
||||
export const GATEWAY_KEYWORDS_CAPABILITY = 'urn:mail-gateway:keywords';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type JMAPResponseResult = Record<string, any>;
|
||||
|
||||
@@ -1502,6 +1504,58 @@ export class JMAPClient implements IJMAPClient {
|
||||
return { keywords, scanned, total: Math.max(total, scanned), complete };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension-facing keyword enumeration.
|
||||
*
|
||||
* Mail Gateway advertises a narrow capability that can enumerate all cached
|
||||
* keywords in one request, including provider labels with no messages. A
|
||||
* normal JMAP server has no equivalent method, so retain the existing
|
||||
* message walk as a transparent fallback.
|
||||
*/
|
||||
async getKeywords(options?: {
|
||||
limit?: number;
|
||||
onProgress?: (scanned: number, total: number) => void;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<KeywordDiscoveryResult> {
|
||||
const supportsGatewayKeywords =
|
||||
this.hasCapability(GATEWAY_KEYWORDS_CAPABILITY) &&
|
||||
this.hasAccountCapability(GATEWAY_KEYWORDS_CAPABILITY);
|
||||
|
||||
if (!supportsGatewayKeywords) {
|
||||
const scan = await this.discoverKeywords(options);
|
||||
const labels: KeywordInfo[] = Object.entries(scan.keywords).map(([id, total]) => ({
|
||||
id,
|
||||
name: id.startsWith('$label:') ? id.slice('$label:'.length) : id,
|
||||
color: null,
|
||||
total,
|
||||
unread: 0,
|
||||
isProviderLabel: false,
|
||||
source: 'message',
|
||||
}));
|
||||
return { ...scan, labels };
|
||||
}
|
||||
|
||||
if (options?.signal?.aborted) {
|
||||
return { keywords: {}, labels: [], scanned: 0, total: 0, complete: false };
|
||||
}
|
||||
|
||||
const response = await this.request(
|
||||
[["Keyword/get", { accountId: this.accountId }, "0"]],
|
||||
["urn:ietf:params:jmap:core", GATEWAY_KEYWORDS_CAPABILITY],
|
||||
);
|
||||
const [method, result] = response.methodResponses?.[0] ?? [];
|
||||
if (method !== 'Keyword/get' || !Array.isArray(result?.list)) {
|
||||
const description = typeof result?.description === 'string' ? `: ${result.description}` : '';
|
||||
throw new Error(`Mail Gateway Keyword/get failed${description}`);
|
||||
}
|
||||
|
||||
const labels = (result.list as KeywordInfo[]).map((item) => ({ ...item }));
|
||||
const keywords = Object.fromEntries(labels.map((item) => [item.id, item.total]));
|
||||
const total = typeof result.totalEmails === 'number' ? result.totalEmails : 0;
|
||||
options?.onProgress?.(total, total);
|
||||
return { keywords, labels, scanned: total, total, complete: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-tab unread counts for message-list category tabs. One Email/query
|
||||
* (limit 0, calculateTotal) per tab, batched into as few requests as the
|
||||
|
||||
@@ -937,18 +937,26 @@ function doKeywordsGetCounts(value?: unknown): Record<string, { total: number; u
|
||||
return selected;
|
||||
}
|
||||
|
||||
async function doKeywordsDiscover(value?: unknown) {
|
||||
function keywordDiscoveryOptions(value: unknown, method: string): { limit: number } | undefined {
|
||||
if (value !== undefined && !isPlainObject(value)) {
|
||||
throw new Error('keywords.discover: options must be an object');
|
||||
throw new Error(`${method}: 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}`);
|
||||
throw new Error(`${method}: limit must be an integer from 1 to ${DEFAULT_KEYWORD_SCAN_LIMIT}`);
|
||||
}
|
||||
return requireClient().discoverKeywords(rawLimit === undefined ? undefined : { limit: rawLimit });
|
||||
return rawLimit === undefined ? undefined : { limit: rawLimit };
|
||||
}
|
||||
|
||||
async function doJmapGetKeywords(value?: unknown) {
|
||||
return requireClient().getKeywords(keywordDiscoveryOptions(value, 'jmap.getKeywords'));
|
||||
}
|
||||
|
||||
async function doKeywordsDiscover(value?: unknown) {
|
||||
return requireClient().discoverKeywords(keywordDiscoveryOptions(value, 'keywords.discover'));
|
||||
}
|
||||
|
||||
async function doKeywordsRefreshCounts(): Promise<Record<string, { total: number; unread: number }>> {
|
||||
@@ -1104,7 +1112,7 @@ 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.getKeywords': return doJmapGetKeywords(args[0]);
|
||||
case 'jmap.setKeywords': {
|
||||
const emailId = assertEmailId(args[0], 'jmap.setKeywords');
|
||||
const accountId = assertOptionalAccountId(args[2], 'jmap.setKeywords');
|
||||
|
||||
@@ -220,9 +220,9 @@ function buildPluginApi(manifest: PluginManifest) {
|
||||
// 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.
|
||||
* Enumerate keywords in the active account. Mail Gateway supplies exact
|
||||
* counts and provider-label metadata; ordinary JMAP servers fall back to
|
||||
* scanning message keywords.
|
||||
*/
|
||||
getKeywords: (options?: { limit?: number }) =>
|
||||
callApi('jmap.getKeywords', [options]) as Promise<{
|
||||
@@ -230,6 +230,15 @@ function buildPluginApi(manifest: PluginManifest) {
|
||||
scanned: number;
|
||||
total: number;
|
||||
complete: boolean;
|
||||
labels: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
color: string | null;
|
||||
total: number;
|
||||
unread: number;
|
||||
isProviderLabel: boolean;
|
||||
source: 'provider' | 'message';
|
||||
}>;
|
||||
}>,
|
||||
/**
|
||||
* Replace an email's complete keyword map. Omitted keywords are removed;
|
||||
|
||||
Reference in New Issue
Block a user