diff --git a/lib/__tests__/extension-package.test.ts b/lib/__tests__/extension-package.test.ts index ce0945a0d..bf1f0f81c 100644 --- a/lib/__tests__/extension-package.test.ts +++ b/lib/__tests__/extension-package.test.ts @@ -133,3 +133,12 @@ describe('compareVersions', () => { expect(compareVersions('0.9.0', '1.0.0')).toBe(-1); }); }); + +describe('deprecated permissions', () => { + it('drops smime:read with a warning instead of refusing the plugin', () => { + const { manifest, warnings } = checkPluginManifest({ ...BASE, permissions: ['email:read', 'smime:read'] }); + + expect(manifest?.permissions).toEqual(['email:read']); + expect(warnings.join()).toMatch(/smime:read/); + }); +}); diff --git a/lib/__tests__/plugin-hook-catalog.test.ts b/lib/__tests__/plugin-hook-catalog.test.ts new file mode 100644 index 000000000..a1e1a928a --- /dev/null +++ b/lib/__tests__/plugin-hook-catalog.test.ts @@ -0,0 +1,79 @@ +// @vitest-environment node +import fs from 'node:fs'; +import path from 'node:path'; +import { describe, it, expect } from 'vitest'; +import { HOOK_CATALOG, type HookGroup } from '../plugin-hook-catalog'; +import * as hooks from '../plugin-hooks'; +import { ALL_PERMISSIONS } from '../plugin-types'; + +const root = path.resolve(__dirname, '../..'); + +/** Every HookBus by name, from all the exported hook groups. */ +function allBuses(): Record { + const out: Record = {}; + for (const [groupName, group] of Object.entries(hooks)) { + if (!groupName.endsWith('Hooks') || typeof group !== 'object' || group === null) continue; + for (const [hookName, bus] of Object.entries(group)) { + if (bus instanceof hooks.HookBus) out[hookName] = groupName; + } + } + return out; +} + +function sourceFiles(dir: string): string[] { + const out: string[] = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (['node_modules', '.next', 'repos', '__tests__', '.git', 'local-data', 'integration', 'scripts'].includes(entry.name)) continue; + const full = path.join(dir, entry.name); + if (entry.isDirectory()) out.push(...sourceFiles(full)); + else if (/\.(tsx?)$/.test(entry.name) && !/\.test\./.test(entry.name)) out.push(full); + } + return out; +} + +const sources = ['app', 'components', 'hooks', 'lib', 'stores'].flatMap((d) => sourceFiles(path.join(root, d))) + .map((f) => fs.readFileSync(f, 'utf8')) + .join('\n'); + +/** A call site that fires the hook: `xHooks.onFoo.emit(` and friends. */ +function isFired(hookName: string, groupName: string): boolean { + const re = new RegExp(`\\b${groupName}\\.${hookName}\\.(emit|emitSync|emitTo|intercept|interceptOutcome|transform|takeOver)\\b`); + return re.test(sources); +} + +// Groups whose call sites are still being wired in this branch. Each +// commit that wires a group removes it here, and the test then insists every +// hook in it has a call site. +const NOT_YET_WIRED = new Set([ + 'mail-list', 'mail-read', 'mail-compose', 'mail-send', 'mail-organize', 'mail-mailboxes', 'mail-search', + 'mail-sync', 'calendar', 'contacts', 'tasks', 'files', 'settings', 'identities', + 'filters', 'vacation', 'templates', 'auth', 'security', 'theme', 'notifications', 'app', 'ui', 'keyboard', +]); + +describe('hook catalogue', () => { + const buses = allBuses(); + + it('has a bus for every catalogued hook and no bus without an entry', () => { + expect(Object.keys(buses).sort()).toEqual(Object.keys(HOOK_CATALOG).sort()); + }); + + it('gates every hook behind a permission the host knows', () => { + const known = new Set(ALL_PERMISSIONS); + const unknown = Object.entries(HOOK_CATALOG).filter(([, spec]) => !known.has(spec.permission)).map(([n]) => n); + expect(unknown).toEqual([]); + }); + + const groups = [...new Set(Object.values(HOOK_CATALOG).map((s) => s.group))]; + it.each(groups)('fires every %s hook somewhere in the app', (group) => { + const missing = Object.entries(HOOK_CATALOG) + .filter(([name, spec]) => spec.group === group && !isFired(name, buses[name])) + .map(([name]) => name); + if (NOT_YET_WIRED.has(group)) { + // Still being wired: the list must not claim a group is unwired once + // it is fully wired, or the check would never switch on. + expect(missing.length, `${group} is fully wired; remove it from NOT_YET_WIRED`).toBeGreaterThan(0); + } else { + expect(missing).toEqual([]); + } + }); +}); diff --git a/lib/extension-package.ts b/lib/extension-package.ts index 548cf2124..9b7f04c1c 100644 --- a/lib/extension-package.ts +++ b/lib/extension-package.ts @@ -10,6 +10,7 @@ import JSZip from 'jszip'; import { ALL_PERMISSIONS, ALLOWED_PLUGIN_FILES, + DEPRECATED_PERMISSIONS, MAX_PLUGIN_SIZE, MAX_THEME_SIZE, type PluginManifest, @@ -427,6 +428,11 @@ export function checkPluginManifest(m: Record, opts: { appVersi errors.push('"permissions" must be a list of strings'); } else { permissions = [...new Set(m.permissions as string[])]; + const deprecated = permissions.filter((p) => DEPRECATED_PERMISSIONS.includes(p)); + if (deprecated.length > 0) { + warnings.push(`Ignored permissions that no longer grant anything: ${deprecated.join(', ')}`); + permissions = permissions.filter((p) => !DEPRECATED_PERMISSIONS.includes(p)); + } const unknown = permissions.filter((p) => !KNOWN_PERMISSIONS.has(p)); if (unknown.length > 0) errors.push(`Unknown permissions: ${unknown.join(', ')} (this Bulwark version does not offer them)`); } diff --git a/lib/plugin-hook-catalog.ts b/lib/plugin-hook-catalog.ts new file mode 100644 index 000000000..689d15778 --- /dev/null +++ b/lib/plugin-hook-catalog.ts @@ -0,0 +1,316 @@ +// Every plugin hook the host offers: what kind it is, which permission a +// plugin needs for it, what it receives, and where the host fires it. +// +// This is the single source of truth. The permission table in +// plugin-sandbox/permissions.ts, the hook reference in the plugin docs and +// the "every hook is fired" test (lib/__tests__/plugin-hook-catalog.test.ts) +// all read it, so a hook can't be declared without a call site, a permission +// and a description. +// +// Kinds: +// - observer: told after something happened; the return value is ignored. +// - intercept: asked before something happens; return false to stop it. +// - transform: handed a value and returns a changed one (or undefined to +// leave it alone); handlers run in order, each sees the previous result. +// - takeover: may do the action itself instead of the host (onComposeSend). +// +// Payloads cross into a sandboxed iframe, so they are plain data. Changing a +// payload object in place has no effect on the host; use the return value. + +import type { Permission } from './plugin-types'; + +export type HookKind = 'observer' | 'intercept' | 'transform' | 'takeover'; + +export type HookGroup = + | 'mail-list' | 'mail-read' | 'mail-compose' | 'mail-send' | 'mail-organize' | 'mail-mailboxes' + | 'mail-search' | 'mail-attachments' | 'mail-sync' + | 'calendar' | 'contacts' | 'tasks' | 'files' + | 'settings' | 'identities' | 'filters' | 'vacation' | 'templates' + | 'auth' | 'security' | 'navigation' | 'theme' | 'notifications' | 'app' | 'ui' | 'keyboard' | 'tabs'; + +export interface HookSpec { + group: HookGroup; + kind: HookKind; + permission: Permission; + /** What the handler receives (and, for transforms, returns). */ + payload: string; + /** When the host fires it. */ + when: string; +} + +const H = (group: HookGroup, kind: HookKind, permission: Permission, payload: string, when: string): HookSpec => + ({ group, kind, permission, payload, when }); + +export const HOOK_CATALOG = { + // ─── Mail: message list ──────────────────────────────────── + onEmailsFetched: H('mail-list', 'transform', 'email:render-takeover', 'Email[] (list rows), { mailboxId }', 'after a page of the message list is loaded, before it is shown'), + onEmailListItemRender: H('mail-list', 'transform', 'email:render-takeover', 'EmailListBadge[], { email: EmailSummary }', 'for each visible row; returned badges are shown next to the subject'), + onEmailSelectionChange: H('mail-list', 'observer', 'email:read', '{ emailIds: string[] }', 'when the set of selected messages changes'), + onMailboxChange: H('mail-list', 'observer', 'email:read', '{ mailboxId, role, previousMailboxId }', 'when the user opens another folder'), + + // ─── Mail: reading ───────────────────────────────────────── + onEmailOpen: H('mail-read', 'observer', 'email:read', 'EmailReadView', 'when a message is opened'), + onEmailClose: H('mail-read', 'observer', 'email:read', '{ emailId }', 'when the open message is closed or replaced'), + onRenderEmailBody: H('mail-read', 'transform', 'email:render-takeover', 'RenderableBody, MessageContext', 'before the body of an opened message is rendered'), + onEmailContentRender: H('mail-read', 'observer', 'email:read', '{ emailId, hasHtml, hasExternalContent }', 'after the body of an opened message is on screen'), + onThreadExpand: H('mail-read', 'observer', 'email:read', '{ threadId, emailIds, expanded }', 'when a conversation is expanded or collapsed'), + onBeforeReply: H('mail-read', 'intercept', 'email:read', 'ReplyContext', 'when Reply is chosen, before the composer opens'), + onBeforeReplyAll: H('mail-read', 'intercept', 'email:read', 'ReplyContext', 'when Reply all is chosen, before the composer opens'), + onBeforeForward: H('mail-read', 'intercept', 'email:read', 'ReplyContext', 'when Forward is chosen, before the composer opens'), + onBeforeExternalLink: H('mail-read', 'intercept', 'ui:observe', 'ExternalLinkContext', 'when a link in a message is clicked; return false to stop it or { href } to change it'), + onAvatarResolve: H('mail-read', 'transform', 'email:read', 'string | null (avatar URL), { email, name }', 'when a sender avatar is needed'), + + // ─── Mail: composing ─────────────────────────────────────── + onBeforeCompose: H('mail-compose', 'intercept', 'email:send', 'ComposeOptions', 'before the composer opens; return false to stop it or a ComposeOptions to prefill it'), + onComposerOpen: H('mail-compose', 'observer', 'email:read', 'ComposerContext', 'after the composer opened'), + onComposerClose: H('mail-compose', 'observer', 'email:read', '{ draftId, sent: boolean }', 'after the composer closed'), + onDraftChange: H('mail-compose', 'observer', 'email:read', 'DraftView', 'while the user writes (debounced)'), + onBeforeDraftAutoSave: H('mail-compose', 'transform', 'email:send', 'AlmostSavedDraft', 'before a draft is saved; return false to skip this save'), + onDraftAutoSave: H('mail-compose', 'observer', 'email:read', '{ draftId, subject }', 'after a draft was saved'), + onBeforeEditDraft: H('mail-compose', 'transform', 'email:send', 'Email', 'before a saved draft is opened in the composer'), + onBuildQuoteHeader: H('mail-compose', 'transform', 'email:send', 'QuoteHeader, QuoteHeaderContext', 'when a reply or forward is set up'), + onRecipientChipsChange: H('mail-compose', 'transform', 'email:send', 'Recipient[]', 'when the recipient fields change'), + onBeforeComposeOpenToReply: H('mail-compose', 'transform', 'email:send', 'Email (the original)', 'before a reply is filled in'), + onBeforeComposeOpenToReplyAll: H('mail-compose', 'transform', 'email:send', 'Email (the original)', 'before a reply-all is filled in'), + onBeforeComposeOpenToForward: H('mail-compose', 'transform', 'email:send', 'Email (the original)', 'before a forward is filled in'), + onBeforeComposeOpenToForwardAsAttachment: H('mail-compose', 'transform', 'email:send', 'Email (the original)', 'before a forward-as-attachment is filled in'), + onMailtoIntercept: H('mail-compose', 'intercept', 'email:read', 'MailtoContext', 'when a mailto: link is opened; return false to stop the composer'), + + // ─── Mail: sending ───────────────────────────────────────── + onBeforeEmailSend: H('mail-send', 'intercept', 'email:send', 'OutgoingEmail', 'when Send is pressed; a handler that fails or times out stops the send'), + onTransformOutgoingEmail: H('mail-send', 'transform', 'email:send', 'OutgoingEmail', 'right before the message is submitted'), + onComposeSend: H('mail-send', 'takeover', 'email:send', 'ComposeSendRequest', 'at the start of sending; may send the message itself'), + onAfterEmailSend: H('mail-send', 'observer', 'email:read', '{ emailId, submissionId, to, cc, bcc, subject, scheduledFor }', 'after the server accepted the message'), + + // ─── Mail: organizing ────────────────────────────────────── + onBeforeEmailDelete: H('mail-organize', 'intercept', 'email:write', '{ emailIds, permanent }', 'before messages are deleted or moved to Trash'), + onAfterEmailDelete: H('mail-organize', 'observer', 'email:read', '{ emailIds, permanent }', 'after messages were deleted'), + onBeforeEmailMove: H('mail-organize', 'intercept', 'email:write', '{ emailIds, fromMailboxId, toMailboxId }', 'before messages are moved'), + onAfterEmailMove: H('mail-organize', 'observer', 'email:read', '{ emailIds, fromMailboxId, toMailboxId }', 'after messages were moved'), + onBeforeEmailArchive: H('mail-organize', 'intercept', 'email:write', '{ emailIds }', 'before messages are archived'), + onEmailArchive: H('mail-organize', 'observer', 'email:read', '{ emailIds }', 'after messages were archived'), + onEmailUnarchive: H('mail-organize', 'observer', 'email:read', '{ emailIds, toMailboxId }', 'after messages were moved out of the archive'), + onBeforeEmailSpamToggle: H('mail-organize', 'intercept', 'email:write', '{ emailIds, spam }', 'before messages are marked as spam or not spam'), + onEmailSpamToggle: H('mail-organize', 'observer', 'email:read', '{ emailIds, spam }', 'after messages were marked as spam or not spam'), + onBeforeEmailReadStateChange: H('mail-organize', 'intercept', 'email:write', '{ emailIds, read }', 'before messages are marked read or unread'), + onEmailReadStateChange: H('mail-organize', 'observer', 'email:read', '{ emailIds, read }', 'after messages were marked read or unread'), + onBeforeEmailStarToggle: H('mail-organize', 'intercept', 'email:write', '{ emailIds, starred }', 'before messages are starred or unstarred'), + onEmailStarToggle: H('mail-organize', 'observer', 'email:read', '{ emailIds, starred }', 'after messages were starred or unstarred'), + onBeforeEmailKeywordChange: H('mail-organize', 'intercept', 'email:write', '{ emailIds, added: string[], removed: string[] }', 'before tags or other keywords are changed'), + onEmailKeywordChange: H('mail-organize', 'observer', 'email:read', '{ emailIds, added: string[], removed: string[] }', 'after tags or other keywords changed'), + onBeforeEmailCategorize: H('mail-organize', 'intercept', 'email:read', 'EmailCategorizeContext', 'before messages are moved to another inbox tab'), + onEmailCategorize: H('mail-organize', 'observer', 'email:read', 'EmailCategorizeContext', 'after messages were moved to another inbox tab'), + + // ─── Mail: folders ───────────────────────────────────────── + onMailboxesRefresh: H('mail-mailboxes', 'observer', 'email:read', 'MailboxView[]', 'after the folder list was loaded or refreshed'), + onBeforeMailboxCreate: H('mail-mailboxes', 'intercept', 'email:write', '{ name, parentId }', 'before a folder is created'), + onMailboxCreate: H('mail-mailboxes', 'observer', 'email:read', '{ mailboxId, name, parentId }', 'after a folder was created'), + onBeforeMailboxRename: H('mail-mailboxes', 'intercept', 'email:write', '{ mailboxId, name }', 'before a folder is renamed'), + onMailboxRename: H('mail-mailboxes', 'observer', 'email:read', '{ mailboxId, name, previousName }', 'after a folder was renamed'), + onBeforeMailboxDelete: H('mail-mailboxes', 'intercept', 'email:write', '{ mailboxId, name }', 'before a folder is deleted'), + onMailboxDelete: H('mail-mailboxes', 'observer', 'email:read', '{ mailboxId, name }', 'after a folder was deleted'), + onBeforeMailboxEmpty: H('mail-mailboxes', 'intercept', 'email:write', '{ mailboxId, role }', 'before a folder (usually Trash or Spam) is emptied'), + onMailboxEmpty: H('mail-mailboxes', 'observer', 'email:read', '{ mailboxId, role, count }', 'after a folder was emptied'), + onQuotaChange: H('mail-mailboxes', 'observer', 'email:read', 'QuotaView', 'when the storage quota was read and changed'), + + // ─── Mail: search ────────────────────────────────────────── + onSearch: H('mail-search', 'transform', 'email:read', '{ query, filters: SearchFilters }', 'before a search runs; may change the query and filters'), + onSearchResults: H('mail-search', 'observer', 'email:read', '{ query, count }', 'after search results arrived'), + onProvideSearchResults: H('mail-search', 'transform', 'email:read', 'ExternalSearchResult[], { query, filters }', 'to add results from elsewhere to a search'), + + // ─── Mail: attachments ───────────────────────────────────── + onBeforeAttachmentUpload: H('mail-attachments', 'intercept', 'email:read', 'AttachmentInfo', 'before a file is attached in the composer'), + onBeforeBlobUpload: H('mail-attachments', 'transform', 'email:blob-write', '{ file, blobId } | ExternalAttachmentResult', 'before an attachment is uploaded; may offload it'), + onAfterAttachmentUpload: H('mail-attachments', 'observer', 'email:read', 'AttachmentInfo', 'after an attachment was uploaded'), + onAttachmentDownload: H('mail-attachments', 'observer', 'email:read', 'AttachmentInfo', 'when an attachment of a message is downloaded'), + onAttachmentPreview: H('mail-attachments', 'transform', 'email:render-takeover', 'AttachmentPreview, AttachmentInfo', 'when an attachment preview opens'), + + // ─── Mail: new mail and sync ─────────────────────────────── + onNewEmailReceived: H('mail-sync', 'observer', 'email:read', 'EmailSummary', 'for each new message in the inbox (push or poll)'), + onPushConnectionChange: H('mail-sync', 'observer', 'email:read', '{ connected: boolean }', 'when live updates connect or drop'), + onSyncStateChange: H('mail-sync', 'observer', 'email:read', '{ state: "syncing" | "idle" | "error", error? }', 'when the app starts or finishes fetching changes from the server'), + + // ─── Calendar ────────────────────────────────────────────── + onCalendarEventOpen: H('calendar', 'observer', 'calendar:read', 'CalendarEventView', 'when an event is opened'), + onCalendarEventFormOpen: H('calendar', 'observer', 'calendar:read', 'CalendarEventFormView', 'when the event form opens'), + onCalendarEventFormSave: H('calendar', 'transform', 'calendar:write', 'CalendarEventFormSavePatch, CalendarEventFormView', 'while the event form is saved'), + onCheckEventConflicts: H('calendar', 'transform', 'calendar:read', 'ConflictWarning[], { event }', 'while start or end change in the event form'), + onBeforeEventCreate: H('calendar', 'intercept', 'calendar:write', 'CalendarEventView', 'before an event is created'), + onAfterEventCreate: H('calendar', 'observer', 'calendar:read', 'CalendarEventView', 'after an event was created'), + onBeforeEventUpdate: H('calendar', 'intercept', 'calendar:write', '{ eventId, patch }', 'before an event is changed'), + onAfterEventUpdate: H('calendar', 'observer', 'calendar:read', 'CalendarEventView', 'after an event was changed'), + onBeforeEventDelete: H('calendar', 'intercept', 'calendar:write', '{ eventId, title }', 'before an event is deleted'), + onAfterEventDelete: H('calendar', 'observer', 'calendar:read', '{ eventId, title }', 'after an event was deleted'), + onBeforeEventRsvp: H('calendar', 'intercept', 'calendar:write', '{ eventId, status }', 'before an invitation is answered'), + onEventRsvp: H('calendar', 'observer', 'calendar:read', '{ eventId, status }', 'after an invitation was answered'), + onBeforeEventsImport: H('calendar', 'intercept', 'calendar:write', '{ calendarId, count }', 'before events from a file are imported'), + onEventsImport: H('calendar', 'observer', 'calendar:read', '{ calendarId, imported, skipped }', 'after events were imported'), + onCalendarDateChange: H('calendar', 'observer', 'calendar:read', '{ date }', 'when the shown date changes'), + onCalendarViewChange: H('calendar', 'observer', 'calendar:read', '{ view }', 'when day, week, month or agenda view is chosen'), + onCalendarChange: H('calendar', 'observer', 'calendar:read', '{ calendarId, change: "created" | "updated" | "removed" }', 'after a calendar was created, changed or removed'), + onCalendarVisibilityToggle: H('calendar', 'observer', 'calendar:read', '{ calendarId, visible }', 'when a calendar is shown or hidden'), + onICalSubscriptionChange: H('calendar', 'observer', 'calendar:read', '{ subscriptionId, change: "added" | "updated" | "removed" | "refreshed" }', 'when a calendar subscription changes'), + onCalendarAlert: H('calendar', 'observer', 'calendar:read', 'CalendarAlertView', 'when an event reminder fires'), + onCalendarAlertAcknowledge: H('calendar', 'observer', 'calendar:read', '{ alertId, eventId }', 'when a reminder is dismissed'), + + // ─── Contacts ────────────────────────────────────────────── + onContactOpen: H('contacts', 'observer', 'contacts:read', 'ContactView', 'when a contact is opened'), + onBeforeContactCreate: H('contacts', 'intercept', 'contacts:write', 'ContactView', 'before a contact is created'), + onAfterContactCreate: H('contacts', 'observer', 'contacts:read', 'ContactView', 'after a contact was created'), + onBeforeContactUpdate: H('contacts', 'intercept', 'contacts:write', '{ contactId, patch }', 'before a contact is changed'), + onAfterContactUpdate: H('contacts', 'observer', 'contacts:read', 'ContactView', 'after a contact was changed'), + onBeforeContactDelete: H('contacts', 'intercept', 'contacts:write', '{ contactIds }', 'before contacts are deleted'), + onAfterContactDelete: H('contacts', 'observer', 'contacts:read', '{ contactIds }', 'after contacts were deleted'), + onContactsImport: H('contacts', 'observer', 'contacts:read', '{ addressBookId, imported }', 'after contacts were imported'), + onContactSelectionChange: H('contacts', 'observer', 'contacts:read', '{ contactIds }', 'when the selected contacts change'), + onContactGroupChange: H('contacts', 'observer', 'contacts:read', '{ groupId, change: "created" | "updated" | "deleted" }', 'after a contact group changed'), + onContactGroupMemberChange: H('contacts', 'observer', 'contacts:read', '{ groupId, added, removed }', 'after members were added to or removed from a group'), + onContactMove: H('contacts', 'observer', 'contacts:read', '{ contactId, toAddressBookId }', 'after a contact moved to another address book'), + onProvideRecipientSuggestions: H('contacts', 'transform', 'contacts:read', 'RecipientSuggestion[], { query }', 'to add suggestions to recipient autocomplete'), + + // ─── Tasks ───────────────────────────────────────────────── + onTasksLoaded: H('tasks', 'observer', 'tasks:read', 'TaskView[]', 'after the task list was loaded'), + onBeforeTaskCreate: H('tasks', 'intercept', 'tasks:write', 'TaskView', 'before a task is created'), + onTaskCreate: H('tasks', 'observer', 'tasks:read', 'TaskView', 'after a task was created'), + onBeforeTaskUpdate: H('tasks', 'intercept', 'tasks:write', '{ taskId, patch }', 'before a task is changed'), + onTaskUpdate: H('tasks', 'observer', 'tasks:read', 'TaskView', 'after a task was changed'), + onBeforeTaskDelete: H('tasks', 'intercept', 'tasks:write', '{ taskId, title }', 'before a task is deleted'), + onTaskDelete: H('tasks', 'observer', 'tasks:read', '{ taskId, title }', 'after a task was deleted'), + onTaskToggleComplete: H('tasks', 'observer', 'tasks:read', '{ taskId, complete }', 'after a task was checked off or reopened'), + onTaskFilterChange: H('tasks', 'observer', 'tasks:read', '{ filter, showCompleted }', 'when the task filter changes'), + + // ─── Files ───────────────────────────────────────────────── + onFileNavigate: H('files', 'observer', 'files:read', '{ path }', 'when another folder is opened'), + onBeforeFileUpload: H('files', 'intercept', 'files:write', '{ name, size, type, path }', 'before a file is uploaded'), + onAfterFileUpload: H('files', 'observer', 'files:read', 'FileResourceView', 'after a file was uploaded'), + onFileUploadCancel: H('files', 'observer', 'files:read', '{ name, path }', 'when an upload is cancelled'), + onFileDownload: H('files', 'observer', 'files:read', 'FileResourceView', 'when a file is downloaded'), + onDirectoryCreate: H('files', 'observer', 'files:read', 'FileResourceView', 'after a folder was created'), + onBeforeFileDelete: H('files', 'intercept', 'files:write', '{ files: FileResourceView[] }', 'before files are deleted'), + onAfterFileDelete: H('files', 'observer', 'files:read', '{ files: FileResourceView[] }', 'after files were deleted'), + onBeforeFileRename: H('files', 'intercept', 'files:write', '{ file, newName }', 'before a file is renamed'), + onFileRename: H('files', 'observer', 'files:read', '{ file, previousName }', 'after a file was renamed'), + onBeforeFileMove: H('files', 'intercept', 'files:write', '{ files, toPath }', 'before files are moved'), + onFileMove: H('files', 'observer', 'files:read', '{ files, toPath }', 'after files were moved'), + onFileCopy: H('files', 'observer', 'files:read', '{ files, toPath }', 'after files were copied'), + onFileDuplicate: H('files', 'observer', 'files:read', 'FileResourceView', 'after a file was duplicated'), + onFileFavoriteToggle: H('files', 'observer', 'files:read', '{ file, favorite }', 'when a file is starred or unstarred'), + onFileSelectionChange: H('files', 'observer', 'files:read', '{ files: FileResourceView[] }', 'when the selected files change'), + onFileUndo: H('files', 'observer', 'files:read', '{ action }', 'after the last file action was undone'), + + // ─── Settings ────────────────────────────────────────────── + onBeforeSettingChange: H('settings', 'intercept', 'settings:write', '{ key, value, previous }', 'before a preference is changed'), + onSettingChange: H('settings', 'observer', 'settings:read', '{ key, value, previous }', 'after a preference changed'), + onSettingsExport: H('settings', 'observer', 'settings:read', '{ keys: string[] }', 'after settings were exported'), + onSettingsImport: H('settings', 'observer', 'settings:read', '{ keys: string[] }', 'after settings were imported'), + onSettingsReset: H('settings', 'observer', 'settings:read', '{}', 'after settings were reset to defaults'), + onSettingsSync: H('settings', 'observer', 'settings:read', '{ direction: "pulled" | "pushed" }', 'after settings were synced with the server'), + onKeywordChange: H('settings', 'observer', 'settings:read', '{ keywords: KeywordView[] }', 'after the tag definitions changed'), + onTrustedSenderChange: H('settings', 'observer', 'settings:read', '{ email, trusted }', 'after a sender was trusted or untrusted for remote content'), + + // ─── Identities ──────────────────────────────────────────── + onIdentitiesLoaded: H('identities', 'observer', 'identity:read', 'IdentityView[]', 'after sender identities were loaded'), + onBeforeIdentityChange: H('identities', 'intercept', 'identity:write', '{ identityId?, change: "create" | "update" | "delete", identity? }', 'before an identity is created, changed or deleted'), + onIdentityCreate: H('identities', 'observer', 'identity:read', 'IdentityView', 'after an identity was created'), + onIdentityUpdate: H('identities', 'observer', 'identity:read', 'IdentityView', 'after an identity was changed'), + onIdentityDelete: H('identities', 'observer', 'identity:read', '{ identityId }', 'after an identity was deleted'), + onIdentitySelect: H('identities', 'observer', 'identity:read', 'IdentityView', 'when another From identity is chosen in the composer'), + onSignatureRender: H('identities', 'transform', 'identity:read', '{ html, text }, { identityId }', 'when a signature is inserted into the composer'), + + // ─── Filters ─────────────────────────────────────────────── + onFiltersLoaded: H('filters', 'observer', 'filters:read', 'FilterRuleView[]', 'after filter rules were loaded'), + onFilterRuleChange: H('filters', 'observer', 'filters:read', '{ ruleId, change: "added" | "updated" | "deleted" | "reordered" | "toggled" }', 'after a filter rule changed in the editor'), + onBeforeFiltersSave: H('filters', 'intercept', 'filters:write', '{ rules: FilterRuleView[] }', 'before filter rules are saved to the server'), + onFiltersSave: H('filters', 'observer', 'filters:read', '{ accountId }', 'after the filter script was saved'), + onSieveScriptChange: H('filters', 'observer', 'filters:read', '{ accountId }', 'after the active script changed'), + onSieveScriptGenerate: H('filters', 'transform', 'filters:write', 'string (Sieve script), SieveScriptGenerateContext', 'before the generated script is uploaded'), + + // ─── Out of office ───────────────────────────────────────── + onVacationLoaded: H('vacation', 'observer', 'vacation:read', 'VacationView', 'after the out-of-office reply was loaded'), + onBeforeVacationUpdate: H('vacation', 'intercept', 'vacation:write', 'VacationView', 'before the out-of-office reply is changed'), + onVacationUpdate: H('vacation', 'observer', 'vacation:read', 'VacationView', 'after the out-of-office reply was changed'), + + // ─── Templates ───────────────────────────────────────────── + onTemplateCreate: H('templates', 'observer', 'templates:read', 'TemplateView', 'after a template was created'), + onTemplateUpdate: H('templates', 'observer', 'templates:read', 'TemplateView', 'after a template was changed'), + onTemplateDelete: H('templates', 'observer', 'templates:read', '{ templateId }', 'after a template was deleted'), + onTemplatesImport: H('templates', 'observer', 'templates:read', '{ count }', 'after templates were imported'), + onTemplateApply: H('templates', 'observer', 'templates:read', 'TemplateView', 'after a template was inserted into the composer'), + onTemplateRender: H('templates', 'transform', 'templates:read', '{ subject, html, text }, { templateId }', 'before a template is inserted; may change the text'), + + // ─── Sign-in and accounts ────────────────────────────────── + onLogin: H('auth', 'observer', 'auth:observe', '{ accountId, username }', 'after a sign-in'), + onAuthReady: H('auth', 'observer', 'auth:observe', '{ accountId }', 'when the session is ready after start or sign-in'), + onTokenRefresh: H('auth', 'observer', 'auth:observe', '{ accountId }', 'after the access token was renewed'), + onAccountAdd: H('auth', 'observer', 'auth:observe', '{ accountId }', 'after another account was added'), + onAccountRemove: H('auth', 'observer', 'auth:observe', '{ accountId }', 'after an account was removed'), + onAccountSwitch: H('auth', 'observer', 'app:lifecycle', '{ accountId, previousAccountId }', 'after another account was made active'), + onBeforeLogout: H('auth', 'observer', 'app:lifecycle', '{ accountId }', 'just before signing out'), + onAfterLogout: H('auth', 'observer', 'app:lifecycle', '{ accountId }', 'after signing out'), + onOAuthCallback: H('auth', 'observer', 'http:fetch', 'OAuthCallbackPayload', 'when a third-party OAuth flow this plugin started returns'), + + // ─── Account security ────────────────────────────────────── + onPasswordChange: H('security', 'observer', 'security:read', '{}', 'after the account password was changed'), + onTotpChange: H('security', 'observer', 'security:read', '{ enabled }', 'after two-factor sign-in was turned on or off'), + onAppPasswordChange: H('security', 'observer', 'security:read', '{ change: "created" | "removed" }', 'after an app password was created or removed'), + onEncryptionChange: H('security', 'observer', 'security:read', '{ enabled }', 'after encryption at rest was changed'), + onDisplayNameChange: H('security', 'observer', 'security:read', '{ displayName }', 'after the account display name was changed'), + + // ─── Navigation ──────────────────────────────────────────── + onNavigate: H('navigation', 'observer', 'ui:observe', 'RouteContext', 'after the app moved to another page'), + onRouteEnter: H('navigation', 'observer', 'ui:observe', 'RouteContext', 'when a page is entered'), + onRouteLeave: H('navigation', 'observer', 'ui:observe', 'RouteContext', 'when a page is left'), + + // ─── Theme and language ──────────────────────────────────── + onThemeChange: H('theme', 'observer', 'ui:observe', '{ theme: "light" | "dark" | "system", resolved: "light" | "dark" }', 'after the colour scheme changed'), + onCustomThemeChange: H('theme', 'observer', 'ui:observe', '{ themeId }', 'after another theme was activated'), + onThemeBeforeApply: H('theme', 'transform', 'ui:observe', 'string (CSS), { themeId, variant }', 'before theme CSS is applied'), + onLocaleChange: H('theme', 'observer', 'ui:observe', '{ locale }', 'after the app language changed'), + + // ─── Notifications ───────────────────────────────────────── + onToastShow: H('notifications', 'observer', 'ui:observe', '{ type, message }', 'when the app shows a toast'), + onToastDismiss: H('notifications', 'observer', 'ui:observe', '{ id }', 'when a toast is dismissed'), + onBrowserNotification: H('notifications', 'transform', 'email:read', '{ title, body, tag } | false, { kind: "mail" | "calendar" | "plugin" }', 'before a system notification is shown; return false to suppress it'), + onNotificationClick: H('notifications', 'observer', 'email:read', '{ tag, data }', 'when a system notification is clicked'), + + // ─── App lifecycle ───────────────────────────────────────── + onAppReady: H('app', 'observer', 'app:lifecycle', '{ accountId }', 'once after the app has started and the mail data is loaded'), + onVisibilityChange: H('app', 'observer', 'app:lifecycle', '{ visible }', 'when the tab is hidden or shown'), + onWindowFocus: H('app', 'observer', 'app:lifecycle', '{}', 'when the window gains focus'), + onWindowBlur: H('app', 'observer', 'app:lifecycle', '{}', 'when the window loses focus'), + onOnline: H('app', 'observer', 'app:lifecycle', '{}', 'when the network comes back'), + onOffline: H('app', 'observer', 'app:lifecycle', '{}', 'when the network goes away'), + onBeforeUnload: H('app', 'observer', 'app:lifecycle', '{}', 'when the page is about to close'), + onAppError: H('app', 'observer', 'app:lifecycle', '{ message }', 'when an unhandled error occurs in the app'), + onInterval: H('app', 'observer', 'app:lifecycle', '{ now }', 'once a minute while the app is open'), + + // ─── Layout ──────────────────────────────────────────────── + onViewChange: H('ui', 'observer', 'ui:observe', '{ view }', 'when the main view changes (list, message, composer)'), + onSidebarToggle: H('ui', 'observer', 'ui:observe', '{ open }', 'when the sidebar is opened or closed'), + onSidebarCollapse: H('ui', 'observer', 'ui:observe', '{ collapsed }', 'when the sidebar is collapsed or expanded'), + onDeviceTypeChange: H('ui', 'observer', 'ui:observe', '{ deviceType: "mobile" | "tablet" | "desktop" }', 'when the layout switches between phone, tablet and desktop'), + onColumnResize: H('ui', 'observer', 'ui:observe', '{ column, width }', 'after a column was resized'), + onMobileBack: H('ui', 'observer', 'ui:observe', '{}', 'when Back is used on the phone layout'), + onTextSelectionChange: H('ui', 'observer', 'email:read', 'SelectionContext', 'when the user selects text (debounced)'), + onDragStart: H('ui', 'observer', 'email:read', '{ kind, ids }', 'when messages or tags start being dragged'), + onDragEnd: H('ui', 'observer', 'email:read', '{ kind, ids, dropped }', 'when a drag ends'), + onEmailDrop: H('ui', 'observer', 'email:read', '{ emailIds, targetMailboxId }', 'after messages were dropped on a folder'), + onTagDrop: H('ui', 'observer', 'email:read', '{ emailIds, keyword }', 'after a tag was dropped on messages'), + onSidebarAppOpen: H('ui', 'observer', 'ui:observe', '{ appId }', 'when a sidebar app is opened'), + onSidebarAppClose: H('ui', 'observer', 'ui:observe', '{ appId }', 'when a sidebar app is closed'), + + // ─── Keyboard ────────────────────────────────────────────── + onBeforeShortcut: H('keyboard', 'intercept', 'ui:keyboard', '{ key, action }', 'before an app shortcut runs; return false to stop it'), + onAfterShortcut: H('keyboard', 'observer', 'ui:keyboard', '{ key, action }', 'after an app shortcut ran'), + + // ─── Inbox tabs ──────────────────────────────────────────── + onTabsChange: H('tabs', 'observer', 'ui:message-list-tabs', 'MessageListTab[]', 'after the set of inbox tabs changed'), + onTabActivate: H('tabs', 'observer', 'ui:message-list-tabs', 'TabActivateContext', 'when another inbox tab is chosen'), + onTabCountsRefresh: H('tabs', 'observer', 'ui:message-list-tabs', 'Record', 'after the unread counts per tab were refreshed'), +} satisfies Record; + +export type HookName = keyof typeof HOOK_CATALOG; + +export function hookSpec(name: string): HookSpec | null { + return Object.prototype.hasOwnProperty.call(HOOK_CATALOG, name) ? HOOK_CATALOG[name as HookName] : null; +} diff --git a/lib/plugin-hooks.ts b/lib/plugin-hooks.ts index 3c6c48dcf..af19b455f 100644 --- a/lib/plugin-hooks.ts +++ b/lib/plugin-hooks.ts @@ -322,6 +322,7 @@ export const emailHooks = { // Return false to cancel opening the composer. onBeforeCompose: new HookBus(), onComposerOpen: new HookBus(), + onComposerClose: new HookBus(), // Fails closed with the long send budget: a guard that asks the user and // gets no answer must not let the mail go out behind its dialog. onBeforeEmailSend: new HookBus(COMPOSE_SEND_TIMEOUT_MS, { failClosed: true }), @@ -338,25 +339,35 @@ export const emailHooks = { onAfterEmailDelete: new HookBus(), onBeforeEmailMove: new HookBus(), onAfterEmailMove: new HookBus(), + onBeforeEmailArchive: 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(), + onBeforeEmailReadStateChange: new HookBus(), onEmailReadStateChange: new HookBus(), + onBeforeEmailStarToggle: new HookBus(), onEmailStarToggle: new HookBus(), + onBeforeEmailSpamToggle: new HookBus(), onEmailSpamToggle: new HookBus(), + onBeforeEmailKeywordChange: new HookBus(), onEmailKeywordChange: new HookBus(), onMailboxChange: new HookBus(), onMailboxesRefresh: new HookBus(), + onBeforeMailboxCreate: new HookBus(), onMailboxCreate: new HookBus(), + onBeforeMailboxRename: new HookBus(), onMailboxRename: new HookBus(), + onBeforeMailboxDelete: new HookBus(), onMailboxDelete: new HookBus(), + onBeforeMailboxEmpty: new HookBus(), onMailboxEmpty: new HookBus(), onSearch: new HookBus(), onSearchResults: new HookBus(), onEmailSelectionChange: new HookBus(), onNewEmailReceived: new HookBus(), onPushConnectionChange: new HookBus(), + onSyncStateChange: 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. @@ -447,7 +458,9 @@ export const calendarHooks = { onAfterEventUpdate: new HookBus(), onBeforeEventDelete: new HookBus(), onAfterEventDelete: new HookBus(), + onBeforeEventRsvp: new HookBus(), onEventRsvp: new HookBus(), + onBeforeEventsImport: new HookBus(), onEventsImport: new HookBus(), onCalendarDateChange: new HookBus(), onCalendarViewChange: new HookBus(), @@ -525,6 +538,7 @@ export const fileHooks = { // Return false to cancel the rename. onBeforeFileRename: new HookBus(), onFileRename: new HookBus(), + onBeforeFileMove: new HookBus(), onFileMove: new HookBus(), onFileCopy: new HookBus(), onFileDuplicate: new HookBus(), @@ -556,6 +570,7 @@ export const authHooks = { // §7.6 Settings Hooks export const settingsHooks = { + onBeforeSettingChange: new HookBus(), onSettingChange: new HookBus(), onSettingsExport: new HookBus(), onSettingsImport: new HookBus(), @@ -568,6 +583,7 @@ export const settingsHooks = { // §7.7 Identity Hooks export const identityHooks = { onIdentitiesLoaded: new HookBus(), + onBeforeIdentityChange: new HookBus(), onIdentityCreate: new HookBus(), onIdentityUpdate: new HookBus(), onIdentityDelete: new HookBus(), @@ -579,6 +595,7 @@ export const identityHooks = { export const filterHooks = { onFiltersLoaded: new HookBus(), onFilterRuleChange: new HookBus(), + onBeforeFiltersSave: new HookBus(), // Observer fired after the host successfully uploads the account's active // Sieve script (visual-builder save or plugin-triggered regenerate). onFiltersSave: new HookBus(), @@ -600,8 +617,11 @@ export const filterHooks = { // §7.9 Task Hooks export const taskHooks = { onTasksLoaded: new HookBus(), + onBeforeTaskCreate: new HookBus(), onTaskCreate: new HookBus(), + onBeforeTaskUpdate: new HookBus(), onTaskUpdate: new HookBus(), + onBeforeTaskDelete: new HookBus(), onTaskDelete: new HookBus(), onTaskToggleComplete: new HookBus(), onTaskFilterChange: new HookBus(), @@ -617,17 +637,10 @@ export const templateHooks = { onTemplateRender: new HookBus(), }; -// §7.11 S/MIME Hooks -export const smimeHooks = { - onSmimeKeyImport: new HookBus(), - onSmimeCertImport: new HookBus(), - onSmimeKeyStateChange: new HookBus(), - onSmimeDefaultsChange: new HookBus(), -}; - // §7.12 Vacation Hooks export const vacationHooks = { onVacationLoaded: new HookBus(), + onBeforeVacationUpdate: new HookBus(), onVacationUpdate: new HookBus(), }; @@ -639,7 +652,6 @@ export const uiHooks = { onDeviceTypeChange: new HookBus(), onColumnResize: new HookBus(), onMobileBack: new HookBus(), - onMobileViewSwitch: new HookBus(), // Intercept hook - fires when the user clicks an external link inside the // app (typically inside an email body iframe). Handler receives // ExternalLinkContext; return false to cancel the navigation. Mutate @@ -690,7 +702,6 @@ export const dragDropHooks = { // §7.17 Keyboard Hooks export const keyboardHooks = { - registerShortcut: new HookBus(), onBeforeShortcut: new HookBus(), onAfterShortcut: new HookBus(), }; @@ -725,7 +736,6 @@ export const accountSecurityHooks = { export const sidebarAppHooks = { onSidebarAppOpen: new HookBus(), onSidebarAppClose: new HookBus(), - onSidebarAppChange: new HookBus(), }; // §7.21 Avatar Hooks @@ -799,7 +809,7 @@ export const messageListTabHooks = { const allHookGroups = [ emailHooks, calendarHooks, calendarFormHooks, contactHooks, fileHooks, authHooks, settingsHooks, identityHooks, filterHooks, - taskHooks, templateHooks, smimeHooks, vacationHooks, + taskHooks, templateHooks, vacationHooks, uiHooks, themeHooks, toastHooks, dragDropHooks, keyboardHooks, appLifecycleHooks, accountSecurityHooks, sidebarAppHooks, avatarHooks, renderHooks, routerHooks, messageListTabHooks, diff --git a/lib/plugin-sandbox/loader.ts b/lib/plugin-sandbox/loader.ts index b06bfde91..791308b74 100644 --- a/lib/plugin-sandbox/loader.ts +++ b/lib/plugin-sandbox/loader.ts @@ -6,7 +6,7 @@ import { pluginStorage } from '../plugin-storage'; import { emailHooks, calendarHooks, calendarFormHooks, contactHooks, fileHooks, authHooks, settingsHooks, identityHooks, filterHooks, - taskHooks, templateHooks, smimeHooks, vacationHooks, + taskHooks, templateHooks, vacationHooks, uiHooks, themeHooks, toastHooks, dragDropHooks, keyboardHooks, appLifecycleHooks, accountSecurityHooks, sidebarAppHooks, avatarHooks, renderHooks, routerHooks, @@ -30,7 +30,7 @@ type AnyBus = { register: (pluginId: string, handler: (...args: unknown[]) => un const HOOK_BUSES: Record = Object.assign({}, emailHooks, calendarHooks, calendarFormHooks, contactHooks, fileHooks, authHooks, settingsHooks, identityHooks, filterHooks, - taskHooks, templateHooks, smimeHooks, vacationHooks, + taskHooks, templateHooks, vacationHooks, uiHooks, themeHooks, toastHooks, dragDropHooks, keyboardHooks, appLifecycleHooks, accountSecurityHooks, sidebarAppHooks, avatarHooks, renderHooks, routerHooks, diff --git a/lib/plugin-sandbox/permissions.ts b/lib/plugin-sandbox/permissions.ts index a0ddfa85d..5bad2b274 100644 --- a/lib/plugin-sandbox/permissions.ts +++ b/lib/plugin-sandbox/permissions.ts @@ -1,5 +1,6 @@ import type { InstalledPlugin, Permission, SlotName } from '../plugin-types'; import { IMPLICIT_PERMISSIONS } from '../plugin-types'; +import { hookSpec } from '../plugin-hook-catalog'; /** * Whether `plugin` may use `perm`: the manifest must declare it, and an @@ -18,239 +19,14 @@ export function pluginHasPermission(plugin: InstalledPlugin, perm: Permission): // ─── 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 = { - // 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', -}; +// The permission each hook needs comes from the hook catalogue +// (lib/plugin-hook-catalog.ts). 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 the catalogue is refused. export function hookPermission(hookName: string): Permission | null { - return Object.prototype.hasOwnProperty.call(HOOK_PERMISSIONS, hookName) ? HOOK_PERMISSIONS[hookName] : null; + return hookSpec(hookName)?.permission ?? null; } export function mayRegisterHook(plugin: InstalledPlugin, hookName: string): boolean { diff --git a/lib/plugin-types.ts b/lib/plugin-types.ts index 57adaa37a..08f0949e7 100644 --- a/lib/plugin-types.ts +++ b/lib/plugin-types.ts @@ -1072,7 +1072,6 @@ export const ALL_PERMISSIONS = [ 'filters:read', 'filters:write', 'tasks:read', 'tasks:write', 'templates:read', 'templates:write', - 'smime:read', 'vacation:read', 'vacation:write', 'settings:read', 'settings:write', 'security:read', @@ -1095,6 +1094,14 @@ export const ALL_PERMISSIONS = [ export type Permission = (typeof ALL_PERMISSIONS)[number]; +/** + * Permissions older plugins may still name that no longer grant anything: + * `smime:read` covered the built-in S/MIME code, which moved into a plugin. + * Accepted and dropped at install, with a warning, so those plugins still + * install. + */ +export const DEPRECATED_PERMISSIONS: readonly string[] = ['smime:read']; + /** Permissions always granted regardless of manifest */ export const IMPLICIT_PERMISSIONS: Permission[] = ['ui:observe', 'app:lifecycle'];