feat: share team templates in a group mailbox

This commit is contained in:
Linus Rath
2026-10-28 06:24:46 +01:00
parent 47c47b4f08
commit 87e41f0403
10 changed files with 525 additions and 24 deletions
@@ -117,4 +117,20 @@ describe('Organisation > Templates', () => {
await waitFor(() => expect(puts).toHaveLength(1));
expect((puts[0].restrictions as Record<string, unknown>).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(<OrgTemplatesPage />);
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<string, unknown> } }>;
expect(scopes.find((scope) => scope.id === 'team')?.policy.restrictions).toEqual({ teamTemplates: { locked: true } });
expect((puts[0].restrictions as Record<string, unknown>).teamTemplates).toBeUndefined();
});
});
@@ -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<string> => '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(<TemplatePicker isOpen onClose={() => {}} 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(<TemplatePicker isOpen onClose={() => {}} 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(<TemplateManagerModal isOpen onClose={() => {}} />);
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(<TemplateManagerModal isOpen onClose={() => {}} />);
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(<TemplateManagerModal isOpen onClose={() => {}} />);
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(<TemplateManagerModal isOpen onClose={() => {}} />);
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();
});
});
@@ -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);
@@ -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({
+6 -2
View File
@@ -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({
+56 -11
View File
@@ -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 = <A extends unknown[]>(fn: (...args: A) => void) =>
async (source: TemplateSource, ...args: A): Promise<TemplateSaveResult> => {
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<TemplateSaveResult> => {
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';
+19 -4
View File
@@ -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]);
}
+138
View File
@@ -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 = <T,>(v: T): T => JSON.parse(JSON.stringify(v));
type Call = [string, Record<string, unknown>, string];
let metadata: Record<string, Record<string, unknown>>;
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<string, Record<string, unknown>>).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> = {}): 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();
});
});
+9
View File
@@ -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[] {
+137
View File
@@ -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<Pick<IJMAPClient, 'getSharedAccounts' | 'getAccountInfo' | 'getPrincipalTypes' | 'getMailboxes'>>;
/** 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<Array<{ id: string; name: string }>> {
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<boolean> {
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<string, boolean> | 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<TeamTemplates[]> {
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<string, number>();
/** 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<TeamSaveResult> {
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<TeamSaveResult> {
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';
}