feat: add message-list category tabs
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
// Contract tests for the plugin-facing message-list category tabs
|
||||
// (api.tabs.set → stores/message-list-tabs-store.ts).
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import {
|
||||
useMessageListTabsStore,
|
||||
validateTabsConfig,
|
||||
resolveTabFilter,
|
||||
} from '@/stores/message-list-tabs-store';
|
||||
import type { MessageListTab } from '@/lib/plugin-types';
|
||||
|
||||
const GMAIL_STYLE = {
|
||||
tabs: [
|
||||
{ id: 'primary', label: 'Primary', keyword: null },
|
||||
{ id: 'promotions', label: 'Promotions', keyword: '$category-promotions' },
|
||||
{ id: 'social', label: 'Social', query: { operator: 'OR', conditions: [{ from: 'reddit.com' }, { from: 'linkedin.com' }] } },
|
||||
{ id: 'updates', label: 'Updates', keyword: '$category-updates' },
|
||||
],
|
||||
};
|
||||
|
||||
function resetStore() {
|
||||
useMessageListTabsStore.setState({
|
||||
registrations: {},
|
||||
tabs: [],
|
||||
mailboxRoles: [],
|
||||
activeTabId: null,
|
||||
tabCounts: {},
|
||||
isCountsLoading: false,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(resetStore);
|
||||
|
||||
describe('validateTabsConfig', () => {
|
||||
it('normalizes a valid config and defaults mailboxRoles to inbox', () => {
|
||||
const v = validateTabsConfig(GMAIL_STYLE);
|
||||
expect(v.tabs).toHaveLength(4);
|
||||
expect(v.mailboxRoles).toEqual(['inbox']);
|
||||
expect(v.tabs[1].keyword).toBe('$category-promotions');
|
||||
expect(v.tabs[2].query).toEqual(GMAIL_STYLE.tabs[2].query);
|
||||
});
|
||||
|
||||
it('lowercases keywords (IMAP keywords are case-insensitive)', () => {
|
||||
const v = validateTabsConfig({
|
||||
tabs: [
|
||||
{ id: 'a', label: 'A', keyword: '$Category-Promotions' },
|
||||
{ id: 'b', label: 'B', keyword: null },
|
||||
],
|
||||
});
|
||||
expect(v.tabs[0].keyword).toBe('$category-promotions');
|
||||
});
|
||||
|
||||
it('rejects reserved and tag keywords', () => {
|
||||
expect(() => validateTabsConfig({
|
||||
tabs: [{ id: 'a', label: 'A', keyword: '$seen' }, { id: 'b', label: 'B' }],
|
||||
})).toThrow(/reserved/);
|
||||
expect(() => validateTabsConfig({
|
||||
tabs: [{ id: 'a', label: 'A', keyword: '$label.x' }, { id: 'b', label: 'B' }],
|
||||
})).toThrow(/reserved/);
|
||||
});
|
||||
|
||||
it('rejects duplicate ids, multiple default tabs, and single-tab configs', () => {
|
||||
expect(() => validateTabsConfig({
|
||||
tabs: [{ id: 'a', label: 'A' }, { id: 'a', label: 'B', keyword: '$x' }],
|
||||
})).toThrow(/duplicate/);
|
||||
expect(() => validateTabsConfig({
|
||||
tabs: [{ id: 'a', label: 'A' }, { id: 'b', label: 'B' }],
|
||||
})).toThrow(/default/);
|
||||
expect(() => validateTabsConfig({
|
||||
tabs: [{ id: 'a', label: 'A', keyword: '$x' }],
|
||||
})).toThrow(/at least 2/);
|
||||
});
|
||||
|
||||
it('rejects non-object and oversized queries', () => {
|
||||
expect(() => validateTabsConfig({
|
||||
tabs: [{ id: 'a', label: 'A', query: 'from:x' as unknown as Record<string, unknown> }, { id: 'b', label: 'B' }],
|
||||
})).toThrow(/filter object/);
|
||||
const huge = { from: 'x'.repeat(20000) };
|
||||
expect(() => validateTabsConfig({
|
||||
tabs: [{ id: 'a', label: 'A', query: huge }, { id: 'b', label: 'B' }],
|
||||
})).toThrow(/exceeds/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveTabFilter', () => {
|
||||
const tabs = validateTabsConfig(GMAIL_STYLE).tabs as MessageListTab[];
|
||||
|
||||
it('uses hasKeyword for keyword tabs and the raw query for search tabs', () => {
|
||||
expect(resolveTabFilter(tabs[1], tabs)).toEqual({ hasKeyword: '$category-promotions' });
|
||||
expect(resolveTabFilter(tabs[2], tabs)).toEqual(GMAIL_STYLE.tabs[2].query);
|
||||
});
|
||||
|
||||
it('resolves the default tab to NOT(all other positive fragments)', () => {
|
||||
const filter = resolveTabFilter(tabs[0], tabs) as { operator: string; conditions: unknown[] };
|
||||
expect(filter.operator).toBe('NOT');
|
||||
expect(filter.conditions).toHaveLength(3);
|
||||
expect(filter.conditions).toContainEqual({ hasKeyword: '$category-promotions' });
|
||||
expect(filter.conditions).toContainEqual(GMAIL_STYLE.tabs[2].query);
|
||||
});
|
||||
});
|
||||
|
||||
describe('store registration', () => {
|
||||
it('registers tabs, activates the default tab, and scopes to the inbox role', () => {
|
||||
const store = useMessageListTabsStore.getState();
|
||||
store.registerTabs('category-tabs', GMAIL_STYLE);
|
||||
|
||||
const s = useMessageListTabsStore.getState();
|
||||
expect(s.tabs.map(t => t.id)).toEqual(['primary', 'promotions', 'social', 'updates']);
|
||||
expect(s.activeTabId).toBe('primary');
|
||||
expect(s.isEnabledForRole('inbox')).toBe(true);
|
||||
expect(s.isEnabledForRole('trash')).toBe(false);
|
||||
// Default tab active → NOT filter.
|
||||
const filter = s.getCategoryFilter('inbox') as { operator: string };
|
||||
expect(filter.operator).toBe('NOT');
|
||||
expect(s.getCategoryFilter('trash')).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps only the first default tab when two plugins both register one', () => {
|
||||
const store = useMessageListTabsStore.getState();
|
||||
store.registerTabs('p1', { tabs: [{ id: 'main', label: 'Main', order: 1 }, { id: 'x', label: 'X', keyword: '$x' }] });
|
||||
store.registerTabs('p2', { tabs: [{ id: 'other', label: 'Other', order: 2 }, { id: 'y', label: 'Y', keyword: '$y' }] });
|
||||
const defaults = useMessageListTabsStore.getState().tabs.filter(t => !t.query && !t.keyword);
|
||||
expect(defaults.map(t => t.id)).toEqual(['main']);
|
||||
});
|
||||
|
||||
it('clears a plugin registration and resets counts when the strip empties', () => {
|
||||
const store = useMessageListTabsStore.getState();
|
||||
store.registerTabs('category-tabs', GMAIL_STYLE);
|
||||
useMessageListTabsStore.setState({ tabCounts: { promotions: 5 } });
|
||||
store.clearTabs('category-tabs');
|
||||
const s = useMessageListTabsStore.getState();
|
||||
expect(s.tabs).toHaveLength(0);
|
||||
expect(s.activeTabId).toBeNull();
|
||||
expect(s.tabCounts).toEqual({});
|
||||
expect(s.getCategoryFilter('inbox')).toBeNull();
|
||||
});
|
||||
|
||||
it('switches the active tab and exposes its filter', () => {
|
||||
const store = useMessageListTabsStore.getState();
|
||||
store.registerTabs('category-tabs', GMAIL_STYLE);
|
||||
store.setActiveTab('promotions', 'inbox-id');
|
||||
expect(useMessageListTabsStore.getState().getCategoryFilter('inbox')).toEqual({ hasKeyword: '$category-promotions' });
|
||||
});
|
||||
});
|
||||
+61
-1
@@ -151,11 +151,43 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
|
||||
// ── Emails ────────────────────────────────────────────────────
|
||||
|
||||
async getEmails(mailboxId?: string, _accountId?: string, limit: number = 50, position: number = 0, _hasKeyword?: string, pinnedFirst?: boolean): Promise<{ emails: Email[]; hasMore: boolean; total: number }> {
|
||||
/**
|
||||
* Minimal JMAP filter evaluator for demo mode: supports the conditions the
|
||||
* message-list category tabs use (hasKeyword / notKeyword / from and
|
||||
* AND / OR / NOT operators). Unknown conditions match nothing.
|
||||
*/
|
||||
private matchesFilter(e: Email, filter: Record<string, unknown>): boolean {
|
||||
if (typeof filter.operator === 'string') {
|
||||
const conditions = (Array.isArray(filter.conditions) ? filter.conditions : []) as Record<string, unknown>[];
|
||||
switch (filter.operator) {
|
||||
case 'AND': return conditions.every(c => this.matchesFilter(e, c));
|
||||
case 'OR': return conditions.some(c => this.matchesFilter(e, c));
|
||||
case 'NOT': return !conditions.some(c => this.matchesFilter(e, c));
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
if (typeof filter.hasKeyword === 'string' && !e.keywords[filter.hasKeyword]) return false;
|
||||
if (typeof filter.notKeyword === 'string' && e.keywords[filter.notKeyword]) return false;
|
||||
if (typeof filter.from === 'string') {
|
||||
const q = filter.from.toLowerCase();
|
||||
const match = (e.from || []).some(f =>
|
||||
(f.email || '').toLowerCase().includes(q) || (f.name || '').toLowerCase().includes(q));
|
||||
if (!match) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async getEmails(mailboxId?: string, _accountId?: string, limit: number = 50, position: number = 0, hasKeyword?: string, pinnedFirst?: boolean, extraFilter?: Record<string, unknown>): Promise<{ emails: Email[]; hasMore: boolean; total: number }> {
|
||||
let filtered = this.data.emails;
|
||||
if (mailboxId) {
|
||||
filtered = filtered.filter(e => e.mailboxIds[mailboxId]);
|
||||
}
|
||||
if (hasKeyword) {
|
||||
filtered = filtered.filter(e => e.keywords[hasKeyword]);
|
||||
}
|
||||
if (extraFilter) {
|
||||
filtered = filtered.filter(e => this.matchesFilter(e, extraFilter));
|
||||
}
|
||||
const pinRank = (e: Email) => (pinnedFirst && e.keywords?.['$pinned'] ? 1 : 0);
|
||||
filtered.sort((a, b) =>
|
||||
pinRank(b) - pinRank(a) ||
|
||||
@@ -199,6 +231,17 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
return result;
|
||||
}
|
||||
|
||||
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> = {};
|
||||
for (const tab of tabs) {
|
||||
result[tab.id] = tab.filter
|
||||
? inBox.filter(e => this.matchesFilter(e, tab.filter as Record<string, unknown>)).length
|
||||
: inBox.length;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async searchEmails(query: string, mailboxId?: string, _accountId?: string, limit: number = 50, position: number = 0): Promise<{ emails: Email[]; hasMore: boolean; total: number }> {
|
||||
const q = query.toLowerCase();
|
||||
let filtered = this.data.emails.filter(e => {
|
||||
@@ -284,6 +327,23 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
if (email) email.keywords[keyword] = true;
|
||||
}
|
||||
|
||||
async removeKeyword(emailId: string, keyword: string): Promise<void> {
|
||||
const email = this.data.emails.find(e => e.id === emailId);
|
||||
if (email) delete email.keywords[keyword];
|
||||
}
|
||||
|
||||
async batchUpdateKeywords(emailIds: string[], patch: Record<string, boolean | null>): Promise<void> {
|
||||
for (const id of emailIds) {
|
||||
const email = this.data.emails.find(e => e.id === id);
|
||||
if (!email) continue;
|
||||
for (const [pointer, value] of Object.entries(patch)) {
|
||||
const keyword = pointer.startsWith('keywords/') ? pointer.slice('keywords/'.length) : pointer;
|
||||
if (value === null || value === false) delete email.keywords[keyword];
|
||||
else email.keywords[keyword] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async migrateKeyword(oldKeyword: string, newKeyword: string): Promise<number> {
|
||||
let count = 0;
|
||||
for (const email of this.data.emails) {
|
||||
|
||||
@@ -80,11 +80,15 @@ export interface IJMAPClient {
|
||||
// ── Emails ────────────────────────────────────────────────────
|
||||
// `pinnedFirst` sorts emails carrying the $pinned keyword to the top
|
||||
// (server-side hasKeyword sort comparator, RFC 8621), then receivedAt desc.
|
||||
getEmails(mailboxId?: string, accountId?: string, limit?: number, position?: number, hasKeyword?: string, pinnedFirst?: boolean): Promise<{ emails: Email[]; hasMore: boolean; total: number }>;
|
||||
// `extraFilter` is an arbitrary JMAP FilterCondition/FilterOperator ANDed
|
||||
// into the view - used by the message-list category tabs (search-based).
|
||||
getEmails(mailboxId?: string, accountId?: string, limit?: number, position?: number, hasKeyword?: string, pinnedFirst?: boolean, extraFilter?: Record<string, unknown>): Promise<{ emails: Email[]; hasMore: boolean; total: number }>;
|
||||
getEmailsInMailbox(mailboxId: string): Promise<Email[]>;
|
||||
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 }>>;
|
||||
/** Per-tab unread counts for message-list category tabs (filter = resolved tab fragment, null = unfiltered). */
|
||||
getCategoryUnreadCounts(mailboxId: string, tabs: Array<{ id: string; filter: Record<string, unknown> | null }>, accountId?: string): Promise<Record<string, number>>;
|
||||
searchEmails(query: string, mailboxId?: string, accountId?: string, limit?: number, position?: number): Promise<{ emails: Email[]; hasMore: boolean; total: number }>;
|
||||
advancedSearchEmails(
|
||||
filter: Record<string, unknown>,
|
||||
@@ -106,6 +110,9 @@ export interface IJMAPClient {
|
||||
toggleStar(emailId: string, starred: boolean, accountId?: string): Promise<void>;
|
||||
updateEmailKeywords(emailId: string, keywords: Record<string, boolean>, accountId?: string): Promise<void>;
|
||||
setKeyword(emailId: string, keyword: string, accountId?: string): Promise<void>;
|
||||
removeKeyword(emailId: string, keyword: string, accountId?: string): Promise<void>;
|
||||
/** Apply one `keywords/<name>` patch fragment (true=add, null=remove) to many messages in a single Email/set. */
|
||||
batchUpdateKeywords(emailIds: string[], patch: Record<string, boolean | null>, accountId?: string): Promise<void>;
|
||||
migrateKeyword(oldKeyword: string, newKeyword: string): Promise<number>;
|
||||
deleteEmail(emailId: string, accountId?: string): Promise<void>;
|
||||
moveToTrash(emailId: string, trashMailboxId: string, accountId?: string, markAsRead?: boolean): Promise<void>;
|
||||
|
||||
+81
-4
@@ -1173,16 +1173,27 @@ export class JMAPClient implements IJMAPClient {
|
||||
}
|
||||
}
|
||||
|
||||
async getEmails(mailboxId?: string, accountId?: string, limit: number = 50, position: number = 0, hasKeyword?: string, pinnedFirst?: boolean): Promise<{ emails: Email[], hasMore: boolean, total: number }> {
|
||||
async getEmails(mailboxId?: string, accountId?: string, limit: number = 50, position: number = 0, hasKeyword?: string, pinnedFirst?: boolean, extraFilter?: Record<string, unknown>): Promise<{ emails: Email[], hasMore: boolean, total: number }> {
|
||||
try {
|
||||
const targetAccountId = accountId || this.accountId;
|
||||
const filter: { inMailbox?: string; hasKeyword?: string } = {};
|
||||
const simple: { inMailbox?: string; hasKeyword?: string } = {};
|
||||
if (mailboxId) {
|
||||
filter.inMailbox = mailboxId;
|
||||
simple.inMailbox = mailboxId;
|
||||
}
|
||||
if (hasKeyword) {
|
||||
filter.hasKeyword = hasKeyword;
|
||||
simple.hasKeyword = hasKeyword;
|
||||
}
|
||||
// `extraFilter` is an arbitrary FilterCondition/FilterOperator ANDed
|
||||
// into the view - the message-list category tabs' search contract.
|
||||
const filter: Record<string, unknown> = extraFilter
|
||||
? {
|
||||
operator: "AND",
|
||||
conditions: [
|
||||
...(Object.keys(simple).length > 0 ? [simple] : []),
|
||||
extraFilter,
|
||||
],
|
||||
}
|
||||
: simple;
|
||||
// Pinned-first uses the hasKeyword sort comparator (RFC 8621 §4.4.2);
|
||||
// every page of a view must use the same sort or pagination tears.
|
||||
const sort = pinnedFirst
|
||||
@@ -1300,6 +1311,46 @@ export class JMAPClient implements IJMAPClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-tab unread counts for message-list category tabs. One Email/query
|
||||
* (limit 0, calculateTotal) per tab, batched in a single request. Each
|
||||
* entry's `filter` is the tab's resolved FilterCondition/FilterOperator
|
||||
* (null = no extra condition, i.e. all unread in the mailbox).
|
||||
*/
|
||||
async getCategoryUnreadCounts(
|
||||
mailboxId: string,
|
||||
tabs: Array<{ id: string; filter: Record<string, unknown> | null }>,
|
||||
accountId?: string,
|
||||
): Promise<Record<string, number>> {
|
||||
if (tabs.length === 0) return {};
|
||||
const targetAccountId = accountId || this.accountId;
|
||||
try {
|
||||
const methodCalls: JMAPMethodCall[] = tabs.map((tab, i) => {
|
||||
const conditions: Record<string, unknown>[] = [
|
||||
{ inMailbox: mailboxId },
|
||||
{ notKeyword: "$seen" },
|
||||
];
|
||||
if (tab.filter) conditions.push(tab.filter);
|
||||
return ["Email/query", {
|
||||
accountId: targetAccountId,
|
||||
filter: { operator: "AND", conditions },
|
||||
limit: 0,
|
||||
calculateTotal: true,
|
||||
}, `tab_${i}`];
|
||||
});
|
||||
|
||||
const response = await this.request(methodCalls);
|
||||
const result: Record<string, number> = {};
|
||||
for (let i = 0; i < tabs.length; i++) {
|
||||
result[tabs[i].id] = response.methodResponses?.[i]?.[1]?.total ?? 0;
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Failed to get category tab counts:', error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async getEmail(emailId: string, accountId?: string): Promise<Email | null> {
|
||||
try {
|
||||
const targetAccountId = accountId || this.accountId;
|
||||
@@ -1458,6 +1509,32 @@ export class JMAPClient implements IJMAPClient {
|
||||
]);
|
||||
}
|
||||
|
||||
async removeKeyword(emailId: string, keyword: string, accountId?: string): Promise<void> {
|
||||
await this.request([
|
||||
["Email/set", {
|
||||
accountId: accountId || this.accountId,
|
||||
update: {
|
||||
[emailId]: {
|
||||
[`keywords/${keyword}`]: null,
|
||||
},
|
||||
},
|
||||
}, "0"],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the same keyword PatchObject fragment to many messages in one
|
||||
* Email/set. `patch` keys are `keywords/<name>` pointers with true (add)
|
||||
* or null (remove) values - the category-tab move primitive.
|
||||
*/
|
||||
async batchUpdateKeywords(emailIds: string[], patch: Record<string, boolean | null>, accountId?: string): Promise<void> {
|
||||
if (emailIds.length === 0 || Object.keys(patch).length === 0) return;
|
||||
const update = Object.fromEntries(emailIds.map(id => [id, { ...patch }]));
|
||||
await this.request([
|
||||
["Email/set", { accountId: accountId || this.accountId, update }, "0"],
|
||||
]);
|
||||
}
|
||||
|
||||
async migrateKeyword(oldKeyword: string, newKeyword: string): Promise<number> {
|
||||
// Query all email IDs that have the old keyword
|
||||
const allIds: string[] = [];
|
||||
|
||||
+35
-1
@@ -392,8 +392,22 @@ export const identityHooks = {
|
||||
export const filterHooks = {
|
||||
onFiltersLoaded: new HookBus(),
|
||||
onFilterRuleChange: new HookBus(),
|
||||
// Observer fired after the host successfully uploads the account's active
|
||||
// Sieve script (visual-builder save or plugin-triggered regenerate).
|
||||
onFiltersSave: new HookBus(),
|
||||
onSieveScriptChange: new HookBus(),
|
||||
/**
|
||||
* Transform hook - runs on the full Sieve script text immediately before
|
||||
* the host uploads it as the account's active script.
|
||||
*
|
||||
* handler(script: string, ctx: SieveScriptGenerateContext): string | undefined
|
||||
*
|
||||
* Return a modified script (e.g. append a plugin-managed categorizer
|
||||
* section) or undefined to pass through. Handlers MUST keep the script
|
||||
* valid — put extra `require` statements at the very top. Trigger a
|
||||
* regeneration from a plugin via `api.sieve.regenerate()`.
|
||||
*/
|
||||
onSieveScriptGenerate: new HookBus(),
|
||||
};
|
||||
|
||||
// §7.9 Task Hooks
|
||||
@@ -565,6 +579,26 @@ export const renderHooks = {
|
||||
onRenderEmailBody: new HookBus(),
|
||||
};
|
||||
|
||||
// §7.24 Message-List Tab Hooks (Gmail-style category tabs)
|
||||
export const messageListTabHooks = {
|
||||
// Observer - the merged tab set changed (a plugin registered or cleared
|
||||
// its tabs). Receives the resolved MessageListTab[] (empty when cleared).
|
||||
onTabsChange: new HookBus(),
|
||||
// Observer - the user switched tabs. Receives TabActivateContext.
|
||||
onTabActivate: new HookBus(),
|
||||
// Intercept - fires before the host applies a category keyword patch to
|
||||
// messages (api.tabs.categorize or native UI). Receives
|
||||
// EmailCategorizeContext; return false to cancel the move.
|
||||
onBeforeEmailCategorize: new HookBus(),
|
||||
// Observer - fires after the keyword patch was applied. Receives the same
|
||||
// EmailCategorizeContext. This is where a plugin persists per-sender
|
||||
// overrides and calls api.sieve.regenerate() ("do this for all mail from X").
|
||||
onEmailCategorize: new HookBus(),
|
||||
// Observer - per-tab unread counts were refreshed. Receives
|
||||
// Record<tabId, number>.
|
||||
onTabCountsRefresh: new HookBus(),
|
||||
};
|
||||
|
||||
// ─── Aggregate: remove all handlers for a plugin across all buses ───
|
||||
|
||||
const allHookGroups = [
|
||||
@@ -573,7 +607,7 @@ const allHookGroups = [
|
||||
taskHooks, templateHooks, smimeHooks, vacationHooks,
|
||||
uiHooks, themeHooks, toastHooks, dragDropHooks,
|
||||
keyboardHooks, appLifecycleHooks, accountSecurityHooks, sidebarAppHooks,
|
||||
avatarHooks, renderHooks, routerHooks,
|
||||
avatarHooks, renderHooks, routerHooks, messageListTabHooks,
|
||||
];
|
||||
|
||||
export function removeAllPluginHooks(pluginId: string): void {
|
||||
|
||||
@@ -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', []),
|
||||
|
||||
@@ -639,6 +639,119 @@ export interface EmailListBadge {
|
||||
title?: string;
|
||||
}
|
||||
|
||||
// ─── Message-list category tabs (Gmail-style inbox tabs) ─────
|
||||
//
|
||||
// The tab contract lives in core so tabs render natively: a plugin registers
|
||||
// tab DEFINITIONS via `api.tabs.set(config)`; the host renders the tab strip
|
||||
// above the message list, ANDs the active tab's filter into the mailbox
|
||||
// Email/query, and maintains per-tab unread badges.
|
||||
//
|
||||
// A tab selects its messages in one of two ways:
|
||||
// 1. `query` — a raw JMAP Email FilterCondition/FilterOperator evaluated
|
||||
// server-side at view time (search-based tabs). Nothing is written to
|
||||
// the messages; classification is a live query (e.g. match the
|
||||
// List-Unsubscribe header, or an OR of social sender domains).
|
||||
// 2. `keyword` — a `hasKeyword` filter (durable tabs). Classification then
|
||||
// happens out-of-band — typically a plugin-managed Sieve section at
|
||||
// delivery (see filterHooks.onSieveScriptGenerate) or explicit
|
||||
// recategorization via `api.tabs.categorize`.
|
||||
// A tab with NEITHER is the default bucket ("Primary"): it matches messages
|
||||
// that match none of the other tabs' filters (server-side NOT).
|
||||
|
||||
/**
|
||||
* One tab in the message-list tab strip.
|
||||
*/
|
||||
export interface MessageListTab {
|
||||
/** Stable id within the plugin, e.g. "promotions". Used in hooks and counts. */
|
||||
id: string;
|
||||
/** Display label (localize in the plugin via api.i18n before registering). */
|
||||
label: string;
|
||||
/**
|
||||
* Search-based selection: a JMAP Email FilterCondition or FilterOperator
|
||||
* (RFC 8621 §4.4.1), e.g. `{ header: ["List-Unsubscribe"] }` or
|
||||
* `{ operator: "OR", conditions: [{ from: "reddit.com" }, ...] }`.
|
||||
* The host ANDs it with the mailbox condition. Takes precedence over
|
||||
* `keyword` when both are set.
|
||||
*/
|
||||
query?: Record<string, unknown>;
|
||||
/**
|
||||
* Keyword-based selection, e.g. "$category-promotions" — filters via
|
||||
* `hasKeyword`. Use for durable, Sieve-assigned categories.
|
||||
*/
|
||||
keyword?: string | null;
|
||||
/** Lucide icon name rendered before the label (optional). */
|
||||
icon?: string;
|
||||
/** CSS color for the active-tab indicator / badge accent (optional). */
|
||||
color?: string;
|
||||
/** Sort order within the strip (ascending, default 100). */
|
||||
order?: number;
|
||||
/** Show the unread-count badge for this tab. Default true. */
|
||||
showUnreadBadge?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registered by a plugin via `api.tabs.set(config)` (permission
|
||||
* `ui:message-list-tabs`). Cleared automatically when the plugin unloads,
|
||||
* or explicitly via `api.tabs.clear()`.
|
||||
*/
|
||||
export interface MessageListTabsConfig {
|
||||
tabs: MessageListTab[];
|
||||
/**
|
||||
* Mailbox roles the strip renders for. Default ['inbox'] — category tabs
|
||||
* on Trash or Sent are almost never what anyone wants.
|
||||
*/
|
||||
mailboxRoles?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Passed to messageListTabHooks.onTabActivate observers when the user
|
||||
* switches tabs.
|
||||
*/
|
||||
export interface TabActivateContext {
|
||||
tabId: string;
|
||||
previousTabId: string | null;
|
||||
/** Store id of the mailbox the strip is filtering. */
|
||||
mailboxId: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Passed to messageListTabHooks.onBeforeEmailCategorize (intercept — return
|
||||
* false to cancel) and onEmailCategorize (observer, after the keyword patch
|
||||
* was applied). Describes a host-driven recategorization triggered by
|
||||
* `api.tabs.categorize(emailIds, toTabId)` or native UI. Only keyword-based
|
||||
* (or default) tabs can be categorize targets — search-based tabs derive
|
||||
* membership from their query, so a plugin implements "move to tab" there by
|
||||
* updating its query (e.g. a per-sender override) and re-registering.
|
||||
*/
|
||||
export interface EmailCategorizeContext {
|
||||
emailIds: string[];
|
||||
/** Target tab id. */
|
||||
toTabId: string;
|
||||
/** Keyword added by the move, or null when the target is the default tab. */
|
||||
keywordAdded: string | null;
|
||||
/** Category keywords removed from the messages by the move. */
|
||||
keywordsRemoved: string[];
|
||||
/**
|
||||
* Unique sender addresses of the affected messages — the hook payload a
|
||||
* plugin needs to offer Gmail-style "do this for all mail from X"
|
||||
* (per-sender overrides, Sieve regeneration).
|
||||
*/
|
||||
senders: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Second argument to filterHooks.onSieveScriptGenerate transform handlers.
|
||||
* The transform's value is the full Sieve script the host is about to upload
|
||||
* as the account's active script; handlers return a modified script (e.g.
|
||||
* with a plugin-managed categorizer section appended) or undefined to pass
|
||||
* through. Keep `require` statements at the top of the script — the host
|
||||
* uploads the returned text verbatim after validation.
|
||||
*/
|
||||
export interface SieveScriptGenerateContext {
|
||||
/** Account whose active script is being (re)generated. */
|
||||
accountId: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Passed to onMailtoIntercept handlers.
|
||||
* Return false to prevent the browser from opening the system mail client.
|
||||
@@ -926,6 +1039,8 @@ export const ALL_PERMISSIONS = [
|
||||
'ui:observe', 'ui:toolbar', 'ui:app-top-banner', 'ui:email-banner', 'ui:email-footer',
|
||||
'ui:email-details',
|
||||
'ui:download-file',
|
||||
// Register native message-list category tabs (Gmail-style inbox tabs).
|
||||
'ui:message-list-tabs',
|
||||
'ui:composer-toolbar', 'ui:composer-sidebar',
|
||||
'ui:sidebar-widget', 'ui:settings-section',
|
||||
'ui:context-menu', 'ui:navigation-rail', 'ui:keyboard',
|
||||
@@ -941,6 +1056,9 @@ export const IMPLICIT_PERMISSIONS: Permission[] = ['ui:observe', 'app:lifecycle'
|
||||
|
||||
// ─── Validation ──────────────────────────────────────────────
|
||||
|
||||
/** Upper bound on tabs a single plugin may register via api.tabs.set. */
|
||||
export const MAX_MESSAGE_LIST_TABS = 8;
|
||||
|
||||
export const MAX_PLUGIN_SIZE = 5 * 1024 * 1024; // 5 MB
|
||||
export const MAX_THEME_SIZE = 2 * 1024 * 1024; // 2 MB (was 1 MB; v2 themes may ship a skin.css)
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user