fix: let identity hooks stop a plugin's change, send notification clicks to plugins that show notifications, and mark reading mail as high risk

This commit is contained in:
Linus Rath
2026-10-14 04:37:24 +02:00
parent 8ee97f23e3
commit 1ba53655e5
6 changed files with 26 additions and 7 deletions
+3 -1
View File
@@ -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 });
}
};
@@ -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', () => {
+1 -1
View File
@@ -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'),
+6 -3
View File
@@ -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<IdentityView> {
export async function doIdentitiesUpdate(id: unknown, patch: unknown): Promise<IdentityView | null> {
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<I
updates.htmlSignature = html.trim() ? sanitizeSignatureHtml(html) : null;
}
await sessionClient(method).updateIdentity(identityId, updates);
// Through the store's hooked update, so onBeforeIdentityChange can stop a
// plugin's change like it stops one made in Settings.
const done = await updateIdentityWithHooks(sessionClient(method), identity as Identity, updates as Parameters<typeof updateIdentityWithHooks>[2]);
if (!done) return null;
useIdentityStore.getState().updateIdentityLocal(identityId, updates as Partial<Identity>);
return identityToView({ ...identity, ...updates } as Identity);
}
+2
View File
@@ -11,6 +11,8 @@ import { ALL_PERMISSIONS, IMPLICIT_PERMISSIONS } from '../plugin-types';
*/
export const HIGH_RISK_PERMISSIONS: ReadonlySet<string> = 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',
+3 -2
View File
@@ -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<IdentityView>,
callApi('identities.update', [id, patch]) as Promise<IdentityView | null>,
},
// Out-of-office reply (permissions: vacation:read / vacation:write).
vacation: {