// Plugin & Theme system types // ─── Common ────────────────────────────────────────────────── export type Disposable = { dispose: () => void }; export type MaybePromise = T | Promise; export type PluginType = 'ui-extension' | 'sidebar-app' | 'hook' | 'theme'; export type PluginStatus = 'installed' | 'enabled' | 'running' | 'disabled' | 'error'; export type ThemeVariant = 'light' | 'dark'; // ─── Manifests ─────────────────────────────────────────────── export interface ThemeManifest { id: string; name: string; version: string; author: string; description: string; type: 'theme'; preview?: string; variants: ThemeVariant[]; minAppVersion?: string; } export interface PluginManifest { id: string; name: string; version: string; author: string; description: string; type: Exclude; permissions: string[]; 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>; /** * External origins this plugin may embed in iframes (e.g. for YouTube, * Vimeo, Jitsi). Each entry is a single CSP origin like * "https://www.youtube-nocookie.com" * "https://*.example.com:8443" * Validated at install time and merged into the host CSP `frame-src`. */ frameOrigins?: string[]; } export interface SettingFieldSchema { type: 'boolean' | 'string' | 'number' | 'select'; label: string; description?: string; default: unknown; options?: string[]; min?: number; max?: number; } // ─── Installed Items ───────────────────────────────────────── export interface InstalledTheme { id: string; name: string; version: string; author: string; description: string; preview?: string; // data: URI or blob URL css: string; // raw CSS text variants: ThemeVariant[]; enabled: boolean; builtIn: boolean; managed?: boolean; forceEnabled?: boolean; } export interface InstalledPlugin { id: string; name: string; version: string; author: string; description: string; type: Exclude; permissions: string[]; entrypoint: string; enabled: boolean; status: PluginStatus; error?: string; // True when plugin was delivered from server-side admin registry. managed?: boolean; // True when plugin is admin-enforced and cannot be disabled locally. forceEnabled?: boolean; // True when plugin has been approved by an admin. Unapproved plugins cannot be enabled. adminApproved?: boolean; settingsSchema?: Record; settings: Record; /** Bundled translations, carried over from the manifest on install. */ locales?: Record>; } // ─── UI Slots ──────────────────────────────────────────────── export type SlotName = | 'toolbar-actions' | 'email-banner' | 'email-footer' | 'composer-toolbar' | 'composer-sidebar' | 'composer-sidebar-right' | 'sidebar-widget' | 'email-detail-sidebar' | 'settings-section' | 'context-menu-email' | 'navigation-rail-bottom' | 'calendar-event-actions' | 'admin-plugin-page'; export interface SlotRegistration { pluginId: string; component: React.ComponentType>; order: number; } // ─── Plugin API Types ──────────────────────────────────────── export interface ToolbarAction { id: string; label: string; icon?: string; onClick: () => void; order?: number; } export interface BannerFactory { shouldShow: (email: EmailReadView) => boolean; render: React.ComponentType<{ email: EmailReadView }>; } export interface SettingsSection { id: string; label: string; icon?: string; render: React.ComponentType; } export interface ComposerAction { id: string; label: string; icon?: string; onClick: () => void; order?: number; } export interface SidebarWidget { id: string; label: string; render: React.ComponentType; order?: number; /** * For composer sidebars, choose which side of the New Message dialog the * panel renders on. Defaults to `'left'` for backwards compatibility. * Ignored by other sidebar slots. */ side?: 'left' | 'right'; } export interface ContextMenuItem { id: string; label: string; icon?: string; onClick: (emailIds: string[]) => void; order?: number; } export interface AdminPageSection { id: string; label: string; icon?: string; render: React.ComponentType; } export interface CalendarEventAction { id: string; label: string; icon?: string; onClick: (eventData: CalendarEventFormView, helpers: { setVirtualLocation: (url: string) => void }) => void; order?: number; } export interface CalendarEventFormView { title: string; description: string; start: string; end: string; isAllDay: boolean; location: string; virtualLocation: string; calendarId: string; } export interface KeyboardShortcut { id: string; keys: string; label: string; category: string; handler: () => void; } // ─── Read-Only View Types ──────────────────────────────────── // Projected views exposed to plugins - no direct store references export interface EmailReadView { id: string; threadId: string; mailboxIds: string[]; from: { name: string; email: string }[]; to: { name: string; email: string }[]; cc: { name: string; email: string }[]; subject: string; receivedAt: string; isRead: boolean; isFlagged: boolean; hasAttachment: boolean; preview: string; keywords: string[]; } export interface DraftView { to: string[]; cc: string[]; bcc: string[]; subject: string; htmlBody: string; textBody: string; identityId: string; inReplyTo?: string; attachments: { name: string; type: string; size: number }[]; } export interface MailboxView { id: string; name: string; role: string | null; totalEmails: number; unreadEmails: number; parentId: string | null; } export interface CalendarEventView { id: string; calendarId: string; title: string; description: string; start: string; end: string; isAllDay: boolean; location: string; status: string; recurrenceRule?: string; } export interface CalendarView { id: string; name: string; color: string; isVisible: boolean; isDefault: boolean; } export interface ContactView { id: string; addressBookId: string; firstName: string; lastName: string; emails: string[]; phones: string[]; company: string; notes: string; } export interface AddressBookView { id: string; name: string; isDefault: boolean; } export interface ContactGroupView { id: string; name: string; memberCount: number; } export interface FileResourceView { id: string; name: string; type: 'file' | 'directory'; size: number; mimeType: string; path: string; modified: string; } export interface IdentityView { id: string; name: string; email: string; replyTo: string | null; bcc: string | null; htmlSignature: string; textSignature: string; } export interface TaskView { id: string; title: string; description: string; isComplete: boolean; dueDate: string | null; priority: string; calendarId: string; } export interface TemplateView { id: string; name: string; subject: string; htmlBody: string; textBody: string; } export interface FilterRuleView { id: string; name: string; isActive: boolean; conditions: unknown[]; actions: unknown[]; } export interface KeywordView { id: string; name: string; color: string; } export interface QuotaView { used: number; total: number; percentUsed: number; } export interface CalendarAlertView { id: string; eventId: string; eventTitle: string; triggerTime: string; } export interface SearchFilters { from?: string; to?: string; subject?: string; hasAttachment?: boolean; after?: string; before?: string; inMailbox?: string; } export interface NewEmailNotification { emailId: string; from: { name: string; email: string }; subject: string; preview: string; } export interface VacationView { isEnabled: boolean; subject: string; htmlBody: string; textBody: string; fromDate: string | null; toDate: string | null; } export interface KeyboardEventView { key: string; code: string; ctrlKey: boolean; shiftKey: boolean; altKey: boolean; metaKey: boolean; } export interface AppConfigView { appName: string; demoMode: boolean; stalwartFeaturesEnabled: boolean; oauthEnabled: boolean; } export interface FileInfo { name: string; size: number; type: string; } export interface ComposerContext { mode: 'new' | 'reply' | 'reply-all' | 'forward'; inReplyToId?: 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): 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 = [ 'email:read', 'email:write', 'email:send', 'calendar:read', 'calendar:write', 'contacts:read', 'contacts:write', 'files:read', 'files:write', 'identity:read', 'identity:write', 'filters:read', 'filters:write', 'tasks:read', 'tasks:write', 'templates:read', 'templates:write', 'smime:read', 'vacation:read', 'vacation:write', 'settings:read', 'settings:write', 'security:read', 'auth:observe', 'http:post', 'ui:observe', 'ui:toolbar', 'ui:email-banner', 'ui:email-footer', 'ui:composer-toolbar', 'ui:composer-sidebar', 'ui:sidebar-widget', 'ui:settings-section', 'ui:context-menu', 'ui:navigation-rail', 'ui:keyboard', 'ui:calendar-action', 'ui:admin-page', 'admin:config', 'app:lifecycle', ] as const; export type Permission = (typeof ALL_PERMISSIONS)[number]; /** Permissions always granted regardless of manifest */ export const IMPLICIT_PERMISSIONS: Permission[] = ['ui:observe', 'app:lifecycle']; // ─── Validation ────────────────────────────────────────────── export const MAX_PLUGIN_SIZE = 5 * 1024 * 1024; // 5 MB export const MAX_THEME_SIZE = 1 * 1024 * 1024; // 1 MB export const ALLOWED_PLUGIN_FILES = new Set([ '.js', '.mjs', '.css', '.json', '.png', '.svg', '.woff2', '.jpg', '.jpeg', '.webp', ]); export const DISALLOWED_CSS_PATTERNS = [ /@import\b/i, /url\s*\(\s*['"]?https?:/i, /url\s*\(\s*['"]?data:/i, /expression\s*\(/i, /javascript\s*:/i, /-moz-binding/i, /behavior\s*:/i, ];