mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
fix: require manifest permissions for plugin hooks and slots
Only the host API methods checked a plugin's permissions. Every hook and slot the sandbox reported was wired as-is, so a plugin declaring no permissions could rewrite recipients and body of every outgoing mail through `onTransformOutgoingEmail`, replace displayed bodies through `onRenderEmailBody`, or read all fetched mail through `onEmailsFetched`. Admin-installed plugins are managed, so users never saw a consent prompt for any of it. Each hook and slot now maps to a permission in `lib/plugin-sandbox/permissions.ts`, checked the same way as API calls (declared, and managed or granted). Hooks that change what is sent need `email:send`, display takeovers need `email:render-takeover`, and a hook or slot missing from the table is refused. Logout and account switches stay implicit so plugins can always drop per-account state.
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { InstalledPlugin, Permission } from '../plugin-types';
|
||||
|
||||
// Hooks and slots used to be wired for whatever the sandbox reported, with
|
||||
// no look at the manifest: a plugin declaring no permissions could add a
|
||||
// Bcc to every outgoing mail through onTransformOutgoingEmail and read every
|
||||
// opened message through onRenderEmailBody.
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
background: { hooks: [] as string[], slots: [] as Array<{ name: string }> },
|
||||
}));
|
||||
|
||||
const CODE = 'export default { activate() {} }';
|
||||
const HASH = createHash('sha256').update(CODE, 'utf-8').digest('hex');
|
||||
|
||||
vi.mock('../plugin-storage', () => ({
|
||||
pluginStorage: { getCode: async () => CODE, saveCode: async () => {}, deleteCode: vi.fn() },
|
||||
}));
|
||||
vi.mock('../plugin-sandbox/bundle-fetch', () => ({ downloadManagedBundle: vi.fn() }));
|
||||
vi.mock('../plugin-sandbox/host-bridge', () => ({
|
||||
createBackgroundInstance: () => ({
|
||||
initPromise: Promise.resolve({ hooks: mocks.background.hooks, slots: mocks.background.slots, shortcuts: [] }),
|
||||
invokeHook: vi.fn(async () => ({ to: ['a@example.org'], bcc: ['attacker@evil.example'] })),
|
||||
destroy: vi.fn(),
|
||||
}),
|
||||
SandboxInstance: class {},
|
||||
}));
|
||||
vi.mock('../plugin-sandbox/host-api', () => ({ cancelPluginDialogs: vi.fn() }));
|
||||
vi.mock('../plugin-sandbox/shortcuts', () => ({ registerShortcuts: () => () => {} }));
|
||||
|
||||
import { loadSandboxedPlugin, unloadSandboxedPlugin, setSandboxStoreAccessor } from '../plugin-sandbox/loader';
|
||||
import { get as getActive } from '../plugin-sandbox/registry';
|
||||
import { emailHooks, renderHooks, appLifecycleHooks } from '../plugin-hooks';
|
||||
import { hookPermission, mayOfferSlot, mayRegisterHook, pluginHasPermission } from '../plugin-sandbox/permissions';
|
||||
|
||||
function plugin(overrides: Partial<InstalledPlugin> = {}): InstalledPlugin {
|
||||
return {
|
||||
id: 'hook-plugin',
|
||||
name: 'Hook plugin',
|
||||
version: '1.0.0',
|
||||
author: 'someone',
|
||||
description: '',
|
||||
type: 'hook',
|
||||
permissions: [],
|
||||
entrypoint: 'index.js',
|
||||
enabled: true,
|
||||
status: 'enabled',
|
||||
settings: {},
|
||||
managed: true,
|
||||
adminApproved: true,
|
||||
bundleHash: HASH,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setSandboxStoreAccessor({ setPluginStatus: () => {} });
|
||||
mocks.background.hooks = ['onTransformOutgoingEmail', 'onRenderEmailBody', 'onAppReady'];
|
||||
mocks.background.slots = [{ name: 'email-banner' }, { name: 'plugin-dialog' }];
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
vi.spyOn(console, 'info').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
unloadSandboxedPlugin('hook-plugin');
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('sandboxed plugin hooks and slots', () => {
|
||||
it('a zero-permission plugin cannot rewrite outgoing mail or read opened messages', async () => {
|
||||
await loadSandboxedPlugin(plugin());
|
||||
|
||||
expect(emailHooks.onTransformOutgoingEmail.size).toBe(0);
|
||||
expect(renderHooks.onRenderEmailBody.size).toBe(0);
|
||||
const outgoing = await emailHooks.onTransformOutgoingEmail.transform({ to: ['a@example.org'], bcc: [] });
|
||||
expect(outgoing).toEqual({ to: ['a@example.org'], bcc: [] });
|
||||
|
||||
// Implicit permissions still work.
|
||||
expect(appLifecycleHooks.onAppReady.size).toBe(1);
|
||||
expect(getActive('hook-plugin')?.slotOffers.map((o) => o.name)).toEqual(['plugin-dialog']);
|
||||
});
|
||||
|
||||
it('wires the hooks and slots a managed plugin declares', async () => {
|
||||
await loadSandboxedPlugin(plugin({ permissions: ['email:send', 'email:render-takeover', 'ui:email-banner'] }));
|
||||
|
||||
expect(emailHooks.onTransformOutgoingEmail.size).toBe(1);
|
||||
expect(renderHooks.onRenderEmailBody.size).toBe(1);
|
||||
expect(getActive('hook-plugin')?.slotOffers.map((o) => o.name)).toEqual(['email-banner', 'plugin-dialog']);
|
||||
});
|
||||
|
||||
it('holds back a declared permission the user has not granted', async () => {
|
||||
await loadSandboxedPlugin(plugin({
|
||||
managed: false,
|
||||
permissions: ['email:send', 'email:render-takeover'],
|
||||
grantedPermissions: ['email:render-takeover'],
|
||||
}));
|
||||
|
||||
expect(emailHooks.onTransformOutgoingEmail.size).toBe(0);
|
||||
expect(renderHooks.onRenderEmailBody.size).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('permission tables', () => {
|
||||
it('refuses a hook it does not know', () => {
|
||||
expect(hookPermission('onSomethingNew')).toBeNull();
|
||||
expect(mayRegisterHook(plugin({ permissions: ['email:read'] }), 'onSomethingNew')).toBe(false);
|
||||
expect(hookPermission('toString')).toBeNull();
|
||||
});
|
||||
|
||||
it('refuses a slot it does not know', () => {
|
||||
expect(mayOfferSlot(plugin({ permissions: ['ui:toolbar'] }), 'made-up-slot')).toBe(false);
|
||||
expect(mayOfferSlot(plugin({ permissions: ['ui:toolbar'] }), 'constructor')).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts either permission for the admin page slot', () => {
|
||||
expect(mayOfferSlot(plugin({ permissions: ['admin:config'] }), 'admin-plugin-page')).toBe(true);
|
||||
expect(mayOfferSlot(plugin({ permissions: ['ui:admin-page'] }), 'admin-plugin-page')).toBe(true);
|
||||
expect(mayOfferSlot(plugin({ permissions: [] }), 'admin-plugin-page')).toBe(false);
|
||||
});
|
||||
|
||||
it('treats implicit permissions as granted', () => {
|
||||
expect(pluginHasPermission(plugin({ managed: false }), 'app:lifecycle' as Permission)).toBe(true);
|
||||
expect(mayRegisterHook(plugin(), 'onAfterLogout')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@
|
||||
// structured-cloneable data back to the iframe.
|
||||
|
||||
import type { InstalledPlugin, Permission } from '../plugin-types';
|
||||
import { IMPLICIT_PERMISSIONS } from '../plugin-types';
|
||||
import { pluginHasPermission } from './permissions';
|
||||
import { toast as appToast } from '@/stores/toast-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useAccountStore } from '@/stores/account-store';
|
||||
@@ -165,16 +165,6 @@ const PERM_PER_METHOD: Record<string, Permission | null> = {
|
||||
'sieve.regenerate': 'filters:write',
|
||||
};
|
||||
|
||||
function hasPermission(plugin: InstalledPlugin, perm: Permission): boolean {
|
||||
if ((IMPLICIT_PERMISSIONS as readonly string[]).includes(perm)) return true;
|
||||
if (!plugin.permissions.includes(perm)) return false;
|
||||
// Defense-in-depth: even if the manifest declares a permission, the host
|
||||
// refuses the API call unless an admin has marked the plugin as managed,
|
||||
// or the user has explicitly granted it via the consent dialog.
|
||||
if (plugin.managed) return true;
|
||||
return (plugin.grantedPermissions ?? []).includes(perm);
|
||||
}
|
||||
|
||||
// ─── Cross-origin allow-list (mirrors lib/plugin-api.ts) ──────
|
||||
|
||||
function originMatchesAllowlist(url: URL, allowlist: string[]): boolean {
|
||||
@@ -1371,7 +1361,7 @@ export async function dispatchApiCall(
|
||||
// Permission gate
|
||||
const requiredPerm = PERM_PER_METHOD[method];
|
||||
if (requiredPerm !== undefined && requiredPerm !== null) {
|
||||
if (!hasPermission(plugin, requiredPerm)) {
|
||||
if (!pluginHasPermission(plugin, requiredPerm)) {
|
||||
throw new Error(`Plugin "${plugin.id}" lacks permission "${requiredPerm}"`);
|
||||
}
|
||||
} else if (!(method in PERM_PER_METHOD)) {
|
||||
|
||||
@@ -21,6 +21,7 @@ import { resolvePluginTier } from './tier';
|
||||
import { register as registerActive, deregister as deregisterActive, all as allActiveEntries } from './registry';
|
||||
import { cancelPluginDialogs } from './host-api';
|
||||
import { registerShortcuts } from './shortcuts';
|
||||
import { mayOfferSlot, mayRegisterHook } from './permissions';
|
||||
|
||||
// ─── Hook-bus lookup (one flat map for name → bus) ────────────
|
||||
|
||||
@@ -165,6 +166,8 @@ export async function loadSandboxedPlugin(plugin: InstalledPlugin): Promise<void
|
||||
// Wire hook proxies: every hookName the plugin registered gets a HookBus
|
||||
// entry whose handler dispatches into the sandbox. `shortcut:<id>` hooks
|
||||
// are dispatched by the keyboard module separately and don't have a bus.
|
||||
// A hook hands the plugin data or a say in what the host sends, so it
|
||||
// needs the same manifest permission as the matching API call.
|
||||
const hookDisposables: Disposable[] = [];
|
||||
for (const hookName of info.hooks) {
|
||||
if (hookName.startsWith('shortcut:')) continue;
|
||||
@@ -173,6 +176,10 @@ export async function loadSandboxedPlugin(plugin: InstalledPlugin): Promise<void
|
||||
console.warn(`[plugin-sandbox] Plugin "${plugin.id}" registered unknown hook "${hookName}"`);
|
||||
continue;
|
||||
}
|
||||
if (!mayRegisterHook(plugin, hookName)) {
|
||||
console.warn(`[plugin-sandbox] Plugin "${plugin.id}" lacks the permission for hook "${hookName}"; not registered`);
|
||||
continue;
|
||||
}
|
||||
const proxy = async (...args: unknown[]) => {
|
||||
try {
|
||||
return await bg.invokeHook(hookName, args);
|
||||
@@ -188,12 +195,18 @@ export async function loadSandboxedPlugin(plugin: InstalledPlugin): Promise<void
|
||||
const shortcutDispose = registerShortcuts(bg, info.shortcuts ?? []);
|
||||
hookDisposables.push({ dispose: shortcutDispose });
|
||||
|
||||
const slotOffers = info.slots.filter((offer) => {
|
||||
if (mayOfferSlot(plugin, offer.name)) return true;
|
||||
console.warn(`[plugin-sandbox] Plugin "${plugin.id}" lacks the permission for slot "${offer.name}"; not rendered`);
|
||||
return false;
|
||||
});
|
||||
|
||||
registerActive({
|
||||
plugin,
|
||||
code,
|
||||
tier,
|
||||
background: bg,
|
||||
slotOffers: info.slots,
|
||||
slotOffers,
|
||||
hookDisposables,
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
import type { InstalledPlugin, Permission, SlotName } from '../plugin-types';
|
||||
import { IMPLICIT_PERMISSIONS } from '../plugin-types';
|
||||
|
||||
/**
|
||||
* Whether `plugin` may use `perm`: the manifest must declare it, and an
|
||||
* admin must have installed the plugin (managed) or the user must have
|
||||
* granted it in the consent dialog. Implicit permissions always pass.
|
||||
*/
|
||||
export function pluginHasPermission(plugin: InstalledPlugin, perm: Permission): boolean {
|
||||
if ((IMPLICIT_PERMISSIONS as readonly string[]).includes(perm)) return true;
|
||||
if (!plugin.permissions.includes(perm)) return false;
|
||||
// Defense-in-depth: even if the manifest declares a permission, the host
|
||||
// refuses it unless an admin has marked the plugin as managed, or the user
|
||||
// has explicitly granted it via the consent dialog.
|
||||
if (plugin.managed) return true;
|
||||
return (plugin.grantedPermissions ?? []).includes(perm);
|
||||
}
|
||||
|
||||
// ─── Hooks ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The permission each hook needs. A hook hands the plugin data (an opened
|
||||
* message, the outgoing mail) or lets it change what the host does with it,
|
||||
* so registering one is as much an API call as `email.get` and is gated the
|
||||
* same way. A hook missing from this table is refused.
|
||||
*/
|
||||
const HOOK_PERMISSIONS: Record<string, Permission> = {
|
||||
// Email: reading
|
||||
onEmailOpen: 'email:read',
|
||||
onEmailClose: 'email:read',
|
||||
onEmailContentRender: 'email:read',
|
||||
onThreadExpand: 'email:read',
|
||||
onComposerOpen: 'email:read',
|
||||
onAfterEmailSend: 'email:read',
|
||||
onDraftAutoSave: 'email:read',
|
||||
onAfterEmailDelete: 'email:read',
|
||||
onAfterEmailMove: 'email:read',
|
||||
onEmailArchive: 'email:read',
|
||||
onEmailUnarchive: 'email:read',
|
||||
onEmailReadStateChange: 'email:read',
|
||||
onEmailStarToggle: 'email:read',
|
||||
onEmailSpamToggle: 'email:read',
|
||||
onEmailKeywordChange: 'email:read',
|
||||
onMailboxChange: 'email:read',
|
||||
onMailboxesRefresh: 'email:read',
|
||||
onMailboxCreate: 'email:read',
|
||||
onMailboxRename: 'email:read',
|
||||
onMailboxDelete: 'email:read',
|
||||
onMailboxEmpty: 'email:read',
|
||||
onSearch: 'email:read',
|
||||
onSearchResults: 'email:read',
|
||||
onEmailSelectionChange: 'email:read',
|
||||
onNewEmailReceived: 'email:read',
|
||||
onPushConnectionChange: 'email:read',
|
||||
onQuotaChange: 'email:read',
|
||||
onMailtoIntercept: 'email:read',
|
||||
onBeforeReply: 'email:read',
|
||||
onBeforeReplyAll: 'email:read',
|
||||
onBeforeForward: 'email:read',
|
||||
onBeforeAttachmentUpload: 'email:read',
|
||||
onAfterAttachmentUpload: 'email:read',
|
||||
onAttachmentDownload: 'email:read',
|
||||
onProvideSearchResults: 'email:read',
|
||||
onDraftChange: 'email:read',
|
||||
// Email: changing mail in the mailbox
|
||||
onBeforeEmailDelete: 'email:write',
|
||||
onBeforeEmailMove: 'email:write',
|
||||
// Email: changing what is sent (recipients, body, drafts, the submission)
|
||||
onBeforeCompose: 'email:send',
|
||||
onBeforeEmailSend: 'email:send',
|
||||
onTransformOutgoingEmail: 'email:send',
|
||||
onComposeSend: 'email:send',
|
||||
onBeforeDraftAutoSave: 'email:send',
|
||||
onBeforeEditDraft: 'email:send',
|
||||
onBuildQuoteHeader: 'email:send',
|
||||
onRecipientChipsChange: 'email:send',
|
||||
onBeforeComposeOpenToForwardAsAttachment: 'email:send',
|
||||
onBeforeComposeOpenToReply: 'email:send',
|
||||
onBeforeComposeOpenToReplyAll: 'email:send',
|
||||
onBeforeComposeOpenToForward: 'email:send',
|
||||
onBeforeBlobUpload: 'email:blob-write',
|
||||
// Email: changing what is displayed
|
||||
onEmailsFetched: 'email:render-takeover',
|
||||
onAttachmentPreview: 'email:render-takeover',
|
||||
onEmailListItemRender: 'email:render-takeover',
|
||||
onRenderEmailBody: 'email:render-takeover',
|
||||
// Email data reached through other surfaces
|
||||
onAvatarResolve: 'email:read',
|
||||
onDragStart: 'email:read',
|
||||
onDragEnd: 'email:read',
|
||||
onEmailDrop: 'email:read',
|
||||
onTagDrop: 'email:read',
|
||||
onBrowserNotification: 'email:read',
|
||||
onNotificationClick: 'email:read',
|
||||
onTextSelectionChange: 'email:read',
|
||||
onBeforeEmailCategorize: 'email:read',
|
||||
onEmailCategorize: 'email:read',
|
||||
|
||||
// Calendar
|
||||
onCalendarEventOpen: 'calendar:read',
|
||||
onAfterEventCreate: 'calendar:read',
|
||||
onAfterEventUpdate: 'calendar:read',
|
||||
onAfterEventDelete: 'calendar:read',
|
||||
onEventRsvp: 'calendar:read',
|
||||
onEventsImport: 'calendar:read',
|
||||
onCalendarDateChange: 'calendar:read',
|
||||
onCalendarViewChange: 'calendar:read',
|
||||
onCalendarChange: 'calendar:read',
|
||||
onCalendarVisibilityToggle: 'calendar:read',
|
||||
onICalSubscriptionChange: 'calendar:read',
|
||||
onCalendarAlert: 'calendar:read',
|
||||
onCalendarAlertAcknowledge: 'calendar:read',
|
||||
onCheckEventConflicts: 'calendar:read',
|
||||
onCalendarEventFormOpen: 'calendar:read',
|
||||
onBeforeEventCreate: 'calendar:write',
|
||||
onBeforeEventUpdate: 'calendar:write',
|
||||
onBeforeEventDelete: 'calendar:write',
|
||||
onCalendarEventFormSave: 'calendar:write',
|
||||
|
||||
// Contacts
|
||||
onContactOpen: 'contacts:read',
|
||||
onAfterContactCreate: 'contacts:read',
|
||||
onAfterContactUpdate: 'contacts:read',
|
||||
onAfterContactDelete: 'contacts:read',
|
||||
onContactsImport: 'contacts:read',
|
||||
onContactSelectionChange: 'contacts:read',
|
||||
onContactGroupChange: 'contacts:read',
|
||||
onContactGroupMemberChange: 'contacts:read',
|
||||
onContactMove: 'contacts:read',
|
||||
onProvideRecipientSuggestions: 'contacts:read',
|
||||
onBeforeContactCreate: 'contacts:write',
|
||||
onBeforeContactUpdate: 'contacts:write',
|
||||
onBeforeContactDelete: 'contacts:write',
|
||||
|
||||
// Files
|
||||
onFileNavigate: 'files:read',
|
||||
onAfterFileUpload: 'files:read',
|
||||
onFileDownload: 'files:read',
|
||||
onFileUploadCancel: 'files:read',
|
||||
onDirectoryCreate: 'files:read',
|
||||
onAfterFileDelete: 'files:read',
|
||||
onFileRename: 'files:read',
|
||||
onFileMove: 'files:read',
|
||||
onFileCopy: 'files:read',
|
||||
onFileDuplicate: 'files:read',
|
||||
onFileFavoriteToggle: 'files:read',
|
||||
onFileSelectionChange: 'files:read',
|
||||
onFileUndo: 'files:read',
|
||||
onBeforeFileUpload: 'files:write',
|
||||
onBeforeFileDelete: 'files:write',
|
||||
onBeforeFileRename: 'files:write',
|
||||
|
||||
// Sign-in state. Logout and account switches are lifecycle events every
|
||||
// plugin needs to drop per-account state, so they stay implicit.
|
||||
onLogin: 'auth:observe',
|
||||
onAccountAdd: 'auth:observe',
|
||||
onAccountRemove: 'auth:observe',
|
||||
onTokenRefresh: 'auth:observe',
|
||||
onAuthReady: 'auth:observe',
|
||||
onBeforeLogout: 'app:lifecycle',
|
||||
onAfterLogout: 'app:lifecycle',
|
||||
onAccountSwitch: 'app:lifecycle',
|
||||
// Carries the code of a plugin's own OAuth flow with a third party.
|
||||
onOAuthCallback: 'http:fetch',
|
||||
|
||||
// Settings
|
||||
onSettingChange: 'settings:read',
|
||||
onSettingsExport: 'settings:read',
|
||||
onSettingsReset: 'settings:read',
|
||||
onSettingsSync: 'settings:read',
|
||||
onKeywordChange: 'settings:read',
|
||||
onTrustedSenderChange: 'settings:read',
|
||||
onSettingsImport: 'settings:write',
|
||||
|
||||
// Identities
|
||||
onIdentitiesLoaded: 'identity:read',
|
||||
onIdentityCreate: 'identity:read',
|
||||
onIdentityUpdate: 'identity:read',
|
||||
onIdentityDelete: 'identity:read',
|
||||
onIdentitySelect: 'identity:read',
|
||||
onSignatureRender: 'identity:read',
|
||||
|
||||
// Filters
|
||||
onFiltersLoaded: 'filters:read',
|
||||
onFilterRuleChange: 'filters:read',
|
||||
onFiltersSave: 'filters:read',
|
||||
onSieveScriptChange: 'filters:read',
|
||||
onSieveScriptGenerate: 'filters:write',
|
||||
|
||||
// Tasks, templates, S/MIME, vacation, account security
|
||||
onTasksLoaded: 'tasks:read',
|
||||
onTaskCreate: 'tasks:read',
|
||||
onTaskUpdate: 'tasks:read',
|
||||
onTaskDelete: 'tasks:read',
|
||||
onTaskToggleComplete: 'tasks:read',
|
||||
onTaskFilterChange: 'tasks:read',
|
||||
onTemplateCreate: 'templates:read',
|
||||
onTemplateUpdate: 'templates:read',
|
||||
onTemplateDelete: 'templates:read',
|
||||
onTemplateApply: 'templates:read',
|
||||
onTemplatesImport: 'templates:read',
|
||||
onTemplateRender: 'templates:read',
|
||||
onSmimeKeyImport: 'smime:read',
|
||||
onSmimeCertImport: 'smime:read',
|
||||
onSmimeKeyStateChange: 'smime:read',
|
||||
onSmimeDefaultsChange: 'smime:read',
|
||||
onVacationLoaded: 'vacation:read',
|
||||
onVacationUpdate: 'vacation:read',
|
||||
onPasswordChange: 'security:read',
|
||||
onTotpChange: 'security:read',
|
||||
onAppPasswordChange: 'security:read',
|
||||
onEncryptionChange: 'security:read',
|
||||
onDisplayNameChange: 'security:read',
|
||||
|
||||
// UI and app state
|
||||
onViewChange: 'ui:observe',
|
||||
onSidebarToggle: 'ui:observe',
|
||||
onSidebarCollapse: 'ui:observe',
|
||||
onDeviceTypeChange: 'ui:observe',
|
||||
onColumnResize: 'ui:observe',
|
||||
onMobileBack: 'ui:observe',
|
||||
onMobileViewSwitch: 'ui:observe',
|
||||
onBeforeExternalLink: 'ui:observe',
|
||||
onThemeChange: 'ui:observe',
|
||||
onCustomThemeChange: 'ui:observe',
|
||||
onLocaleChange: 'ui:observe',
|
||||
onThemeBeforeApply: 'ui:observe',
|
||||
onToastShow: 'ui:observe',
|
||||
onToastDismiss: 'ui:observe',
|
||||
onSidebarAppOpen: 'ui:observe',
|
||||
onSidebarAppClose: 'ui:observe',
|
||||
onSidebarAppChange: 'ui:observe',
|
||||
onNavigate: 'ui:observe',
|
||||
onRouteEnter: 'ui:observe',
|
||||
onRouteLeave: 'ui:observe',
|
||||
onTabsChange: 'ui:message-list-tabs',
|
||||
onTabActivate: 'ui:message-list-tabs',
|
||||
onTabCountsRefresh: 'ui:message-list-tabs',
|
||||
onBeforeShortcut: 'ui:keyboard',
|
||||
onAfterShortcut: 'ui:keyboard',
|
||||
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',
|
||||
};
|
||||
|
||||
export function hookPermission(hookName: string): Permission | null {
|
||||
return Object.prototype.hasOwnProperty.call(HOOK_PERMISSIONS, hookName) ? HOOK_PERMISSIONS[hookName] : null;
|
||||
}
|
||||
|
||||
export function mayRegisterHook(plugin: InstalledPlugin, hookName: string): boolean {
|
||||
const perm = hookPermission(hookName);
|
||||
return perm !== null && pluginHasPermission(plugin, perm);
|
||||
}
|
||||
|
||||
// ─── Slots ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Permissions that allow rendering into each slot (any one suffices). Slot
|
||||
* components receive the data of the surface they sit on (the open message,
|
||||
* the draft), so they are gated like hooks.
|
||||
*/
|
||||
const SLOT_PERMISSIONS: Record<SlotName, readonly Permission[]> = {
|
||||
'toolbar-actions': ['ui:toolbar'],
|
||||
'app-top-banner': ['ui:app-top-banner'],
|
||||
'email-banner': ['ui:email-banner'],
|
||||
'email-footer': ['ui:email-footer'],
|
||||
'composer-toolbar': ['ui:composer-toolbar'],
|
||||
'composer-sidebar': ['ui:composer-sidebar'],
|
||||
'composer-sidebar-right': ['ui:composer-sidebar'],
|
||||
'composer-attachment-source': ['ui:composer-toolbar', 'ui:composer-sidebar'],
|
||||
'sidebar-widget': ['ui:sidebar-widget'],
|
||||
'email-detail-sidebar': ['ui:email-details'],
|
||||
'email-details-section': ['ui:email-details'],
|
||||
'settings-section': ['ui:settings-section'],
|
||||
'context-menu-email': ['ui:context-menu'],
|
||||
'navigation-rail-bottom': ['ui:navigation-rail'],
|
||||
'calendar-event-actions': ['ui:calendar-action'],
|
||||
'admin-plugin-page': ['ui:admin-page', 'admin:config'],
|
||||
'contact-cryptokeys': ['ui:contact-cryptokeys'],
|
||||
'attachment-actions': ['email:read'],
|
||||
// Only shown inside a dialog the plugin opened through the host API.
|
||||
'plugin-dialog': ['ui:observe'],
|
||||
};
|
||||
|
||||
export function mayOfferSlot(plugin: InstalledPlugin, slot: string): boolean {
|
||||
const perms = Object.prototype.hasOwnProperty.call(SLOT_PERMISSIONS, slot)
|
||||
? SLOT_PERMISSIONS[slot as SlotName]
|
||||
: null;
|
||||
return !!perms && perms.some((perm) => pluginHasPermission(plugin, perm));
|
||||
}
|
||||
Reference in New Issue
Block a user