diff --git a/components/providers/plugin-app-events.tsx b/components/providers/plugin-app-events.tsx index b0aff57dc..85592bf50 100644 --- a/components/providers/plugin-app-events.tsx +++ b/components/providers/plugin-app-events.tsx @@ -69,7 +69,9 @@ export function PluginAppEvents() { const onSwMessage = (e: MessageEvent) => { const msg = e.data as { kind?: string; tag?: string; data?: unknown } | null; if (msg && msg.kind === 'notificationclick' && typeof msg.tag === 'string') { - void toastHooks.onNotificationClick.emit({ tag: msg.tag, data: msg.data }); + // App notifications (new mail, reminders): the tag only. Their data + // names messages and events, which this hook's permission doesn't cover. + void toastHooks.onNotificationClick.emit({ tag: msg.tag }); } }; diff --git a/lib/__tests__/plugin-host-settings-apis.test.ts b/lib/__tests__/plugin-host-settings-apis.test.ts index 3d4ac565b..e244cf96c 100644 --- a/lib/__tests__/plugin-host-settings-apis.test.ts +++ b/lib/__tests__/plugin-host-settings-apis.test.ts @@ -7,6 +7,7 @@ import { useTemplateStore } from '@/stores/template-store'; import { useIdentityStore } from '@/stores/identity-store'; import { useVacationStore } from '@/stores/vacation-store'; import { HIGH_RISK_PERMISSIONS } from '../plugin-sandbox/permission-text'; +import { clearAllHooks, identityHooks } from '../plugin-hooks'; vi.mock('@/stores/toast-store', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }, @@ -132,6 +133,16 @@ describe('api.identities.update', () => { expect(client.updateIdentity).not.toHaveBeenCalled(); expect(HIGH_RISK_PERMISSIONS.has('identity:write')).toBe(true); }); + + it('lets another plugin stop the change, like one made in Settings', async () => { + identityHooks.onBeforeIdentityChange.register('guard', () => false); + try { + await expect(call(ALL, 'identities.update', 'id1', { name: 'x' })).resolves.toBeNull(); + expect(client.updateIdentity).not.toHaveBeenCalled(); + } finally { + clearAllHooks(); + } + }); }); describe('api.vacation', () => { diff --git a/lib/plugin-hook-catalog.ts b/lib/plugin-hook-catalog.ts index 69d1c73aa..3f09f8f43 100644 --- a/lib/plugin-hook-catalog.ts +++ b/lib/plugin-hook-catalog.ts @@ -270,7 +270,7 @@ export const HOOK_CATALOG = { // ─── Notifications ───────────────────────────────────────── onToastShow: H('notifications', 'observer', 'ui:observe', '{ id, type, title, message }', 'when the app shows a toast'), onToastDismiss: H('notifications', 'observer', 'ui:observe', '{ id }', 'when a toast is dismissed'), - onNotificationClick: H('notifications', 'observer', 'email:read', '{ tag, data }', 'when a system notification is clicked'), + onNotificationClick: H('notifications', 'observer', 'ui:notifications', '{ tag }', 'when a notification the plugin showed, or one from the app, is clicked'), // ─── App lifecycle ───────────────────────────────────────── onAppReady: H('app', 'observer', 'app:lifecycle', '{ accountId }', 'once after the app has started and the mail data is loaded'), diff --git a/lib/plugin-sandbox/host-api-settings.ts b/lib/plugin-sandbox/host-api-settings.ts index 192080268..8aafffec1 100644 --- a/lib/plugin-sandbox/host-api-settings.ts +++ b/lib/plugin-sandbox/host-api-settings.ts @@ -9,7 +9,7 @@ import type { } from '../plugin-types'; import type { EmailAddress, Identity } from '../jmap/types'; import { useTemplateStore } from '@/stores/template-store'; -import { useIdentityStore } from '@/stores/identity-store'; +import { updateIdentityWithHooks, useIdentityStore } from '@/stores/identity-store'; import { useVacationStore } from '@/stores/vacation-store'; import { identityToView, templateToView, vacationToView } from '../plugin-projection'; import { sanitizeEmailHtml, sanitizePluginBodyHtml, sanitizeSignatureHtml } from '../email-sanitization'; @@ -112,7 +112,7 @@ const MAX_HTML_SIGNATURE = 100_000; * address itself can't be changed; an HTML signature is sanitised exactly * like one typed into Settings > Identities. */ -export async function doIdentitiesUpdate(id: unknown, patch: unknown): Promise { +export async function doIdentitiesUpdate(id: unknown, patch: unknown): Promise { const method = 'identities.update'; const identityId = readId(id, method); if (patch && typeof patch === 'object' && 'email' in patch) { @@ -141,7 +141,10 @@ export async function doIdentitiesUpdate(id: unknown, patch: unknown): Promise[2]); + if (!done) return null; useIdentityStore.getState().updateIdentityLocal(identityId, updates as Partial); return identityToView({ ...identity, ...updates } as Identity); } diff --git a/lib/plugin-sandbox/permission-text.ts b/lib/plugin-sandbox/permission-text.ts index 90d8d7366..c5c7fe0cd 100644 --- a/lib/plugin-sandbox/permission-text.ts +++ b/lib/plugin-sandbox/permission-text.ts @@ -11,6 +11,8 @@ import { ALL_PERMISSIONS, IMPLICIT_PERMISSIONS } from '../plugin-types'; */ export const HIGH_RISK_PERMISSIONS: ReadonlySet = new Set([ 'crypto:full', + // email.get reads any message in the account, not only the open one. + 'email:read', 'email:send', 'email:raw-send', 'email:blob-read', diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx index ed574f1e7..af1e36d1d 100644 --- a/lib/plugin-sandbox/runtime.tsx +++ b/lib/plugin-sandbox/runtime.tsx @@ -693,10 +693,11 @@ function buildPluginApi(manifest: PluginManifest) { /** * Change the name, reply-to addresses or signatures of one identity. * The address can't be changed. An HTML signature is sanitised like - * one typed into Settings. Resolves to the identity as changed. + * one typed into Settings. Resolves to the identity as changed, or null + * when an onBeforeIdentityChange handler stopped it. */ update: (id: string, patch: IdentityPatch) => - callApi('identities.update', [id, patch]) as Promise, + callApi('identities.update', [id, patch]) as Promise, }, // Out-of-office reply (permissions: vacation:read / vacation:write). vacation: {