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;
+ },
+}));