feat: add i18n API, render hooks, and new intercept hooks to plugin system

This commit is contained in:
Linus Rath
2026-04-12 13:57:20 +02:00
parent 44e0e17203
commit 24949e183f
5 changed files with 285 additions and 4 deletions
+27 -2
View File
@@ -14,6 +14,7 @@ import type {
AdminPageSection, AdminPageSection,
CalendarEventAction, CalendarEventAction,
SlotName, SlotName,
PluginI18n,
} from './plugin-types'; } from './plugin-types';
import { IMPLICIT_PERMISSIONS as IMPLICIT } from './plugin-types'; import { IMPLICIT_PERMISSIONS as IMPLICIT } from './plugin-types';
import { import {
@@ -22,8 +23,9 @@ import {
taskHooks, templateHooks, smimeHooks, vacationHooks, taskHooks, templateHooks, smimeHooks, vacationHooks,
uiHooks, themeHooks, toastHooks, dragDropHooks, uiHooks, themeHooks, toastHooks, dragDropHooks,
keyboardHooks, appLifecycleHooks, accountSecurityHooks, keyboardHooks, appLifecycleHooks, accountSecurityHooks,
sidebarAppHooks, avatarHooks, sidebarAppHooks, avatarHooks, renderHooks,
} from './plugin-hooks'; } from './plugin-hooks';
import { createPluginI18n } from './plugin-i18n';
import { toast as appToast } from '@/stores/toast-store'; import { toast as appToast } from '@/stores/toast-store';
import { useAuthStore } from '@/stores/auth-store'; import { useAuthStore } from '@/stores/auth-store';
@@ -109,6 +111,8 @@ function createPluginLogger(pluginId: string) {
export interface PluginAPI { export interface PluginAPI {
plugin: { id: string; version: string; settings: Record<string, unknown> }; plugin: { id: string; version: string; settings: Record<string, unknown> };
/** Localisation API — register translations and call t() to get strings */
i18n: PluginI18n;
ui: { ui: {
registerToolbarAction: (action: ToolbarAction) => Disposable; registerToolbarAction: (action: ToolbarAction) => Disposable;
registerEmailBanner: (factory: BannerFactory) => Disposable; registerEmailBanner: (factory: BannerFactory) => Disposable;
@@ -149,6 +153,8 @@ export interface PluginHooksAPI {
onEmailClose: (handler: () => void) => Disposable; onEmailClose: (handler: () => void) => Disposable;
onEmailContentRender: (handler: (...args: unknown[]) => unknown) => Disposable; onEmailContentRender: (handler: (...args: unknown[]) => unknown) => Disposable;
onThreadExpand: (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<boolean | void>) => Disposable;
onComposerOpen: (handler: (...args: unknown[]) => unknown) => Disposable; onComposerOpen: (handler: (...args: unknown[]) => unknown) => Disposable;
onBeforeEmailSend: (handler: (...args: unknown[]) => unknown) => Disposable; onBeforeEmailSend: (handler: (...args: unknown[]) => unknown) => Disposable;
onAfterEmailSend: (handler: (...args: unknown[]) => unknown) => Disposable; onAfterEmailSend: (handler: (...args: unknown[]) => unknown) => Disposable;
@@ -157,6 +163,10 @@ export interface PluginHooksAPI {
onAfterEmailDelete: (handler: (...args: unknown[]) => unknown) => Disposable; onAfterEmailDelete: (handler: (...args: unknown[]) => unknown) => Disposable;
onBeforeEmailMove: (handler: (...args: unknown[]) => unknown) => Disposable; onBeforeEmailMove: (handler: (...args: unknown[]) => unknown) => Disposable;
onAfterEmailMove: (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; onEmailReadStateChange: (handler: (...args: unknown[]) => unknown) => Disposable;
onEmailStarToggle: (handler: (...args: unknown[]) => unknown) => Disposable; onEmailStarToggle: (handler: (...args: unknown[]) => unknown) => Disposable;
onEmailSpamToggle: (handler: (...args: unknown[]) => unknown) => Disposable; onEmailSpamToggle: (handler: (...args: unknown[]) => unknown) => Disposable;
@@ -173,6 +183,8 @@ export interface PluginHooksAPI {
onNewEmailReceived: (handler: (...args: unknown[]) => unknown) => Disposable; onNewEmailReceived: (handler: (...args: unknown[]) => unknown) => Disposable;
onPushConnectionChange: (handler: (...args: unknown[]) => unknown) => Disposable; onPushConnectionChange: (handler: (...args: unknown[]) => unknown) => Disposable;
onQuotaChange: (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<boolean | void>) => Disposable;
// Calendar // Calendar
onCalendarEventOpen: (handler: (...args: unknown[]) => unknown) => Disposable; onCalendarEventOpen: (handler: (...args: unknown[]) => unknown) => Disposable;
onBeforeEventCreate: (handler: (...args: unknown[]) => unknown) => Disposable; onBeforeEventCreate: (handler: (...args: unknown[]) => unknown) => Disposable;
@@ -215,6 +227,8 @@ export interface PluginHooksAPI {
onDirectoryCreate: (handler: (...args: unknown[]) => unknown) => Disposable; onDirectoryCreate: (handler: (...args: unknown[]) => unknown) => Disposable;
onBeforeFileDelete: (handler: (...args: unknown[]) => unknown) => Disposable; onBeforeFileDelete: (handler: (...args: unknown[]) => unknown) => Disposable;
onAfterFileDelete: (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<boolean | void>) => Disposable;
onFileRename: (handler: (...args: unknown[]) => unknown) => Disposable; onFileRename: (handler: (...args: unknown[]) => unknown) => Disposable;
onFileMove: (handler: (...args: unknown[]) => unknown) => Disposable; onFileMove: (handler: (...args: unknown[]) => unknown) => Disposable;
onFileCopy: (handler: (...args: unknown[]) => unknown) => Disposable; onFileCopy: (handler: (...args: unknown[]) => unknown) => Disposable;
@@ -316,6 +330,9 @@ export interface PluginHooksAPI {
onSidebarAppChange: (handler: (...args: unknown[]) => unknown) => Disposable; onSidebarAppChange: (handler: (...args: unknown[]) => unknown) => Disposable;
// Avatar // Avatar
onAvatarResolve: (handler: (...args: unknown[]) => unknown) => Disposable; 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 ---------------------------- // --- Permission mapping for hooks ----------------------------
@@ -324,14 +341,17 @@ const HOOK_PERMISSIONS: Record<string, Permission> = {
// Email // Email
onEmailOpen: 'email:read', onEmailClose: 'email:read', onEmailOpen: 'email:read', onEmailClose: 'email:read',
onEmailContentRender: 'email:read', onThreadExpand: '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', onMailboxChange: 'email:read', onMailboxesRefresh: 'email:read',
onSearch: 'email:read', onSearchResults: 'email:read', onSearch: 'email:read', onSearchResults: 'email:read',
onEmailSelectionChange: 'email:read', onNewEmailReceived: 'email:read', onEmailSelectionChange: 'email:read', onNewEmailReceived: 'email:read',
onPushConnectionChange: 'email:read', onQuotaChange: 'email:read', onPushConnectionChange: 'email:read', onQuotaChange: 'email:read',
onMailtoIntercept: 'email:read', onEmailListItemRender: 'email:read',
onBeforeEmailSend: 'email:send', onAfterEmailSend: 'email:send', onBeforeEmailSend: 'email:send', onAfterEmailSend: 'email:send',
onBeforeEmailDelete: 'email:write', onAfterEmailDelete: 'email:write', onBeforeEmailDelete: 'email:write', onAfterEmailDelete: 'email:write',
onBeforeEmailMove: 'email:write', onAfterEmailMove: 'email:write', onBeforeEmailMove: 'email:write', onAfterEmailMove: 'email:write',
onEmailArchive: 'email:write', onEmailUnarchive: 'email:write',
onEmailReadStateChange: 'email:write', onEmailStarToggle: 'email:write', onEmailReadStateChange: 'email:write', onEmailStarToggle: 'email:write',
onEmailSpamToggle: 'email:write', onEmailKeywordChange: 'email:write', onEmailSpamToggle: 'email:write', onEmailKeywordChange: 'email:write',
onMailboxCreate: 'email:write', onMailboxRename: 'email:write', onMailboxCreate: 'email:write', onMailboxRename: 'email:write',
@@ -358,6 +378,7 @@ const HOOK_PERMISSIONS: Record<string, Permission> = {
onBeforeFileUpload: 'files:write', onAfterFileUpload: 'files:write', onBeforeFileUpload: 'files:write', onAfterFileUpload: 'files:write',
onFileUploadCancel: 'files:write', onDirectoryCreate: 'files:write', onFileUploadCancel: 'files:write', onDirectoryCreate: 'files:write',
onBeforeFileDelete: 'files:write', onAfterFileDelete: 'files:write', onBeforeFileDelete: 'files:write', onAfterFileDelete: 'files:write',
onBeforeFileRename: 'files:write',
onFileRename: 'files:write', onFileMove: 'files:write', onFileCopy: 'files:write', onFileRename: 'files:write', onFileMove: 'files:write', onFileCopy: 'files:write',
onFileDuplicate: 'files:write', onFileFavoriteToggle: 'files:write', onFileUndo: 'files:write', onFileDuplicate: 'files:write', onFileFavoriteToggle: 'files:write', onFileUndo: 'files:write',
// Auth // Auth
@@ -469,6 +490,8 @@ const HOOK_BUSES: Record<string, { register: (pluginId: string, handler: (...arg
...Object.fromEntries(Object.entries(sidebarAppHooks)), ...Object.fromEntries(Object.entries(sidebarAppHooks)),
// Avatar // Avatar
...Object.fromEntries(Object.entries(avatarHooks)), ...Object.fromEntries(Object.entries(avatarHooks)),
// Render
...Object.fromEntries(Object.entries(renderHooks)),
}; };
// --- Slot registration bridge -------------------------------- // --- Slot registration bridge --------------------------------
@@ -530,6 +553,8 @@ export function createPluginAPI(plugin: InstalledPlugin): PluginAPI {
settings: { ...plugin.settings }, settings: { ...plugin.settings },
}, },
i18n: createPluginI18n(plugin.id),
ui: { ui: {
registerToolbarAction: (action: ToolbarAction) => { registerToolbarAction: (action: ToolbarAction) => {
requirePermission(plugin, 'ui:toolbar'); requirePermission(plugin, 'ui:toolbar');
+26 -1
View File
@@ -172,6 +172,10 @@ export const emailHooks = {
onEmailClose: new HookBus(), onEmailClose: new HookBus(),
onEmailContentRender: new HookBus(), onEmailContentRender: new HookBus(),
onThreadExpand: 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(), onComposerOpen: new HookBus(),
onBeforeEmailSend: new HookBus(), onBeforeEmailSend: new HookBus(),
onAfterEmailSend: new HookBus(), onAfterEmailSend: new HookBus(),
@@ -180,6 +184,10 @@ export const emailHooks = {
onAfterEmailDelete: new HookBus(), onAfterEmailDelete: new HookBus(),
onBeforeEmailMove: new HookBus(), onBeforeEmailMove: new HookBus(),
onAfterEmailMove: 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(), onEmailReadStateChange: new HookBus(),
onEmailStarToggle: new HookBus(), onEmailStarToggle: new HookBus(),
onEmailSpamToggle: new HookBus(), onEmailSpamToggle: new HookBus(),
@@ -196,6 +204,9 @@ export const emailHooks = {
onNewEmailReceived: new HookBus(), onNewEmailReceived: new HookBus(),
onPushConnectionChange: new HookBus(), onPushConnectionChange: new HookBus(),
onQuotaChange: 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 // §7.2 Calendar Hooks
@@ -250,6 +261,10 @@ export const fileHooks = {
onDirectoryCreate: new HookBus(), onDirectoryCreate: new HookBus(),
onBeforeFileDelete: new HookBus(), onBeforeFileDelete: new HookBus(),
onAfterFileDelete: 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(), onFileRename: new HookBus(),
onFileMove: new HookBus(), onFileMove: new HookBus(),
onFileCopy: new HookBus(), onFileCopy: new HookBus(),
@@ -406,6 +421,16 @@ export const avatarHooks = {
onAvatarResolve: new HookBus(), 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 ─── // ─── Aggregate: remove all handlers for a plugin across all buses ───
const allHookGroups = [ const allHookGroups = [
@@ -414,7 +439,7 @@ const allHookGroups = [
taskHooks, templateHooks, smimeHooks, vacationHooks, taskHooks, templateHooks, smimeHooks, vacationHooks,
uiHooks, themeHooks, toastHooks, dragDropHooks, uiHooks, themeHooks, toastHooks, dragDropHooks,
keyboardHooks, appLifecycleHooks, accountSecurityHooks, sidebarAppHooks, keyboardHooks, appLifecycleHooks, accountSecurityHooks, sidebarAppHooks,
avatarHooks, avatarHooks, renderHooks,
]; ];
export function removeAllPluginHooks(pluginId: string): void { export function removeAllPluginHooks(pluginId: string): void {
+118
View File
@@ -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<string, Map<string, Record<string, string>>>();
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, string | number>): 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<string, string>): void {
let byLocale = registry.get(pluginId);
if (!byLocale) {
byLocale = new Map<string, Record<string, string>>();
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, string | number>): 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<typeof createPluginI18n>;
+25 -1
View File
@@ -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 type { InstalledPlugin, Disposable } from './plugin-types';
import { pluginStorage } from './plugin-storage'; import { pluginStorage } from './plugin-storage';
import { createPluginAPI, type PluginAPI } from './plugin-api'; import { createPluginAPI, type PluginAPI } from './plugin-api';
import { removeAllPluginHooks, pluginErrorTracker } from './plugin-hooks'; import { removeAllPluginHooks, pluginErrorTracker } from './plugin-hooks';
import { setPluginI18nLocale, clearPluginI18nTranslations } from './plugin-i18n';
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import * as ReactJSX from 'react/jsx-runtime'; import * as ReactJSX from 'react/jsx-runtime';
// --- Shared React (window.__PLUGIN_EXTERNALS__) ------------- // --- Shared React (window.__PLUGIN_EXTERNALS__) -------------
let localeSyncInitialised = false;
export function exposePluginExternals(): void { export function exposePluginExternals(): void {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -18,6 +21,16 @@ export function exposePluginExternals(): void {
ReactDOM, ReactDOM,
ReactJSX, 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 ---------------------------------- // --- Active plugin tracking ----------------------------------
@@ -78,6 +91,14 @@ export async function loadPlugin(plugin: InstalledPlugin): Promise<void> {
// 4. Build sandboxed API // 4. Build sandboxed API
const api = createPluginAPI(plugin); 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 // 5. Call activate
const disposable = await mod.activate(api); const disposable = await mod.activate(api);
@@ -119,6 +140,9 @@ export function deactivatePlugin(pluginId: string): void {
// Remove all hook subscriptions for this plugin // Remove all hook subscriptions for this plugin
removeAllPluginHooks(pluginId); removeAllPluginHooks(pluginId);
// Clear cached translations (avoids memory leak on repeated enable/disable cycles)
clearPluginI18nTranslations(pluginId);
// Reset error tracker // Reset error tracker
pluginErrorTracker.reset(pluginId); pluginErrorTracker.reset(pluginId);
+89
View File
@@ -34,6 +34,13 @@ export interface PluginManifest {
entrypoint: string; entrypoint: string;
minAppVersion?: string; minAppVersion?: string;
settingsSchema?: Record<string, SettingFieldSchema>; settingsSchema?: Record<string, SettingFieldSchema>;
/**
* 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<string, Record<string, string>>;
} }
export interface SettingFieldSchema { export interface SettingFieldSchema {
@@ -83,6 +90,8 @@ export interface InstalledPlugin {
adminApproved?: boolean; adminApproved?: boolean;
settingsSchema?: Record<string, SettingFieldSchema>; settingsSchema?: Record<string, SettingFieldSchema>;
settings: Record<string, unknown>; settings: Record<string, unknown>;
/** Bundled translations, carried over from the manifest on install. */
locales?: Record<string, Record<string, string>>;
} }
// ─── UI Slots ──────────────────────────────────────────────── // ─── UI Slots ────────────────────────────────────────────────
@@ -388,6 +397,86 @@ export interface ComposerContext {
originalSubject?: string; 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<string, string>): 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, string | number>): string;
/** The current app locale string (e.g. "en", "de", "fr") */
getLocale(): string;
}
// ─── Permission Reference ──────────────────────────────────── // ─── Permission Reference ────────────────────────────────────
export const ALL_PERMISSIONS = [ export const ALL_PERMISSIONS = [