// PluginAPI factory — builds the sandboxed API facade for each plugin import type { Disposable, InstalledPlugin, Permission, ToolbarAction, BannerFactory, SettingsSection, ComposerAction, SidebarWidget, ContextMenuItem, KeyboardShortcut, AdminPageSection, CalendarEventAction, SlotName, PluginI18n, } from './plugin-types'; import { IMPLICIT_PERMISSIONS as IMPLICIT } from './plugin-types'; import { emailHooks, calendarHooks, calendarFormHooks, contactHooks, fileHooks, authHooks, settingsHooks, identityHooks, filterHooks, taskHooks, templateHooks, smimeHooks, vacationHooks, uiHooks, themeHooks, toastHooks, dragDropHooks, keyboardHooks, appLifecycleHooks, accountSecurityHooks, sidebarAppHooks, avatarHooks, renderHooks, routerHooks, } from './plugin-hooks'; import { createPluginI18n } from './plugin-i18n'; import { toast as appToast } from '@/stores/toast-store'; import { useAuthStore } from '@/stores/auth-store'; import { apiFetch } from '@/lib/browser-navigation'; // --- Permission helpers -------------------------------------- // eslint-disable-next-line @typescript-eslint/no-explicit-any function getPluginExternals(): any { // eslint-disable-next-line @typescript-eslint/no-explicit-any return (globalThis as any).__PLUGIN_EXTERNALS__; } function hasPermission(plugin: InstalledPlugin, perm: Permission): boolean { if ((IMPLICIT as readonly string[]).includes(perm)) return true; return plugin.permissions.includes(perm); } function requirePermission(plugin: InstalledPlugin, perm: Permission): void { if (!hasPermission(plugin, perm)) { throw new Error(`Plugin "${plugin.id}" lacks permission "${perm}"`); } } /** Returns a no-op disposable when permission is missing (silent failure) */ function guardedHook unknown>( plugin: InstalledPlugin, perm: Permission, // eslint-disable-next-line @typescript-eslint/no-explicit-any bus: { register: (pluginId: string, handler: any, order?: number) => Disposable }, handler: T, order: number = 100, ): Disposable { if (!hasPermission(plugin, perm)) { return { dispose: () => {} }; } return bus.register(plugin.id, handler, order); } // --- Plugin-scoped storage ----------------------------------- function createPluginStorage(pluginId: string) { const prefix = `plugin:${pluginId}:`; return { get: (key: string): T | null => { if (typeof window === 'undefined') return null; const raw = localStorage.getItem(prefix + key); if (raw === null) return null; try { return JSON.parse(raw) as T; } catch { return null; } }, set: (key: string, value: T): void => { if (typeof window === 'undefined') return; localStorage.setItem(prefix + key, JSON.stringify(value)); }, remove: (key: string): void => { if (typeof window === 'undefined') return; localStorage.removeItem(prefix + key); }, keys: (): string[] => { if (typeof window === 'undefined') return []; const keys: string[] = []; for (let i = 0; i < localStorage.length; i++) { const k = localStorage.key(i); if (k?.startsWith(prefix)) keys.push(k.slice(prefix.length)); } return keys; }, }; } // --- Plugin-scoped logger ------------------------------------ function createPluginLogger(pluginId: string) { const tag = `[plugin:${pluginId}]`; return { debug: (...args: unknown[]) => console.debug(tag, ...args), info: (...args: unknown[]) => console.info(tag, ...args), warn: (...args: unknown[]) => console.warn(tag, ...args), error: (...args: unknown[]) => console.error(tag, ...args), }; } // --- Cross-origin fetch helpers ------------------------------ /** * Returns true when `url`'s origin is allowed by one of the plugin's * declared `httpOrigins` patterns. Patterns are either a literal origin * (`https://host[:port]`) or a wildcard subdomain form (`https://*.host`). * * Wildcards match exactly one subdomain layer above `host` — e.g. * `https://*.example.com` matches `https://a.example.com` but NOT * `https://example.com` and NOT `https://a.b.example.com`. This mirrors how * the CSP frame-src handles wildcards and avoids accidentally widening * access when the manifest only intended a single tier. */ function originMatchesAllowlist(url: URL, allowlist: string[]): boolean { if (url.protocol !== 'https:') return false; for (const entry of allowlist) { let parsed: URL; try { parsed = new URL(entry.replace('*.', '')); } catch { continue; } if (parsed.protocol !== 'https:') continue; const port = url.port || ''; const expectedPort = parsed.port || ''; if (port !== expectedPort) continue; if (entry.includes('*.')) { const suffix = '.' + parsed.hostname.toLowerCase(); if (url.hostname.toLowerCase().endsWith(suffix)) { const prefix = url.hostname.slice(0, url.hostname.length - suffix.length); // Require exactly one non-empty subdomain label. if (prefix.length > 0 && !prefix.includes('.')) return true; } } else { if (url.hostname.toLowerCase() === parsed.hostname.toLowerCase()) return true; } } return false; } // --- Cross-origin fetch types -------------------------------- export interface PluginFetchInit { /** HTTP method. Defaults to GET. */ method?: string; /** Request headers. Plain object only — no Headers / cookies forwarded. */ headers?: Record; /** Body. Plain string, ArrayBuffer, Uint8Array, Blob, or FormData. */ body?: string | ArrayBuffer | ArrayBufferView | Blob | FormData | null; /** Optional AbortSignal for cancellation. */ signal?: AbortSignal; } export interface PluginFetchResponse { ok: boolean; status: number; statusText: string; /** Response headers, lower-cased keys. */ headers: Record; /** Resolves the body as text. */ text: () => Promise; /** Resolves the body as parsed JSON, or null on parse error. */ json: () => Promise; /** Resolves the body as raw bytes. */ arrayBuffer: () => Promise; /** Resolves the body as a Blob. */ blob: () => Promise; } // --- PluginAPI interface ------------------------------------- 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; registerEmailFooter: (component: React.ComponentType) => Disposable; registerSettingsSection: (section: SettingsSection) => Disposable; registerComposerAction: (action: ComposerAction) => Disposable; registerSidebarWidget: (widget: SidebarWidget) => Disposable; registerComposerSidebar: (widget: SidebarWidget) => Disposable; registerDetailSidebar: (widget: SidebarWidget) => Disposable; registerContextMenuItem: (item: ContextMenuItem) => Disposable; registerNavigationRailItem: (component: React.ComponentType) => Disposable; registerCalendarEventAction: (action: CalendarEventAction) => Disposable; registerAdminPage: (page: AdminPageSection) => Disposable; }; hooks: PluginHooksAPI; toast: { success: (message: string) => void; error: (message: string) => void; info: (message: string) => void; warning: (message: string) => void; }; http: { post: (path: string, body: Record) => Promise<{ ok: boolean; status: number; data: unknown }>; /** * Cross-origin fetch against an origin declared in the manifest's * `httpOrigins` allowlist. Requires `http:fetch` permission. * * No webmail credentials are forwarded — the plugin must supply its own * `Authorization` (or other auth) header. Each call is gated on origin * even when the URL came from plugin settings, so a user-pasted URL * outside the allowlist is rejected at the boundary. */ fetch: (url: string, init?: PluginFetchInit) => Promise; }; storage: ReturnType; log: ReturnType; admin: { getConfig: (key: string) => Promise; getAllConfig: () => Promise>; setConfig: (key: string, value: unknown) => Promise; deleteConfig: (key: string) => Promise; }; } // Simplified hooks API type (all hooks return Disposable) export interface PluginHooksAPI { // Email onEmailOpen: (handler: (...args: unknown[]) => unknown) => Disposable; 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; onDraftAutoSave: (handler: (...args: unknown[]) => unknown) => Disposable; onBeforeEmailDelete: (handler: (...args: unknown[]) => unknown) => Disposable; 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; onEmailKeywordChange: (handler: (...args: unknown[]) => unknown) => Disposable; onMailboxChange: (handler: (...args: unknown[]) => unknown) => Disposable; onMailboxesRefresh: (handler: (...args: unknown[]) => unknown) => Disposable; onMailboxCreate: (handler: (...args: unknown[]) => unknown) => Disposable; onMailboxRename: (handler: (...args: unknown[]) => unknown) => Disposable; onMailboxDelete: (handler: (...args: unknown[]) => unknown) => Disposable; onMailboxEmpty: (handler: (...args: unknown[]) => unknown) => Disposable; onSearch: (handler: (...args: unknown[]) => unknown) => Disposable; onSearchResults: (handler: (...args: unknown[]) => unknown) => Disposable; onEmailSelectionChange: (handler: (...args: unknown[]) => unknown) => Disposable; 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; /** Transform - receives the OutgoingEmail and returns a (possibly modified) copy */ onTransformOutgoingEmail: (handler: (email: import('./plugin-types').OutgoingEmail) => import('./plugin-types').OutgoingEmail | void | Promise) => Disposable; /** Intercept - receives ReplyContext, return false to cancel */ onBeforeReply: (handler: (ctx: import('./plugin-types').ReplyContext) => boolean | void | Promise) => Disposable; onBeforeReplyAll: (handler: (ctx: import('./plugin-types').ReplyContext) => boolean | void | Promise) => Disposable; onBeforeForward: (handler: (ctx: import('./plugin-types').ReplyContext) => boolean | void | Promise) => Disposable; /** Intercept - receives AttachmentInfo, return false to refuse the upload */ onBeforeAttachmentUpload: (handler: (info: import('./plugin-types').AttachmentInfo) => boolean | void | Promise) => Disposable; onAfterAttachmentUpload: (handler: (info: import('./plugin-types').AttachmentInfo) => void) => Disposable; onAttachmentDownload: (handler: (info: import('./plugin-types').AttachmentInfo) => void) => Disposable; /** Transform - receives AttachmentPreview, may return a modified preview */ onAttachmentPreview: (handler: (preview: import('./plugin-types').AttachmentPreview, info: import('./plugin-types').AttachmentInfo) => import('./plugin-types').AttachmentPreview | void | Promise) => Disposable; /** Transform - receives ExternalSearchResult[] and returns an extended array */ onProvideSearchResults: (handler: (results: import('./plugin-types').ExternalSearchResult[], ctx: { query: string; filters: import('./plugin-types').SearchFilters }) => import('./plugin-types').ExternalSearchResult[] | void | Promise) => Disposable; /** Observer - debounced snapshot of the composer draft */ onDraftChange: (handler: (draft: import('./plugin-types').DraftView) => void) => Disposable; // Calendar onCalendarEventOpen: (handler: (...args: unknown[]) => unknown) => Disposable; onBeforeEventCreate: (handler: (...args: unknown[]) => unknown) => Disposable; onAfterEventCreate: (handler: (...args: unknown[]) => unknown) => Disposable; onBeforeEventUpdate: (handler: (...args: unknown[]) => unknown) => Disposable; onAfterEventUpdate: (handler: (...args: unknown[]) => unknown) => Disposable; onBeforeEventDelete: (handler: (...args: unknown[]) => unknown) => Disposable; onAfterEventDelete: (handler: (...args: unknown[]) => unknown) => Disposable; onEventRsvp: (handler: (...args: unknown[]) => unknown) => Disposable; onEventsImport: (handler: (...args: unknown[]) => unknown) => Disposable; onCalendarDateChange: (handler: (...args: unknown[]) => unknown) => Disposable; onCalendarViewChange: (handler: (...args: unknown[]) => unknown) => Disposable; onCalendarChange: (handler: (...args: unknown[]) => unknown) => Disposable; onCalendarVisibilityToggle: (handler: (...args: unknown[]) => unknown) => Disposable; onICalSubscriptionChange: (handler: (...args: unknown[]) => unknown) => Disposable; onCalendarAlert: (handler: (...args: unknown[]) => unknown) => Disposable; onCalendarAlertAcknowledge: (handler: (...args: unknown[]) => unknown) => Disposable; /** Transform - receives ConflictWarning[] and returns an extended array */ onCheckEventConflicts: (handler: (warnings: import('./plugin-types').ConflictWarning[], ctx: { event: import('./plugin-types').CalendarEventFormView }) => import('./plugin-types').ConflictWarning[] | void | Promise) => Disposable; // Calendar Form onCalendarEventFormOpen: (handler: (...args: unknown[]) => unknown) => Disposable; onCalendarEventFormSave: (handler: (...args: unknown[]) => unknown) => Disposable; // Contacts onContactOpen: (handler: (...args: unknown[]) => unknown) => Disposable; onBeforeContactCreate: (handler: (...args: unknown[]) => unknown) => Disposable; onAfterContactCreate: (handler: (...args: unknown[]) => unknown) => Disposable; onBeforeContactUpdate: (handler: (...args: unknown[]) => unknown) => Disposable; onAfterContactUpdate: (handler: (...args: unknown[]) => unknown) => Disposable; onBeforeContactDelete: (handler: (...args: unknown[]) => unknown) => Disposable; onAfterContactDelete: (handler: (...args: unknown[]) => unknown) => Disposable; onContactsImport: (handler: (...args: unknown[]) => unknown) => Disposable; onContactSelectionChange: (handler: (...args: unknown[]) => unknown) => Disposable; onContactGroupChange: (handler: (...args: unknown[]) => unknown) => Disposable; onContactGroupMemberChange: (handler: (...args: unknown[]) => unknown) => Disposable; onContactMove: (handler: (...args: unknown[]) => unknown) => Disposable; /** Transform - receives RecipientSuggestion[] and returns an extended array */ onProvideRecipientSuggestions: (handler: (suggestions: import('./plugin-types').RecipientSuggestion[], ctx: { query: string }) => import('./plugin-types').RecipientSuggestion[] | void | Promise) => Disposable; // Files onFileNavigate: (handler: (...args: unknown[]) => unknown) => Disposable; onBeforeFileUpload: (handler: (...args: unknown[]) => unknown) => Disposable; onAfterFileUpload: (handler: (...args: unknown[]) => unknown) => Disposable; onFileDownload: (handler: (...args: unknown[]) => unknown) => Disposable; onFileUploadCancel: (handler: (...args: unknown[]) => unknown) => Disposable; 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; onFileDuplicate: (handler: (...args: unknown[]) => unknown) => Disposable; onFileFavoriteToggle: (handler: (...args: unknown[]) => unknown) => Disposable; onFileSelectionChange: (handler: (...args: unknown[]) => unknown) => Disposable; onFileUndo: (handler: (...args: unknown[]) => unknown) => Disposable; // Auth onLogin: (handler: (...args: unknown[]) => unknown) => Disposable; onBeforeLogout: (handler: () => void) => Disposable; onAfterLogout: (handler: () => void) => Disposable; onAccountSwitch: (handler: (...args: unknown[]) => unknown) => Disposable; onAccountAdd: (handler: (...args: unknown[]) => unknown) => Disposable; onAccountRemove: (handler: (...args: unknown[]) => unknown) => Disposable; onTokenRefresh: (handler: () => void) => Disposable; onAuthReady: (handler: (...args: unknown[]) => unknown) => Disposable; // Settings onSettingChange: (handler: (...args: unknown[]) => unknown) => Disposable; onSettingsExport: (handler: () => void) => Disposable; onSettingsImport: (handler: (...args: unknown[]) => unknown) => Disposable; onSettingsReset: (handler: () => void) => Disposable; onSettingsSync: (handler: (...args: unknown[]) => unknown) => Disposable; onKeywordChange: (handler: (...args: unknown[]) => unknown) => Disposable; onTrustedSenderChange: (handler: (...args: unknown[]) => unknown) => Disposable; // Identity onIdentitiesLoaded: (handler: (...args: unknown[]) => unknown) => Disposable; onIdentityCreate: (handler: (...args: unknown[]) => unknown) => Disposable; onIdentityUpdate: (handler: (...args: unknown[]) => unknown) => Disposable; onIdentityDelete: (handler: (...args: unknown[]) => unknown) => Disposable; onIdentitySelect: (handler: (...args: unknown[]) => unknown) => Disposable; onSignatureRender: (handler: (...args: unknown[]) => unknown) => Disposable; // Filters onFiltersLoaded: (handler: (...args: unknown[]) => unknown) => Disposable; onFilterRuleChange: (handler: (...args: unknown[]) => unknown) => Disposable; onFiltersSave: (handler: (...args: unknown[]) => unknown) => Disposable; onSieveScriptChange: (handler: (...args: unknown[]) => unknown) => Disposable; // Tasks onTasksLoaded: (handler: (...args: unknown[]) => unknown) => Disposable; onTaskCreate: (handler: (...args: unknown[]) => unknown) => Disposable; onTaskUpdate: (handler: (...args: unknown[]) => unknown) => Disposable; onTaskDelete: (handler: (...args: unknown[]) => unknown) => Disposable; onTaskToggleComplete: (handler: (...args: unknown[]) => unknown) => Disposable; onTaskFilterChange: (handler: (...args: unknown[]) => unknown) => Disposable; // Templates onTemplateCreate: (handler: (...args: unknown[]) => unknown) => Disposable; onTemplateUpdate: (handler: (...args: unknown[]) => unknown) => Disposable; onTemplateDelete: (handler: (...args: unknown[]) => unknown) => Disposable; onTemplateApply: (handler: (...args: unknown[]) => unknown) => Disposable; onTemplatesImport: (handler: (...args: unknown[]) => unknown) => Disposable; onTemplateRender: (handler: (...args: unknown[]) => unknown) => Disposable; // S/MIME onSmimeKeyImport: (handler: (...args: unknown[]) => unknown) => Disposable; onSmimeCertImport: (handler: (...args: unknown[]) => unknown) => Disposable; onSmimeKeyStateChange: (handler: (...args: unknown[]) => unknown) => Disposable; onSmimeDefaultsChange: (handler: (...args: unknown[]) => unknown) => Disposable; // Vacation onVacationLoaded: (handler: (...args: unknown[]) => unknown) => Disposable; onVacationUpdate: (handler: (...args: unknown[]) => unknown) => Disposable; // UI onViewChange: (handler: (...args: unknown[]) => unknown) => Disposable; onSidebarToggle: (handler: (...args: unknown[]) => unknown) => Disposable; onSidebarCollapse: (handler: (...args: unknown[]) => unknown) => Disposable; onDeviceTypeChange: (handler: (...args: unknown[]) => unknown) => Disposable; onColumnResize: (handler: (...args: unknown[]) => unknown) => Disposable; onMobileBack: (handler: () => void) => Disposable; onMobileViewSwitch: (handler: (...args: unknown[]) => unknown) => Disposable; /** Intercept - receives ExternalLinkContext, return false to cancel navigation */ onBeforeExternalLink: (handler: (ctx: import('./plugin-types').ExternalLinkContext) => boolean | void | Promise) => Disposable; /** Observer - debounced text-selection change */ onTextSelectionChange: (handler: (ctx: import('./plugin-types').SelectionContext) => void) => Disposable; // Theme onThemeChange: (handler: (...args: unknown[]) => unknown) => Disposable; onCustomThemeChange: (handler: (...args: unknown[]) => unknown) => Disposable; onLocaleChange: (handler: (...args: unknown[]) => unknown) => Disposable; // Toast onToastShow: (handler: (...args: unknown[]) => unknown) => Disposable; onToastDismiss: (handler: (...args: unknown[]) => unknown) => Disposable; onBrowserNotification: (handler: (...args: unknown[]) => unknown) => Disposable; /** Observer fired when an OS-level notification is clicked */ onNotificationClick: (handler: (ctx: { tag: string; data?: unknown }) => void) => Disposable; // Drag & Drop onDragStart: (handler: (...args: unknown[]) => unknown) => Disposable; onDragEnd: (handler: (...args: unknown[]) => unknown) => Disposable; onEmailDrop: (handler: (...args: unknown[]) => unknown) => Disposable; onTagDrop: (handler: (...args: unknown[]) => unknown) => Disposable; // Keyboard registerShortcut: (shortcut: KeyboardShortcut) => Disposable; onBeforeShortcut: (handler: (...args: unknown[]) => unknown) => Disposable; onAfterShortcut: (handler: (...args: unknown[]) => unknown) => Disposable; // App Lifecycle onAppReady: (handler: () => void) => Disposable; onVisibilityChange: (handler: (...args: unknown[]) => unknown) => Disposable; onBeforeUnload: (handler: () => void) => Disposable; onAppError: (handler: (...args: unknown[]) => unknown) => Disposable; onInterval: (handler: () => void, intervalMs: number) => Disposable; /** Observer - browser window focus / blur */ onWindowFocus: (handler: () => void) => Disposable; onWindowBlur: (handler: () => void) => Disposable; /** Observer - network connectivity transitions */ onOnline: (handler: () => void) => Disposable; onOffline: (handler: () => void) => Disposable; // Account Security onPasswordChange: (handler: () => void) => Disposable; onTotpChange: (handler: (...args: unknown[]) => unknown) => Disposable; onAppPasswordChange: (handler: (...args: unknown[]) => unknown) => Disposable; onEncryptionChange: (handler: () => void) => Disposable; onDisplayNameChange: (handler: (...args: unknown[]) => unknown) => Disposable; // Sidebar Apps onSidebarAppOpen: (handler: (...args: unknown[]) => unknown) => Disposable; onSidebarAppClose: (handler: (...args: unknown[]) => unknown) => Disposable; 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; // Router /** Observer - fired on every in-app navigation. RouteContext.from holds the previous path. */ onNavigate: (handler: (ctx: import('./plugin-types').RouteContext) => void) => Disposable; onRouteEnter: (handler: (ctx: import('./plugin-types').RouteContext) => void) => Disposable; onRouteLeave: (handler: (ctx: import('./plugin-types').RouteContext) => void) => Disposable; } // --- Permission mapping for hooks ---------------------------- const HOOK_PERMISSIONS: Record = { // Email onEmailOpen: 'email:read', onEmailClose: 'email:read', onEmailContentRender: 'email:read', onThreadExpand: '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', onBeforeReply: 'email:read', onBeforeReplyAll: 'email:read', onBeforeForward: 'email:read', onAttachmentDownload: 'email:read', onAttachmentPreview: 'email:read', onProvideSearchResults: 'email:read', onDraftChange: 'email:read', onBeforeAttachmentUpload: 'email:write', onAfterAttachmentUpload: 'email:write', onBeforeEmailSend: 'email:send', onAfterEmailSend: 'email:send', onTransformOutgoingEmail: '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', onMailboxDelete: 'email:write', onMailboxEmpty: 'email:write', // Calendar onCalendarEventOpen: 'calendar:read', onCalendarDateChange: 'calendar:read', onCalendarViewChange: 'calendar:read', onCalendarVisibilityToggle: 'calendar:read', onCalendarAlert: 'calendar:read', onCalendarAlertAcknowledge: 'calendar:read', onCheckEventConflicts: 'calendar:read', onCalendarEventFormOpen: 'calendar:read', onCalendarEventFormSave: 'calendar:write', onBeforeEventCreate: 'calendar:write', onAfterEventCreate: 'calendar:write', onBeforeEventUpdate: 'calendar:write', onAfterEventUpdate: 'calendar:write', onBeforeEventDelete: 'calendar:write', onAfterEventDelete: 'calendar:write', onEventRsvp: 'calendar:write', onEventsImport: 'calendar:write', onCalendarChange: 'calendar:write', onICalSubscriptionChange: 'calendar:write', // Contacts onContactOpen: 'contacts:read', onContactSelectionChange: 'contacts:read', onProvideRecipientSuggestions: 'contacts:read', onBeforeContactCreate: 'contacts:write', onAfterContactCreate: 'contacts:write', onBeforeContactUpdate: 'contacts:write', onAfterContactUpdate: 'contacts:write', onBeforeContactDelete: 'contacts:write', onAfterContactDelete: 'contacts:write', onContactsImport: 'contacts:write', onContactGroupChange: 'contacts:write', onContactGroupMemberChange: 'contacts:write', onContactMove: 'contacts:write', // Files onFileNavigate: 'files:read', onFileDownload: 'files:read', onFileSelectionChange: 'files:read', 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 onLogin: 'auth:observe', onBeforeLogout: 'auth:observe', onAfterLogout: 'auth:observe', onAccountSwitch: 'auth:observe', onAccountAdd: 'auth:observe', onAccountRemove: 'auth:observe', onTokenRefresh: 'auth:observe', onAuthReady: 'auth:observe', // Settings onSettingChange: 'settings:read', onSettingsExport: 'settings:read', onSettingsImport: 'settings:read', onSettingsReset: 'settings:read', onSettingsSync: 'settings:read', onKeywordChange: 'settings:read', onTrustedSenderChange: 'settings:read', // Identity onIdentitiesLoaded: 'identity:read', onIdentitySelect: 'identity:read', onSignatureRender: 'identity:read', onIdentityCreate: 'identity:write', onIdentityUpdate: 'identity:write', onIdentityDelete: 'identity:write', // Filters onFiltersLoaded: 'filters:read', onFilterRuleChange: 'filters:write', onFiltersSave: 'filters:write', onSieveScriptChange: 'filters:write', // Tasks onTasksLoaded: 'tasks:read', onTaskFilterChange: 'tasks:read', onTaskCreate: 'tasks:write', onTaskUpdate: 'tasks:write', onTaskDelete: 'tasks:write', onTaskToggleComplete: 'tasks:write', // Templates onTemplateApply: 'templates:read', onTemplateRender: 'templates:read', onTemplateCreate: 'templates:write', onTemplateUpdate: 'templates:write', onTemplateDelete: 'templates:write', onTemplatesImport: 'templates:write', // S/MIME onSmimeKeyImport: 'smime:read', onSmimeCertImport: 'smime:read', onSmimeKeyStateChange: 'smime:read', onSmimeDefaultsChange: 'smime:read', // Vacation onVacationLoaded: 'vacation:read', onVacationUpdate: 'vacation:write', // UI onViewChange: 'ui:observe', onSidebarToggle: 'ui:observe', onSidebarCollapse: 'ui:observe', onDeviceTypeChange: 'ui:observe', onColumnResize: 'ui:observe', onMobileBack: 'ui:observe', onMobileViewSwitch: 'ui:observe', onBeforeExternalLink: 'ui:observe', onTextSelectionChange: 'ui:observe', // Theme onThemeChange: 'ui:observe', onCustomThemeChange: 'ui:observe', onLocaleChange: 'ui:observe', // Toast onToastShow: 'ui:observe', onToastDismiss: 'ui:observe', onBrowserNotification: 'ui:observe', onNotificationClick: 'ui:observe', // Drag & Drop onDragStart: 'ui:observe', onDragEnd: 'ui:observe', onEmailDrop: 'ui:observe', onTagDrop: 'ui:observe', // Keyboard registerShortcut: 'ui:keyboard', onBeforeShortcut: 'ui:keyboard', onAfterShortcut: 'ui:keyboard', // App Lifecycle onAppReady: 'app:lifecycle', onVisibilityChange: 'app:lifecycle', onBeforeUnload: 'app:lifecycle', onAppError: 'app:lifecycle', onInterval: 'app:lifecycle', onWindowFocus: 'app:lifecycle', onWindowBlur: 'app:lifecycle', onOnline: 'app:lifecycle', onOffline: 'app:lifecycle', // Account Security onPasswordChange: 'security:read', onTotpChange: 'security:read', onAppPasswordChange: 'security:read', onEncryptionChange: 'security:read', onDisplayNameChange: 'security:read', // Sidebar Apps onSidebarAppOpen: 'ui:observe', onSidebarAppClose: 'ui:observe', onSidebarAppChange: 'ui:observe', // Avatar onAvatarResolve: 'email:read', // Router onNavigate: 'ui:observe', onRouteEnter: 'ui:observe', onRouteLeave: 'ui:observe', }; // Map hook names → actual HookBus instances const HOOK_BUSES: Record unknown, order?: number) => Disposable }> = { // Email ...Object.fromEntries(Object.entries(emailHooks)), // Calendar ...Object.fromEntries(Object.entries(calendarHooks)), // Calendar Form ...Object.fromEntries(Object.entries(calendarFormHooks)), // Contacts ...Object.fromEntries(Object.entries(contactHooks)), // Files ...Object.fromEntries(Object.entries(fileHooks)), // Auth ...Object.fromEntries(Object.entries(authHooks)), // Settings ...Object.fromEntries(Object.entries(settingsHooks)), // Identity ...Object.fromEntries(Object.entries(identityHooks)), // Filters ...Object.fromEntries(Object.entries(filterHooks)), // Tasks ...Object.fromEntries(Object.entries(taskHooks)), // Templates ...Object.fromEntries(Object.entries(templateHooks)), // S/MIME ...Object.fromEntries(Object.entries(smimeHooks)), // Vacation ...Object.fromEntries(Object.entries(vacationHooks)), // UI ...Object.fromEntries(Object.entries(uiHooks)), // Theme ...Object.fromEntries(Object.entries(themeHooks)), // Toast ...Object.fromEntries(Object.entries(toastHooks)), // Drag & Drop ...Object.fromEntries(Object.entries(dragDropHooks)), // Keyboard ...Object.fromEntries(Object.entries(keyboardHooks)), // App Lifecycle ...Object.fromEntries(Object.entries(appLifecycleHooks)), // Account Security ...Object.fromEntries(Object.entries(accountSecurityHooks)), // Sidebar Apps ...Object.fromEntries(Object.entries(sidebarAppHooks)), // Avatar ...Object.fromEntries(Object.entries(avatarHooks)), // Render ...Object.fromEntries(Object.entries(renderHooks)), // Router ...Object.fromEntries(Object.entries(routerHooks)), }; // --- Slot registration bridge -------------------------------- // Lazy import to avoid circular dependency — plugin-store imports plugin-api indirectly let registerSlotFn: ((name: SlotName, reg: { pluginId: string; component: React.ComponentType>; order: number }) => Disposable) | null = null; export function setSlotRegistrationBridge(fn: typeof registerSlotFn): void { registerSlotFn = fn; } function registerSlot( pluginId: string, slotName: SlotName, component: React.ComponentType>, order: number = 100, ): Disposable { if (!registerSlotFn) { console.warn(`[plugin:${pluginId}] Slot registration not available yet`); return { dispose: () => {} }; } return registerSlotFn(slotName, { pluginId, component, order }); } // --- Factory ------------------------------------------------- export function createPluginAPI(plugin: InstalledPlugin): PluginAPI { // Build hooks proxy — each hook method checks permission and registers on the right bus const hooks: PluginHooksAPI = {} as PluginHooksAPI; for (const [hookName, bus] of Object.entries(HOOK_BUSES)) { const perm = HOOK_PERMISSIONS[hookName]; if (!perm) continue; if (hookName === 'onInterval') { // Special: onInterval takes (handler, intervalMs) (hooks as unknown as Record)[hookName] = (handler: () => void, intervalMs: number) => { if (!hasPermission(plugin, perm)) return { dispose: () => {} }; const safeMs = Math.max(intervalMs, 60_000); // min 60s const id = setInterval(handler, safeMs); return { dispose: () => clearInterval(id) }; }; } else if (hookName === 'registerShortcut') { // Special: registerShortcut takes a KeyboardShortcut object (hooks as unknown as Record)[hookName] = (shortcut: KeyboardShortcut) => { return guardedHook(plugin, perm, bus, shortcut.handler); }; } else { (hooks as unknown as Record)[hookName] = (handler: (...args: unknown[]) => unknown) => { return guardedHook(plugin, perm, bus, handler); }; } } return { plugin: { id: plugin.id, version: plugin.version, settings: { ...plugin.settings }, }, i18n: createPluginI18n(plugin.id), ui: { registerToolbarAction: (action: ToolbarAction) => { requirePermission(plugin, 'ui:toolbar'); const Component = () => { const externals = getPluginExternals(); const React = externals?.React; if (!React) return null; const createElement = (React as { createElement: typeof import('react').createElement }).createElement; return createElement('button', { onClick: action.onClick, className: 'plugin-toolbar-action', title: action.label, }, action.label); }; return registerSlot(plugin.id, 'toolbar-actions', Component as React.ComponentType>, action.order ?? 100); }, registerEmailBanner: (factory: BannerFactory) => { requirePermission(plugin, 'ui:email-banner'); return registerSlot(plugin.id, 'email-banner', factory.render as unknown as React.ComponentType>, 100); }, registerEmailFooter: (component: React.ComponentType) => { requirePermission(plugin, 'ui:email-footer'); return registerSlot(plugin.id, 'email-footer', component as React.ComponentType>, 100); }, registerSettingsSection: (section: SettingsSection) => { requirePermission(plugin, 'ui:settings-section'); return registerSlot(plugin.id, 'settings-section', section.render as React.ComponentType>, 100); }, registerComposerAction: (action: ComposerAction) => { requirePermission(plugin, 'ui:composer-toolbar'); const Component = () => { const externals = getPluginExternals(); const React = externals?.React; if (!React) return null; const createElement = (React as { createElement: typeof import('react').createElement }).createElement; return createElement('button', { onClick: action.onClick, className: 'plugin-composer-action', title: action.label, }, action.label); }; return registerSlot(plugin.id, 'composer-toolbar', Component as React.ComponentType>, action.order ?? 100); }, registerSidebarWidget: (widget: SidebarWidget) => { requirePermission(plugin, 'ui:sidebar-widget'); return registerSlot(plugin.id, 'sidebar-widget', widget.render as React.ComponentType>, widget.order ?? 100); }, registerComposerSidebar: (widget: SidebarWidget) => { requirePermission(plugin, 'ui:composer-sidebar'); const slot = widget.side === 'right' ? 'composer-sidebar-right' : 'composer-sidebar'; return registerSlot(plugin.id, slot, widget.render as React.ComponentType>, widget.order ?? 100); }, registerDetailSidebar: (widget: SidebarWidget) => { requirePermission(plugin, 'ui:sidebar-widget'); return registerSlot(plugin.id, 'email-detail-sidebar', widget.render as React.ComponentType>, widget.order ?? 100); }, registerContextMenuItem: (item: ContextMenuItem) => { requirePermission(plugin, 'ui:context-menu'); const Component = () => { const externals = getPluginExternals(); const React = externals?.React; if (!React) return null; const createElement = (React as { createElement: typeof import('react').createElement }).createElement; return createElement('button', { onClick: () => item.onClick([]), className: 'plugin-context-menu-item', }, item.label); }; return registerSlot(plugin.id, 'context-menu-email', Component as React.ComponentType>, item.order ?? 100); }, registerNavigationRailItem: (component: React.ComponentType) => { requirePermission(plugin, 'ui:navigation-rail'); return registerSlot(plugin.id, 'navigation-rail-bottom', component as React.ComponentType>, 100); }, registerCalendarEventAction: (action: CalendarEventAction) => { requirePermission(plugin, 'ui:calendar-action'); const Component = (props: Record) => { const externals = getPluginExternals(); const React = externals?.React; if (!React) return null; const createElement = (React as { createElement: typeof import('react').createElement }).createElement; const iconSpan = createElement('span', { 'aria-hidden': 'true', style: { display: 'contents' }, dangerouslySetInnerHTML: { __html: '', }, }); return createElement('button', { onClick: () => action.onClick( props.eventData as import('./plugin-types').CalendarEventFormView, { setVirtualLocation: props.setVirtualLocation as (url: string) => void }, ), className: 'inline-flex items-center gap-1.5 h-9 px-3 text-sm font-medium rounded-md border border-input bg-background hover:bg-accent hover:text-accent-foreground transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background cursor-pointer', title: action.label, type: 'button', }, iconSpan, action.label); }; return registerSlot(plugin.id, 'calendar-event-actions', Component as React.ComponentType>, action.order ?? 100); }, registerAdminPage: (page: AdminPageSection) => { requirePermission(plugin, 'ui:admin-page'); return registerSlot(plugin.id, 'admin-plugin-page', page.render as React.ComponentType>, 100); }, }, hooks, toast: { success: (message: string) => appToast.success(message), error: (message: string) => appToast.error(message), info: (message: string) => appToast.info(message), warning: (message: string) => appToast.warning(message), }, http: { post: async (path: string, body: Record) => { requirePermission(plugin, 'http:post'); if (typeof path !== 'string' || !path.startsWith('/api/')) { throw new Error('path must start with /api/'); } const url = new URL(path, globalThis.location.origin); if (url.origin !== globalThis.location.origin) { throw new Error('path must resolve to the same origin'); } const { client } = useAuthStore.getState(); const headers: Record = { 'Content-Type': 'application/json' }; if (client) { headers['Authorization'] = client.getAuthHeader(); headers['X-JMAP-Username'] = client.getUsername(); } const res = await fetch(url.pathname + url.search, { method: 'POST', headers, body: JSON.stringify(body), }); const data = await res.json().catch(() => null); return { ok: res.ok, status: res.status, data }; }, fetch: async (rawUrl: string, init?: PluginFetchInit) => { requirePermission(plugin, 'http:fetch'); if (typeof rawUrl !== 'string') { throw new Error('url must be a string'); } let url: URL; try { url = new URL(rawUrl); } catch { throw new Error('url must be an absolute https:// URL'); } const allowlist = plugin.httpOrigins ?? []; if (allowlist.length === 0) { throw new Error(`Plugin "${plugin.id}" has no httpOrigins declared`); } if (!originMatchesAllowlist(url, allowlist)) { throw new Error(`Origin ${url.origin} not in plugin httpOrigins allowlist`); } // Defence-in-depth: don't let the plugin smuggle a header that the // host's same-origin /api flow uses to authenticate the user. const safeHeaders: Record = {}; if (init?.headers) { for (const [k, v] of Object.entries(init.headers)) { const lower = k.toLowerCase(); if (lower === 'cookie' || lower === 'x-jmap-username') continue; safeHeaders[k] = v; } } const res = await fetch(url.toString(), { method: init?.method ?? 'GET', headers: safeHeaders, // eslint-disable-next-line no-undef body: (init?.body ?? undefined) as BodyInit | undefined, signal: init?.signal, credentials: 'omit', mode: 'cors', redirect: 'follow', }); const headersOut: Record = {}; res.headers.forEach((value, key) => { headersOut[key.toLowerCase()] = value; }); return { ok: res.ok, status: res.status, statusText: res.statusText, headers: headersOut, text: () => res.text(), json: () => res.json().catch(() => null), arrayBuffer: () => res.arrayBuffer(), blob: () => res.blob(), }; }, }, storage: createPluginStorage(plugin.id), log: createPluginLogger(plugin.id), admin: { getConfig: async (key: string) => { requirePermission(plugin, 'admin:config'); const res = await apiFetch(`/api/admin/plugins/${encodeURIComponent(plugin.id)}/config`); if (!res.ok) return null; const data = await res.json(); return data[key] ?? null; }, getAllConfig: async () => { requirePermission(plugin, 'admin:config'); const res = await apiFetch(`/api/admin/plugins/${encodeURIComponent(plugin.id)}/config`); if (!res.ok) return {}; return res.json(); }, setConfig: async (key: string, value: unknown) => { requirePermission(plugin, 'admin:config'); await apiFetch(`/api/admin/plugins/${encodeURIComponent(plugin.id)}/config`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key, value }), }); }, deleteConfig: async (key: string) => { requirePermission(plugin, 'admin:config'); await apiFetch(`/api/admin/plugins/${encodeURIComponent(plugin.id)}/config`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key }), }); }, }, }; }