refactor: describe every plugin hook in one catalogue

The hook buses, the permission each hook needs and the docs were three
separate lists that had drifted: 145 of 198 hooks were never fired and
nothing noticed. `lib/plugin-hook-catalog.ts` now names each hook with
its kind, permission, payload and when it fires; the permission check
reads it, and a test fails for any catalogued hook without a call site.
Groups still being wired in this branch are listed in that test.

Also:
- new before-hooks for archive, spam, read state, star, keywords, folder
  changes, RSVP, event import, tasks, file moves, settings, identities,
  filters and out-of-office, plus onComposerClose and onSyncStateChange
- removed hooks that can never fire: the S/MIME group (S/MIME is a
  plugin now), onMobileViewSwitch, onSidebarAppChange and the
  registerShortcut pseudo-hook
- smime:read no longer grants anything; manifests that name it still
  install, with a warning
This commit is contained in:
Linus Rath
2026-10-14 03:14:29 +02:00
parent 480a1bb226
commit a3cb9e630e
8 changed files with 449 additions and 246 deletions
+9
View File
@@ -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/);
});
});
+79
View File
@@ -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<string, string> {
const out: Record<string, string> = {};
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<HookGroup>([
'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<string>(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([]);
}
});
});
+6
View File
@@ -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<string, unknown>, 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)`);
}
+316
View File
@@ -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<tabId, number>', 'after the unread counts per tab were refreshed'),
} satisfies Record<string, HookSpec>;
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;
}
+22 -12
View File
@@ -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,
+2 -2
View File
@@ -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<string, AnyBus> = 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,
+7 -231
View File
@@ -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<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',
};
// 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 {
+8 -1
View File
@@ -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'];