mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
feat: share team templates in a group mailbox
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
Reference in New Issue
Block a user