From 24949e183fab5da280a9074d264b554d22b47b6f Mon Sep 17 00:00:00 2001 From: Linus Rath Date: Sun, 12 Apr 2026 13:57:20 +0200 Subject: [PATCH 1/2] feat: add i18n API, render hooks, and new intercept hooks to plugin system --- lib/plugin-api.ts | 29 ++++++++++- lib/plugin-hooks.ts | 27 +++++++++- lib/plugin-i18n.ts | 118 +++++++++++++++++++++++++++++++++++++++++++ lib/plugin-loader.ts | 26 +++++++++- lib/plugin-types.ts | 89 ++++++++++++++++++++++++++++++++ 5 files changed, 285 insertions(+), 4 deletions(-) create mode 100644 lib/plugin-i18n.ts diff --git a/lib/plugin-api.ts b/lib/plugin-api.ts index 4ca7144e..70306630 100644 --- a/lib/plugin-api.ts +++ b/lib/plugin-api.ts @@ -14,6 +14,7 @@ import type { AdminPageSection, CalendarEventAction, SlotName, + PluginI18n, } from './plugin-types'; import { IMPLICIT_PERMISSIONS as IMPLICIT } from './plugin-types'; import { @@ -22,8 +23,9 @@ import { taskHooks, templateHooks, smimeHooks, vacationHooks, uiHooks, themeHooks, toastHooks, dragDropHooks, keyboardHooks, appLifecycleHooks, accountSecurityHooks, - sidebarAppHooks, avatarHooks, + sidebarAppHooks, avatarHooks, renderHooks, } from './plugin-hooks'; +import { createPluginI18n } from './plugin-i18n'; import { toast as appToast } from '@/stores/toast-store'; import { useAuthStore } from '@/stores/auth-store'; @@ -109,6 +111,8 @@ function createPluginLogger(pluginId: string) { export interface PluginAPI { plugin: { id: string; version: string; settings: Record }; + /** Localisation API — register translations and call t() to get strings */ + i18n: PluginI18n; ui: { registerToolbarAction: (action: ToolbarAction) => Disposable; registerEmailBanner: (factory: BannerFactory) => Disposable; @@ -149,6 +153,8 @@ export interface PluginHooksAPI { onEmailClose: (handler: () => void) => Disposable; onEmailContentRender: (handler: (...args: unknown[]) => unknown) => Disposable; onThreadExpand: (handler: (...args: unknown[]) => unknown) => Disposable; + /** Intercept — receives ComposeOptions, may mutate fields, return false to cancel */ + onBeforeCompose: (handler: (options: import('./plugin-types').ComposeOptions) => boolean | void | Promise) => Disposable; onComposerOpen: (handler: (...args: unknown[]) => unknown) => Disposable; onBeforeEmailSend: (handler: (...args: unknown[]) => unknown) => Disposable; onAfterEmailSend: (handler: (...args: unknown[]) => unknown) => Disposable; @@ -157,6 +163,10 @@ export interface PluginHooksAPI { onAfterEmailDelete: (handler: (...args: unknown[]) => unknown) => Disposable; onBeforeEmailMove: (handler: (...args: unknown[]) => unknown) => Disposable; onAfterEmailMove: (handler: (...args: unknown[]) => unknown) => Disposable; + /** Emitted after emails are moved to the Archive mailbox */ + onEmailArchive: (handler: (emailIds: string[]) => void) => Disposable; + /** Emitted after emails are moved out of the Archive mailbox */ + onEmailUnarchive: (handler: (emailIds: string[]) => void) => Disposable; onEmailReadStateChange: (handler: (...args: unknown[]) => unknown) => Disposable; onEmailStarToggle: (handler: (...args: unknown[]) => unknown) => Disposable; onEmailSpamToggle: (handler: (...args: unknown[]) => unknown) => Disposable; @@ -173,6 +183,8 @@ export interface PluginHooksAPI { onNewEmailReceived: (handler: (...args: unknown[]) => unknown) => Disposable; onPushConnectionChange: (handler: (...args: unknown[]) => unknown) => Disposable; onQuotaChange: (handler: (...args: unknown[]) => unknown) => Disposable; + /** Intercept — receives MailtoContext, return false to prevent the system mail client */ + onMailtoIntercept: (handler: (ctx: import('./plugin-types').MailtoContext) => boolean | void | Promise) => Disposable; // Calendar onCalendarEventOpen: (handler: (...args: unknown[]) => unknown) => Disposable; onBeforeEventCreate: (handler: (...args: unknown[]) => unknown) => Disposable; @@ -215,6 +227,8 @@ export interface PluginHooksAPI { onDirectoryCreate: (handler: (...args: unknown[]) => unknown) => Disposable; onBeforeFileDelete: (handler: (...args: unknown[]) => unknown) => Disposable; onAfterFileDelete: (handler: (...args: unknown[]) => unknown) => Disposable; + /** Intercept — receives { file: FileResourceView, newName: string }, return false to cancel */ + onBeforeFileRename: (handler: (ctx: { file: import('./plugin-types').FileResourceView; newName: string }) => boolean | void | Promise) => Disposable; onFileRename: (handler: (...args: unknown[]) => unknown) => Disposable; onFileMove: (handler: (...args: unknown[]) => unknown) => Disposable; onFileCopy: (handler: (...args: unknown[]) => unknown) => Disposable; @@ -316,6 +330,9 @@ export interface PluginHooksAPI { onSidebarAppChange: (handler: (...args: unknown[]) => unknown) => Disposable; // Avatar onAvatarResolve: (handler: (...args: unknown[]) => unknown) => Disposable; + // Render — transform hook for email list row badges + // Handler: (badges: EmailListBadge[], ctx: { emailId: string; email: EmailReadView }) => EmailListBadge[] + onEmailListItemRender: (handler: (...args: unknown[]) => unknown) => Disposable; } // --- Permission mapping for hooks ---------------------------- @@ -324,14 +341,17 @@ const HOOK_PERMISSIONS: Record = { // Email onEmailOpen: 'email:read', onEmailClose: 'email:read', onEmailContentRender: 'email:read', onThreadExpand: 'email:read', - onComposerOpen: 'email:read', onDraftAutoSave: 'email:read', + onBeforeCompose: 'email:read', onComposerOpen: 'email:read', + onDraftAutoSave: 'email:read', onMailboxChange: 'email:read', onMailboxesRefresh: 'email:read', onSearch: 'email:read', onSearchResults: 'email:read', onEmailSelectionChange: 'email:read', onNewEmailReceived: 'email:read', onPushConnectionChange: 'email:read', onQuotaChange: 'email:read', + onMailtoIntercept: 'email:read', onEmailListItemRender: 'email:read', onBeforeEmailSend: 'email:send', onAfterEmailSend: 'email:send', onBeforeEmailDelete: 'email:write', onAfterEmailDelete: 'email:write', onBeforeEmailMove: 'email:write', onAfterEmailMove: 'email:write', + onEmailArchive: 'email:write', onEmailUnarchive: 'email:write', onEmailReadStateChange: 'email:write', onEmailStarToggle: 'email:write', onEmailSpamToggle: 'email:write', onEmailKeywordChange: 'email:write', onMailboxCreate: 'email:write', onMailboxRename: 'email:write', @@ -358,6 +378,7 @@ const HOOK_PERMISSIONS: Record = { onBeforeFileUpload: 'files:write', onAfterFileUpload: 'files:write', onFileUploadCancel: 'files:write', onDirectoryCreate: 'files:write', onBeforeFileDelete: 'files:write', onAfterFileDelete: 'files:write', + onBeforeFileRename: 'files:write', onFileRename: 'files:write', onFileMove: 'files:write', onFileCopy: 'files:write', onFileDuplicate: 'files:write', onFileFavoriteToggle: 'files:write', onFileUndo: 'files:write', // Auth @@ -469,6 +490,8 @@ const HOOK_BUSES: Record { requirePermission(plugin, 'ui:toolbar'); diff --git a/lib/plugin-hooks.ts b/lib/plugin-hooks.ts index 01ae6f06..c3df1b10 100644 --- a/lib/plugin-hooks.ts +++ b/lib/plugin-hooks.ts @@ -172,6 +172,10 @@ export const emailHooks = { onEmailClose: new HookBus(), onEmailContentRender: new HookBus(), onThreadExpand: new HookBus(), + // Intercept hook — fires before the composer opens. + // Handlers receive ComposeOptions and may mutate fields in place. + // Return false to cancel opening the composer. + onBeforeCompose: new HookBus(), onComposerOpen: new HookBus(), onBeforeEmailSend: new HookBus(), onAfterEmailSend: new HookBus(), @@ -180,6 +184,10 @@ export const emailHooks = { onAfterEmailDelete: new HookBus(), onBeforeEmailMove: new HookBus(), onAfterEmailMove: new HookBus(), + // Fired after one or more emails are archived to the Archive mailbox + onEmailArchive: new HookBus(), + // Fired after one or more emails are moved out of the Archive mailbox + onEmailUnarchive: new HookBus(), onEmailReadStateChange: new HookBus(), onEmailStarToggle: new HookBus(), onEmailSpamToggle: new HookBus(), @@ -196,6 +204,9 @@ export const emailHooks = { onNewEmailReceived: new HookBus(), onPushConnectionChange: new HookBus(), onQuotaChange: new HookBus(), + // Intercept hook — fired when a mailto: link is clicked. + // Return false to prevent the browser from opening the system mail client. + onMailtoIntercept: new HookBus(), }; // §7.2 Calendar Hooks @@ -250,6 +261,10 @@ export const fileHooks = { onDirectoryCreate: new HookBus(), onBeforeFileDelete: new HookBus(), onAfterFileDelete: new HookBus(), + // Intercept hook — fires before a file is renamed. + // Receives { file: FileResourceView, newName: string }. + // Return false to cancel the rename. + onBeforeFileRename: new HookBus(), onFileRename: new HookBus(), onFileMove: new HookBus(), onFileCopy: new HookBus(), @@ -406,6 +421,16 @@ export const avatarHooks = { onAvatarResolve: new HookBus(), }; +// §7.22 Render Hooks +export const renderHooks = { + // Transform hook — runs for each visible email list row. + // Initial value: EmailListBadge[] (always starts as []) + // Second argument: { emailId: string; email: EmailReadView } + // Handlers return a new (or extended) badges array. + // Rendered by the email list row component next to the subject line. + onEmailListItemRender: new HookBus(), +}; + // ─── Aggregate: remove all handlers for a plugin across all buses ─── const allHookGroups = [ @@ -414,7 +439,7 @@ const allHookGroups = [ taskHooks, templateHooks, smimeHooks, vacationHooks, uiHooks, themeHooks, toastHooks, dragDropHooks, keyboardHooks, appLifecycleHooks, accountSecurityHooks, sidebarAppHooks, - avatarHooks, + avatarHooks, renderHooks, ]; export function removeAllPluginHooks(pluginId: string): void { diff --git a/lib/plugin-i18n.ts b/lib/plugin-i18n.ts new file mode 100644 index 00000000..b3ab7faf --- /dev/null +++ b/lib/plugin-i18n.ts @@ -0,0 +1,118 @@ +// Plugin i18n registry — manages per-plugin translation tables +// +// Each plugin gets its own namespace keyed by: +// pluginId → locale → { messageKey → translated string } +// +// Resolution order when calling t(key): +// 1. Exact locale match ("fr-CA") +// 2. Language-prefix match ("fr" from "fr-CA") +// 3. English fallback ("en") +// 4. Raw key (plugin is never broken by missing strings) +// +// Interpolation uses {paramName} placeholders. + +// ─── Registry ──────────────────────────────────────────────── + +/** pluginId → locale → key → translated string */ +const registry = new Map>>(); + +let currentLocale = 'en'; + +// ─── Locale sync (called by plugin-loader) ─────────────────── + +/** Keep the registry in sync with the app locale */ +export function setPluginI18nLocale(locale: string): void { + currentLocale = locale; +} + +export function getPluginI18nLocale(): string { + return currentLocale; +} + +// ─── Cleanup ───────────────────────────────────────────────── + +/** Remove all translations for a plugin (called on deactivation) */ +export function clearPluginI18nTranslations(pluginId: string): void { + registry.delete(pluginId); +} + +// ─── Helpers ───────────────────────────────────────────────── + +function interpolate(template: string, params?: Record): string { + if (!params) return template; + return template.replace(/\{(\w+)\}/g, (_, key) => String(params[key] ?? `{${key}}`)); +} + +function resolve(pluginId: string, key: string): string | undefined { + const byLocale = registry.get(pluginId); + if (!byLocale) return undefined; + + // 1. Exact locale (e.g. "fr-CA") + const exact = byLocale.get(currentLocale)?.[key]; + if (exact !== undefined) return exact; + + // 2. Language prefix (e.g. "fr" from "fr-CA") + const lang = currentLocale.split('-')[0]; + if (lang !== currentLocale) { + const langMatch = byLocale.get(lang)?.[key]; + if (langMatch !== undefined) return langMatch; + } + + // 3. English fallback + return byLocale.get('en')?.[key]; +} + +// ─── Public API factory ────────────────────────────────────── + +/** + * Build the i18n API object exposed as `api.i18n` inside each plugin. + * + * @example + * // In your plugin activate(): + * api.i18n.addTranslations('en', { 'banner.title': 'Hello' }); + * api.i18n.addTranslations('de', { 'banner.title': 'Hallo' }); + * + * // Later, in any React component the plugin renders: + * const title = api.i18n.t('banner.title'); + * const greeting = api.i18n.t('welcome', { name: 'Alice' }); // 'Hello, {name}!' + */ +export function createPluginI18n(pluginId: string) { + return { + /** + * Register translations for one locale. + * Multiple calls for the same locale are merged (last-write-wins on key collision). + * + * @param locale BCP-47 locale tag, e.g. "en", "de", "fr-CA" + * @param strings Key → translated string map. Use {paramName} for interpolation. + */ + addTranslations(locale: string, strings: Record): void { + let byLocale = registry.get(pluginId); + if (!byLocale) { + byLocale = new Map>(); + registry.set(pluginId, byLocale); + } + const existing = byLocale.get(locale) ?? {}; + byLocale.set(locale, { ...existing, ...strings }); + }, + + /** + * Translate a key using the current app locale. + * Falls back through: exact locale → language prefix → 'en' → raw key. + * + * @param key Translation key, e.g. `'banner.title'` + * @param params Optional interpolation values, e.g. `{ count: 3 }` + */ + t(key: string, params?: Record): string { + const template = resolve(pluginId, key); + if (template !== undefined) return interpolate(template, params); + return key; // never throw — just return the key + }, + + /** The current app locale (e.g. "en", "de", "fr") */ + getLocale(): string { + return currentLocale; + }, + }; +} + +export type PluginI18nInstance = ReturnType; diff --git a/lib/plugin-loader.ts b/lib/plugin-loader.ts index 7eb1719a..80547fc8 100644 --- a/lib/plugin-loader.ts +++ b/lib/plugin-loader.ts @@ -1,15 +1,18 @@ -// Plugin Loader — loads and activates plugins via blob URL dynamic import +// Plugin Loader — loads and activates plugins via blob URL dynamic import import type { InstalledPlugin, Disposable } from './plugin-types'; import { pluginStorage } from './plugin-storage'; import { createPluginAPI, type PluginAPI } from './plugin-api'; import { removeAllPluginHooks, pluginErrorTracker } from './plugin-hooks'; +import { setPluginI18nLocale, clearPluginI18nTranslations } from './plugin-i18n'; import React from 'react'; import ReactDOM from 'react-dom'; import * as ReactJSX from 'react/jsx-runtime'; // --- Shared React (window.__PLUGIN_EXTERNALS__) ------------- +let localeSyncInitialised = false; + export function exposePluginExternals(): void { if (typeof window === 'undefined') return; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -18,6 +21,16 @@ export function exposePluginExternals(): void { ReactDOM, ReactJSX, }; + + // Sync plugin i18n with the app locale (runs once per page load) + if (!localeSyncInitialised) { + localeSyncInitialised = true; + // Dynamic import avoids a circular dependency chain at module evaluation time + import('@/stores/locale-store').then(({ useLocaleStore }) => { + setPluginI18nLocale(useLocaleStore.getState().locale); + useLocaleStore.subscribe((state) => setPluginI18nLocale(state.locale)); + }).catch(() => {/* locale sync is best-effort */}); + } } // --- Active plugin tracking ---------------------------------- @@ -78,6 +91,14 @@ export async function loadPlugin(plugin: InstalledPlugin): Promise { // 4. Build sandboxed API const api = createPluginAPI(plugin); + // 4b. Auto-register translations bundled in the manifest (plugin.locales) + // Plugins may still call api.i18n.addTranslations() in activate() to add more. + if (plugin.locales) { + for (const [locale, strings] of Object.entries(plugin.locales)) { + api.i18n.addTranslations(locale, strings); + } + } + // 5. Call activate const disposable = await mod.activate(api); @@ -119,6 +140,9 @@ export function deactivatePlugin(pluginId: string): void { // Remove all hook subscriptions for this plugin removeAllPluginHooks(pluginId); + // Clear cached translations (avoids memory leak on repeated enable/disable cycles) + clearPluginI18nTranslations(pluginId); + // Reset error tracker pluginErrorTracker.reset(pluginId); diff --git a/lib/plugin-types.ts b/lib/plugin-types.ts index 1936fc6e..f4721f3d 100644 --- a/lib/plugin-types.ts +++ b/lib/plugin-types.ts @@ -34,6 +34,13 @@ export interface PluginManifest { entrypoint: string; minAppVersion?: string; settingsSchema?: Record; + /** + * Bundled translations shipped inside the plugin ZIP. + * Keyed by BCP-47 locale tag ("en", "de", "fr-CA", …). + * The loader auto-registers these before calling activate(), + * so plugins can use api.i18n.t() without calling addTranslations() first. + */ + locales?: Record>; } export interface SettingFieldSchema { @@ -83,6 +90,8 @@ export interface InstalledPlugin { adminApproved?: boolean; settingsSchema?: Record; settings: Record; + /** Bundled translations, carried over from the manifest on install. */ + locales?: Record>; } // ─── UI Slots ──────────────────────────────────────────────── @@ -388,6 +397,86 @@ export interface ComposerContext { originalSubject?: string; } +// ─── New hook context types ────────────────────────────────── + +/** + * Passed to onBeforeCompose handlers. + * Handlers may mutate the object in place to pre-fill fields; returning false cancels the compose. + */ +export interface ComposeOptions { + to: string[]; + cc: string[]; + subject: string; + body: string; + mode: 'new' | 'reply' | 'reply-all' | 'forward'; +} + +/** + * A small visual indicator injected into an email list row via onEmailListItemRender. + */ +export interface EmailListBadge { + /** Stable unique key within the plugin — used as React key */ + key: string; + /** Short label text displayed in the badge */ + label: string; + /** CSS color value for the badge background, e.g. "#e74c3c" or "var(--color-warning)" */ + color?: string; + /** Tooltip / aria-label */ + title?: string; +} + +/** + * Passed to onMailtoIntercept handlers. + * Return false to prevent the browser from opening the system mail client. + */ +export interface MailtoContext { + /** The raw href, e.g. "mailto:alice@example.com?subject=Hello" */ + href: string; + /** Parsed list of recipient addresses */ + to: string[]; + subject?: string; + body?: string; +} + +// ─── Plugin i18n API ───────────────────────────────────────── + +/** + * Localisation API exposed as `api.i18n` inside every plugin. + * + * Plugins ship their own translation tables; the app locale is tracked + * automatically so `t()` always returns the right string without any + * extra setup from the plugin side. + */ +export interface PluginI18n { + /** + * Register translations for one locale. + * Multiple calls for the same locale are merged (last-write-wins per key). + * + * @param locale BCP-47 tag, e.g. "en", "de", "fr-CA" + * @param strings Key → translated string map. Use {paramName} for interpolation. + * + * @example + * api.i18n.addTranslations('en', { 'banner.title': 'Tracking blocked' }); + * api.i18n.addTranslations('de', { 'banner.title': 'Tracking blockiert' }); + */ + addTranslations(locale: string, strings: Record): void; + + /** + * Return the translated string for `key` using the current app locale, + * with optional {param} interpolation. + * + * Falls back: exact locale → language prefix → "en" → raw key. + * + * @example + * api.i18n.t('banner.title') + * api.i18n.t('items_found', { count: 3 }) // 'Found {count} items' → 'Found 3 items' + */ + t(key: string, params?: Record): string; + + /** The current app locale string (e.g. "en", "de", "fr") */ + getLocale(): string; +} + // ─── Permission Reference ──────────────────────────────────── export const ALL_PERMISSIONS = [ From 1b816d3185b15ca1311ee938babee9d7fb676a0c Mon Sep 17 00:00:00 2001 From: Linus Rath Date: Sun, 12 Apr 2026 15:37:57 +0200 Subject: [PATCH 2/2] fix: standardize tag naming and fix unknown keyword display #184 #185 --- components/email/email-list-item.tsx | 4 +-- components/email/email-viewer.tsx | 16 ++++++------ components/email/thread-list-item.tsx | 6 ++--- components/filters/filter-rule-modal.tsx | 15 ++++++++--- lib/sieve/__tests__/generator.test.ts | 4 +-- lib/sieve/generator.ts | 2 +- locales/de/common.json | 24 +++++++++--------- locales/en/common.json | 30 +++++++++++----------- locales/es/common.json | 18 ++++++------- locales/fr/common.json | 26 +++++++++---------- locales/it/common.json | 22 ++++++++-------- locales/ja/common.json | 24 +++++++++--------- locales/ko/common.json | 32 ++++++++++++------------ locales/lv/common.json | 32 ++++++++++++------------ locales/nl/common.json | 22 ++++++++-------- locales/pl/common.json | 22 ++++++++-------- locales/pt/common.json | 24 +++++++++--------- locales/ru/common.json | 30 +++++++++++----------- locales/zh/common.json | 26 +++++++++---------- 19 files changed, 193 insertions(+), 186 deletions(-) diff --git a/components/email/email-list-item.tsx b/components/email/email-list-item.tsx index 0db55e12..f12b5a6d 100644 --- a/components/email/email-list-item.tsx +++ b/components/email/email-list-item.tsx @@ -51,9 +51,9 @@ export function EmailListItem({ email, selected, onClick, onContextMenu, onToggl const isFocusedMailLayout = mailLayout === 'focus'; const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : ''; - // Resolve color tags using keyword definitions from settings + // Resolve color tags using keyword definitions from settings; unknown tags fall back to gray const colorTagIds = getEmailColorTags(email.keywords); - const keywordDefs = colorTagIds.map(id => emailKeywords.find(k => k.id === id)).filter(Boolean) as typeof emailKeywords; + const keywordDefs = colorTagIds.map(id => emailKeywords.find(k => k.id === id) ?? { id, label: id, color: 'gray' }); // Use first tag for background coloring const keywordDef = keywordDefs[0] ?? null; const colorTag = keywordDef ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null; diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index e5000d14..eb8c1fb9 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -3070,13 +3070,13 @@ export function EmailViewer({ <> {currentColors.slice(0, 3).map((tagId) => { - const kw = emailKeywords.find(k => k.id === tagId); - return kw ? : null; + const kw = emailKeywords.find(k => k.id === tagId) ?? { id: tagId, label: tagId, color: 'gray' }; + return ; })} {showToolbarLabels && currentColors.length === 1 && ( - {emailKeywords.find(k => k.id === currentColors[0])?.label} + {emailKeywords.find(k => k.id === currentColors[0])?.label ?? currentColors[0]} )} @@ -3674,11 +3674,11 @@ export function EmailViewer({ {currentColors.length > 0 && ( {currentColors.map((tagId) => { - const kw = emailKeywords.find(k => k.id === tagId); - const dotClass = kw ? KEYWORD_PALETTE[kw.color]?.dot : null; - return dotClass ? ( - - ) : null; + const kw = emailKeywords.find(k => k.id === tagId) ?? { id: tagId, label: tagId, color: 'gray' }; + const dotClass = KEYWORD_PALETTE[kw.color]?.dot || 'bg-gray-500'; + return ( + + ); })} )} diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx index 9ee8e705..819d180d 100644 --- a/components/email/thread-list-item.tsx +++ b/components/email/thread-list-item.tsx @@ -67,9 +67,9 @@ const SingleEmailItem = React.forwardRef( const isFocusedMailLayout = mailLayout === 'focus'; const inlinePreview = showPreview && email.preview ? ` ${email.preview}` : ''; - // Resolve color tags using keyword definitions + // Resolve color tags using keyword definitions; unknown tags fall back to gray const tagIds = getEmailColorTags(email.keywords); - const resolvedKeywordDefs = tagIds.map(id => emailKeywords.find(k => k.id === id)).filter(Boolean) as typeof emailKeywords; + const resolvedKeywordDefs = tagIds.map(id => emailKeywords.find(k => k.id === id) ?? { id, label: id, color: 'gray' }); const resolvedKeywordDef = resolvedKeywordDefs[0] ?? null; const resolvedColorTag = (() => { if (colorTag) return colorTag; @@ -375,7 +375,7 @@ export const ThreadListItem = React.forwardRef state.emailKeywords); - const keywordDef = threadColor ? emailKeywordDefs.find(k => k.id === threadColor) : null; + const keywordDef = threadColor ? (emailKeywordDefs.find(k => k.id === threadColor) ?? { id: threadColor, label: threadColor, color: 'gray' }) : null; const colorTag = keywordDef ? KEYWORD_PALETTE[keywordDef.color]?.bg ?? null : null; const isSelected = selectedEmailId === latestEmail.id || diff --git a/components/filters/filter-rule-modal.tsx b/components/filters/filter-rule-modal.tsx index 3dd5f4da..96c7b293 100644 --- a/components/filters/filter-rule-modal.tsx +++ b/components/filters/filter-rule-modal.tsx @@ -17,6 +17,7 @@ import type { } from "@/lib/jmap/sieve-types"; import type { Mailbox } from "@/lib/jmap/types"; import { buildMailboxTree, flattenMailboxTree, type MailboxNode, generateUUID } from "@/lib/utils"; +import { useSettingsStore } from "@/stores/settings-store"; interface FilterRuleModalProps { rule?: FilterRule; @@ -58,6 +59,7 @@ export function FilterRuleModal({ }: FilterRuleModalProps) { const t = useTranslations("settings.filters"); const isEdit = !!rule; + const emailKeywords = useSettingsStore((state) => state.emailKeywords); const [name, setName] = useState(rule?.name || ""); const [matchType, setMatchType] = useState<"all" | "any">(rule?.matchType || "all"); @@ -375,12 +377,17 @@ export function FilterRuleModal({ )} {action.type === "add_label" && ( - updateAction(index, { value: e.target.value })} - placeholder={t("label_placeholder")} - className="flex-1 min-w-[140px]" - /> + className={`${selectClass} flex-1 min-w-[140px]`} + aria-label={t("label_placeholder")} + > + + {emailKeywords.map((kw) => ( + + ))} + )}