From 4aa4606b844d2d15a38553e91e577681e5b58ee0 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 07:34:23 +0100 Subject: [PATCH] fix: give plugins no personal templates when the admin hid them --- .../plugin-host-settings-apis.test.ts | 34 ++++++++++++++++++- lib/plugin-sandbox/host-api-settings.ts | 14 ++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/lib/__tests__/plugin-host-settings-apis.test.ts b/lib/__tests__/plugin-host-settings-apis.test.ts index e244cf96c..c22f93e6c 100644 --- a/lib/__tests__/plugin-host-settings-apis.test.ts +++ b/lib/__tests__/plugin-host-settings-apis.test.ts @@ -6,6 +6,9 @@ import { useAuthStore } from '@/stores/auth-store'; import { useTemplateStore } from '@/stores/template-store'; import { useIdentityStore } from '@/stores/identity-store'; import { useVacationStore } from '@/stores/vacation-store'; +import { usePolicyStore } from '@/stores/policy-store'; +import { emptyPartition, useAccountTemplatesStore } from '@/stores/account-templates-store'; +import { DEFAULT_POLICY } from '../admin/types'; import { HIGH_RISK_PERMISSIONS } from '../plugin-sandbox/permission-text'; import { clearAllHooks, identityHooks } from '../plugin-hooks'; @@ -53,7 +56,9 @@ beforeEach(() => { }); afterEach(() => { - useAuthStore.setState({ client: null }); + useAuthStore.setState({ client: null, activeAccountId: null }); + useAccountTemplatesStore.setState({ personal: {} }); + usePolicyStore.setState({ policy: DEFAULT_POLICY }); useTemplateStore.setState({ templates: [], deletedTemplateIds: {} }); useIdentityStore.setState({ identities: [] }); useVacationStore.getState().clearState(); @@ -101,6 +106,33 @@ describe('api.templates', () => { }); }); +describe('api.templates and organisation settings', () => { + it('works on the account\'s own copy when the account keeps its templates (Stalwart 1.0)', async () => { + const inAccount = { ...greeting, id: 'acc-1', name: 'In the account' }; + useAuthStore.setState({ activeAccountId: 'ann@mail' }); + useAccountTemplatesStore.setState({ personal: { 'ann@mail': { ...emptyPartition(), templates: [inAccount] } } }); + expect((await call(ALL, 'templates.list') as Array<{ name: string }>).map((t) => t.name)).toEqual(['In the account']); + const created = await call(ALL, 'templates.create', { name: 'From a plugin' }) as { id: string }; + await call(ALL, 'templates.update', 'acc-1', { subject: 'Changed' }); + await call(ALL, 'templates.delete', created.id); + const copy = useAccountTemplatesStore.getState().personal['ann@mail']; + expect(copy.templates.map((t) => t.subject)).toEqual(['Changed']); + expect(copy.deletedTemplateIds).toHaveProperty(created.id); + // The browser's list is left alone. + expect(useTemplateStore.getState().templates.map((t) => t.id)).toEqual(['tpl-1']); + }); + + it('shows and accepts nothing when the admin hid personal templates', async () => { + usePolicyStore.setState({ policy: { ...DEFAULT_POLICY, restrictions: { personalTemplates: { hidden: true } } } }); + expect(await call(ALL, 'templates.list')).toEqual([]); + expect(await call(ALL, 'templates.get', 'tpl-1')).toBeNull(); + await expect(call(ALL, 'templates.create', { name: 'x' })).rejects.toThrow(/turned personal templates off/); + await expect(call(ALL, 'templates.update', 'tpl-1', { name: 'x' })).rejects.toThrow(/turned personal templates off/); + await expect(call(ALL, 'templates.delete', 'tpl-1')).rejects.toThrow(/turned personal templates off/); + expect(useTemplateStore.getState().templates.map((t) => t.name)).toEqual(['Greeting']); + }); +}); + describe('api.identities.update', () => { it('changes name, reply-to and signatures and never the address', async () => { const view = await call(ALL, 'identities.update', 'id1', { diff --git a/lib/plugin-sandbox/host-api-settings.ts b/lib/plugin-sandbox/host-api-settings.ts index 39c2fa143..9582b02a4 100644 --- a/lib/plugin-sandbox/host-api-settings.ts +++ b/lib/plugin-sandbox/host-api-settings.ts @@ -10,6 +10,7 @@ import type { } from '../plugin-types'; import type { EmailAddress, Identity } from '../jmap/types'; import { personalTemplatesFor } from '@/lib/templates/personal-templates'; +import { usePolicyStore } from '@/stores/policy-store'; import { updateIdentityWithHooks, useIdentityStore } from '@/stores/identity-store'; import { useVacationStore } from '@/stores/vacation-store'; import { identityToView, templateToView, vacationToView } from '../plugin-projection'; @@ -34,12 +35,22 @@ const TEMPLATE_FIELDS = ['name', 'subject', 'body', 'isHTML', 'category'] as con // in the browser's list otherwise (lib/templates/personal-templates.ts). const personal = () => personalTemplatesFor(); +// The admin hid personal templates for the active account +// (`personalTemplates`): plugins see none and can't make any either. +const personalHidden = () => usePolicyStore.getState().policy.restrictions?.personalTemplates?.hidden === true; + +function refuseIfHidden(method: string): void { + if (personalHidden()) throw new Error(`${method}: the administrator turned personal templates off`); +} + export function doTemplatesList(): TemplateView[] { + if (personalHidden()) return []; return personal().templates().map(templateToView); } export function doTemplatesGet(id: unknown): TemplateView | null { const templateId = readId(id, 'templates.get'); + if (personalHidden()) return null; const template = personal().templates().find((t) => t.id === templateId); return template ? templateToView(template) : null; } @@ -64,6 +75,7 @@ export function doTemplatesCreate(input: unknown): TemplateView { const method = 'templates.create'; const data = readObject(input, method, 'template', TEMPLATE_FIELDS) as TemplateInput & Record; if (data.name === undefined) throw new Error(`${method}: name is required`); + refuseIfHidden(method); const store = personal(); if (store.templates().length >= MAX_PLUGIN_TEMPLATES) { throw new Error(`${method}: there are already ${MAX_PLUGIN_TEMPLATES} templates`); @@ -86,6 +98,7 @@ export function doTemplatesUpdate(id: unknown, patch: unknown): TemplateView { const templateId = readId(id, method); const data = readObject(patch, method, 'patch', TEMPLATE_FIELDS); if (Object.keys(data).length === 0) throw new Error(`${method}: patch is empty`); + refuseIfHidden(method); const store = personal(); const existing = store.templates().find((t) => t.id === templateId); if (!existing) throw new Error(`${method}: no template with id "${templateId}"`); @@ -100,6 +113,7 @@ export function doTemplatesUpdate(id: unknown, patch: unknown): TemplateView { export function doTemplatesDelete(id: unknown): void { const method = 'templates.delete'; const templateId = readId(id, method); + refuseIfHidden(method); const store = personal(); if (!store.templates().some((t) => t.id === templateId)) { throw new Error(`${method}: no template with id "${templateId}"`);