From 5105e000f5c23014f9d6c56ce79df98abcf1679b Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:22:20 +0200 Subject: [PATCH] feat: add message-list category tabs --- app/(main)/[locale]/page.tsx | 4 + components/email/message-list-tabs.tsx | 121 +++++++ lib/__tests__/message-list-tabs-store.test.ts | 144 ++++++++ lib/demo/demo-client.ts | 62 +++- lib/jmap/client-interface.ts | 9 +- lib/jmap/client.ts | 85 ++++- lib/plugin-hooks.ts | 36 +- lib/plugin-sandbox/host-api.ts | 147 +++++++- lib/plugin-sandbox/loader.ts | 6 + lib/plugin-sandbox/protocol.ts | 8 +- lib/plugin-sandbox/runtime.tsx | 45 +++ lib/plugin-types.ts | 118 ++++++ stores/email-store.ts | 32 +- stores/filter-store.ts | 13 + stores/message-list-tabs-store.ts | 339 ++++++++++++++++++ 15 files changed, 1158 insertions(+), 11 deletions(-) create mode 100644 components/email/message-list-tabs.tsx create mode 100644 lib/__tests__/message-list-tabs-store.test.ts create mode 100644 stores/message-list-tabs-store.ts diff --git a/app/(main)/[locale]/page.tsx b/app/(main)/[locale]/page.tsx index f0ae5a56..ba9c7f79 100644 --- a/app/(main)/[locale]/page.tsx +++ b/app/(main)/[locale]/page.tsx @@ -5,6 +5,7 @@ import { usePathname } from "next/navigation"; import { useTranslations } from "next-intl"; import { Sidebar } from "@/components/layout/sidebar"; import { EmailList } from "@/components/email/email-list"; +import { MessageListTabs } from "@/components/email/message-list-tabs"; import { EmailViewer } from "@/components/email/email-viewer"; import { EmailComposer } from "@/components/email/email-composer"; import type { ComposerDraftData } from "@/components/email/email-composer"; @@ -3126,6 +3127,9 @@ export default function Home() { )}
+ {/* Plugin-registered category tabs (Gmail-style). Renders nothing + unless an enabled plugin registered tabs via api.tabs.set. */} + {!isScheduledView && } diff --git a/components/email/message-list-tabs.tsx b/components/email/message-list-tabs.tsx new file mode 100644 index 00000000..9f0d0d74 --- /dev/null +++ b/components/email/message-list-tabs.tsx @@ -0,0 +1,121 @@ +'use client'; + +// Native tab strip for plugin-registered message-list category tabs +// (Gmail-style Primary / Promotions / Social / Updates). Renders above the +// email list; the active tab's resolved JMAP filter is ANDed into the +// mailbox query by email-store.fetchEmails. Plugins only contribute tab +// DEFINITIONS (stores/message-list-tabs-store.ts) - no plugin iframe here. + +import { useEffect, useRef } from 'react'; +import { icons as lucideIcons, type LucideIcon } from 'lucide-react'; +import { useMessageListTabsStore } from '@/stores/message-list-tabs-store'; +import { useEmailStore } from '@/stores/email-store'; +import { useAuthStore } from '@/stores/auth-store'; +import { cn } from '@/lib/utils'; + +export function MessageListTabs() { + const tabs = useMessageListTabsStore((s) => s.tabs); + const mailboxRoles = useMessageListTabsStore((s) => s.mailboxRoles); + const activeTabId = useMessageListTabsStore((s) => s.activeTabId); + const tabCounts = useMessageListTabsStore((s) => s.tabCounts); + + const selectedMailbox = useEmailStore((s) => s.selectedMailbox); + const mailboxes = useEmailStore((s) => s.mailboxes); + const selectedKeyword = useEmailStore((s) => s.selectedKeyword); + const searchQuery = useEmailStore((s) => s.searchQuery); + const isUnifiedView = useEmailStore((s) => s.isUnifiedView); + const client = useAuthStore((s) => s.client); + + const mailbox = mailboxes.find((mb) => mb.id === selectedMailbox); + const role = mailbox?.role?.toLowerCase() ?? null; + // Tabs only make sense on a plain mailbox view: tag views, searches and + // unified fan-outs bypass the category filter in fetchEmails, so the strip + // must disappear rather than lie about what's being shown. + const visible = + tabs.length > 0 && + !!role && + mailboxRoles.includes(role) && + !selectedKeyword && + !searchQuery && + !isUnifiedView; + + useEffect(() => { + if (!visible || !client || !mailbox) return; + const jmapMailboxId = mailbox.originalId || mailbox.id; + const accountId = mailbox.isShared ? mailbox.accountId : undefined; + void useMessageListTabsStore.getState().refreshCounts(client, jmapMailboxId, accountId); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [visible, client, selectedMailbox, tabs]); + + // A plugin registering (or clearing) tabs after the list was fetched leaves + // the visible list out of sync with the strip's active-tab filter - refetch + // exactly when the merged tab set changes, never on ordinary view switches + // (those already refetch through their own flows). + const prevTabsRef = useRef(tabs); + useEffect(() => { + if (prevTabsRef.current === tabs) return; + prevTabsRef.current = tabs; + if (client) void useEmailStore.getState().fetchEmails(client); + }, [tabs, client]); + + if (!visible) return null; + + const handleSelect = (tabId: string) => { + if (tabId === activeTabId) return; + useMessageListTabsStore.getState().setActiveTab(tabId, selectedMailbox); + if (client) void useEmailStore.getState().fetchEmails(client); + }; + + return ( +
+ {tabs.map((tab) => { + const Icon = tab.icon + ? (lucideIcons[tab.icon as keyof typeof lucideIcons] as LucideIcon | undefined) + : undefined; + const unread = tabCounts[tab.id] ?? 0; + const isActive = tab.id === activeTabId; + return ( + + ); + })} +
+ ); +} diff --git a/lib/__tests__/message-list-tabs-store.test.ts b/lib/__tests__/message-list-tabs-store.test.ts new file mode 100644 index 00000000..8d10e9c5 --- /dev/null +++ b/lib/__tests__/message-list-tabs-store.test.ts @@ -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 }, { 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' }); + }); +}); diff --git a/lib/demo/demo-client.ts b/lib/demo/demo-client.ts index 844184ff..76aa0104 100644 --- a/lib/demo/demo-client.ts +++ b/lib/demo/demo-client.ts @@ -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): boolean { + if (typeof filter.operator === 'string') { + const conditions = (Array.isArray(filter.conditions) ? filter.conditions : []) as Record[]; + 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): 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 | null }>, _accountId?: string): Promise> { + const inBox = this.data.emails.filter(e => e.mailboxIds[mailboxId] && !e.keywords.$seen); + const result: Record = {}; + for (const tab of tabs) { + result[tab.id] = tab.filter + ? inBox.filter(e => this.matchesFilter(e, tab.filter as Record)).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 { + const email = this.data.emails.find(e => e.id === emailId); + if (email) delete email.keywords[keyword]; + } + + async batchUpdateKeywords(emailIds: string[], patch: Record): Promise { + 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 { let count = 0; for (const email of this.data.emails) { diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index 1b189d03..aef7cc44 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -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): Promise<{ emails: Email[]; hasMore: boolean; total: number }>; getEmailsInMailbox(mailboxId: string): Promise; getEmail(emailId: string, accountId?: string): Promise; getSomeEmails(emailsId: string[], accountId?: string): Promise getTagCounts(tagIds: string[]): Promise>; + /** Per-tab unread counts for message-list category tabs (filter = resolved tab fragment, null = unfiltered). */ + getCategoryUnreadCounts(mailboxId: string, tabs: Array<{ id: string; filter: Record | null }>, accountId?: string): Promise>; searchEmails(query: string, mailboxId?: string, accountId?: string, limit?: number, position?: number): Promise<{ emails: Email[]; hasMore: boolean; total: number }>; advancedSearchEmails( filter: Record, @@ -106,6 +110,9 @@ export interface IJMAPClient { toggleStar(emailId: string, starred: boolean, accountId?: string): Promise; updateEmailKeywords(emailId: string, keywords: Record, accountId?: string): Promise; setKeyword(emailId: string, keyword: string, accountId?: string): Promise; + removeKeyword(emailId: string, keyword: string, accountId?: string): Promise; + /** Apply one `keywords/` patch fragment (true=add, null=remove) to many messages in a single Email/set. */ + batchUpdateKeywords(emailIds: string[], patch: Record, accountId?: string): Promise; migrateKeyword(oldKeyword: string, newKeyword: string): Promise; deleteEmail(emailId: string, accountId?: string): Promise; moveToTrash(emailId: string, trashMailboxId: string, accountId?: string, markAsRead?: boolean): Promise; diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 0ffc49b6..9c83b076 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -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): 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 = 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 | null }>, + accountId?: string, + ): Promise> { + if (tabs.length === 0) return {}; + const targetAccountId = accountId || this.accountId; + try { + const methodCalls: JMAPMethodCall[] = tabs.map((tab, i) => { + const conditions: Record[] = [ + { 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 = {}; + 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 { try { const targetAccountId = accountId || this.accountId; @@ -1458,6 +1509,32 @@ export class JMAPClient implements IJMAPClient { ]); } + async removeKeyword(emailId: string, keyword: string, accountId?: string): Promise { + 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/` pointers with true (add) + * or null (remove) values - the category-tab move primitive. + */ + async batchUpdateKeywords(emailIds: string[], patch: Record, accountId?: string): Promise { + 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 { // Query all email IDs that have the old keyword const allIds: string[] = []; diff --git a/lib/plugin-hooks.ts b/lib/plugin-hooks.ts index a298e933..353de471 100644 --- a/lib/plugin-hooks.ts +++ b/lib/plugin-hooks.ts @@ -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. + 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 { diff --git a/lib/plugin-sandbox/host-api.ts b/lib/plugin-sandbox/host-api.ts index e40a8e4d..5ffd8acf 100644 --- a/lib/plugin-sandbox/host-api.ts +++ b/lib/plugin-sandbox/host-api.ts @@ -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 = { '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> { + 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 { + 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 { + 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> { @@ -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}"`); } diff --git a/lib/plugin-sandbox/loader.ts b/lib/plugin-sandbox/loader.ts index 013cd09b..a22d053c 100644 --- a/lib/plugin-sandbox/loader.ts +++ b/lib/plugin-sandbox/loader.ts @@ -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 = Object.assign({}, uiHooks, themeHooks, toastHooks, dragDropHooks, keyboardHooks, appLifecycleHooks, accountSecurityHooks, sidebarAppHooks, avatarHooks, renderHooks, routerHooks, + messageListTabHooks, ) as Record; // ─── 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); diff --git a/lib/plugin-sandbox/protocol.ts b/lib/plugin-sandbox/protocol.ts index 6dc901e2..7de67a53 100644 --- a/lib/plugin-sandbox/protocol.ts +++ b/lib/plugin-sandbox/protocol.ts @@ -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]; diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx index 93ed82e8..13decccb 100644 --- a/lib/plugin-sandbox/runtime.tsx +++ b/lib/plugin-sandbox/runtime.tsx @@ -249,6 +249,51 @@ function buildPluginApi(manifest: PluginManifest) { downloadFile: (opts: { content: string; filename: string; contentType?: string }) => callApi('ui.downloadFile', [opts]) as Promise, }, + // Email keyword mutations (permission: email:write). Keywords follow JMAP + // syntax, e.g. '$category-promotions' or '$label:'. + email: { + setKeyword: (emailId: string, keyword: string, accountId?: string) => + callApi('email.setKeyword', [emailId, keyword, accountId]) as Promise, + removeKeyword: (emailId: string, keyword: string, accountId?: string) => + callApi('email.removeKeyword', [emailId, keyword, accountId]) as Promise, + }, + // 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, + /** Remove this plugin's tabs from the strip. */ + clear: () => callApi('tabs.clear', []) as Promise, + /** Current merged tabs, active tab id and unread counts. */ + getState: () => callApi('tabs.getState', []) as Promise<{ + tabs: unknown[]; activeTabId: string | null; tabCounts: Record; + }>, + /** Re-query per-tab unread counts for the current mailbox. */ + refreshCounts: () => callApi('tabs.refreshCounts', []) as Promise>, + /** + * 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, + }, + // 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, + 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, + }, admin: { getConfig: (key: string) => callApi('admin.getConfig', [key]), getAllConfig: () => callApi('admin.getAllConfig', []), diff --git a/lib/plugin-types.ts b/lib/plugin-types.ts index 32fcb573..f7fb9442 100644 --- a/lib/plugin-types.ts +++ b/lib/plugin-types.ts @@ -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; + /** + * 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) /** diff --git a/stores/email-store.ts b/stores/email-store.ts index 4896544b..abaf065f 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -10,6 +10,7 @@ import type { ExternalSearchResult } from "@/lib/plugin-types"; import { fetchUnifiedEmails, fetchUnifiedMailboxCounts, searchUnifiedEmails, advancedSearchUnifiedEmails, fetchCrossViewEmails, searchCrossViewEmails, advancedSearchCrossViewEmails, getCrossUnreadTotal, type UnifiedAccountClient, type UnifiedMailboxCounts } from "@/lib/unified-mailbox"; import { useAuthStore } from "@/stores/auth-store"; import { useAccountStore } from "@/stores/account-store"; +import { useMessageListTabsStore } from "@/stores/message-list-tabs-store"; type ScheduledSubmissionMetadata = { submissionId: string; @@ -1060,9 +1061,23 @@ export const useEmailStore = create((set, get) => ({ const { selectedKeyword } = get(); const keywordFilter = selectedKeyword ? `$label:${selectedKeyword}` : undefined; + // Plugin-registered category tabs (Gmail-style) AND their resolved JMAP + // filter fragment into the mailbox view. Tag views take precedence. + const categoryFilter = selectedKeyword + ? null + : useMessageListTabsStore.getState().getCategoryFilter(mailbox?.role); + // When filtering by tag, omit the mailbox constraint so emails across // all folders that carry the tag are returned. - const result = await effectiveClient.getEmails(selectedKeyword ? undefined : jmapMailboxId, accountId, emailsPerPage, 0, keywordFilter, true); + const result = await effectiveClient.getEmails( + selectedKeyword ? undefined : jmapMailboxId, + accountId, + emailsPerPage, + 0, + keywordFilter, + true, + categoryFilter ?? undefined, + ); const enrichedEmails = await emailHooks.onEmailsFetched.transform(result.emails); set({ emails: annotateScheduledEmails(enrichedEmails, get().scheduledSubmissionByEmailId), @@ -1217,7 +1232,20 @@ export const useEmailStore = create((set, get) => ({ const jmapMailboxId = mailbox?.originalId || selectedMailbox; // When filtering by tag, omit the mailbox constraint (same rationale as fetchEmails). - result = await effectiveClient.getEmails(selectedKeyword ? undefined : jmapMailboxId, accountId, emailsPerPage, position, selectedKeyword ? `$label:${selectedKeyword}` : undefined, true); + // Category tabs (plugin-registered) must filter pagination the same + // way as the initial fetch or pages would mix categories. + const categoryFilter = selectedKeyword + ? null + : useMessageListTabsStore.getState().getCategoryFilter(mailbox?.role); + result = await effectiveClient.getEmails( + selectedKeyword ? undefined : jmapMailboxId, + accountId, + emailsPerPage, + position, + selectedKeyword ? `$label:${selectedKeyword}` : undefined, + true, + categoryFilter ?? undefined, + ); } // Use fresh state when merging to avoid overwriting concurrent updates diff --git a/stores/filter-store.ts b/stores/filter-store.ts index 165d5d66..0964aefc 100644 --- a/stores/filter-store.ts +++ b/stores/filter-store.ts @@ -3,6 +3,7 @@ import type { IJMAPClient } from '@/lib/jmap/client-interface'; import type { FilterRule, SieveCapabilities, VacationSieveConfig } from '@/lib/jmap/sieve-types'; import { parseScript } from '@/lib/sieve/parser'; import { generateScript } from '@/lib/sieve/generator'; +import { filterHooks } from '@/lib/plugin-hooks'; import { debug } from '@/lib/debug'; interface SieveAccount { @@ -144,6 +145,16 @@ export const useFilterStore = create()((set, get) => ({ content = generateScript(rules, vacationSettings || undefined, { externalRequires }); } + // Let plugins graft their managed sections (e.g. an inbox-category + // classifier) into the script before it becomes the active one. A + // handler returning a non-string is ignored to keep the upload valid. + const transformed = await filterHooks.onSieveScriptGenerate.transform(content, { + accountId: selectedAccountId || null, + }); + if (typeof transformed === 'string' && transformed.trim().length > 0) { + content = transformed; + } + if (activeScriptId) { await client.updateSieveScript(activeScriptId, content, true, selectedAccountId || undefined); } else { @@ -153,6 +164,8 @@ export const useFilterStore = create()((set, get) => ({ set({ isSaving: false, rawScript: content }); debug.log('filters', 'Filters saved successfully'); + void filterHooks.onFiltersSave.emit({ accountId: selectedAccountId || null }); + void filterHooks.onSieveScriptChange.emit({ accountId: selectedAccountId || null, script: content }); } catch (error) { debug.error('Failed to save filters:', error); set({ diff --git a/stores/message-list-tabs-store.ts b/stores/message-list-tabs-store.ts new file mode 100644 index 00000000..7b0e7415 --- /dev/null +++ b/stores/message-list-tabs-store.ts @@ -0,0 +1,339 @@ +// Host-side registry for plugin-provided message-list category tabs +// (Gmail-style Primary / Promotions / Social / Updates). +// +// Plugins register tab DEFINITIONS via `api.tabs.set(config)`; this store +// merges them, tracks the active tab, and resolves the JMAP filter fragment +// the email store ANDs into the mailbox Email/query. Tabs are search-first: +// a tab's `query` is evaluated server-side at view time (no mail mutation); +// `keyword` tabs filter via hasKeyword for durable Sieve-assigned categories. +// The tab strip itself renders natively (components/email/message-list-tabs.tsx) +// — no plugin iframe involved, per the core contract in lib/plugin-types.ts. + +import { create } from 'zustand'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; +import type { Email } from '@/lib/jmap/types'; +import type { MessageListTab, MessageListTabsConfig } from '@/lib/plugin-types'; +import { MAX_MESSAGE_LIST_TABS } from '@/lib/plugin-types'; +import { messageListTabHooks } from '@/lib/plugin-hooks'; +import { useEmailStore } from './email-store'; + +// ─── Validation ────────────────────────────────────────────── + +// System keywords a tab may never claim: filtering the inbox by these would +// hijack read/star/pin semantics, and `$label:` keywords belong to user tags. +const RESERVED_KEYWORDS = new Set([ + '$seen', '$flagged', '$draft', '$answered', '$forwarded', + '$recent', '$junk', '$notjunk', '$phishing', '$pinned', +]); + +const TAB_ID_RE = /^[a-z0-9][a-z0-9_-]{0,31}$/i; +// RFC 5788 keyword syntax, conservatively narrowed. Keywords are matched +// case-insensitively by IMAP servers, so we lowercase on normalization. +const KEYWORD_RE = /^\$[a-z0-9][a-z0-9_.-]{0,63}$/; + +// Upper bound on a serialized tab query — a filter tree bigger than this is +// either a bug or an abuse vector for the JMAP request budget. +const MAX_QUERY_JSON_BYTES = 16 * 1024; + +function validateTabQuery(tabId: string, query: unknown): Record { + if (typeof query !== 'object' || query === null || Array.isArray(query)) { + throw new Error(`tabs.set: tab "${tabId}" query must be a JMAP filter object`); + } + let json: string; + try { + json = JSON.stringify(query); + } catch { + throw new Error(`tabs.set: tab "${tabId}" query is not serializable`); + } + if (json.length > MAX_QUERY_JSON_BYTES) { + throw new Error(`tabs.set: tab "${tabId}" query exceeds ${MAX_QUERY_JSON_BYTES} bytes`); + } + // Deep-clone via JSON so the stored filter is plain data (no prototypes, + // functions or getters can cross into request bodies). + return JSON.parse(json) as Record; +} + +/** + * Validate and normalize a plugin-supplied tabs config. Throws with a + * developer-readable message on any violation (surfaces as the api.tabs.set + * promise rejection inside the plugin sandbox). + */ +export function validateTabsConfig(config: MessageListTabsConfig): MessageListTabsConfig { + if (!config || !Array.isArray(config.tabs)) throw new Error('tabs.set: config.tabs must be an array'); + if (config.tabs.length < 2) throw new Error('tabs.set: at least 2 tabs required'); + if (config.tabs.length > MAX_MESSAGE_LIST_TABS) throw new Error(`tabs.set: at most ${MAX_MESSAGE_LIST_TABS} tabs allowed`); + + const ids = new Set(); + let defaults = 0; + const tabs: MessageListTab[] = config.tabs.map((t) => { + if (!t || typeof t.id !== 'string' || !TAB_ID_RE.test(t.id)) { + throw new Error(`tabs.set: invalid tab id "${String(t?.id)}"`); + } + if (ids.has(t.id)) throw new Error(`tabs.set: duplicate tab id "${t.id}"`); + ids.add(t.id); + if (typeof t.label !== 'string' || !t.label.trim() || t.label.length > 40) { + throw new Error(`tabs.set: tab "${t.id}" needs a label (max 40 chars)`); + } + + const query = t.query !== undefined ? validateTabQuery(t.id, t.query) : undefined; + + let keyword: string | null | undefined = undefined; + if (t.keyword !== null && t.keyword !== undefined) { + keyword = String(t.keyword).toLowerCase(); + if (!KEYWORD_RE.test(keyword)) { + throw new Error(`tabs.set: tab "${t.id}" keyword "${t.keyword}" is not a valid JMAP keyword (must match ${KEYWORD_RE})`); + } + if (RESERVED_KEYWORDS.has(keyword) || keyword.startsWith('$label')) { + throw new Error(`tabs.set: tab "${t.id}" may not use reserved keyword "${keyword}"`); + } + } + if (!query && !keyword) defaults++; + + return { + id: t.id, + label: t.label.trim(), + query, + keyword: keyword ?? null, + icon: typeof t.icon === 'string' ? t.icon.slice(0, 40) : undefined, + color: typeof t.color === 'string' ? t.color.slice(0, 40) : undefined, + order: typeof t.order === 'number' && Number.isFinite(t.order) ? t.order : 100, + showUnreadBadge: t.showUnreadBadge !== false, + }; + }); + if (defaults > 1) throw new Error('tabs.set: at most one default tab (no query, no keyword) allowed'); + + const mailboxRoles = Array.isArray(config.mailboxRoles) && config.mailboxRoles.length > 0 + ? config.mailboxRoles.filter((r): r is string => typeof r === 'string').map((r) => r.toLowerCase()) + : ['inbox']; + + return { tabs, mailboxRoles }; +} + +// ─── Filter resolution ─────────────────────────────────────── + +function isDefaultTab(tab: MessageListTab): boolean { + return !tab.query && !tab.keyword; +} + +/** The JMAP filter fragment that positively selects a (non-default) tab. */ +function positiveFragment(tab: MessageListTab): Record | null { + if (tab.query) return tab.query; + if (tab.keyword) return { hasKeyword: tab.keyword }; + return null; +} + +/** + * Resolve the filter fragment for one tab in the context of all tabs. + * Default tab = NOT(every other tab's positive fragment) — RFC 8620 §5.5: + * a NOT FilterOperator is true iff none of its conditions match. + */ +export function resolveTabFilter(tab: MessageListTab, allTabs: MessageListTab[]): Record | null { + const positive = positiveFragment(tab); + if (positive) return positive; + const others = allTabs + .filter((t) => t.id !== tab.id) + .map(positiveFragment) + .filter((f): f is Record => !!f); + return others.length > 0 ? { operator: 'NOT', conditions: others } : null; +} + +// ─── Store ─────────────────────────────────────────────────── + +interface MessageListTabsStore { + /** pluginId → validated config. */ + registrations: Record; + /** Merged, order-sorted tabs across all registrations. */ + tabs: MessageListTab[]; + /** Union of mailbox roles the strip is enabled for (lowercase). */ + mailboxRoles: string[]; + activeTabId: string | null; + /** tabId → unread count for the current mailbox. */ + tabCounts: Record; + isCountsLoading: boolean; + + registerTabs: (pluginId: string, config: MessageListTabsConfig) => void; + clearTabs: (pluginId: string) => void; + setActiveTab: (tabId: string, mailboxId: string | null) => void; + /** + * JMAP filter fragment for the active tab (to AND into the mailbox query), + * or null when tabs don't apply to this mailbox role. + */ + getCategoryFilter: (mailboxRole: string | null | undefined) => Record | null; + /** True when the strip should render for this mailbox role. */ + isEnabledForRole: (mailboxRole: string | null | undefined) => boolean; + refreshCounts: (client: IJMAPClient, jmapMailboxId: string, accountId?: string) => Promise; + /** + * Move messages to a keyword-based (or default) tab: patches category + * keywords via Email/set, updates the visible list optimistically, and + * fires the categorize hooks. Returns false when a plugin intercept + * cancelled the move or the target tab is search-based. + */ + categorizeEmails: (client: IJMAPClient, emailIds: string[], toTabId: string) => Promise; +} + +function mergeRegistrations(registrations: Record): { + tabs: MessageListTab[]; + mailboxRoles: string[]; +} { + const tabs: MessageListTab[] = []; + const roles = new Set(); + let haveDefault = false; + const sorted = Object.values(registrations) + .flatMap((cfg) => { + for (const r of cfg.mailboxRoles ?? ['inbox']) roles.add(r); + return cfg.tabs; + }) + .sort((a, b) => (a.order ?? 100) - (b.order ?? 100)); + for (const tab of sorted) { + // Across plugins, only the first default tab (by order) survives — two + // "everything else" buckets can't both be right. + if (isDefaultTab(tab)) { + if (haveDefault) continue; + haveDefault = true; + } + tabs.push(tab); + } + return { tabs, mailboxRoles: [...roles] }; +} + +function pickActiveTab(tabs: MessageListTab[], prevActive: string | null): string | null { + if (tabs.some((t) => t.id === prevActive)) return prevActive; + return (tabs.find(isDefaultTab) ?? tabs[0])?.id ?? null; +} + +export const useMessageListTabsStore = create()((set, get) => ({ + registrations: {}, + tabs: [], + mailboxRoles: [], + activeTabId: null, + tabCounts: {}, + isCountsLoading: false, + + registerTabs: (pluginId, config) => { + const validated = validateTabsConfig(config); + const registrations = { ...get().registrations, [pluginId]: validated }; + const merged = mergeRegistrations(registrations); + set({ registrations, ...merged, activeTabId: pickActiveTab(merged.tabs, get().activeTabId) }); + void messageListTabHooks.onTabsChange.emit(merged.tabs); + }, + + clearTabs: (pluginId) => { + if (!(pluginId in get().registrations)) return; + const registrations = { ...get().registrations }; + delete registrations[pluginId]; + const merged = mergeRegistrations(registrations); + set({ + registrations, + ...merged, + activeTabId: pickActiveTab(merged.tabs, get().activeTabId), + ...(merged.tabs.length === 0 ? { tabCounts: {} } : {}), + }); + void messageListTabHooks.onTabsChange.emit(merged.tabs); + }, + + setActiveTab: (tabId, mailboxId) => { + const { tabs, activeTabId } = get(); + if (!tabs.some((t) => t.id === tabId) || tabId === activeTabId) return; + set({ activeTabId: tabId }); + void messageListTabHooks.onTabActivate.emit({ tabId, previousTabId: activeTabId, mailboxId }); + }, + + isEnabledForRole: (mailboxRole) => { + const { tabs, mailboxRoles } = get(); + return tabs.length > 0 && !!mailboxRole && mailboxRoles.includes(mailboxRole.toLowerCase()); + }, + + getCategoryFilter: (mailboxRole) => { + const { tabs, activeTabId } = get(); + if (!get().isEnabledForRole(mailboxRole)) return null; + const active = tabs.find((t) => t.id === activeTabId) ?? tabs.find(isDefaultTab) ?? tabs[0]; + if (!active) return null; + return resolveTabFilter(active, tabs); + }, + + refreshCounts: async (client, jmapMailboxId, accountId) => { + const { tabs } = get(); + if (tabs.length === 0) return; + set({ isCountsLoading: true }); + try { + const counts = await client.getCategoryUnreadCounts( + jmapMailboxId, + tabs.map((t) => ({ id: t.id, filter: resolveTabFilter(t, tabs) })), + accountId, + ); + set({ tabCounts: counts, isCountsLoading: false }); + void messageListTabHooks.onTabCountsRefresh.emit(counts); + } catch (err) { + console.error('Failed to refresh category tab counts:', err); + set({ isCountsLoading: false }); + } + }, + + categorizeEmails: async (client, emailIds, toTabId) => { + const { tabs } = get(); + const target = tabs.find((t) => t.id === toTabId); + if (!target || emailIds.length === 0) return false; + // Search-based tabs derive membership from their query — there is no + // keyword to write. The owning plugin implements moves there by updating + // its query (per-sender override) and re-registering. + if (target.query) return false; + + const allKeywords = tabs.map((t) => t.keyword).filter((k): k is string => !!k); + const keywordAdded = target.keyword ?? null; + const keywordsRemoved = allKeywords.filter((k) => k !== keywordAdded); + if (!keywordAdded && keywordsRemoved.length === 0) return false; + + const emailState = useEmailStore.getState(); + const affected = emailState.emails.filter((e) => emailIds.includes(e.id)); + const senders = [...new Set( + affected.flatMap((e) => (e.from ?? []).map((f) => f.email?.toLowerCase()).filter((x): x is string => !!x)), + )]; + + const ctx = { emailIds, toTabId, keywordAdded, keywordsRemoved, senders }; + const proceed = await messageListTabHooks.onBeforeEmailCategorize.intercept(ctx); + if (!proceed) return false; + + // One PatchObject per message: drop every other category keyword, add the + // target's (removals of absent keywords are no-ops per RFC 8620 §5.3). + const patch: Record = {}; + for (const k of keywordsRemoved) patch[`keywords/${k}`] = null; + if (keywordAdded) patch[`keywords/${keywordAdded}`] = true; + + // Group by owning account so shared-mailbox messages patch correctly. + const byAccount = new Map(); + for (const id of emailIds) { + const email = affected.find((e) => e.id === id) as (Email & { accountId?: string }) | undefined; + const acct = email?.accountId; + const list = byAccount.get(acct) ?? []; + list.push(id); + byAccount.set(acct, list); + } + for (const [acct, ids] of byAccount) { + await client.batchUpdateKeywords(ids, patch, acct); + } + + // Optimistic local update: fix keywords in place, and drop moved messages + // from a keyword-filtered view they no longer belong to. (Query-based + // active tabs are left as-is — membership there is server-derived.) + const idSet = new Set(emailIds); + const activeTab = tabs.find((t) => t.id === get().activeTabId); + const shouldDrop = !!activeTab && !activeTab.query && activeTab.id !== toTabId + && get().isEnabledForRole( + emailState.mailboxes.find((mb) => mb.id === emailState.selectedMailbox)?.role ?? null, + ); + useEmailStore.setState((state) => ({ + emails: state.emails + .map((e) => { + if (!idSet.has(e.id)) return e; + const keywords = { ...e.keywords }; + for (const k of keywordsRemoved) delete keywords[k]; + if (keywordAdded) keywords[keywordAdded] = true; + return { ...e, keywords }; + }) + .filter((e) => !shouldDrop || !idSet.has(e.id)), + })); + + void messageListTabHooks.onEmailCategorize.emit(ctx); + return true; + }, +}));