mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 16:14:07 +00:00
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:
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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)`);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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'];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user