Files
SRCmail/stores/message-list-tabs-store.ts
Bernd Rodler cfdd091d22 feat: Phase 3+4 — security hardening + polish + offline + Electron push
Phase 3 (security):
- P3.1: Feature gate server-side enforcement (403 on disabled features)
- P3.2: Unified auth error interceptor (401→logout)
- P3.3: Store-level state isolation via StoreSnapshot contract
  (added message-list-tabs + task stores to snapshot/restore cycle)
- P3.4: Push event bus extraction — email-store no longer imports
  calendar/contact/filter/file stores directly
- P1.3: Auth localStorage AES-GCM encryption via custom Zustand adapter

Phase 4 (polish):
- P4.1: Offline write queue — pending operations in localStorage,
  auto-retry on reconnect, offline-queue-indicator banner
- P4.2: Identity spoofing — fromOverrideEmail domain validation
- P4.3: WebSocket push for Electron via main-process IPC bridge
  (ws package with Authorization headers)
2026-08-07 22:10:26 +02:00

350 lines
15 KiB
TypeScript

// 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<string, unknown> {
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<string, unknown>;
}
/**
* 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<string>();
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<string, unknown> | 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<string, unknown> | 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<string, unknown> => !!f);
return others.length > 0 ? { operator: 'NOT', conditions: others } : null;
}
// ─── Store ───────────────────────────────────────────────────
interface MessageListTabsStore {
/** pluginId → validated config. */
registrations: Record<string, MessageListTabsConfig>;
/** 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<string, number>;
isCountsLoading: boolean;
registerTabs: (pluginId: string, config: MessageListTabsConfig) => void;
clearTabs: (pluginId: string) => void;
clearState: () => 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<string, unknown> | 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<void>;
/**
* 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<boolean>;
}
function mergeRegistrations(registrations: Record<string, MessageListTabsConfig>): {
tabs: MessageListTab[];
mailboxRoles: string[];
} {
const tabs: MessageListTab[] = [];
const roles = new Set<string>();
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<MessageListTabsStore>()((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<string, boolean | null> = {};
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<string | undefined, string[]>();
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;
},
clearState: () => set({
registrations: {},
tabs: [],
mailboxRoles: [],
activeTabId: null,
tabCounts: {},
isCountsLoading: false,
}),
}));