diff --git a/app/(main)/admin/__tests__/org-templates-page.test.tsx b/app/(main)/admin/__tests__/org-templates-page.test.tsx index 22564f6d2..6abcd5bf4 100644 --- a/app/(main)/admin/__tests__/org-templates-page.test.tsx +++ b/app/(main)/admin/__tests__/org-templates-page.test.tsx @@ -117,4 +117,20 @@ describe('Organisation > Templates', () => { await waitFor(() => expect(puts).toHaveLength(1)); expect((puts[0].restrictions as Record).personalTemplates).toEqual({ hidden: true }); }); + + it('locks team templates for a group scope only', async () => { + stored = JSON.parse(JSON.stringify({ + ...POLICY, + scopes: [...POLICY.scopes, { id: 'team', label: 'Team', match: { groups: ['org-team@example.org'] }, policy: {} }], + })); + render(); + await screen.findAllByTestId('org-template-item'); + fireEvent.click(screen.getByRole('button', { name: 'Team' })); + fireEvent.click(screen.getByLabelText('Lock')); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + await waitFor(() => expect(puts).toHaveLength(1)); + const scopes = puts[0].scopes as Array<{ id: string; policy: { restrictions?: Record } }>; + expect(scopes.find((scope) => scope.id === 'team')?.policy.restrictions).toEqual({ teamTemplates: { locked: true } }); + expect((puts[0].restrictions as Record).teamTemplates).toBeUndefined(); + }); }); diff --git a/components/templates/__tests__/template-team-sources.test.tsx b/components/templates/__tests__/template-team-sources.test.tsx new file mode 100644 index 000000000..98f00ae32 --- /dev/null +++ b/components/templates/__tests__/template-team-sources.test.tsx @@ -0,0 +1,129 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; + +// Team templates of a group mailbox: under the group's name in the picker, +// editable in the manager by members who may, read-only with "Set by your +// organisation" when the admin locked them (`teamTemplates`). + +const save = vi.hoisted(() => vi.fn(async (..._args: unknown[]): Promise => 'ok')); +vi.mock('@/lib/templates/team-templates', () => ({ + saveTeamTemplate: save, + deleteTeamTemplate: vi.fn(async () => 'ok'), + refreshTeamTemplates: vi.fn(), +})); + +import { TemplatePicker } from '../template-picker'; +import { TemplateManagerModal } from '../template-manager-modal'; +import { DEFAULT_POLICY, type SettingsPolicy } from '@/lib/admin/types'; +import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy'; +import { resolvePolicy } from '@/lib/admin/policy-scopes'; +import { resetAccountSubjects } from '@/lib/org/account-subjects'; +import { usePolicyStore } from '@/stores/policy-store'; +import { useAuthStore } from '@/stores/auth-store'; +import { useTemplateStore } from '@/stores/template-store'; +import { useAccountTemplatesStore } from '@/stores/account-templates-store'; +import type { EmailTemplate } from '@/lib/template-types'; + +const tpl = (id: string, name: string): EmailTemplate => ({ + id, name, subject: '', body: `${name} body`, category: '', isFavorite: false, + createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', +}); + +const RAW: SettingsPolicy = { + ...DEFAULT_POLICY, + organisation: { ...DEFAULT_ORGANISATION_POLICY, templates: { library: [{ id: 'org-1', name: 'Company', subject: '', body: 'x', isHTML: false, category: '' }] } }, +}; +const ALICE = { email: 'alice@example.org', groups: ['team@example.org'] }; + +function setPolicy(raw: SettingsPolicy) { + usePolicyStore.setState({ rawPolicy: raw, policy: resolvePolicy(raw, ALICE).policy, subject: ALICE }); +} + +function setTeam(mayWrite = true) { + useAccountTemplatesStore.setState({ + personal: {}, + teams: { a1: [{ accountId: 'g1', name: 'team@example.org', templates: [tpl('t1', 'Team reply')], mayWrite }] }, + active: 'a1', + }); +} + +const original = useAuthStore.getState(); + +beforeEach(() => { + save.mockClear(); + save.mockResolvedValue('ok'); + resetAccountSubjects(); + useAuthStore.setState({ activeAccountId: 'a1' } as never); + setPolicy(RAW); + useTemplateStore.setState({ templates: [tpl('mine', 'Mine')], recentTemplateIds: [], deletedTemplateIds: {} }); + setTeam(); +}); +afterEach(() => { + resetAccountSubjects(); + useAuthStore.setState({ activeAccountId: original.activeAccountId } as never); +}); + +describe('team templates in the picker', () => { + it('heads them with the group\'s name between the user\'s own and the organisation\'s', () => { + render( {}} onSelect={() => {}} />); + const headings = screen.getAllByRole('heading', { level: 2 }).map((h) => h.textContent).slice(1); + expect(headings).toEqual(['section_personal', 'team@example.org', 'section_organisation']); + expect(within(screen.getByTestId('template-source-team')).getByText('Team reply')).toBeInTheDocument(); + }); + + it('shows a team template the admin locked, which members still use', () => { + setPolicy({ ...RAW, restrictions: { teamTemplates: { locked: true } } }); + const onSelect = vi.fn(); + render( {}} onSelect={onSelect} />); + fireEvent.click(screen.getByText('Team reply')); + expect(onSelect.mock.calls[0][0]).toMatchObject({ id: 't1', body: 'Team reply body' }); + }); +}); + +describe('team templates in the manager', () => { + it('adds a template to the group mailbox', async () => { + render( {}} />); + fireEvent.click(screen.getByTestId('template-source-tab-team')); + expect(screen.getByTestId('template-source-note')).toHaveTextContent('team_note'); + fireEvent.click(screen.getByRole('button', { name: 'add' })); + // A team template has no sending identity and no favourite. + expect(screen.queryByText('favorite')).toBeNull(); + fireEvent.change(screen.getByPlaceholderText('name_placeholder'), { target: { value: 'For the team' } }); + fireEvent.click(screen.getByRole('button', { name: 'create' })); + await waitFor(() => expect(save).toHaveBeenCalledTimes(1)); + expect(save.mock.calls[0][0]).toBe('a1'); + expect(save.mock.calls[0][1]).toBe('g1'); + expect(save.mock.calls[0][2]).toMatchObject({ name: 'For the team' }); + }); + + it('keeps what the user typed when the server refuses it', async () => { + save.mockResolvedValue('too-large'); + render( {}} />); + fireEvent.click(screen.getByTestId('template-source-tab-team')); + fireEvent.click(screen.getByRole('button', { name: 'add' })); + fireEvent.change(screen.getByPlaceholderText('name_placeholder'), { target: { value: 'Too long' } }); + fireEvent.click(screen.getByRole('button', { name: 'create' })); + expect(await screen.findByTestId('template-save-error')).toHaveTextContent('save_failed_too_large'); + expect((screen.getByPlaceholderText('name_placeholder') as HTMLInputElement).value).toBe('Too long'); + }); + + it('is read-only with "Set by your organisation" when the admin locked team templates', () => { + setPolicy({ ...RAW, restrictions: { teamTemplates: { locked: true } } }); + render( {}} />); + fireEvent.click(screen.getByTestId('template-source-tab-team')); + expect(screen.getByTestId('template-source-note').querySelector('[data-org-locked]')).not.toBeNull(); + expect(screen.queryByRole('button', { name: 'add' })).toBeNull(); + const row = screen.getByTestId('template-row'); + expect(within(row).queryByRole('button', { name: 'edit' })).toBeNull(); + expect(within(row).queryByRole('button', { name: 'confirm_delete' })).toBeNull(); + expect(within(row).getByRole('button', { name: 'copy_to_personal' })).toBeInTheDocument(); + }); + + it('is read-only for a member without write rights, and says so', () => { + setTeam(false); + render( {}} />); + fireEvent.click(screen.getByTestId('template-source-tab-team')); + expect(screen.getByTestId('template-source-note')).toHaveTextContent('team_read_only'); + expect(screen.queryByRole('button', { name: 'add' })).toBeNull(); + }); +}); diff --git a/components/templates/account-template-sync.tsx b/components/templates/account-template-sync.tsx index a99d78290..1bd457272 100644 --- a/components/templates/account-template-sync.tsx +++ b/components/templates/account-template-sync.tsx @@ -4,12 +4,13 @@ import { useEffect } from 'react'; import { useAuthStore } from '@/stores/auth-store'; import { useAccountTemplatesStore } from '@/stores/account-templates-store'; import { refreshAccountTemplates } from '@/lib/templates/personal-templates'; +import { refreshTeamTemplates } from '@/lib/templates/team-templates'; /** - * Syncs the active account's templates with the account (Stalwart 1.0 - * metadata) when it signs in or becomes active, and again when the tab - * comes back into view. Renders nothing; does nothing on servers without - * metadata. + * Syncs the active account's templates with the account and reads its + * team templates (Stalwart 1.0 metadata) when it signs in or becomes + * active, and again when the tab comes back into view. Renders nothing; + * does nothing on servers without metadata. */ export function AccountTemplateSync() { const activeAccountId = useAuthStore((s) => s.activeAccountId); @@ -23,8 +24,11 @@ export function AccountTemplateSync() { useEffect(() => { if (!isAuthenticated || !activeAccountId || !client) return; refreshAccountTemplates(activeAccountId, 0); + refreshTeamTemplates(activeAccountId, 0); const onVisible = () => { - if (document.visibilityState === 'visible') refreshAccountTemplates(activeAccountId, 60_000); + if (document.visibilityState !== 'visible') return; + refreshAccountTemplates(activeAccountId, 60_000); + refreshTeamTemplates(activeAccountId, 60_000); }; document.addEventListener('visibilitychange', onVisible); return () => document.removeEventListener('visibilitychange', onVisible); diff --git a/components/templates/template-manager-modal.tsx b/components/templates/template-manager-modal.tsx index abb06ca8a..01eb46959 100644 --- a/components/templates/template-manager-modal.tsx +++ b/components/templates/template-manager-modal.tsx @@ -14,7 +14,8 @@ import { useTemplateSourceActions, type TemplateSaveResult } from '@/hooks/use-t import { OrgLockedNote } from '@/components/settings/org-locked-note'; import { filterTemplates } from '@/lib/template-utils'; import { PERSONAL_SOURCE, type TemplateSource } from '@/lib/templates/template-sources'; -import { refreshAccountTemplates } from '@/lib/templates/personal-templates'; +import { refreshAccountTemplates, resolveAccountId } from '@/lib/templates/personal-templates'; +import { refreshTeamTemplates } from '@/lib/templates/team-templates'; import type { EmailTemplate } from '@/lib/template-types'; interface TemplateManagerModalProps { @@ -53,7 +54,10 @@ export function TemplateManagerModal({ isOpen, onClose, accountId }: TemplateMan }; useEffect(() => { - if (isOpen) refreshAccountTemplates(accountId); + if (!isOpen) return; + refreshAccountTemplates(accountId); + const id = resolveAccountId(accountId); + if (id) refreshTeamTemplates(id); }, [isOpen, accountId]); const modalRef = useFocusTrap({ diff --git a/components/templates/template-picker.tsx b/components/templates/template-picker.tsx index 0eb766aae..dd1cb2291 100644 --- a/components/templates/template-picker.tsx +++ b/components/templates/template-picker.tsx @@ -15,7 +15,8 @@ import { getAutoFilledPlaceholders, } from '@/lib/template-utils'; import type { TemplateSource } from '@/lib/templates/template-sources'; -import { refreshAccountTemplates } from '@/lib/templates/personal-templates'; +import { refreshAccountTemplates, resolveAccountId } from '@/lib/templates/personal-templates'; +import { refreshTeamTemplates } from '@/lib/templates/team-templates'; import { PlaceholderFillModal } from './placeholder-fill-modal'; import type { EmailTemplate } from '@/lib/template-types'; @@ -47,7 +48,10 @@ export function TemplatePicker({ isOpen, onClose, onSelect, accountId }: Templat // What another device or a team member changed shows up when the picker opens. useEffect(() => { - if (isOpen) refreshAccountTemplates(accountId); + if (!isOpen) return; + refreshAccountTemplates(accountId); + const id = resolveAccountId(accountId); + if (id) refreshTeamTemplates(id); }, [isOpen, accountId]); const modalRef = useFocusTrap({ diff --git a/hooks/use-template-source-actions.ts b/hooks/use-template-source-actions.ts index d1a689b5c..7ff499221 100644 --- a/hooks/use-template-source-actions.ts +++ b/hooks/use-template-source-actions.ts @@ -2,6 +2,8 @@ import { useMemo } from 'react'; import type { EmailTemplate } from '@/lib/template-types'; import type { TemplateSource } from '@/lib/templates/template-sources'; import { personalTemplatesFor, resolveAccountId } from '@/lib/templates/personal-templates'; +import { deleteTeamTemplate, saveTeamTemplate } from '@/lib/templates/team-templates'; +import { generateUUID } from '@/lib/utils'; import { useAuthStore } from '@/stores/auth-store'; import { useAccountTemplatesStore, type LocalOnlyReason } from '@/stores/account-templates-store'; @@ -41,18 +43,61 @@ export function useTemplateSourceActions(accountId?: string | null): TemplateSou const localOnly = partition?.localOnly ?? NO_LOCAL_ONLY; return useMemo(() => { const ops = () => personalTemplatesFor(resolveAccountId(resolvedId)); - const personalOnly = (fn: (...args: A) => void) => - async (source: TemplateSource, ...args: A): Promise => { - if (source.kind !== 'personal' || !source.editable) return 'forbidden'; - fn(...args); - return 'ok'; - }; + const account = () => resolveAccountId(resolvedId); + const group = (source: TemplateSource) => source.key.slice('team:'.length); + const now = () => new Date().toISOString(); + const saveTeam = async (source: TemplateSource, template: EmailTemplate): Promise => { + const id = account(); + return id ? saveTeamTemplate(id, group(source), template) : 'failed'; + }; + const find = (source: TemplateSource, id: string) => source.templates.find((t) => t.id === id); return { - add: personalOnly((data: TemplateData) => { ops().add(data); }), - update: personalOnly((id: string, data: TemplateData) => ops().update(id, data)), - remove: personalOnly((id: string) => ops().remove(id)), - duplicate: personalOnly((id: string, suffix: string) => { ops().duplicate(id, suffix); }), - toggleFavorite: personalOnly((id: string) => ops().toggleFavorite(id)), + add: async (source, data) => { + if (!source.editable) return 'forbidden'; + if (source.kind === 'personal') { + ops().add(data); + return 'ok'; + } + if (source.kind !== 'team') return 'forbidden'; + const at = now(); + return saveTeam(source, { ...data, id: generateUUID(), createdAt: at, updatedAt: at }); + }, + update: async (source, id, data) => { + if (!source.editable) return 'forbidden'; + if (source.kind === 'personal') { + ops().update(id, data); + return 'ok'; + } + const existing = find(source, id); + if (source.kind !== 'team' || !existing) return 'forbidden'; + return saveTeam(source, { ...existing, ...data, id, updatedAt: now() }); + }, + remove: async (source, id) => { + if (!source.editable) return 'forbidden'; + if (source.kind === 'personal') { + ops().remove(id); + return 'ok'; + } + const accountId = account(); + if (source.kind !== 'team' || !accountId) return 'forbidden'; + return deleteTeamTemplate(accountId, group(source), id); + }, + duplicate: async (source, id, suffix) => { + if (!source.editable) return 'forbidden'; + if (source.kind === 'personal') { + ops().duplicate(id, suffix); + return 'ok'; + } + const existing = find(source, id); + if (source.kind !== 'team' || !existing) return 'forbidden'; + const at = now(); + return saveTeam(source, { ...existing, id: generateUUID(), name: `${existing.name} ${suffix}`, createdAt: at, updatedAt: at }); + }, + toggleFavorite: async (source, id) => { + if (source.kind !== 'personal' || !source.editable) return 'forbidden'; + ops().toggleFavorite(id); + return 'ok'; + }, copyToPersonal: async (template) => { ops().add(copyOf(template)); return 'ok'; diff --git a/hooks/use-template-sources.ts b/hooks/use-template-sources.ts index 08c69fe7c..108f2a4b6 100644 --- a/hooks/use-template-sources.ts +++ b/hooks/use-template-sources.ts @@ -5,6 +5,7 @@ import { ORGANISATION_SOURCE, PERSONAL_SOURCE, byName, + teamSourceKey, type TemplateSource, } from '@/lib/templates/template-sources'; import { useTemplateStore } from '@/stores/template-store'; @@ -20,9 +21,10 @@ export interface TemplateSources { /** * The templates a user can pick when writing from the signed-in account - * `accountId` (the active one when left out): their own, and the admin - * library of that account's policy, so a composer sending from another - * account shows that account's own and organisation templates. + * `accountId` (the active one when left out): their own, those of the + * account's group mailboxes, and the admin library of the account's + * policy, so a composer sending from another account shows that + * account's templates. */ export function useTemplateSources(accountId?: string | null): TemplateSources { const policy = useAccountPolicy(accountId); @@ -32,16 +34,29 @@ export function useTemplateSources(accountId?: string | null): TemplateSources { const accountCopy = useAccountTemplatesStore((s) => (resolvedId ? s.personal[resolvedId]?.templates : undefined)); const browserList = useTemplateStore((s) => s.templates); const personal = accountCopy ?? browserList; + const teams = useAccountTemplatesStore((s) => (resolvedId ? s.teams[resolvedId] : undefined)); const personalHidden = policy.restrictions?.personalTemplates?.hidden === true; + // `teamTemplates` locked: members can use them, not change them. + const teamsLocked = policy.restrictions?.teamTemplates?.locked === true; return useMemo(() => { const sources: TemplateSource[] = []; if (!personalHidden) { sources.push({ key: PERSONAL_SOURCE, kind: 'personal', templates: personal, editable: true, locked: false }); } + for (const team of teams ?? []) { + sources.push({ + key: teamSourceKey(team.accountId), + kind: 'team', + name: team.name, + templates: byName(team.templates), + editable: team.mayWrite && !teamsLocked, + locked: teamsLocked, + }); + } const organisation = orgLibraryTemplates(policy); if (organisation.length > 0) { sources.push({ key: ORGANISATION_SOURCE, kind: 'organisation', templates: byName(organisation), editable: false, locked: false }); } return { sources, personalHidden }; - }, [policy, personal, personalHidden]); + }, [policy, personal, personalHidden, teams, teamsLocked]); } diff --git a/lib/__tests__/team-templates.test.ts b/lib/__tests__/team-templates.test.ts new file mode 100644 index 000000000..d6012fdce --- /dev/null +++ b/lib/__tests__/team-templates.test.ts @@ -0,0 +1,138 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { JMAPClient } from '../jmap/client'; +import { deleteTeamTemplate, groupAccounts, loadTeamTemplates, saveTeamTemplate } from '../templates/team-templates'; +import { useAccountTemplatesStore } from '@/stores/account-templates-store'; +import { useAuthStore } from '@/stores/auth-store'; +import type { EmailTemplate } from '../template-types'; +import recorded from './fixtures/stalwart-1.0.0-template-metadata.json'; + +// Team templates live in the Inbox metadata of a group account. Only group +// principals count: a mailbox another person shared is non-personal too, +// and stays out. The answers use the envelopes recorded on stw-rel. + +const META = { 'urn:ietf:params:jmap:metadata': { dataTypes: { Mailbox: { namespaces: [], supportsVendorNamespaces: true, supportsPrivate: true, maxDepth: 8 } } } }; +const MAIL = { 'urn:ietf:params:jmap:mail': {} }; + +function session() { + return { + capabilities: { 'urn:ietf:params:jmap:core': {}, 'urn:ietf:params:jmap:mail': {}, 'urn:ietf:params:jmap:metadata': {}, 'urn:ietf:params:jmap:principals': {} }, + accounts: { + bj: { name: 'org-alice@example.org', isPersonal: true, isReadOnly: false, accountCapabilities: { ...MAIL, ...META } }, + bi: { name: 'org-team@example.org', isPersonal: false, isReadOnly: false, accountCapabilities: { ...MAIL, ...META } }, + bk: { name: 'org-carol@example.org', isPersonal: false, isReadOnly: false, accountCapabilities: { ...MAIL, ...META } }, + }, + primaryAccounts: { 'urn:ietf:params:jmap:mail': 'bj' }, + apiUrl: 'https://mail.example.com/jmap/api', + downloadUrl: 'https://mail.example.com/jmap/download/{accountId}/{blobId}/{name}', + uploadUrl: 'https://mail.example.com/jmap/upload/{accountId}/', + eventSourceUrl: '', + }; +} + +const json = (body: unknown) => new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } }); +const clone = (v: T): T => JSON.parse(JSON.stringify(v)); +type Call = [string, Record, string]; + +let metadata: Record>; +let maySetMetadata: boolean; +let requests: Call[][]; +let refuse: string | null; + +function answer(body: { methodCalls: Call[] }): Response { + requests.push(body.methodCalls); + const [method, args] = body.methodCalls[body.methodCalls.length - 1]; + const accountId = args.accountId as string; + if (method === 'Principal/get') { + return json({ methodResponses: [['Principal/get', { list: [{ id: 'bi', type: 'group' }, { id: 'bk', type: 'individual' }], notFound: [] }, '0']] }); + } + if (method === 'Mailbox/get' && !args['#ids']) { + const rights = { mayReadItems: true, mayAddItems: true, mayRemoveItems: true, maySetSeen: true, maySetKeywords: true, mayCreateChild: true, mayRename: true, maySubmit: true, mayDelete: true, mayShare: true, maySetMetadata }; + return json({ methodResponses: [['Mailbox/get', { accountId, list: [{ id: 'a', name: 'Inbox', role: 'inbox', myRights: rights }] }, '0']] }); + } + if (method === 'Mailbox/get') { + const out = clone(recorded.getEmpty); + (out.methodResponses[1][1] as { list: Array<{ metadata: unknown }> }).list[0].metadata = clone(metadata[accountId] ?? {}); + return json(out); + } + if (method === 'Mailbox/set') { + if (refuse) return json({ methodResponses: [['Mailbox/set', { notUpdated: { a: { type: 'forbidden', description: refuse } } }, '0']] }); + const [path, value] = Object.entries((args.update as Record>).a)[0]; + const namespace = path.replace(/^metadata\//, ''); + metadata[accountId] = { ...metadata[accountId] }; + if (value === null) delete metadata[accountId][namespace]; + else metadata[accountId][namespace] = value; + return json(clone(recorded.setOne)); + } + throw new Error(`unexpected ${method}`); +} + +const tpl = (id: string, name: string, extra: Partial = {}): EmailTemplate => ({ + id, name, subject: '', body: `${name} body`, isHTML: false, category: '', isFavorite: false, + createdAt: '2026-10-01T00:00:00.000Z', updatedAt: '2026-10-01T00:00:00.000Z', ...extra, +}); + +let client: JMAPClient; +const original = useAuthStore.getState(); + +beforeEach(async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + metadata = { + bi: { 'org.bulwarkmail.template.t1': tpl('t1', 'Team reply') }, + bk: { 'org.bulwarkmail.template.c1': tpl('c1', 'Carol’s own') }, + }; + maySetMetadata = true; + refuse = null; + requests = []; + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + fetchSpy.mockResolvedValueOnce(json(session())); + client = new JMAPClient('https://mail.example.com', 'org-alice@example.org', 'pw'); + await client.connect(); + fetchSpy.mockReset(); + fetchSpy.mockImplementation(async (_url: RequestInfo | URL, init?: RequestInit) => answer(JSON.parse(String(init?.body ?? '{}')))); + useAccountTemplatesStore.setState({ personal: {}, teams: {}, active: null }); + useAuthStore.setState({ activeAccountId: 'alice@mail', getClientForAccount: (id: string) => (id === 'alice@mail' ? client : undefined) } as never); +}); +afterEach(() => { + vi.restoreAllMocks(); + useAuthStore.setState({ activeAccountId: original.activeAccountId, getClientForAccount: original.getClientForAccount } as never); +}); + +describe('team templates', () => { + it('reads the group mailbox\'s templates, not those of a mailbox a person shared', async () => { + expect(await groupAccounts(client)).toEqual([{ id: 'bi', name: 'org-team@example.org' }]); + const teams = await loadTeamTemplates('alice@mail', client); + expect(teams).toEqual([{ accountId: 'bi', name: 'org-team@example.org', templates: [tpl('t1', 'Team reply')], mayWrite: true }]); + expect(useAccountTemplatesStore.getState().teams['alice@mail']).toEqual(teams); + expect(requests.flat().some(([, args]) => args.accountId === 'bk' && args['#ids'])).toBe(false); + }); + + it('is read-only for a member without the right to set the Inbox\'s metadata', async () => { + maySetMetadata = false; + const [team] = await loadTeamTemplates('alice@mail', client); + expect(team.mayWrite).toBe(false); + }); + + it('saves into the group\'s Inbox without the member\'s identity or favourite, and deletes', async () => { + await loadTeamTemplates('alice@mail', client); + const result = await saveTeamTemplate('alice@mail', 'bi', tpl('t2', 'Offer', { identityId: 'alice-identity', isFavorite: true })); + expect(result).toBe('ok'); + const saved = metadata.bi['org.bulwarkmail.template.t2'] as EmailTemplate; + expect(saved.name).toBe('Offer'); + expect(saved.identityId).toBeUndefined(); + expect(saved.isFavorite).toBe(false); + expect(useAccountTemplatesStore.getState().teams['alice@mail'][0].templates.map((t) => t.id).sort()).toEqual(['t1', 't2']); + + expect(await deleteTeamTemplate('alice@mail', 'bi', 't1')).toBe('ok'); + expect(metadata.bi['org.bulwarkmail.template.t1']).toBeUndefined(); + expect(useAccountTemplatesStore.getState().teams['alice@mail'][0].templates.map((t) => t.id)).toEqual(['t2']); + }); + + it('says why the server refused a change', async () => { + await loadTeamTemplates('alice@mail', client); + expect(await saveTeamTemplate('alice@mail', 'bi', tpl('t3', 'Huge', { body: 'x'.repeat(9000) }))).toBe('too-large'); + refuse = 'You are not allowed to modify this mailbox.'; + expect(await saveTeamTemplate('alice@mail', 'bi', tpl('t4', 'Small'))).toBe('forbidden'); + expect(metadata.bi['org.bulwarkmail.template.t4']).toBeUndefined(); + }); +}); diff --git a/lib/admin/restriction-keys.ts b/lib/admin/restriction-keys.ts index 81065b89a..fa84ca1bb 100644 --- a/lib/admin/restriction-keys.ts +++ b/lib/admin/restriction-keys.ts @@ -60,6 +60,15 @@ export const RESTRICTION_KEYS: readonly RestrictionKeyDef[] = [ page: 'org-templates', modes: ['hidden'], }, + { + key: 'teamTemplates', + label: 'Team templates', + description: 'Lock: members of a group mailbox can use its templates but not add, change or delete them.', + category: 'Composer', + type: 'boolean', + page: 'org-templates', + modes: ['locked'], + }, ]; export function restrictionKeysForPage(page: AdminPageId): RestrictionKeyDef[] { diff --git a/lib/templates/team-templates.ts b/lib/templates/team-templates.ts new file mode 100644 index 000000000..d6d2357bd --- /dev/null +++ b/lib/templates/team-templates.ts @@ -0,0 +1,137 @@ +/** + * Team templates: kept in the Inbox metadata of a group account + * (Stalwart v1.0.0 and later), one namespace per template like personal + * ones (lib/templates/metadata-templates.ts). Every member sees them; a + * member whose Inbox rights include setting metadata may change them, + * unless the admin locked them (`teamTemplates`). There is no copy in the + * browser: the server copy is read when an account becomes active and + * when the picker or the template manager opens, and written at once. + * + * Only group principals count, not a mailbox another person shared + * (facts §5); on servers without metadata there are no team templates. + */ + +import type { IJMAPClient } from '@/lib/jmap/client-interface'; +import { MetadataTooLargeError } from '@/lib/jmap/bulwark-metadata'; +import type { EmailTemplate } from '@/lib/template-types'; +import { debug } from '@/lib/debug'; +import { useAuthStore } from '@/stores/auth-store'; +import { useAccountTemplatesStore, type TeamTemplates } from '@/stores/account-templates-store'; +import { hasMetadata, type MetadataClient } from './account-template-sync'; +import { isTooLargeForMetadata, templateKey, templateValue, templatesFromMetadata } from './metadata-templates'; + +type TeamClient = MetadataClient & Partial>; + +/** What a change came to; 'ok' or why it was refused. */ +export type TeamSaveResult = 'ok' | 'too-large' | 'no-room' | 'forbidden' | 'failed'; + +/** + * The group accounts in a client's session: non-personal accounts whose + * principal is a group. When the server won't say, none. + */ +export async function groupAccounts(client: TeamClient): Promise> { + if (typeof client.getSharedAccounts !== 'function' || typeof client.getPrincipalTypes !== 'function') return []; + const shared = client.getSharedAccounts() + .filter((account) => !account.isPrimary && client.getAccountInfo?.(account.id)?.isPersonal === false); + if (shared.length === 0) return []; + const types = await client.getPrincipalTypes(shared.map((account) => account.id)).catch(() => null); + if (!types) return []; + return shared + .filter((account) => types[account.id] === 'group') + .map((account) => ({ id: account.id, name: account.name })); +} + +/** Whether the member may change the group Inbox's metadata (myRights.maySetMetadata on v1.0.0). */ +async function inboxMayWrite(client: TeamClient, accountId: string): Promise { + if (client.getAccountInfo?.(accountId)?.isReadOnly) return false; + if (typeof client.getMailboxes !== 'function') return true; + try { + const inbox = (await client.getMailboxes(accountId)).find((mailbox) => mailbox.role === 'inbox'); + const rights = inbox?.myRights as unknown as Record | undefined; + // A server that doesn't name the right refuses the write itself. + return rights?.maySetMetadata !== false; + } catch { + return false; + } +} + +/** Reads the team templates of every group mailbox of the signed-in account `localAccountId`. */ +export async function loadTeamTemplates(localAccountId: string, client: TeamClient): Promise { + const teams: TeamTemplates[] = []; + for (const group of await groupAccounts(client)) { + if (!hasMetadata(client, group.id)) continue; + const entries = await client.getBulwarkMetadata(group.id); + if (!entries) continue; + teams.push({ + accountId: group.id, + name: group.name, + templates: Object.values(templatesFromMetadata(entries)), + mayWrite: await inboxMayWrite(client, group.id), + }); + } + useAccountTemplatesStore.getState().setTeams(localAccountId, teams); + return teams; +} + +function clientFor(localAccountId: string): TeamClient | null { + const auth = useAuthStore.getState(); + const client = auth.getClientForAccount?.(localAccountId) ?? (localAccountId === auth.activeAccountId ? auth.client : null); + return (client as unknown as TeamClient | null) ?? null; +} + +const lastLoad = new Map(); + +/** Read the team templates again when the last read is older than `maxAgeMs`. */ +export function refreshTeamTemplates(localAccountId: string, maxAgeMs = 15_000): void { + const last = lastLoad.get(localAccountId) ?? 0; + if (Date.now() - last < maxAgeMs) return; + const client = clientFor(localAccountId); + if (!client || typeof client.getAccountId !== 'function') return; + lastLoad.set(localAccountId, Date.now()); + void loadTeamTemplates(localAccountId, client).catch((error) => { + debug.warn('templates', 'Could not read team templates:', error); + }); +} + +function patchTeam(localAccountId: string, groupAccountId: string, change: (templates: EmailTemplate[]) => EmailTemplate[]) { + const store = useAccountTemplatesStore.getState(); + const teams = store.teams[localAccountId] ?? []; + store.setTeams(localAccountId, teams.map((team) => (team.accountId === groupAccountId ? { ...team, templates: change(team.templates) } : team))); +} + +function refusal(error: unknown, template: EmailTemplate | null): TeamSaveResult { + if (error instanceof MetadataTooLargeError) return template && isTooLargeForMetadata(template) ? 'too-large' : 'no-room'; + const message = error instanceof Error ? error.message : ''; + if (/forbidden|not allowed|permission/i.test(message)) return 'forbidden'; + debug.warn('templates', 'Could not change a team template:', error); + return 'failed'; +} + +/** Saves a team template (new or changed) in the group's Inbox. */ +export async function saveTeamTemplate(localAccountId: string, groupAccountId: string, template: EmailTemplate): Promise { + const client = clientFor(localAccountId); + if (!client || !hasMetadata(client, groupAccountId)) return 'failed'; + // Identities are each member's own; a favourite is a personal choice. + const shared: EmailTemplate = { ...template, identityId: undefined, isFavorite: false }; + if (isTooLargeForMetadata(shared)) return 'too-large'; + try { + await client.setBulwarkMetadata(groupAccountId, templateKey(shared.id), templateValue(shared)); + } catch (error) { + return refusal(error, shared); + } + patchTeam(localAccountId, groupAccountId, (templates) => [...templates.filter((t) => t.id !== shared.id), shared]); + return 'ok'; +} + +/** Removes a team template from the group's Inbox. */ +export async function deleteTeamTemplate(localAccountId: string, groupAccountId: string, id: string): Promise { + const client = clientFor(localAccountId); + if (!client || !hasMetadata(client, groupAccountId)) return 'failed'; + try { + await client.setBulwarkMetadata(groupAccountId, templateKey(id), null); + } catch (error) { + return refusal(error, null); + } + patchTeam(localAccountId, groupAccountId, (templates) => templates.filter((t) => t.id !== id)); + return 'ok'; +}