feat: add message-list category tabs
This commit is contained in:
@@ -7,6 +7,9 @@ import { IMPLICIT_PERMISSIONS } from '../plugin-types';
|
||||
import { toast as appToast } from '@/stores/toast-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import { useFilterStore } from '@/stores/filter-store';
|
||||
import { useMessageListTabsStore } from '@/stores/message-list-tabs-store';
|
||||
import type { MessageListTabsConfig } from '../plugin-types';
|
||||
import { apiFetch } from '../browser-navigation';
|
||||
import { awaitDialog, awaitPrompt, type PromptField } from './host-dialog';
|
||||
import { fileStorage } from '../plugin-storage';
|
||||
@@ -65,7 +68,23 @@ const PERM_PER_METHOD: Record<string, Permission | null> = {
|
||||
'ui.rerenderEmail': null,
|
||||
'ui.rerenderFetchedEmails': null,
|
||||
'ui.openExternalUrl': null,
|
||||
'ui.downloadFile': 'ui:download-file'
|
||||
'ui.downloadFile': 'ui:download-file',
|
||||
// email keyword mutations
|
||||
'email.setKeyword': 'email:write',
|
||||
'email.removeKeyword': 'email:write',
|
||||
// message-list category tabs
|
||||
'tabs.set': 'ui:message-list-tabs',
|
||||
'tabs.clear': 'ui:message-list-tabs',
|
||||
'tabs.getState': 'ui:message-list-tabs',
|
||||
'tabs.refreshCounts': 'ui:message-list-tabs',
|
||||
// categorize rewrites message keywords, so it needs the write permission
|
||||
// (a tabs-only plugin can still render tabs without it).
|
||||
'tabs.categorize': 'email:write',
|
||||
// sieve (delivery-time classification)
|
||||
'sieve.isSupported': 'filters:read',
|
||||
'sieve.getActiveScript': 'filters:read',
|
||||
'sieve.validateScript': 'filters:write',
|
||||
'sieve.regenerate': 'filters:write',
|
||||
};
|
||||
|
||||
function hasPermission(plugin: InstalledPlugin, perm: Permission): boolean {
|
||||
@@ -511,6 +530,90 @@ async function downloadFile(args: { content: string; filename: string; contentTy
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Email keyword mutations ──────────────────────────────────
|
||||
|
||||
// Syntactic JMAP keyword check (RFC 5788 charset, conservative). Semantics
|
||||
// (reserved keywords for category tabs) are enforced by the tabs store.
|
||||
const PLUGIN_KEYWORD_RE = /^[a-z0-9$][a-z0-9$_.:-]{0,127}$/i;
|
||||
|
||||
function assertPluginKeyword(keyword: unknown): string {
|
||||
if (typeof keyword !== 'string' || !PLUGIN_KEYWORD_RE.test(keyword)) {
|
||||
throw new Error(`Invalid JMAP keyword "${String(keyword)}"`);
|
||||
}
|
||||
return keyword;
|
||||
}
|
||||
|
||||
function requireClient() {
|
||||
const { client } = useAuthStore.getState();
|
||||
if (!client) throw new Error('No active session');
|
||||
return client;
|
||||
}
|
||||
|
||||
// ─── Message-list category tabs ───────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolve the currently viewed mailbox to its JMAP id + owning account, the
|
||||
* same way email-store's fetchEmails does (shared mailboxes use namespaced
|
||||
* store ids).
|
||||
*/
|
||||
function resolveSelectedMailboxForQuery(): { jmapMailboxId: string; accountId?: string } | null {
|
||||
const { selectedMailbox, mailboxes } = useEmailStore.getState();
|
||||
if (!selectedMailbox) return null;
|
||||
const mailbox = mailboxes.find((mb) => mb.id === selectedMailbox);
|
||||
if (!mailbox) return null;
|
||||
return {
|
||||
jmapMailboxId: mailbox.originalId || mailbox.id,
|
||||
accountId: mailbox.isShared ? mailbox.accountId : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async function doTabsRefreshCounts(): Promise<Record<string, number>> {
|
||||
const client = requireClient();
|
||||
const resolved = resolveSelectedMailboxForQuery();
|
||||
if (!resolved) return {};
|
||||
await useMessageListTabsStore.getState().refreshCounts(client, resolved.jmapMailboxId, resolved.accountId);
|
||||
return useMessageListTabsStore.getState().tabCounts;
|
||||
}
|
||||
|
||||
async function doTabsCategorize(emailIds: unknown, tabId: unknown): Promise<boolean> {
|
||||
if (!Array.isArray(emailIds) || emailIds.some((id) => typeof id !== 'string')) {
|
||||
throw new Error('tabs.categorize: emailIds must be a string array');
|
||||
}
|
||||
if (typeof tabId !== 'string') throw new Error('tabs.categorize: tabId must be a string');
|
||||
const client = requireClient();
|
||||
const moved = await useMessageListTabsStore.getState().categorizeEmails(client, emailIds as string[], tabId);
|
||||
if (moved) void doTabsRefreshCounts().catch(() => { /* counts refresh is best-effort */ });
|
||||
return moved;
|
||||
}
|
||||
|
||||
// ─── Sieve (delivery-time classification) ─────────────────────
|
||||
|
||||
async function doSieveGetActiveScript(): Promise<{ id: string; name: string; content: string } | null> {
|
||||
const client = requireClient();
|
||||
if (!client.supportsSieve()) return null;
|
||||
const scripts = await client.getSieveScripts();
|
||||
const active = scripts.find((s) => s.isActive);
|
||||
if (!active) return null;
|
||||
const content = await client.getSieveScriptContent(active.blobId);
|
||||
return { id: active.id, name: active.name, content };
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-generate and re-upload the account's active Sieve script through the
|
||||
* filter store, which runs the filterHooks.onSieveScriptGenerate transform -
|
||||
* the supported way for a plugin to install/update its managed section
|
||||
* (e.g. the inbox-category classifier) without clobbering user filters.
|
||||
*/
|
||||
async function doSieveRegenerate(): Promise<void> {
|
||||
const client = requireClient();
|
||||
if (!client.supportsSieve()) throw new Error('Sieve is not supported by this server');
|
||||
const filterStore = useFilterStore.getState();
|
||||
// Sync from the server first: a background plugin may call this before the
|
||||
// filters settings page has ever populated the store.
|
||||
await filterStore.fetchFilters(client);
|
||||
await useFilterStore.getState().saveFilters(client);
|
||||
}
|
||||
|
||||
// ─── admin config (same as before) ────────────────────────────
|
||||
|
||||
async function adminGetAll(pluginId: string): Promise<Record<string, unknown>> {
|
||||
@@ -680,6 +783,48 @@ export async function dispatchApiCall(
|
||||
return downloadFile(opts);
|
||||
}
|
||||
|
||||
case 'email.setKeyword': {
|
||||
const keyword = assertPluginKeyword(args[1]);
|
||||
await requireClient().setKeyword(String(args[0]), keyword, args[2] as string | undefined);
|
||||
return undefined;
|
||||
}
|
||||
case 'email.removeKeyword': {
|
||||
const keyword = assertPluginKeyword(args[1]);
|
||||
await requireClient().removeKeyword(String(args[0]), keyword, args[2] as string | undefined);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
case 'tabs.set': {
|
||||
// validateTabsConfig (inside registerTabs) throws a developer-readable
|
||||
// error that surfaces as the api.tabs.set rejection in the sandbox.
|
||||
useMessageListTabsStore.getState().registerTabs(plugin.id, args[0] as MessageListTabsConfig);
|
||||
return undefined;
|
||||
}
|
||||
case 'tabs.clear': {
|
||||
useMessageListTabsStore.getState().clearTabs(plugin.id);
|
||||
return undefined;
|
||||
}
|
||||
case 'tabs.getState': {
|
||||
const { tabs, activeTabId, tabCounts } = useMessageListTabsStore.getState();
|
||||
return { tabs, activeTabId, tabCounts };
|
||||
}
|
||||
case 'tabs.refreshCounts': return doTabsRefreshCounts();
|
||||
case 'tabs.categorize': return doTabsCategorize(args[0], args[1]);
|
||||
|
||||
case 'sieve.isSupported': {
|
||||
const { client } = useAuthStore.getState();
|
||||
return !!client?.supportsSieve();
|
||||
}
|
||||
case 'sieve.getActiveScript': return doSieveGetActiveScript();
|
||||
case 'sieve.validateScript': {
|
||||
if (typeof args[0] !== 'string') throw new Error('sieve.validateScript: content must be a string');
|
||||
return requireClient().validateSieveScript(args[0]);
|
||||
}
|
||||
case 'sieve.regenerate': {
|
||||
await doSieveRegenerate();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`Unhandled method "${method}"`);
|
||||
}
|
||||
|
||||
@@ -10,8 +10,10 @@ import {
|
||||
uiHooks, themeHooks, toastHooks, dragDropHooks,
|
||||
keyboardHooks, appLifecycleHooks, accountSecurityHooks,
|
||||
sidebarAppHooks, avatarHooks, renderHooks, routerHooks,
|
||||
messageListTabHooks,
|
||||
removeAllPluginHooks, pluginErrorTracker,
|
||||
} from '../plugin-hooks';
|
||||
import { useMessageListTabsStore } from '@/stores/message-list-tabs-store';
|
||||
import { verifyBundle } from './bundle-integrity';
|
||||
import { createBackgroundInstance } from './host-bridge';
|
||||
import { resolvePluginTier } from './tier';
|
||||
@@ -30,6 +32,7 @@ const HOOK_BUSES: Record<string, AnyBus> = Object.assign({},
|
||||
uiHooks, themeHooks, toastHooks, dragDropHooks,
|
||||
keyboardHooks, appLifecycleHooks, accountSecurityHooks,
|
||||
sidebarAppHooks, avatarHooks, renderHooks, routerHooks,
|
||||
messageListTabHooks,
|
||||
) as Record<string, AnyBus>;
|
||||
|
||||
// ─── Store accessor (status updates flow through the existing store) ──
|
||||
@@ -179,6 +182,9 @@ export function unloadSandboxedPlugin(pluginId: string): void {
|
||||
try { d.dispose(); } catch { /* ignore */ }
|
||||
}
|
||||
removeAllPluginHooks(pluginId);
|
||||
// Drop any message-list category tabs the plugin registered so the strip
|
||||
// disappears (and the inbox unfilters) the moment the plugin is disabled.
|
||||
try { useMessageListTabsStore.getState().clearTabs(pluginId); } catch { /* ignore */ }
|
||||
try { entry.background.destroy(); } catch { /* ignore */ }
|
||||
cancelPluginDialogs(pluginId);
|
||||
pluginErrorTracker.reset(pluginId);
|
||||
|
||||
@@ -246,7 +246,13 @@ export const API_METHODS = [
|
||||
'upfiles.get', 'upfiles.save',
|
||||
'admin.getConfig', 'admin.getAllConfig', 'admin.setConfig', 'admin.deleteConfig',
|
||||
'toast.success', 'toast.error', 'toast.info', 'toast.warning',
|
||||
'ui.confirm', 'ui.alert', 'ui.prompt', 'ui.rerenderEmail', 'ui.rerenderFetchedEmails', 'ui.openExternalUrl', 'ui.downloadFile'
|
||||
'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',
|
||||
// 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];
|
||||
|
||||
@@ -249,6 +249,51 @@ function buildPluginApi(manifest: PluginManifest) {
|
||||
downloadFile: (opts: { content: string; filename: string; contentType?: string }) =>
|
||||
callApi('ui.downloadFile', [opts]) as Promise<void>,
|
||||
},
|
||||
// Email keyword mutations (permission: email:write). Keywords follow JMAP
|
||||
// syntax, e.g. '$category-promotions' or '$label:<tagId>'.
|
||||
email: {
|
||||
setKeyword: (emailId: string, keyword: string, accountId?: string) =>
|
||||
callApi('email.setKeyword', [emailId, keyword, accountId]) as Promise<void>,
|
||||
removeKeyword: (emailId: string, keyword: string, accountId?: string) =>
|
||||
callApi('email.removeKeyword', [emailId, keyword, accountId]) as Promise<void>,
|
||||
},
|
||||
// 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
|
||||
// mailbox Email/query - see MessageListTabsConfig in plugin-types. Tabs
|
||||
// are cleared automatically when the plugin unloads.
|
||||
tabs: {
|
||||
/** Register (or replace) this plugin's tab set. */
|
||||
set: (config: unknown) => callApi('tabs.set', [config]) as Promise<void>,
|
||||
/** Remove this plugin's tabs from the strip. */
|
||||
clear: () => callApi('tabs.clear', []) as Promise<void>,
|
||||
/** Current merged tabs, active tab id and unread counts. */
|
||||
getState: () => callApi('tabs.getState', []) as Promise<{
|
||||
tabs: unknown[]; activeTabId: string | null; tabCounts: Record<string, number>;
|
||||
}>,
|
||||
/** Re-query per-tab unread counts for the current mailbox. */
|
||||
refreshCounts: () => callApi('tabs.refreshCounts', []) as Promise<Record<string, number>>,
|
||||
/**
|
||||
* Move messages to a tab (patches the category keywords via Email/set).
|
||||
* Fires onBeforeEmailCategorize (cancellable) and onEmailCategorize.
|
||||
* Resolves false when cancelled.
|
||||
*/
|
||||
categorize: (emailIds: string[], tabId: string) =>
|
||||
callApi('tabs.categorize', [emailIds, tabId]) as Promise<boolean>,
|
||||
},
|
||||
// Sieve integration for delivery-time classification (permissions:
|
||||
// filters:read / filters:write). Plugins never write scripts directly -
|
||||
// they register an onSieveScriptGenerate transform hook and call
|
||||
// regenerate(), so user filter rules and plugin sections coexist in the
|
||||
// single active script.
|
||||
sieve: {
|
||||
isSupported: () => callApi('sieve.isSupported', []) as Promise<boolean>,
|
||||
getActiveScript: () => callApi('sieve.getActiveScript', []) as Promise<{ id: string; name: string; content: string } | null>,
|
||||
validateScript: (content: string) =>
|
||||
callApi('sieve.validateScript', [content]) as Promise<{ isValid: boolean; errors?: string[] }>,
|
||||
/** Rebuild + re-upload the active script, running onSieveScriptGenerate. */
|
||||
regenerate: () => callApi('sieve.regenerate', []) as Promise<void>,
|
||||
},
|
||||
admin: {
|
||||
getConfig: (key: string) => callApi('admin.getConfig', [key]),
|
||||
getAllConfig: () => callApi('admin.getAllConfig', []),
|
||||
|
||||
Reference in New Issue
Block a user