mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
Merge branch 'feat/org-templates' into feat/org-features
This commit is contained in:
@@ -21,6 +21,7 @@ import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
|
||||
import { PushNotificationPrompt } from "@/components/push-notification-prompt";
|
||||
import { AccountArchiveRestorePrompt } from "@/components/account-archive-prompt";
|
||||
import { SessionTimeoutGuard } from "@/components/session/session-timeout-guard";
|
||||
import { AccountTemplateSync } from "@/components/templates/account-template-sync";
|
||||
import { IS_LITE } from "@/lib/lite";
|
||||
import { setRequestLocale } from "next-intl/server";
|
||||
import { locales } from "@/i18n/routing";
|
||||
@@ -64,6 +65,7 @@ export default async function LocaleLayout({
|
||||
<ImpersonationReconciler />
|
||||
<PushBindingsHost />
|
||||
<SessionTimeoutGuard />
|
||||
<AccountTemplateSync />
|
||||
{children}
|
||||
<PluginDialogHost />
|
||||
<ExternalRecipientConfirmHost />
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
|
||||
// Organisation > Templates: the admin library per scope (a scope adds to
|
||||
// Everyone's by id) and the restrictions on users' own and team templates,
|
||||
// saved in one PUT.
|
||||
|
||||
const puts: Array<Record<string, unknown>> = [];
|
||||
let stored: Record<string, unknown> = {};
|
||||
vi.mock('@/lib/browser-navigation', () => ({
|
||||
apiFetch: vi.fn(async (_url: string, init?: RequestInit) => {
|
||||
if (init?.method === 'PUT') {
|
||||
const body = JSON.parse(String(init.body));
|
||||
puts.push(body);
|
||||
stored = body;
|
||||
return new Response('{"ok":true}');
|
||||
}
|
||||
return new Response(JSON.stringify(stored));
|
||||
}),
|
||||
withBasePath: (p: string) => p,
|
||||
getPathPrefix: () => '',
|
||||
}));
|
||||
|
||||
import { OrgTemplatesPage } from '../_pages/org-templates';
|
||||
import { EVERYONE, useOrgScopeStore } from '@/components/admin/org/use-org-policy-draft';
|
||||
|
||||
const WELCOME = { id: 'welcome', name: 'Welcome', subject: 'Hello', body: '<p>Hi <b>there</b></p>', isHTML: true, category: 'Sales' };
|
||||
|
||||
const POLICY = {
|
||||
restrictions: {},
|
||||
features: {}, defaults: {}, themePolicy: {},
|
||||
forceEnabledPlugins: [], approvedPlugins: [], forceEnabledThemes: [],
|
||||
organisation: { templates: { library: [WELCOME] } },
|
||||
scopes: [{ id: 'two', label: 'Org two', match: { domains: ['org-two.test'] }, policy: {} }],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
puts.length = 0;
|
||||
stored = JSON.parse(JSON.stringify(POLICY));
|
||||
useOrgScopeStore.setState({ scopeId: EVERYONE });
|
||||
});
|
||||
|
||||
describe('Organisation > Templates', () => {
|
||||
it('edits Everyone\'s library with a preview', async () => {
|
||||
render(<OrgTemplatesPage />);
|
||||
const items = await screen.findAllByTestId('org-template-item');
|
||||
expect(items).toHaveLength(1);
|
||||
fireEvent.click(within(items[0]).getByRole('button', { name: /^Welcome/ }));
|
||||
expect(screen.getByTestId('org-template-preview').innerHTML).toContain('<b>there</b>');
|
||||
|
||||
fireEvent.click(screen.getByTestId('org-template-add'));
|
||||
const names = screen.getAllByTestId('org-template-name');
|
||||
fireEvent.change(names[1], { target: { value: 'Offer' } });
|
||||
fireEvent.change(screen.getAllByTestId('org-template-body')[1], { target: { value: 'Our offer' } });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
await waitFor(() => expect(puts).toHaveLength(1));
|
||||
const library = (puts[0].organisation as { templates: { library: Array<{ name: string; body: string }> } }).templates.library;
|
||||
expect(library.map((t) => t.name)).toEqual(['Welcome', 'Offer']);
|
||||
expect(library[1].body).toBe('Our offer');
|
||||
});
|
||||
|
||||
it('adds templates for a scope without copying Everyone\'s, and changes one of Everyone\'s for that scope only', async () => {
|
||||
render(<OrgTemplatesPage />);
|
||||
await screen.findAllByTestId('org-template-item');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Org two' }));
|
||||
|
||||
// Everyone's template is shown, not editable, and the scope starts empty.
|
||||
expect(within(screen.getByTestId('org-template-inherited')).getByText('Welcome')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('org-template-empty')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByTestId('org-template-add'));
|
||||
fireEvent.change(screen.getByTestId('org-template-name'), { target: { value: 'Two only' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /Change for this scope/ }));
|
||||
const subjects = screen.getAllByTestId('org-template-subject');
|
||||
fireEvent.click(within(screen.getAllByTestId('org-template-item')[1]).getByRole('button', { name: /^Welcome/ }));
|
||||
fireEvent.change(screen.getAllByTestId('org-template-subject')[1], { target: { value: 'Hello from two' } });
|
||||
expect(subjects).toHaveLength(1);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
await waitFor(() => expect(puts).toHaveLength(1));
|
||||
const scope = (puts[0].scopes as Array<{ policy: { organisation: { templates: { library: Array<{ id: string; name: string; subject: string }> } } } }>)[0];
|
||||
const library = scope.policy.organisation.templates.library;
|
||||
expect(library.map((t) => t.name)).toEqual(['Two only', 'Welcome']);
|
||||
expect(library[1]).toMatchObject({ id: 'welcome', subject: 'Hello from two' });
|
||||
// Everyone keeps its own.
|
||||
expect((puts[0].organisation as { templates: { library: Array<{ subject: string }> } }).templates.library[0].subject).toBe('Hello');
|
||||
});
|
||||
|
||||
it('removing the last template of a scope inherits Everyone\'s library again', async () => {
|
||||
stored = JSON.parse(JSON.stringify({
|
||||
...POLICY,
|
||||
scopes: [{ ...POLICY.scopes[0], policy: { organisation: { templates: { library: [{ ...WELCOME, id: 'x', name: 'X' }] } } } }],
|
||||
}));
|
||||
render(<OrgTemplatesPage />);
|
||||
await screen.findAllByTestId('org-template-item');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Org two' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove X' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
await waitFor(() => expect(puts).toHaveLength(1));
|
||||
const policy = (puts[0].scopes as Array<{ policy: { organisation?: Record<string, unknown> } }>)[0].policy;
|
||||
expect(policy.organisation?.templates).toBeUndefined();
|
||||
});
|
||||
|
||||
it('says that a template without a name is not saved', async () => {
|
||||
render(<OrgTemplatesPage />);
|
||||
await screen.findAllByTestId('org-template-item');
|
||||
fireEvent.click(screen.getByTestId('org-template-add'));
|
||||
expect(screen.getByText(/A template without a name is not saved/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides personal templates through the restriction row', async () => {
|
||||
render(<OrgTemplatesPage />);
|
||||
await screen.findAllByTestId('org-template-item');
|
||||
fireEvent.click(screen.getByLabelText('Hide'));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,191 @@
|
||||
'use client';
|
||||
|
||||
import { useId, useState } from 'react';
|
||||
import { ChevronDown, ChevronRight, Plus, RotateCcw, Trash2 } from '@/components/icons';
|
||||
import { SettingsSection } from '@/components/settings/settings-section';
|
||||
import { TemplateBodyPreview } from '@/components/templates/template-body-preview';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { OrgTemplate } from '@/lib/admin/organisation-policy';
|
||||
import { restrictionKeysForPage } from '@/lib/admin/restriction-keys';
|
||||
import { cn, generateUUID } from '@/lib/utils';
|
||||
import { RestrictionRow } from '../org-page';
|
||||
import type { OrgPolicyDraft } from '../use-org-policy-draft';
|
||||
|
||||
/** Organisation feature 3; filled in by its own step (local-data/org-features-plan.md). */
|
||||
export function TemplatesSection({ draft }: { draft: OrgPolicyDraft }) {
|
||||
void draft;
|
||||
const inputClass =
|
||||
'block w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground ' +
|
||||
'placeholder:text-muted-foreground hover:border-muted-foreground focus-visible:outline-none focus-visible:ring-2 ' +
|
||||
'focus-visible:ring-ring focus-visible:border-ring';
|
||||
const labelClass = 'mb-1 block text-xs font-medium text-foreground';
|
||||
|
||||
/** Same cap as the sanitiser (organisation-policy.ts): more are not saved. */
|
||||
const MAX_LIBRARY = 200;
|
||||
|
||||
function TemplateEditor({ template, onChange, onRemove }: {
|
||||
template: OrgTemplate;
|
||||
onChange: (template: OrgTemplate) => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const id = useId();
|
||||
const [open, setOpen] = useState(!template.name);
|
||||
const set = (patch: Partial<OrgTemplate>) => onChange({ ...template, ...patch });
|
||||
return (
|
||||
<SettingsSection id="library" title="Organisation library" description="Templates every user can pick in the composer.">
|
||||
<p className="text-sm text-muted-foreground">Nothing to set here yet.</p>
|
||||
</SettingsSection>
|
||||
<li className="rounded-md border border-border" data-testid="org-template-item">
|
||||
<div className="flex items-center gap-2 p-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
aria-expanded={open}
|
||||
className="flex min-w-0 flex-1 items-center gap-2 rounded px-1 py-1 text-start text-sm hover:bg-muted"
|
||||
>
|
||||
{open ? <ChevronDown className="h-4 w-4 shrink-0" /> : <ChevronRight className="h-4 w-4 shrink-0" />}
|
||||
<span className={cn('truncate font-medium', !template.name && 'text-muted-foreground')}>{template.name || 'New template'}</span>
|
||||
{template.category && <span className="max-w-[40%] shrink-0 truncate rounded-full bg-primary/10 px-2 py-0.5 text-xs text-primary">{template.category}</span>}
|
||||
</button>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={onRemove} className="h-8 shrink-0 px-2 hover:text-red-600" aria-label={`Remove ${template.name || 'template'}`}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{!template.name.trim() && (
|
||||
<p className="px-3 pb-2 text-xs text-red-600 dark:text-red-400">A template without a name is not saved. Give it one.</p>
|
||||
)}
|
||||
{open && (
|
||||
<div className="space-y-3 border-t border-border p-3">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label htmlFor={`${id}-name`} className={labelClass}>Name</label>
|
||||
<input id={`${id}-name`} data-testid="org-template-name" className={inputClass} value={template.name} maxLength={200} onChange={(e) => set({ name: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor={`${id}-category`} className={labelClass}>Category</label>
|
||||
<input id={`${id}-category`} className={inputClass} value={template.category} maxLength={100} onChange={(e) => set({ category: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor={`${id}-subject`} className={labelClass}>Subject</label>
|
||||
<input id={`${id}-subject`} data-testid="org-template-subject" className={inputClass} value={template.subject} onChange={(e) => set({ subject: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor={`${id}-body`} className={labelClass}>Text</label>
|
||||
<textarea
|
||||
id={`${id}-body`}
|
||||
data-testid="org-template-body"
|
||||
rows={6}
|
||||
className={cn(inputClass, template.isHTML && 'font-mono text-xs')}
|
||||
value={template.body}
|
||||
onChange={(e) => set({ body: e.target.value })}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Placeholders such as {'{{recipient_name}}'} work as in users' own templates: users fill them in when they pick the template.
|
||||
</p>
|
||||
</div>
|
||||
<label className="flex cursor-pointer items-center gap-2 text-sm text-foreground">
|
||||
<input type="checkbox" className="rounded border-input" checked={!!template.isHTML} onChange={(e) => set({ isHTML: e.target.checked })} />
|
||||
The text is HTML
|
||||
</label>
|
||||
<div>
|
||||
<p className={labelClass}>Preview</p>
|
||||
<div className="rounded-md border border-border bg-background p-3" data-testid="org-template-preview">
|
||||
{template.subject && <p className="mb-2 text-sm font-medium">{template.subject}</p>}
|
||||
{template.body ? <TemplateBodyPreview template={template} /> : <p className="text-sm text-muted-foreground">No text</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Organisation > Templates (feature 3): the admin's template library
|
||||
* (policy.json `organisation.templates.library`) at the selected scope, and
|
||||
* the restrictions on users' own and team templates. A scope's library adds
|
||||
* to Everyone's by id; the same id replaces Everyone's template for that
|
||||
* scope (mergeOrganisation).
|
||||
*/
|
||||
export function TemplatesSection({ draft }: { draft: OrgPolicyDraft }) {
|
||||
const library = draft.orgValue('templates', 'library');
|
||||
const inScope = !!draft.scope;
|
||||
const own: OrgTemplate[] = inScope ? (library.own ? library.value : []) : library.value;
|
||||
const everyone = library.everyone;
|
||||
const ownIds = new Set(own.map((t) => t.id));
|
||||
|
||||
const setOwn = (next: OrgTemplate[]) => {
|
||||
if (inScope && next.length === 0) draft.clearOrgValue('templates', 'library');
|
||||
else draft.setOrgValue('templates', 'library', next);
|
||||
};
|
||||
const add = () => setOwn([...own, { id: generateUUID(), name: '', subject: '', body: '', isHTML: false, category: '' }]);
|
||||
const change = (index: number, template: OrgTemplate) => setOwn(own.map((t, i) => (i === index ? template : t)));
|
||||
const remove = (index: number) => setOwn(own.filter((_, i) => i !== index));
|
||||
const replaceInScope = (template: OrgTemplate) => setOwn([...own, { ...template }]);
|
||||
|
||||
const total = new Set([...everyone.map((t) => t.id), ...own.map((t) => t.id)]).size;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSection
|
||||
id="library"
|
||||
title="Organisation library"
|
||||
description="Users see these templates under “Organisation” in the template picker and in Settings > Templates. They can use and copy them, not change them."
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{inScope && (
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-medium text-foreground">From Everyone</p>
|
||||
{everyone.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Everyone's library is empty.</p>
|
||||
) : (
|
||||
<ul className="space-y-1.5" data-testid="org-template-inherited">
|
||||
{everyone.map((template) => (
|
||||
<li key={template.id} className="flex flex-wrap items-center justify-between gap-2 rounded-md border border-dashed border-border px-3 py-2 text-sm">
|
||||
<span className="min-w-0 truncate">{template.name}</span>
|
||||
{ownIds.has(template.id) ? (
|
||||
<span className="text-xs text-muted-foreground">Changed for this scope</span>
|
||||
) : (
|
||||
<button type="button" onClick={() => replaceInScope(template)} className="inline-flex items-center gap-1 text-xs text-muted-foreground underline-offset-2 hover:underline">
|
||||
<RotateCcw className="h-3 w-3" /> Change for this scope
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<p className="mt-4 mb-1 text-sm font-medium text-foreground">For this scope</p>
|
||||
<p className="mb-2 text-xs text-muted-foreground">
|
||||
Users in this scope get Everyone's templates and these. A template changed here replaces Everyone's for this scope only.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{own.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground" data-testid="org-template-empty">
|
||||
{inScope ? 'Nothing added for this scope.' : 'No templates yet. Users only have their own.'}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-2" data-testid="org-template-list">
|
||||
{own.map((template, index) => (
|
||||
<TemplateEditor key={template.id} template={template} onChange={(t) => change(index, t)} onRemove={() => remove(index)} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button type="button" variant="outline" size="sm" onClick={add} disabled={total >= MAX_LIBRARY} data-testid="org-template-add">
|
||||
<Plus className="me-1 h-4 w-4" /> Add template
|
||||
</Button>
|
||||
{total >= MAX_LIBRARY && <span className="text-xs text-muted-foreground">A library holds at most {MAX_LIBRARY} templates.</span>}
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
id="user-templates"
|
||||
title="Personal and team templates"
|
||||
description="On Stalwart 1.0 and later, users' own templates are kept in their account, and the members of a group mailbox share its team templates. On older servers, users keep their own templates in the browser and there are no team templates."
|
||||
>
|
||||
<div>
|
||||
{restrictionKeysForPage('org-templates').map((def) => <RestrictionRow key={def.key} draft={draft} def={def} />)}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -83,7 +83,9 @@ import type { DirectoryEntry } from "@/stores/contact-store";
|
||||
import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||
import type { FileNode } from "@/lib/jmap/types";
|
||||
import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
|
||||
import { useTemplateStore, renderTemplateWithPlugins, notifyTemplateApplied } from "@/stores/template-store";
|
||||
import { renderTemplateWithPlugins, notifyTemplateApplied } from "@/stores/template-store";
|
||||
import { useTemplateSources } from "@/hooks/use-template-sources";
|
||||
import { personalTemplatesFor } from "@/lib/templates/personal-templates";
|
||||
import { notifyIdentitySelected } from "@/stores/identity-store";
|
||||
import { usePluginSignature } from "@/hooks/use-plugin-signature";
|
||||
import { useOrgSignature, useOrgSignatureIdentity } from "@/hooks/use-org-signature";
|
||||
@@ -1553,7 +1555,15 @@ export function EmailComposer({
|
||||
const addToTrustedSendersBook = useContactStore((s) => s.addToTrustedSendersBook);
|
||||
const addTrustedSender = useSettingsStore((s) => s.addTrustedSender);
|
||||
const trustedSendersAddressBook = useSettingsStore((s) => s.trustedSendersAddressBook);
|
||||
const addTemplate = useTemplateStore((s) => s.addTemplate);
|
||||
// "Save as template" keeps it with the account the message is written from.
|
||||
const addTemplate = useCallback(
|
||||
(data: Parameters<ReturnType<typeof personalTemplatesFor>['add']>[0]) => personalTemplatesFor(composingAccountId).add(data),
|
||||
[composingAccountId],
|
||||
);
|
||||
// The admin may leave users only organisation and team templates.
|
||||
const composingTemplates = useTemplateSources(composingAccountId);
|
||||
const personalTemplatesHidden = composingTemplates.personalHidden;
|
||||
const composingPersonalTemplates = composingTemplates.sources.find((source) => source.kind === 'personal')?.templates;
|
||||
// Sign/encrypt is provided by crypto plugins (S/MIME, PGP) via the
|
||||
// composer-toolbar slot + the onComposeSend hook — the host stays
|
||||
// crypto-agnostic.
|
||||
@@ -4676,9 +4686,11 @@ export function EmailComposer({
|
||||
<ComposerToolbarMenuItem icon={FileText} onSelect={() => { close(); setShowTemplatePicker(true); }}>
|
||||
{t('insert_template')}
|
||||
</ComposerToolbarMenuItem>
|
||||
<ComposerToolbarMenuItem icon={BookmarkPlus} onSelect={() => { close(); setShowSaveAsTemplate(true); }}>
|
||||
{t('save_as_template')}
|
||||
</ComposerToolbarMenuItem>
|
||||
{!personalTemplatesHidden && (
|
||||
<ComposerToolbarMenuItem icon={BookmarkPlus} onSelect={() => { close(); setShowSaveAsTemplate(true); }}>
|
||||
{t('save_as_template')}
|
||||
</ComposerToolbarMenuItem>
|
||||
)}
|
||||
</>}
|
||||
</ComposerToolbarMenu>
|
||||
)}
|
||||
@@ -4833,6 +4845,7 @@ export function EmailComposer({
|
||||
isOpen={showTemplatePicker}
|
||||
onClose={() => setShowTemplatePicker(false)}
|
||||
onSelect={handleTemplateSelect}
|
||||
accountId={composingAccountId}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -4862,6 +4875,7 @@ export function EmailComposer({
|
||||
>
|
||||
<h3 className="text-lg font-semibold text-foreground mb-4">{t('save_as_template')}</h3>
|
||||
<TemplateForm
|
||||
categorySource={composingPersonalTemplates}
|
||||
initialData={{
|
||||
subject,
|
||||
body,
|
||||
|
||||
@@ -5,7 +5,8 @@ import { useTranslations } from 'next-intl';
|
||||
import { SettingsSection } from './settings-section';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { TemplateManagerModal } from '@/components/templates/template-manager-modal';
|
||||
import { useTemplateStore } from '@/stores/template-store';
|
||||
import { personalTemplatesFor } from '@/lib/templates/personal-templates';
|
||||
import { useTemplateSources } from '@/hooks/use-template-sources';
|
||||
import { toast } from '@/stores/toast-store';
|
||||
import { debug } from '@/lib/debug';
|
||||
import {
|
||||
@@ -20,13 +21,15 @@ export function TemplateSettings() {
|
||||
const t = useTranslations('settings.templates');
|
||||
const tNotif = useTranslations('notifications');
|
||||
|
||||
const { templates, exportAllTemplates, importTemplates: storeImport } = useTemplateStore();
|
||||
const { sources, personalHidden } = useTemplateSources();
|
||||
const personal = sources.find((source) => source.kind === 'personal')?.templates ?? [];
|
||||
const total = sources.reduce((n, source) => n + source.templates.length, 0);
|
||||
|
||||
const [showManager, setShowManager] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleExport = () => {
|
||||
const json = exportAllTemplates();
|
||||
const json = personalTemplatesFor().exportAll();
|
||||
const blob = new Blob([json], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
@@ -56,7 +59,7 @@ export function TemplateSettings() {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const content = e.target?.result as string;
|
||||
const result = storeImport(content);
|
||||
const result = personalTemplatesFor().importJson(content);
|
||||
|
||||
if (result.errors.length > 0) {
|
||||
toast.error(tNotif('templates_import_errors'));
|
||||
@@ -83,7 +86,7 @@ export function TemplateSettings() {
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<FileText className="w-4 h-4" />
|
||||
<span>
|
||||
{t('count', { count: templates.length })}
|
||||
{t('count', { count: total })}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
@@ -96,13 +99,13 @@ export function TemplateSettings() {
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection id="templates-export" advanced title={t('export_import')} description={t('export_import_description')}>
|
||||
{!personalHidden && <SettingsSection id="templates-export" advanced title={t('export_import')} description={t('export_import_description')}>
|
||||
<div className="flex items-center gap-3 py-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleExport}
|
||||
disabled={templates.length === 0}
|
||||
disabled={personal.length === 0}
|
||||
>
|
||||
<Download className="w-4 h-4 me-1" />
|
||||
{t('export')}
|
||||
@@ -125,7 +128,7 @@ export function TemplateSettings() {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</SettingsSection>}
|
||||
|
||||
{showManager && (
|
||||
<TemplateManagerModal
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render } from '@testing-library/react';
|
||||
|
||||
// The driver syncs the active account's templates when its synced settings
|
||||
// have loaded, also when the load changed nothing in the browser's list:
|
||||
// only then is it known whose that list is.
|
||||
|
||||
const sync = vi.hoisted(() => ({ schedule: vi.fn(), refresh: vi.fn() }));
|
||||
vi.mock('@/lib/templates/personal-templates', () => ({
|
||||
scheduleAccountSync: sync.schedule,
|
||||
refreshAccountTemplates: sync.refresh,
|
||||
}));
|
||||
vi.mock('@/lib/templates/team-templates', () => ({ refreshTeamTemplates: vi.fn() }));
|
||||
vi.mock('@/lib/browser-navigation', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
apiFetch: vi.fn(async () => new Response(JSON.stringify({ settings: { theme: 'dark' } }), { status: 200 })),
|
||||
}));
|
||||
|
||||
import { AccountTemplateSync } from '../account-template-sync';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
|
||||
const ACCOUNT = 'carol@example.org@mail.example.com';
|
||||
const original = useAuthStore.getState();
|
||||
|
||||
beforeEach(() => {
|
||||
sync.schedule.mockClear();
|
||||
localStorage.removeItem('settings-sync-owner');
|
||||
useAuthStore.setState({ isAuthenticated: true, activeAccountId: ACCOUNT, client: {} as never });
|
||||
});
|
||||
afterEach(() => {
|
||||
useAuthStore.setState({ isAuthenticated: original.isAuthenticated, activeAccountId: original.activeAccountId, client: original.client });
|
||||
});
|
||||
|
||||
describe('AccountTemplateSync', () => {
|
||||
it('syncs the active account once its settings have loaded', async () => {
|
||||
render(<AccountTemplateSync />);
|
||||
expect(sync.schedule).not.toHaveBeenCalled();
|
||||
await useSettingsStore.getState().loadFromServer('carol@example.org', 'https://mail.example.com');
|
||||
expect(sync.schedule).toHaveBeenCalledWith(ACCOUNT);
|
||||
});
|
||||
|
||||
it('leaves another account\'s settings load alone', async () => {
|
||||
render(<AccountTemplateSync />);
|
||||
await useSettingsStore.getState().loadFromServer('dave@example.org', 'https://mail.example.com');
|
||||
expect(sync.schedule).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
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 { useTemplateStore } from '@/stores/template-store';
|
||||
import type { EmailTemplate } from '@/lib/template-types';
|
||||
import { emptyPartition, useAccountTemplatesStore } from '@/stores/account-templates-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
|
||||
// The template manager lists the organisation library read-only next to
|
||||
// the user's own templates; a template from it can be copied into the
|
||||
// user's own.
|
||||
|
||||
const own = (id: string, name: string): EmailTemplate => ({
|
||||
id, name, subject: '', 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: 'welcome', name: 'Welcome', subject: 'Hello', body: 'Welcome text', isHTML: false, category: '' }] },
|
||||
},
|
||||
};
|
||||
const ALICE = { email: 'alice@example.org', groups: [] };
|
||||
|
||||
function setPolicy(raw: SettingsPolicy) {
|
||||
usePolicyStore.setState({ rawPolicy: raw, policy: resolvePolicy(raw, ALICE).policy, subject: ALICE });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetAccountSubjects();
|
||||
setPolicy(RAW);
|
||||
useTemplateStore.setState({ templates: [own('mine', 'Mine')], recentTemplateIds: [], deletedTemplateIds: {} });
|
||||
useAccountTemplatesStore.setState({ personal: {}, teams: {}, active: null });
|
||||
});
|
||||
afterEach(() => {
|
||||
resetAccountSubjects();
|
||||
useAuthStore.setState({ activeAccountId: null } as never);
|
||||
});
|
||||
|
||||
describe('template manager sources', () => {
|
||||
it('shows the organisation library read-only, with a preview and a copy into the user\'s own', () => {
|
||||
render(<TemplateManagerModal isOpen onClose={() => {}} />);
|
||||
expect(screen.getByText('Mine')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByTestId('template-source-tab-organisation'));
|
||||
|
||||
expect(screen.getByTestId('template-source-note')).toHaveTextContent('organisation_note');
|
||||
const row = screen.getByTestId('template-row');
|
||||
expect(within(row).getByText('Welcome')).toBeInTheDocument();
|
||||
expect(within(row).queryByRole('button', { name: 'edit' })).toBeNull();
|
||||
expect(within(row).queryByRole('button', { name: 'confirm_delete' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: 'add' })).toBeNull();
|
||||
|
||||
fireEvent.click(within(row).getByRole('button', { name: 'preview' }));
|
||||
expect(screen.getByTestId('template-preview')).toHaveTextContent('Welcome text');
|
||||
|
||||
fireEvent.click(within(row).getByRole('button', { name: 'copy_to_personal' }));
|
||||
const names = useTemplateStore.getState().templates.map((t) => t.name);
|
||||
expect(names).toEqual(['Mine', 'Welcome']);
|
||||
expect(useTemplateStore.getState().templates[1].id).not.toBe('welcome');
|
||||
});
|
||||
|
||||
it('offers no personal templates and no copy when the admin hid them', () => {
|
||||
setPolicy({ ...RAW, restrictions: { personalTemplates: { hidden: true } } });
|
||||
render(<TemplateManagerModal isOpen onClose={() => {}} />);
|
||||
expect(screen.queryByTestId('template-source-tab-personal')).toBeNull();
|
||||
expect(screen.queryByText('Mine')).toBeNull();
|
||||
const row = screen.getByTestId('template-row');
|
||||
expect(within(row).getByText('Welcome')).toBeInTheDocument();
|
||||
expect(within(row).queryByRole('button', { name: 'copy_to_personal' })).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the account's templates in the account, says so, and names the ones that stay in this browser", () => {
|
||||
useAuthStore.setState({ activeAccountId: 'a1' } as never);
|
||||
useAccountTemplatesStore.setState({
|
||||
personal: { a1: { ...emptyPartition(), templates: [own('big', 'Big one')], localOnly: { big: 'too-large' } } },
|
||||
});
|
||||
render(<TemplateManagerModal isOpen onClose={() => {}} />);
|
||||
expect(screen.getByText('Big one')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Mine')).toBeNull();
|
||||
expect(screen.getByTestId('template-local-only')).toHaveTextContent('local_only_too_large');
|
||||
expect(screen.getByTestId('template-storage-note')).toHaveTextContent('personal_in_account');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'add' }));
|
||||
fireEvent.change(screen.getByPlaceholderText('name_placeholder'), { target: { value: 'New in the account' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'create' }));
|
||||
return waitFor(() => {
|
||||
expect(useAccountTemplatesStore.getState().personal.a1.templates.map((t) => t.name)).toEqual(['Big one', 'New in the account']);
|
||||
expect(useTemplateStore.getState().templates.map((t) => t.name)).toEqual(['Mine']);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { TemplatePicker } from '../template-picker';
|
||||
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 type { EmailTemplate } from '@/lib/template-types';
|
||||
import { emptyPartition, useAccountTemplatesStore } from '@/stores/account-templates-store';
|
||||
|
||||
// The picker shows the user's own templates, then the admin library under
|
||||
// "Organisation", from the policy of the account the message is written
|
||||
// from. The global next-intl mock returns keys.
|
||||
|
||||
const own = (id: string, name: string, extra: Partial<EmailTemplate> = {}): EmailTemplate => ({
|
||||
id, name, subject: '', body: `${name} body`, category: '', isFavorite: false,
|
||||
createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', ...extra,
|
||||
});
|
||||
|
||||
const lib = (id: string, name: string) => ({ id, name, subject: `${name} subject`, body: `<p>${name}</p><script>x()</script>`, isHTML: true, category: '' });
|
||||
|
||||
const RAW: SettingsPolicy = {
|
||||
...DEFAULT_POLICY,
|
||||
organisation: { ...DEFAULT_ORGANISATION_POLICY, templates: { library: [lib('welcome', 'Welcome')] } },
|
||||
scopes: [
|
||||
{ id: 'two', label: 'Two', match: { domains: ['org-two.test'] }, policy: { organisation: { templates: { library: [lib('two', 'Two only')] } } } },
|
||||
],
|
||||
};
|
||||
const ALICE = { email: 'alice@example.org', groups: [] };
|
||||
const DAVE_CLIENT = {
|
||||
getUsername: () => 'dave@org-two.test',
|
||||
getSharedAccounts: () => [],
|
||||
getAccountInfo: () => null,
|
||||
};
|
||||
|
||||
const original = useAuthStore.getState();
|
||||
|
||||
function setPolicy(raw: SettingsPolicy) {
|
||||
usePolicyStore.setState({ rawPolicy: raw, policy: resolvePolicy(raw, ALICE).policy, subject: ALICE });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetAccountSubjects();
|
||||
useAuthStore.setState({
|
||||
activeAccountId: 'a1',
|
||||
getClientForAccount: (id: string) => (id === 'a2' ? DAVE_CLIENT : undefined),
|
||||
} as never);
|
||||
setPolicy(RAW);
|
||||
useTemplateStore.setState({ templates: [own('mine', 'Mine')], recentTemplateIds: [], deletedTemplateIds: {} });
|
||||
useAccountTemplatesStore.setState({ personal: {}, teams: {}, active: null });
|
||||
});
|
||||
afterEach(() => {
|
||||
resetAccountSubjects();
|
||||
useAuthStore.setState({ activeAccountId: original.activeAccountId, getClientForAccount: original.getClientForAccount } as never);
|
||||
});
|
||||
|
||||
const headings = () => screen.getAllByRole('heading', { level: 2 }).map((h) => h.textContent).slice(1);
|
||||
|
||||
describe('template picker sources', () => {
|
||||
it('heads the user\'s own templates and the organisation library', () => {
|
||||
render(<TemplatePicker isOpen onClose={() => {}} onSelect={() => {}} />);
|
||||
expect(headings()).toEqual(['section_personal', 'section_organisation']);
|
||||
const organisation = screen.getByTestId('template-source-organisation');
|
||||
expect(within(organisation).getByText('Welcome')).toBeInTheDocument();
|
||||
expect(within(organisation).queryByText('Two only')).toBeNull();
|
||||
expect(within(screen.getByTestId('template-source-personal')).getByText('Mine')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('looks as before when there is no organisation library', () => {
|
||||
setPolicy({ ...RAW, organisation: DEFAULT_ORGANISATION_POLICY, scopes: [] });
|
||||
render(<TemplatePicker isOpen onClose={() => {}} onSelect={() => {}} />);
|
||||
expect(headings()).toEqual([]);
|
||||
expect(screen.queryByTestId('template-source-personal')).toBeNull();
|
||||
expect(screen.getByText('Mine')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the library of the account the message is sent from', () => {
|
||||
render(<TemplatePicker isOpen onClose={() => {}} onSelect={() => {}} accountId="a2" />);
|
||||
const organisation = screen.getByTestId('template-source-organisation');
|
||||
expect(within(organisation).getByText('Welcome')).toBeInTheDocument();
|
||||
expect(within(organisation).getByText('Two only')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hands an organisation template to the composer sanitised', () => {
|
||||
const onSelect = vi.fn();
|
||||
render(<TemplatePicker isOpen onClose={() => {}} onSelect={onSelect} />);
|
||||
fireEvent.click(screen.getByText('Welcome'));
|
||||
expect(onSelect).toHaveBeenCalledTimes(1);
|
||||
const template = onSelect.mock.calls[0][0] as EmailTemplate;
|
||||
expect(template.subject).toBe('Welcome subject');
|
||||
expect(template.body).toContain('<p>Welcome</p>');
|
||||
expect(template.body).not.toContain('script');
|
||||
// Recently used is for the user's own templates.
|
||||
expect(useTemplateStore.getState().recentTemplateIds).toEqual([]);
|
||||
});
|
||||
|
||||
it('leaves out personal templates the admin hid', () => {
|
||||
setPolicy({ ...RAW, restrictions: { personalTemplates: { hidden: true } } });
|
||||
render(<TemplatePicker isOpen onClose={() => {}} onSelect={() => {}} />);
|
||||
expect(screen.queryByText('Mine')).toBeNull();
|
||||
expect(screen.getByText('Welcome')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('searches every source under its heading', () => {
|
||||
useTemplateStore.setState({ templates: [own('mine', 'Welcome back')] });
|
||||
render(<TemplatePicker isOpen onClose={() => {}} onSelect={() => {}} />);
|
||||
fireEvent.change(screen.getByPlaceholderText('search_placeholder'), { target: { value: 'welc' } });
|
||||
expect(within(screen.getByTestId('template-source-personal')).getByText('Welcome back')).toBeInTheDocument();
|
||||
expect(within(screen.getByTestId('template-source-organisation')).getByText('Welcome')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the own templates of the account the message is sent from once it keeps them', () => {
|
||||
useAccountTemplatesStore.setState({ personal: { a2: { ...emptyPartition(), templates: [own('dave-1', "Dave's own")] } } });
|
||||
const { unmount } = render(<TemplatePicker isOpen onClose={() => {}} onSelect={() => {}} accountId="a2" />);
|
||||
expect(screen.getByText("Dave's own")).toBeInTheDocument();
|
||||
expect(screen.queryByText('Mine')).toBeNull();
|
||||
unmount();
|
||||
// The active account has no copy of its own yet: the browser's list.
|
||||
render(<TemplatePicker isOpen onClose={() => {}} onSelect={() => {}} />);
|
||||
expect(screen.getByText('Mine')).toBeInTheDocument();
|
||||
expect(screen.queryByText("Dave's own")).toBeNull();
|
||||
});
|
||||
|
||||
it('shows who a template addresses before it is inserted', () => {
|
||||
useTemplateStore.setState({ templates: [own('mine', 'Mine', { defaultRecipients: { to: ['a@x.test', 'b@x.test'], cc: ['c@x.test'], bcc: ['hidden@x.test'] } })] });
|
||||
render(<TemplatePicker isOpen onClose={() => {}} onSelect={() => {}} />);
|
||||
const line = screen.getByTestId('template-recipients');
|
||||
expect(line).toHaveTextContent('preview_to');
|
||||
expect(line).toHaveTextContent('preview_cc');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
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();
|
||||
});
|
||||
|
||||
it('says that someone else changed it, and keeps what the user typed', async () => {
|
||||
save.mockResolvedValue('conflict');
|
||||
render(<TemplateManagerModal isOpen onClose={() => {}} />);
|
||||
fireEvent.click(screen.getByTestId('template-source-tab-team'));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'edit' }));
|
||||
fireEvent.change(screen.getByPlaceholderText('name_placeholder'), { target: { value: 'My change' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'update' }));
|
||||
expect(await screen.findByTestId('template-save-error')).toHaveTextContent('save_failed_conflict');
|
||||
expect((screen.getByPlaceholderText('name_placeholder') as HTMLInputElement).value).toBe('My change');
|
||||
// The edit started from the version the user saw.
|
||||
expect(save.mock.calls[0][3]).toBe('2026-01-01T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('moves between the lists with the arrow keys, as a tab list', () => {
|
||||
render(<TemplateManagerModal isOpen onClose={() => {}} />);
|
||||
const personal = screen.getByTestId('template-source-tab-personal');
|
||||
expect(personal).toHaveAttribute('aria-selected', 'true');
|
||||
expect(screen.getByRole('tabpanel')).toHaveAttribute('aria-labelledby', personal.id);
|
||||
personal.focus();
|
||||
fireEvent.keyDown(personal, { key: 'ArrowRight' });
|
||||
const team = screen.getByTestId('template-source-tab-team');
|
||||
expect(team).toHaveAttribute('aria-selected', 'true');
|
||||
expect(document.activeElement).toBe(team);
|
||||
expect(team).toHaveAttribute('tabindex', '0');
|
||||
expect(personal).toHaveAttribute('tabindex', '-1');
|
||||
fireEvent.keyDown(team, { key: 'End' });
|
||||
expect(screen.getByTestId('template-source-tab-organisation')).toHaveAttribute('aria-selected', 'true');
|
||||
fireEvent.keyDown(document.activeElement!, { key: 'ArrowRight' });
|
||||
expect(personal).toHaveAttribute('aria-selected', 'true');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useAccountTemplatesStore } from '@/stores/account-templates-store';
|
||||
import { refreshAccountTemplates, scheduleAccountSync } from '@/lib/templates/personal-templates';
|
||||
import { useTemplateStore } from '@/stores/template-store';
|
||||
import { onSettingsLoadSettled } from '@/stores/settings-store';
|
||||
import { refreshTeamTemplates } from '@/lib/templates/team-templates';
|
||||
|
||||
/**
|
||||
* 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);
|
||||
const client = useAuthStore((s) => s.client);
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||
|
||||
useEffect(() => {
|
||||
useAccountTemplatesStore.setState({ active: isAuthenticated ? activeAccountId : null });
|
||||
}, [activeAccountId, isAuthenticated]);
|
||||
|
||||
// The browser's list changing (the account's synced settings just
|
||||
// loaded, or a template changed there) is merged in at once.
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated || !activeAccountId) return;
|
||||
const unsubscribe = useTemplateStore.subscribe(() => scheduleAccountSync(activeAccountId));
|
||||
// Once the account's synced settings have loaded, its browser list is
|
||||
// known, even when the load changed nothing in it.
|
||||
const unlisten = onSettingsLoadSettled((accountId) => {
|
||||
if (accountId === activeAccountId) scheduleAccountSync(activeAccountId);
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
unlisten();
|
||||
};
|
||||
}, [activeAccountId, isAuthenticated]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated || !activeAccountId || !client) return;
|
||||
refreshAccountTemplates(activeAccountId, 0);
|
||||
refreshTeamTemplates(activeAccountId, 0);
|
||||
const onVisible = () => {
|
||||
if (document.visibilityState !== 'visible') return;
|
||||
refreshAccountTemplates(activeAccountId, 60_000);
|
||||
refreshTeamTemplates(activeAccountId, 60_000);
|
||||
};
|
||||
document.addEventListener('visibilitychange', onVisible);
|
||||
return () => document.removeEventListener('visibilitychange', onVisible);
|
||||
}, [activeAccountId, client, isAuthenticated]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import { sanitizeEmailHtml } from '@/lib/email-sanitization';
|
||||
import type { EmailTemplate } from '@/lib/template-types';
|
||||
|
||||
/** A template's body as it goes into the composer, for templates the user can't open in the editor. */
|
||||
export function TemplateBodyPreview({ template }: { template: Pick<EmailTemplate, 'body' | 'isHTML'> }) {
|
||||
if (template.isHTML) {
|
||||
return (
|
||||
<div
|
||||
className="max-h-60 overflow-auto text-sm break-words text-foreground"
|
||||
dangerouslySetInnerHTML={{ __html: sanitizeEmailHtml(template.body || '') }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <pre className="max-h-60 overflow-auto whitespace-pre-wrap break-words font-sans text-sm text-foreground">{template.body}</pre>;
|
||||
}
|
||||
@@ -24,15 +24,25 @@ interface TemplateFormProps {
|
||||
};
|
||||
onSave: (data: Omit<EmailTemplate, 'id' | 'createdAt' | 'updatedAt'>) => void;
|
||||
onCancel: () => void;
|
||||
/**
|
||||
* A template others use too (a team's): no sending identity, since
|
||||
* identity ids are each member's own, and no favourite.
|
||||
*/
|
||||
shared?: boolean;
|
||||
/** A save is under way: the save button waits. */
|
||||
busy?: boolean;
|
||||
/** The templates whose categories the category field suggests; the browser's list when left out. */
|
||||
categorySource?: EmailTemplate[];
|
||||
}
|
||||
|
||||
export function TemplateForm({ template, initialData, onSave, onCancel }: TemplateFormProps) {
|
||||
export function TemplateForm({ template, initialData, onSave, onCancel, shared = false, busy = false, categorySource }: TemplateFormProps) {
|
||||
const t = useTranslations('templates');
|
||||
const tSettings = useTranslations('settings.templates');
|
||||
const tComposer = useTranslations('email_composer');
|
||||
|
||||
const { identities } = useAuthStore();
|
||||
const templates = useTemplateStore((s) => s.templates);
|
||||
const browserList = useTemplateStore((s) => s.templates);
|
||||
const templates = categorySource ?? browserList;
|
||||
|
||||
const [name, setName] = useState(template?.name || '');
|
||||
const [category, setCategory] = useState(template?.category || '');
|
||||
@@ -82,8 +92,8 @@ export function TemplateForm({ template, initialData, onSave, onCancel }: Templa
|
||||
defaultRecipients: to.length || cc.length || bcc.length
|
||||
? { to: to.length ? to : undefined, cc: cc.length ? cc : undefined, bcc: bcc.length ? bcc : undefined }
|
||||
: undefined,
|
||||
identityId: identityId || undefined,
|
||||
isFavorite,
|
||||
identityId: shared ? undefined : identityId || undefined,
|
||||
isFavorite: shared ? false : isFavorite,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -232,7 +242,7 @@ export function TemplateForm({ template, initialData, onSave, onCancel }: Templa
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{identities.length > 1 && (
|
||||
{!shared && identities.length > 1 && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground">{tSettings('identity')}</label>
|
||||
<select
|
||||
@@ -251,20 +261,22 @@ export function TemplateForm({ template, initialData, onSave, onCancel }: Templa
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsFavorite(!isFavorite)}
|
||||
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Star className={cn('w-4 h-4', isFavorite && 'fill-amber-400 text-amber-400')} />
|
||||
{tSettings('favorite')}
|
||||
</button>
|
||||
{shared ? <span /> : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsFavorite(!isFavorite)}
|
||||
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Star className={cn('w-4 h-4', isFavorite && 'fill-amber-400 text-amber-400')} />
|
||||
{tSettings('favorite')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="ghost" size="sm" onClick={onCancel}>
|
||||
{tSettings('cancel')}
|
||||
</Button>
|
||||
<Button type="submit" size="sm">
|
||||
<Button type="submit" size="sm" disabled={busy}>
|
||||
{template ? tSettings('update') : tSettings('create')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,72 +1,137 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { X, FileText, Pencil, Trash2, Star, Copy, Search } from '@/components/icons';
|
||||
import { X, FileText, Pencil, Trash2, Star, Copy, Search, Eye } from '@/components/icons';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { TemplateForm } from './template-form';
|
||||
import { useTemplateStore } from '@/stores/template-store';
|
||||
import { TemplateBodyPreview } from './template-body-preview';
|
||||
import { useFocusTrap } from '@/hooks/use-focus-trap';
|
||||
import { useTemplateSources } from '@/hooks/use-template-sources';
|
||||
import { useTemplateSourceActions, type TemplateSaveResult } from '@/hooks/use-template-source-actions';
|
||||
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, resolveAccountId } from '@/lib/templates/personal-templates';
|
||||
import { refreshTeamTemplates } from '@/lib/templates/team-templates';
|
||||
import type { EmailTemplate } from '@/lib/template-types';
|
||||
|
||||
interface TemplateManagerModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
/** The signed-in account whose templates are managed; the active one when left out. */
|
||||
accountId?: string | null;
|
||||
}
|
||||
|
||||
export function TemplateManagerModal({ isOpen, onClose }: TemplateManagerModalProps) {
|
||||
type TemplateData = Omit<EmailTemplate, 'id' | 'createdAt' | 'updatedAt'>;
|
||||
|
||||
const PANEL_ID = 'template-source-panel';
|
||||
const tabId = (key: string) => `template-source-tab-${key.replace(/[^a-zA-Z0-9_-]/g, '-')}`;
|
||||
|
||||
export function TemplateManagerModal({ isOpen, onClose, accountId }: TemplateManagerModalProps) {
|
||||
const t = useTranslations('templates');
|
||||
const tSettings = useTranslations('settings.templates');
|
||||
const tOrg = useTranslations('org_templates');
|
||||
|
||||
const {
|
||||
templates,
|
||||
addTemplate,
|
||||
updateTemplate,
|
||||
deleteTemplate,
|
||||
duplicateTemplate,
|
||||
toggleFavorite,
|
||||
searchTemplates,
|
||||
} = useTemplateStore();
|
||||
const { sources, personalHidden } = useTemplateSources(accountId);
|
||||
const actions = useTemplateSourceActions(accountId);
|
||||
|
||||
const [selectedKey, setSelectedKey] = useState<string>(PERSONAL_SOURCE);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||
const [previewId, setPreviewId] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const source: TemplateSource | undefined = sources.find((s) => s.key === selectedKey) ?? sources[0];
|
||||
const personal = sources.find((s) => s.kind === 'personal');
|
||||
|
||||
const closeForm = () => {
|
||||
setIsCreating(false);
|
||||
setEditingId(null);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
refreshAccountTemplates(accountId);
|
||||
const id = resolveAccountId(accountId);
|
||||
if (id) refreshTeamTemplates(id);
|
||||
}, [isOpen, accountId]);
|
||||
|
||||
const modalRef = useFocusTrap({
|
||||
isActive: isOpen,
|
||||
onEscape: () => {
|
||||
if (isCreating || editingId) {
|
||||
setIsCreating(false);
|
||||
setEditingId(null);
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
if (isCreating || editingId) closeForm();
|
||||
else onClose();
|
||||
},
|
||||
restoreFocus: true,
|
||||
});
|
||||
|
||||
const filtered = searchQuery ? searchTemplates(searchQuery) : templates;
|
||||
const selectSource = (key: string) => {
|
||||
setSelectedKey(key);
|
||||
closeForm();
|
||||
setDeleteConfirmId(null);
|
||||
setPreviewId(null);
|
||||
};
|
||||
|
||||
const handleSave = (data: Omit<EmailTemplate, 'id' | 'createdAt' | 'updatedAt'>) => {
|
||||
if (editingId) {
|
||||
updateTemplate(editingId, data);
|
||||
setEditingId(null);
|
||||
} else {
|
||||
addTemplate(data);
|
||||
setIsCreating(false);
|
||||
const sourceTitle = (s: TemplateSource) =>
|
||||
s.kind === 'organisation' ? tOrg('section_organisation')
|
||||
: s.kind === 'team' ? (s.name ?? '')
|
||||
: tOrg('section_personal');
|
||||
|
||||
const errorText = (result: TemplateSaveResult) =>
|
||||
result === 'too-large' ? tOrg('save_failed_too_large')
|
||||
: result === 'no-room' ? tOrg('save_failed_no_room')
|
||||
: result === 'forbidden' ? tOrg('save_failed_forbidden')
|
||||
: result === 'conflict' ? tOrg('save_failed_conflict')
|
||||
: tOrg('save_failed');
|
||||
|
||||
// A failed save keeps the form open with what the user typed.
|
||||
const run = async (step: () => Promise<TemplateSaveResult>, after: () => void) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await step();
|
||||
if (result === 'ok') after();
|
||||
else setError(errorText(result));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = (data: TemplateData) => {
|
||||
if (!source?.editable) return;
|
||||
if (editingId) void run(() => actions.update(source, editingId, data), closeForm);
|
||||
else void run(() => actions.add(source, data), closeForm);
|
||||
};
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
deleteTemplate(id);
|
||||
setDeleteConfirmId(null);
|
||||
if (!source?.editable) return;
|
||||
void run(() => actions.remove(source, id), () => setDeleteConfirmId(null));
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const templates = source?.templates ?? [];
|
||||
const filtered = searchQuery ? filterTemplates(templates, searchQuery) : templates;
|
||||
const editable = !!source?.editable;
|
||||
const isPersonal = source?.kind === 'personal';
|
||||
const showTabs = sources.length > 1 || personalHidden;
|
||||
|
||||
let note: React.ReactNode = null;
|
||||
if (source?.kind === 'organisation') {
|
||||
note = <p className="text-xs text-muted-foreground">{tOrg('organisation_note')}</p>;
|
||||
} else if (source?.kind === 'team') {
|
||||
note = source.locked
|
||||
? <OrgLockedNote />
|
||||
: <p className="text-xs text-muted-foreground">{source.editable ? tOrg('team_note', { name: source.name ?? '' }) : tOrg('team_read_only')}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-[1px] flex items-center justify-center z-50 p-4 animate-in fade-in duration-150">
|
||||
<div
|
||||
@@ -74,20 +139,23 @@ export function TemplateManagerModal({ isOpen, onClose }: TemplateManagerModalPr
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="template-manager-title"
|
||||
// The mail shortcuts behind the dialog must not see its keys.
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
className={cn(
|
||||
'bg-background border border-border rounded-lg shadow-xl',
|
||||
'w-full max-w-3xl max-h-[90vh] overflow-hidden',
|
||||
'animate-in zoom-in-95 duration-200'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<FileText className="w-5 h-5 text-muted-foreground" />
|
||||
<h2 id="template-manager-title" className="text-lg font-semibold text-foreground">
|
||||
<div className="flex items-center justify-between px-4 sm:px-6 py-4 border-b border-border">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<FileText className="w-5 h-5 text-muted-foreground flex-shrink-0" />
|
||||
<h2 id="template-manager-title" className="text-lg font-semibold text-foreground truncate">
|
||||
{tSettings('title')}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
@@ -95,8 +163,67 @@ export function TemplateManagerModal({ isOpen, onClose }: TemplateManagerModalPr
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 overflow-y-auto max-h-[calc(90vh-80px)]">
|
||||
{!isCreating && !editingId && (
|
||||
<div className="p-4 sm:p-6 overflow-y-auto max-h-[calc(90vh-80px)]">
|
||||
{showTabs && (
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label={tOrg('sources_label')}
|
||||
className="flex flex-wrap gap-2 mb-3"
|
||||
data-testid="template-sources"
|
||||
onKeyDown={(e) => {
|
||||
// Arrow keys, Home and End move between the lists, as in any tab list.
|
||||
const index = sources.findIndex((s) => s.key === source?.key);
|
||||
const next = e.key === 'ArrowRight' || e.key === 'ArrowDown' ? index + 1
|
||||
: e.key === 'ArrowLeft' || e.key === 'ArrowUp' ? index - 1
|
||||
: e.key === 'Home' ? 0
|
||||
: e.key === 'End' ? sources.length - 1
|
||||
: null;
|
||||
if (next === null || sources.length === 0) return;
|
||||
e.preventDefault();
|
||||
const target = sources[(next + sources.length) % sources.length];
|
||||
selectSource(target.key);
|
||||
document.getElementById(tabId(target.key))?.focus();
|
||||
}}
|
||||
>
|
||||
{sources.map((s) => (
|
||||
<button
|
||||
key={s.key}
|
||||
id={tabId(s.key)}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={s.key === source?.key}
|
||||
aria-controls={PANEL_ID}
|
||||
tabIndex={s.key === source?.key ? 0 : -1}
|
||||
data-testid={`template-source-tab-${s.kind}`}
|
||||
onClick={() => selectSource(s.key)}
|
||||
className={cn(
|
||||
'max-w-full truncate rounded-full border px-3 py-1 text-sm transition-colors',
|
||||
s.key === source?.key
|
||||
? 'border-primary bg-primary/10 text-primary'
|
||||
: 'border-border text-muted-foreground hover:text-foreground hover:bg-muted'
|
||||
)}
|
||||
>
|
||||
{sourceTitle(s)} <span className="text-xs opacity-70">{s.templates.length}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
id={PANEL_ID}
|
||||
role={showTabs ? 'tabpanel' : undefined}
|
||||
aria-labelledby={showTabs && source ? tabId(source.key) : undefined}
|
||||
>
|
||||
{note && <div className="mb-4" data-testid="template-source-note">{note}</div>}
|
||||
|
||||
{!source && (
|
||||
<div className="flex flex-col items-center py-8 text-muted-foreground">
|
||||
<FileText className="w-10 h-10 mb-3 opacity-40" />
|
||||
<p className="text-sm">{tSettings('no_templates')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{source && !isCreating && !editingId && (
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
@@ -107,129 +234,185 @@ export function TemplateManagerModal({ isOpen, onClose }: TemplateManagerModalPr
|
||||
className="ps-9"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setIsCreating(true)}
|
||||
size="sm"
|
||||
>
|
||||
{tSettings('add')}
|
||||
</Button>
|
||||
{editable && (
|
||||
<Button onClick={() => { setIsCreating(true); setError(null); }} size="sm">
|
||||
{tSettings('add')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isCreating && (
|
||||
{error && (
|
||||
<p role="alert" className="mb-3 text-sm text-red-600 dark:text-red-400" data-testid="template-save-error">{error}</p>
|
||||
)}
|
||||
|
||||
{editable && isCreating && (
|
||||
<div className="mb-6 p-4 border border-border rounded-lg bg-muted/30">
|
||||
<h3 className="text-sm font-semibold mb-4">{tSettings('add')}</h3>
|
||||
<TemplateForm
|
||||
categorySource={templates}
|
||||
shared={!isPersonal}
|
||||
busy={busy}
|
||||
onSave={handleSave}
|
||||
onCancel={() => setIsCreating(false)}
|
||||
onCancel={closeForm}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editingId && (
|
||||
{editable && editingId && (
|
||||
<div className="mb-6 p-4 border border-border rounded-lg bg-muted/30">
|
||||
<h3 className="text-sm font-semibold mb-4">{tSettings('edit')}</h3>
|
||||
<TemplateForm
|
||||
template={templates.find((t) => t.id === editingId)}
|
||||
template={templates.find((tpl) => tpl.id === editingId)}
|
||||
categorySource={templates}
|
||||
shared={!isPersonal}
|
||||
busy={busy}
|
||||
onSave={handleSave}
|
||||
onCancel={() => setEditingId(null)}
|
||||
onCancel={closeForm}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{filtered.map((template) => (
|
||||
<div
|
||||
key={template.id}
|
||||
className="flex items-center gap-3 p-3 rounded-md border border-border hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleFavorite(template.id)}
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
<Star
|
||||
className={cn(
|
||||
'w-4 h-4 transition-colors',
|
||||
template.isFavorite
|
||||
? 'fill-amber-400 text-amber-400'
|
||||
: 'text-muted-foreground hover:text-amber-400'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
{source && filtered.map((template) => (
|
||||
<div key={template.id} className="rounded-md border border-border hover:bg-muted/50 transition-colors" data-testid="template-row">
|
||||
<div className="flex items-center gap-2 sm:gap-3 p-3">
|
||||
{isPersonal && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void actions.toggleFavorite(source, template.id)}
|
||||
aria-label={tSettings('favorite')}
|
||||
aria-pressed={template.isFavorite}
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
<Star
|
||||
className={cn(
|
||||
'w-4 h-4 transition-colors',
|
||||
template.isFavorite
|
||||
? 'fill-amber-400 text-amber-400'
|
||||
: 'text-muted-foreground hover:text-amber-400'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium text-foreground truncate">
|
||||
{template.name}
|
||||
</p>
|
||||
{template.category && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-primary/10 text-primary font-medium flex-shrink-0">
|
||||
{template.category}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<p className="text-sm font-medium text-foreground truncate">
|
||||
{template.name}
|
||||
</p>
|
||||
{template.category && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-primary/10 text-primary font-medium flex-shrink-0 max-w-[40%] truncate">
|
||||
{template.category}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{template.subject && (
|
||||
<p className="text-xs text-muted-foreground truncate mt-0.5">
|
||||
{template.subject}
|
||||
</p>
|
||||
)}
|
||||
{isPersonal && actions.localOnly[template.id] && (
|
||||
<p className="text-xs text-amber-700 dark:text-amber-400 mt-1" data-testid="template-local-only">
|
||||
{actions.localOnly[template.id] === 'too-large' ? tOrg('local_only_too_large') : tOrg('local_only_no_room')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{template.subject && (
|
||||
<p className="text-xs text-muted-foreground truncate mt-0.5">
|
||||
{template.subject}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => { setEditingId(template.id); setIsCreating(false); }}
|
||||
disabled={!!editingId || isCreating}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => duplicateTemplate(template.id, t('copy_suffix'))}
|
||||
disabled={!!editingId || isCreating}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
{deleteConfirmId === template.id ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(template.id)}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
{tSettings('confirm_delete')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setDeleteConfirmId(null)}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
{tSettings('cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setDeleteConfirmId(template.id)}
|
||||
disabled={!!editingId || isCreating}
|
||||
className="h-8 w-8 p-0 hover:text-red-600 dark:hover:text-red-400"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{editable ? (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => { setEditingId(template.id); setIsCreating(false); setError(null); }}
|
||||
disabled={!!editingId || isCreating}
|
||||
aria-label={tSettings('edit')}
|
||||
className="h-11 w-11 sm:h-8 sm:w-8 p-0"
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => void run(() => actions.duplicate(source, template.id, t('copy_suffix')), () => {})}
|
||||
disabled={!!editingId || isCreating || busy}
|
||||
aria-label={tOrg('duplicate')}
|
||||
className="h-11 w-11 sm:h-8 sm:w-8 p-0"
|
||||
>
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
{deleteConfirmId === template.id ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(template.id)}
|
||||
disabled={busy}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
{tSettings('confirm_delete')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setDeleteConfirmId(null)}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
{tSettings('cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setDeleteConfirmId(template.id)}
|
||||
disabled={!!editingId || isCreating}
|
||||
aria-label={tSettings('confirm_delete')}
|
||||
className="h-11 w-11 sm:h-8 sm:w-8 p-0 hover:text-red-600 dark:hover:text-red-400"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setPreviewId(previewId === template.id ? null : template.id)}
|
||||
aria-label={t('preview')}
|
||||
aria-expanded={previewId === template.id}
|
||||
className="h-11 w-11 sm:h-8 sm:w-8 p-0"
|
||||
>
|
||||
<Eye className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
{personal?.editable && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => void run(() => actions.copyToPersonal(template), () => {})}
|
||||
disabled={busy}
|
||||
aria-label={tOrg('copy_to_personal')}
|
||||
title={tOrg('copy_to_personal')}
|
||||
className="h-11 w-11 sm:h-8 sm:w-8 p-0"
|
||||
>
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{previewId === template.id && (
|
||||
<div className="border-t border-border px-3 py-2" data-testid="template-preview">
|
||||
<TemplateBodyPreview template={template} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{filtered.length === 0 && !isCreating && (
|
||||
{source && filtered.length === 0 && !isCreating && (
|
||||
<div className="flex flex-col items-center py-8 text-muted-foreground">
|
||||
<FileText className="w-10 h-10 mb-3 opacity-40" />
|
||||
<p className="text-sm">
|
||||
@@ -238,6 +421,11 @@ export function TemplateManagerModal({ isOpen, onClose }: TemplateManagerModalPr
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isPersonal && actions.storedInAccount && (
|
||||
<p className="mt-4 text-xs text-muted-foreground" data-testid="template-storage-note">{tOrg('personal_in_account')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslations, useLocale } from 'next-intl';
|
||||
import { X, Search, Star, FileText } from '@/components/icons';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -8,10 +8,15 @@ import { Input } from '@/components/ui/input';
|
||||
import { useTemplateStore } from '@/stores/template-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useFocusTrap } from '@/hooks/use-focus-trap';
|
||||
import { useTemplateSources } from '@/hooks/use-template-sources';
|
||||
import {
|
||||
filterTemplates,
|
||||
getPlaceholdersFromTemplate,
|
||||
getAutoFilledPlaceholders,
|
||||
} from '@/lib/template-utils';
|
||||
import type { TemplateSource } from '@/lib/templates/template-sources';
|
||||
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';
|
||||
|
||||
@@ -19,34 +24,46 @@ interface TemplatePickerProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSelect: (template: EmailTemplate, filledValues: Record<string, string>) => void;
|
||||
/**
|
||||
* The signed-in account the message is written from: its own templates,
|
||||
* its team templates and its organisation library. The active one when
|
||||
* left out.
|
||||
*/
|
||||
accountId?: string | null;
|
||||
}
|
||||
|
||||
export function TemplatePicker({ isOpen, onClose, onSelect }: TemplatePickerProps) {
|
||||
export function TemplatePicker({ isOpen, onClose, onSelect, accountId }: TemplatePickerProps) {
|
||||
const t = useTranslations('templates');
|
||||
const tOrg = useTranslations('org_templates');
|
||||
const locale = useLocale();
|
||||
|
||||
const { templates, getFavorites, getRecent, getTemplatesByCategory, searchTemplates, recordUsage } =
|
||||
useTemplateStore();
|
||||
const recordUsage = useTemplateStore((s) => s.recordUsage);
|
||||
const recentTemplateIds = useTemplateStore((s) => s.recentTemplateIds);
|
||||
const { primaryIdentity } = useAuthStore();
|
||||
const { sources } = useTemplateSources(accountId);
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<EmailTemplate | null>(null);
|
||||
const [showFillModal, setShowFillModal] = useState(false);
|
||||
|
||||
// What another device or a team member changed shows up when the picker opens.
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
refreshAccountTemplates(accountId);
|
||||
const id = resolveAccountId(accountId);
|
||||
if (id) refreshTeamTemplates(id);
|
||||
}, [isOpen, accountId]);
|
||||
|
||||
const modalRef = useFocusTrap({
|
||||
isActive: isOpen && !showFillModal,
|
||||
onEscape: onClose,
|
||||
restoreFocus: true,
|
||||
});
|
||||
|
||||
const favorites = getFavorites();
|
||||
const recent = getRecent();
|
||||
const byCategory = getTemplatesByCategory();
|
||||
const filtered = searchQuery ? searchTemplates(searchQuery) : null;
|
||||
|
||||
const handleSelectTemplate = (template: EmailTemplate) => {
|
||||
const handleSelectTemplate = (template: EmailTemplate, source: TemplateSource) => {
|
||||
const placeholders = getPlaceholdersFromTemplate(template);
|
||||
recordUsage(template.id);
|
||||
// Recently used lists the user's own templates only.
|
||||
if (source.kind === 'personal') recordUsage(template.id);
|
||||
|
||||
if (placeholders.length > 0) {
|
||||
setSelectedTemplate(template);
|
||||
@@ -71,22 +88,22 @@ export function TemplatePicker({ isOpen, onClose, onSelect }: TemplatePickerProp
|
||||
locale,
|
||||
});
|
||||
|
||||
const renderTemplateItem = (template: EmailTemplate) => (
|
||||
const renderTemplateItem = (template: EmailTemplate, source: TemplateSource) => (
|
||||
<button
|
||||
key={template.id}
|
||||
key={`${source.key}:${template.id}`}
|
||||
type="button"
|
||||
onClick={() => handleSelectTemplate(template)}
|
||||
onClick={() => handleSelectTemplate(template, source)}
|
||||
className="w-full text-start p-3 rounded-md hover:bg-muted transition-colors group"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{template.isFavorite && (
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{source.kind === 'personal' && template.isFavorite && (
|
||||
<Star className="w-3 h-3 fill-amber-400 text-amber-400 flex-shrink-0" />
|
||||
)}
|
||||
<span className="text-sm font-medium text-foreground truncate">
|
||||
{template.name}
|
||||
</span>
|
||||
{template.category && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded-full bg-primary/10 text-primary font-medium flex-shrink-0">
|
||||
<span className="text-xs px-1.5 py-0.5 rounded-full bg-primary/10 text-primary font-medium flex-shrink-0 max-w-[40%] truncate">
|
||||
{template.category}
|
||||
</span>
|
||||
)}
|
||||
@@ -96,33 +113,89 @@ export function TemplatePicker({ isOpen, onClose, onSelect }: TemplatePickerProp
|
||||
{template.subject}
|
||||
</p>
|
||||
)}
|
||||
{/* Who the template addresses, before it is put into the message. */}
|
||||
{(template.defaultRecipients?.to?.length || template.defaultRecipients?.cc?.length) ? (
|
||||
<p className="text-xs text-muted-foreground truncate mt-1" data-testid="template-recipients">
|
||||
{[
|
||||
template.defaultRecipients?.to?.length ? tOrg('preview_to', { list: template.defaultRecipients.to.join(', ') }) : null,
|
||||
template.defaultRecipients?.cc?.length ? tOrg('preview_cc', { list: template.defaultRecipients.cc.join(', ') }) : null,
|
||||
].filter(Boolean).join(' · ')}
|
||||
</p>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
|
||||
const renderSection = (title: string, items: EmailTemplate[]) => {
|
||||
const renderSection = (title: string, items: EmailTemplate[], source: TemplateSource) => {
|
||||
if (items.length === 0) return null;
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<div className="mb-4" key={`${source.key}:${title}`}>
|
||||
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-1 px-3">
|
||||
{title}
|
||||
</h3>
|
||||
<div className="space-y-0.5">{items.map(renderTemplateItem)}</div>
|
||||
<div className="space-y-0.5">{items.map((item) => renderTemplateItem(item, source))}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const categorizedEntries = Object.entries(byCategory).filter(
|
||||
([cat]) => cat !== ''
|
||||
);
|
||||
const favoriteIds = new Set(favorites.map((f) => f.id));
|
||||
const recentFiltered = recent.filter((r) => !favoriteIds.has(r.id));
|
||||
const shownIds = new Set([
|
||||
...favoriteIds,
|
||||
...recentFiltered.map((r) => r.id),
|
||||
]);
|
||||
const uncategorizedFiltered = (byCategory[''] || []).filter(
|
||||
(i) => !shownIds.has(i.id)
|
||||
);
|
||||
const sourceTitle = (source: TemplateSource) =>
|
||||
source.kind === 'organisation' ? tOrg('section_organisation')
|
||||
: source.kind === 'team' ? (source.name ?? '')
|
||||
: tOrg('section_personal');
|
||||
|
||||
// The user's own templates keep their favourites, recently used and
|
||||
// categories. With team or organisation templates next to them, the
|
||||
// whole block gets a heading of its own.
|
||||
const renderPersonal = (source: TemplateSource, withHeading: boolean) => {
|
||||
const templates = source.templates;
|
||||
const favorites = templates.filter((tpl) => tpl.isFavorite);
|
||||
const favoriteIds = new Set(favorites.map((f) => f.id));
|
||||
const recent = recentTemplateIds
|
||||
.map((id) => templates.find((tpl) => tpl.id === id))
|
||||
.filter((tpl): tpl is EmailTemplate => !!tpl && !favoriteIds.has(tpl.id));
|
||||
const shownIds = new Set([...favoriteIds, ...recent.map((r) => r.id)]);
|
||||
const byCategory: Record<string, EmailTemplate[]> = {};
|
||||
for (const tpl of templates) {
|
||||
if (shownIds.has(tpl.id)) continue;
|
||||
(byCategory[tpl.category || ''] ??= []).push(tpl);
|
||||
}
|
||||
const body = (
|
||||
<>
|
||||
{renderSection(t('section_favorites'), favorites, source)}
|
||||
{renderSection(t('section_recent'), recent, source)}
|
||||
{Object.entries(byCategory)
|
||||
.filter(([cat]) => cat !== '')
|
||||
.map(([cat, items]) => renderSection(cat, items, source))}
|
||||
{renderSection(t('section_uncategorized'), byCategory[''] ?? [], source)}
|
||||
</>
|
||||
);
|
||||
if (!withHeading) return <div key={source.key}>{body}</div>;
|
||||
if (templates.length === 0) return null;
|
||||
return (
|
||||
<section key={source.key} data-testid="template-source-personal" aria-label={sourceTitle(source)} className="mb-2">
|
||||
<h2 className="text-sm font-semibold text-foreground px-3 pt-1 pb-2">{sourceTitle(source)}</h2>
|
||||
{body}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
const renderShared = (source: TemplateSource, items: EmailTemplate[]) => {
|
||||
if (items.length === 0) return null;
|
||||
return (
|
||||
<section key={source.key} data-testid={`template-source-${source.kind}`} aria-label={sourceTitle(source)} className="mb-2">
|
||||
<h2 className="text-sm font-semibold text-foreground px-3 pt-1 pb-2 truncate">{sourceTitle(source)}</h2>
|
||||
<div className="space-y-0.5 mb-4">{items.map((item) => renderTemplateItem(item, source))}</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
const total = sources.reduce((n, source) => n + source.templates.length, 0);
|
||||
// Only the user's own templates: the picker looks as it always did.
|
||||
const onlyPersonal = sources.every((source) => source.kind === 'personal' || source.templates.length === 0);
|
||||
|
||||
const results = searchQuery
|
||||
? sources.map((source) => ({ source, items: filterTemplates(source.templates, searchQuery) }))
|
||||
: null;
|
||||
const resultCount = results?.reduce((n, r) => n + r.items.length, 0) ?? 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -132,6 +205,8 @@ export function TemplatePicker({ isOpen, onClose, onSelect }: TemplatePickerProp
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="template-picker-title"
|
||||
// The mail shortcuts behind the dialog must not see its keys.
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
className={cn(
|
||||
'bg-background border border-border rounded-lg shadow-xl',
|
||||
'w-full max-w-md max-h-[70vh] overflow-hidden',
|
||||
@@ -141,6 +216,7 @@ export function TemplatePicker({ isOpen, onClose, onSelect }: TemplatePickerProp
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||
<h2 id="template-picker-title" className="text-sm font-semibold text-foreground">{t('picker_title')}</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-md hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
@@ -162,35 +238,31 @@ export function TemplatePicker({ isOpen, onClose, onSelect }: TemplatePickerProp
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto max-h-[calc(70vh-120px)] p-2">
|
||||
{templates.length === 0 && (
|
||||
{total === 0 && (
|
||||
<div className="flex flex-col items-center py-8 text-muted-foreground">
|
||||
<FileText className="w-8 h-8 mb-2 opacity-40" />
|
||||
<p className="text-sm">{t('no_templates')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filtered ? (
|
||||
filtered.length > 0 ? (
|
||||
<div className="space-y-0.5">
|
||||
{filtered.map(renderTemplateItem)}
|
||||
</div>
|
||||
{results ? (
|
||||
resultCount > 0 ? (
|
||||
onlyPersonal ? (
|
||||
<div className="space-y-0.5">
|
||||
{results.flatMap(({ source, items }) => items.map((item) => renderTemplateItem(item, source)))}
|
||||
</div>
|
||||
) : (
|
||||
results.map(({ source, items }) => renderShared(source, items))
|
||||
)
|
||||
) : (
|
||||
<div className="py-6 text-center text-sm text-muted-foreground">
|
||||
{t('no_results')}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
{renderSection(t('section_favorites'), favorites)}
|
||||
{renderSection(t('section_recent'), recentFiltered)}
|
||||
{categorizedEntries.map(([cat, items]) =>
|
||||
renderSection(
|
||||
cat,
|
||||
items.filter((i) => !shownIds.has(i.id))
|
||||
)
|
||||
)}
|
||||
{renderSection(t('section_uncategorized'), uncategorizedFiltered)}
|
||||
</>
|
||||
sources.map((source) => source.kind === 'personal'
|
||||
? renderPersonal(source, !onlyPersonal)
|
||||
: renderShared(source, source.templates))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { SettingsPolicy } from '@/lib/admin/types';
|
||||
import { subjectForAccount, useAccountSubjects } from '@/lib/org/account-subjects';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
|
||||
/**
|
||||
* The effective policy of a signed-in account: the active account's from
|
||||
* the policy store, another account's resolved for its own domain and group
|
||||
* scopes (a composer sending from that account). Follows the policy and the
|
||||
* account's subject as they load.
|
||||
*/
|
||||
export function useAccountPolicy(accountId: string | null | undefined): SettingsPolicy {
|
||||
const policy = usePolicyStore((s) => s.policy);
|
||||
const rawPolicy = usePolicyStore((s) => s.rawPolicy);
|
||||
const subject = usePolicyStore((s) => s.subject);
|
||||
const resolveFor = usePolicyStore((s) => s.resolveFor);
|
||||
// Re-read when another account's subject has been worked out.
|
||||
const accountSubjects = useAccountSubjects((s) => s.subjects);
|
||||
return useMemo(() => {
|
||||
const accountSubject = subjectForAccount(accountId);
|
||||
return accountSubject === subject ? policy : resolveFor(accountSubject);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [policy, rawPolicy, subject, resolveFor, accountId, accountSubjects]);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { EmailTemplate } from '@/lib/template-types';
|
||||
import type { TemplateSaveResult, TemplateSource } from '@/lib/templates/template-sources';
|
||||
import type { TemplateData } from '@/lib/templates/template-reducer';
|
||||
import { personalTemplatesFor } 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';
|
||||
|
||||
export type { LocalOnlyReason, TemplateSaveResult };
|
||||
|
||||
export interface TemplateSourceActions {
|
||||
add: (source: TemplateSource, data: TemplateData) => Promise<TemplateSaveResult>;
|
||||
update: (source: TemplateSource, id: string, data: TemplateData) => Promise<TemplateSaveResult>;
|
||||
remove: (source: TemplateSource, id: string) => Promise<TemplateSaveResult>;
|
||||
duplicate: (source: TemplateSource, id: string, nameSuffix: string) => Promise<TemplateSaveResult>;
|
||||
toggleFavorite: (source: TemplateSource, id: string) => Promise<TemplateSaveResult>;
|
||||
/** A read-only (organisation or team) template copied into the user's own. */
|
||||
copyToPersonal: (template: EmailTemplate) => Promise<TemplateSaveResult>;
|
||||
/** Personal templates that stay in this browser, by id. */
|
||||
localOnly: Record<string, LocalOnlyReason>;
|
||||
/** Personal templates live in the account (server metadata), not only in this browser. */
|
||||
storedInAccount: boolean;
|
||||
}
|
||||
|
||||
const NO_LOCAL_ONLY: Record<string, LocalOnlyReason> = {};
|
||||
|
||||
/**
|
||||
* Changes to the templates of each source the manager shows, for the
|
||||
* signed-in account `accountId` (the active one when left out). Personal
|
||||
* templates change in this browser at once and go to the account in the
|
||||
* background; team templates are written to the group's Inbox at once;
|
||||
* read-only sources refuse every change.
|
||||
*/
|
||||
export function useTemplateSourceActions(accountId?: string | null): TemplateSourceActions {
|
||||
const resolvedId = useAuthStore((s) => accountId || s.activeAccountId);
|
||||
const partition = useAccountTemplatesStore((s) => (resolvedId ? s.personal[resolvedId] : undefined));
|
||||
const storedInAccount = !!partition;
|
||||
const localOnly = partition?.localOnly ?? NO_LOCAL_ONLY;
|
||||
return useMemo(() => {
|
||||
const ops = () => personalTemplatesFor(resolvedId);
|
||||
const group = (source: TemplateSource) => source.key.slice('team:'.length);
|
||||
const find = (source: TemplateSource, id: string) => source.templates.find((t) => t.id === id);
|
||||
const team = (source: TemplateSource, template: EmailTemplate, base: string | null): Promise<TemplateSaveResult> =>
|
||||
resolvedId ? saveTeamTemplate(resolvedId, group(source), template, base) : Promise.resolve('failed');
|
||||
const fresh = (data: TemplateData): EmailTemplate => {
|
||||
const at = new Date().toISOString();
|
||||
return { ...data, id: generateUUID(), createdAt: at, updatedAt: at };
|
||||
};
|
||||
return {
|
||||
add: async (source, data) => {
|
||||
if (!source.editable) return 'forbidden';
|
||||
if (source.kind === 'personal') {
|
||||
ops().add(data);
|
||||
return 'ok';
|
||||
}
|
||||
return source.kind === 'team' ? team(source, fresh(data), null) : 'forbidden';
|
||||
},
|
||||
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 team(source, { ...existing, ...data, id, updatedAt: new Date().toISOString() }, existing.updatedAt);
|
||||
},
|
||||
remove: async (source, id) => {
|
||||
if (!source.editable) return 'forbidden';
|
||||
if (source.kind === 'personal') {
|
||||
ops().remove(id);
|
||||
return 'ok';
|
||||
}
|
||||
const existing = find(source, id);
|
||||
if (source.kind !== 'team' || !existing || !resolvedId) return 'forbidden';
|
||||
return deleteTeamTemplate(resolvedId, group(source), id, existing.updatedAt);
|
||||
},
|
||||
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';
|
||||
return team(source, fresh({ ...copyOf(existing), name: `${existing.name} ${suffix}` }), null);
|
||||
},
|
||||
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';
|
||||
},
|
||||
localOnly,
|
||||
storedInAccount,
|
||||
};
|
||||
}, [resolvedId, localOnly, storedInAccount]);
|
||||
}
|
||||
|
||||
/** The fields a copy of a shared template takes into the user's own list. */
|
||||
export function copyOf(template: EmailTemplate): TemplateData {
|
||||
return {
|
||||
name: template.name,
|
||||
subject: template.subject,
|
||||
body: template.body,
|
||||
isHTML: template.isHTML,
|
||||
category: template.category,
|
||||
defaultRecipients: template.defaultRecipients,
|
||||
isFavorite: false,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useAccountPolicy } from '@/hooks/use-account-policy';
|
||||
import { orgLibraryTemplates } from '@/lib/templates/org-library';
|
||||
import {
|
||||
ORGANISATION_SOURCE,
|
||||
PERSONAL_SOURCE,
|
||||
byName,
|
||||
teamSourceKey,
|
||||
type TemplateSource,
|
||||
} from '@/lib/templates/template-sources';
|
||||
import { useTemplateStore } from '@/stores/template-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useAccountTemplatesStore } from '@/stores/account-templates-store';
|
||||
|
||||
export interface TemplateSources {
|
||||
/** Personal first, then teams, then the organisation library; empty sources left out except personal. */
|
||||
sources: TemplateSource[];
|
||||
/** The admin hid personal templates (`personalTemplates` hidden): only organisation and team templates. */
|
||||
personalHidden: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The templates a user can pick when writing from the signed-in account
|
||||
* `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);
|
||||
const resolvedId = useAuthStore((s) => accountId || s.activeAccountId);
|
||||
// The account's own copy once it keeps its templates (Stalwart 1.0),
|
||||
// the browser's list before that and on other servers.
|
||||
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, teams, teamsLocked]);
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
import { JmapClient } from './helpers/jmap';
|
||||
import { accountSwitcher, neutralizeDevOverlay } from './helpers/app';
|
||||
import {
|
||||
ORG,
|
||||
ORG_PASSWORD,
|
||||
ORG_SERVER,
|
||||
adminContext,
|
||||
contextOptions,
|
||||
getPolicy,
|
||||
putPolicy,
|
||||
requireOrgEnv,
|
||||
shot,
|
||||
userPage,
|
||||
} from './helpers/org';
|
||||
|
||||
/**
|
||||
* Shared templates (organisation feature 3), through the real UI on a VM 106
|
||||
* test server.
|
||||
*
|
||||
* On the v1.0.0 release (label 1.0.0, the only one with working metadata):
|
||||
* the admin builds a library on Organisation > Templates, for Everyone and
|
||||
* for org-two.test; org-alice adds a team template in org-team, org-bob
|
||||
* picks it; org-alice's own template reaches a second browser; org-dave
|
||||
* sees the library of his domain; the admin locks team templates for the
|
||||
* group; the picker, the manager and the admin page work at phone width.
|
||||
*
|
||||
* On 0.16 and the 1.0 test build: no team section, personal templates stay
|
||||
* in the browser as before, and the app stays online.
|
||||
*
|
||||
* The admin's policy is put back, and every template entry these tests put
|
||||
* in the accounts' Inbox metadata is removed again.
|
||||
*/
|
||||
|
||||
test.describe.configure({ mode: 'serial', timeout: 240000 });
|
||||
|
||||
const METADATA = ORG_SERVER === '1.0.0';
|
||||
const STAMP = Date.now().toString(36);
|
||||
const LIBRARY = `Org welcome ${STAMP}`;
|
||||
const SCOPE_TEMPLATE = `Two offer ${STAMP}`;
|
||||
const TEAM_TEMPLATE = `Team reply ${STAMP}`;
|
||||
const TEAM_BODY = `The team answers ${STAMP}`;
|
||||
const PERSONAL = `Alice own ${STAMP}`;
|
||||
const LOCAL = `Local only ${STAMP}`;
|
||||
const SEEDED_A = `Browser A ${STAMP}`;
|
||||
const SEEDED_B = `Browser B ${STAMP}`;
|
||||
|
||||
const CORE = 'urn:ietf:params:jmap:core';
|
||||
const MAIL = 'urn:ietf:params:jmap:mail';
|
||||
const META = 'urn:ietf:params:jmap:metadata';
|
||||
|
||||
let original: Record<string, unknown>;
|
||||
|
||||
/** Bulwark's template entries in the Inbox metadata of an account the user can open. */
|
||||
async function templateEntries(email: string, accountName = email): Promise<Record<string, Record<string, unknown>>> {
|
||||
const client = await JmapClient.connect(email, ORG_PASSWORD);
|
||||
const accountId = accountName === email ? client.accountId : client.accountIdByName(accountName);
|
||||
const r = await client.request([
|
||||
['Mailbox/query', { accountId, filter: { role: 'inbox' } }, 'q'],
|
||||
['Mailbox/get', { accountId, '#ids': { resultOf: 'q', name: 'Mailbox/query', path: '/ids' }, properties: ['id', 'metadata'] }, 'g'],
|
||||
], [CORE, MAIL, META]);
|
||||
const metadata = (r.methodResponses[1][1].list[0]?.metadata ?? {}) as Record<string, Record<string, unknown>>;
|
||||
return Object.fromEntries(Object.entries(metadata).filter(([key]) => key === 'org.bulwarkmail.templates' || key.startsWith('org.bulwarkmail.template.')));
|
||||
}
|
||||
|
||||
/** Removes Bulwark's template entries (never the shared tags or anything else) from an Inbox. */
|
||||
async function clearTemplateEntries(email: string, accountName = email): Promise<void> {
|
||||
const client = await JmapClient.connect(email, ORG_PASSWORD);
|
||||
const accountId = accountName === email ? client.accountId : client.accountIdByName(accountName);
|
||||
const inbox = await client.request([['Mailbox/query', { accountId, filter: { role: 'inbox' } }, 'q']], [CORE, MAIL]);
|
||||
const inboxId = inbox.methodResponses[0][1].ids[0];
|
||||
for (const key of Object.keys(await templateEntries(email, accountName))) {
|
||||
await client.request([['Mailbox/set', { accountId, update: { [inboxId]: { [`metadata/${key}`]: null } } }, '0']], [CORE, MAIL, META]);
|
||||
}
|
||||
}
|
||||
|
||||
async function clearAll(): Promise<void> {
|
||||
if (!METADATA) return;
|
||||
for (const email of [ORG.alice, ORG.bob, ORG.carol, ORG.dave]) await clearTemplateEntries(email);
|
||||
await clearTemplateEntries(ORG.alice, ORG.team);
|
||||
}
|
||||
|
||||
async function startCompose(page: Page): Promise<void> {
|
||||
await page.getByRole('button', { name: 'Compose', exact: true }).first().click();
|
||||
await page.locator('[data-testid="email-composer"]').waitFor({ state: 'visible', timeout: 15000 });
|
||||
}
|
||||
|
||||
/** Opens the template picker of the open composer. */
|
||||
async function openPicker(page: Page) {
|
||||
const menu = page.getByTestId('composer-templates-menu');
|
||||
if (await menu.isVisible().catch(() => false)) {
|
||||
await menu.click();
|
||||
await page.getByRole('menuitem', { name: 'Insert template' }).or(page.getByRole('button', { name: 'Insert template' })).first().click();
|
||||
} else {
|
||||
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur());
|
||||
await page.keyboard.press('t');
|
||||
}
|
||||
const dialog = page.getByRole('dialog', { name: 'Choose a Template' });
|
||||
await expect(dialog).toBeVisible();
|
||||
return dialog;
|
||||
}
|
||||
|
||||
/** Settings > Composing > Templates > Manage Templates. */
|
||||
async function openManager(page: Page) {
|
||||
await page.goto('/settings/templates');
|
||||
const manage = page.getByRole('button', { name: 'Manage Templates' });
|
||||
if (!(await manage.isVisible().catch(() => false))) {
|
||||
// Phone: the settings list first.
|
||||
await page.getByRole('button', { name: 'Composing' }).or(page.getByRole('link', { name: 'Composing' })).first().click();
|
||||
}
|
||||
await page.getByRole('button', { name: 'Manage Templates' }).click();
|
||||
const dialog = page.getByRole('dialog', { name: 'Email Templates' });
|
||||
await expect(dialog).toBeVisible();
|
||||
return dialog;
|
||||
}
|
||||
|
||||
async function createTemplate(dialog: ReturnType<Page['getByRole']>, name: string, body: string): Promise<void> {
|
||||
await dialog.getByRole('button', { name: 'New Template' }).click();
|
||||
await dialog.getByPlaceholder('e.g., Follow-up email').fill(name);
|
||||
await dialog.getByPlaceholder('Email body content...').fill(body);
|
||||
await dialog.getByRole('button', { name: 'Create' }).click();
|
||||
await expect(dialog.getByTestId('template-row').filter({ hasText: name })).toBeVisible({ timeout: 20000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* A browser that already holds templates of its own from before (in
|
||||
* template-storage), signed in as `email`.
|
||||
*/
|
||||
async function userWithLocalTemplates(browser: import('@playwright/test').Browser, email: string, templates: Array<{ id: string; name: string }>): Promise<Page> {
|
||||
const context = await browser.newContext(contextOptions());
|
||||
const now = new Date().toISOString();
|
||||
const stored = JSON.stringify({
|
||||
state: {
|
||||
templates: templates.map((t) => ({ ...t, subject: '', body: `${t.name} body`, isHTML: false, category: '', isFavorite: false, createdAt: now, updatedAt: now })),
|
||||
recentTemplateIds: [],
|
||||
deletedTemplateIds: {},
|
||||
},
|
||||
version: 1,
|
||||
});
|
||||
await context.addInitScript((value) => {
|
||||
if (!localStorage.getItem('template-storage')) localStorage.setItem('template-storage', value);
|
||||
}, stored);
|
||||
const page = await context.newPage();
|
||||
await neutralizeDevOverlay(page);
|
||||
await page.goto('/');
|
||||
await page.fill('#username', email);
|
||||
await page.fill('#password', ORG_PASSWORD);
|
||||
await page.click('button[type="submit"]');
|
||||
await accountSwitcher(page).waitFor({ state: 'visible', timeout: 60000 });
|
||||
return page;
|
||||
}
|
||||
|
||||
async function expectOnline(page: Page): Promise<void> {
|
||||
await expect(page.getByText('Connection lost. Attempting to reconnect…')).toHaveCount(0);
|
||||
}
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
requireOrgEnv();
|
||||
const admin = await adminContext(browser);
|
||||
original = await getPolicy(admin);
|
||||
await putPolicy(admin, {
|
||||
...original,
|
||||
restrictions: {},
|
||||
organisation: {},
|
||||
scopes: [
|
||||
{ id: 'org-two', label: 'Org two', match: { domains: ['org-two.test'] }, policy: {} },
|
||||
{ id: 'org-team', label: 'Org team', match: { groups: [ORG.team] }, policy: {} },
|
||||
],
|
||||
});
|
||||
await admin.close();
|
||||
await clearAll();
|
||||
});
|
||||
|
||||
test.afterAll(async ({ browser }) => {
|
||||
const admin = await adminContext(browser);
|
||||
await putPolicy(admin, original);
|
||||
await admin.close();
|
||||
await clearAll();
|
||||
});
|
||||
|
||||
test('the admin builds the library for Everyone and for org-two.test', async ({ browser }) => {
|
||||
const admin = await adminContext(browser);
|
||||
const page = await admin.newPage();
|
||||
await page.goto('/admin/org-templates');
|
||||
await expect(page.getByRole('heading', { name: 'Organisation library' })).toBeVisible();
|
||||
// The dev server loads the policy twice (strict mode): wait for it.
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await page.getByTestId('org-template-add').click();
|
||||
await page.getByTestId('org-template-name').last().fill(LIBRARY);
|
||||
await page.getByTestId('org-template-subject').last().fill('Welcome');
|
||||
await page.getByTestId('org-template-body').last().fill(`<p>Welcome to <b>Example</b> ${STAMP}</p>`);
|
||||
await page.getByLabel('The text is HTML').last().check();
|
||||
await expect(page.getByTestId('org-template-preview').last().locator('b')).toHaveText('Example');
|
||||
await page.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect(page.getByText('Saved.')).toBeVisible();
|
||||
await shot(page, 'templates-admin-page');
|
||||
|
||||
await page.getByRole('button', { name: 'Org two' }).click();
|
||||
await expect(page.getByTestId('org-template-inherited')).toContainText(LIBRARY);
|
||||
await page.getByTestId('org-template-add').click();
|
||||
await page.getByTestId('org-template-name').last().fill(SCOPE_TEMPLATE);
|
||||
await page.getByTestId('org-template-body').last().fill(`Our offer ${STAMP}`);
|
||||
await page.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect(page.getByText('Saved.')).toBeVisible();
|
||||
await shot(page, 'templates-admin-scope');
|
||||
|
||||
const policy = await getPolicy(admin);
|
||||
const everyone = (policy.organisation as { templates: { library: Array<{ name: string; isHTML: boolean }> } }).templates.library;
|
||||
expect(everyone.map((t) => t.name)).toEqual([LIBRARY]);
|
||||
expect(everyone[0].isHTML).toBe(true);
|
||||
const two = (policy.scopes as Array<{ id: string; policy: { organisation?: { templates?: { library: Array<{ name: string }> } } } }>)
|
||||
.find((s) => s.id === 'org-two')!;
|
||||
expect(two.policy.organisation?.templates?.library.map((t) => t.name)).toEqual([SCOPE_TEMPLATE]);
|
||||
await admin.close();
|
||||
});
|
||||
|
||||
test('the library is read-only for users and follows their domain', async ({ browser }) => {
|
||||
const alice = await userPage(browser, ORG.alice);
|
||||
await startCompose(alice);
|
||||
let picker = await openPicker(alice);
|
||||
const organisation = picker.getByTestId('template-source-organisation');
|
||||
await expect(organisation).toContainText(LIBRARY);
|
||||
await expect(organisation).not.toContainText(SCOPE_TEMPLATE);
|
||||
await alice.keyboard.press('Escape');
|
||||
|
||||
const manager = await openManager(alice);
|
||||
await manager.getByTestId('template-source-tab-organisation').click();
|
||||
const row = manager.getByTestId('template-row').filter({ hasText: LIBRARY });
|
||||
await expect(row.getByRole('button', { name: 'Edit Template' })).toHaveCount(0);
|
||||
await expect(manager.getByRole('button', { name: 'New Template' })).toHaveCount(0);
|
||||
await row.getByRole('button', { name: 'Preview' }).click();
|
||||
await expect(manager.getByTestId('template-preview').locator('b')).toHaveText('Example');
|
||||
await shot(alice, 'templates-manager-organisation');
|
||||
await expectOnline(alice);
|
||||
await alice.context().close();
|
||||
|
||||
const dave = await userPage(browser, ORG.dave);
|
||||
await startCompose(dave);
|
||||
picker = await openPicker(dave);
|
||||
await expect(picker.getByTestId('template-source-organisation')).toContainText(LIBRARY);
|
||||
await expect(picker.getByTestId('template-source-organisation')).toContainText(SCOPE_TEMPLATE);
|
||||
await shot(dave, 'templates-picker-org-two');
|
||||
await dave.context().close();
|
||||
});
|
||||
|
||||
test('personal templates hidden by the admin: only organisation templates, no saving', async ({ browser }) => {
|
||||
const admin = await adminContext(browser);
|
||||
const page = await admin.newPage();
|
||||
await page.goto('/admin/org-templates');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.getByRole('button', { name: 'Everyone' }).click();
|
||||
await page.getByLabel('Hide').check();
|
||||
await page.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect(page.getByText('Saved.')).toBeVisible();
|
||||
expect(((await getPolicy(admin)).restrictions as Record<string, unknown>).personalTemplates).toEqual({ hidden: true });
|
||||
|
||||
const alice = await userPage(browser, ORG.alice);
|
||||
await startCompose(alice);
|
||||
const menu = alice.getByTestId('composer-templates-menu');
|
||||
await menu.click();
|
||||
await expect(alice.getByText('Save as template')).toHaveCount(0);
|
||||
await alice.getByText('Insert template').first().click();
|
||||
const picker = alice.getByRole('dialog', { name: 'Choose a Template' });
|
||||
await expect(picker.getByTestId('template-source-organisation')).toContainText(LIBRARY);
|
||||
await expect(picker.getByTestId('template-source-personal')).toHaveCount(0);
|
||||
await shot(alice, 'templates-picker-personal-hidden');
|
||||
await alice.keyboard.press('Escape');
|
||||
const manager = await openManager(alice);
|
||||
await expect(manager.getByTestId('template-source-tab-personal')).toHaveCount(0);
|
||||
await alice.context().close();
|
||||
|
||||
const policy = await getPolicy(admin);
|
||||
const restrictions = { ...(policy.restrictions as Record<string, unknown>) };
|
||||
delete restrictions.personalTemplates;
|
||||
await putPolicy(admin, { ...policy, restrictions });
|
||||
await admin.close();
|
||||
});
|
||||
|
||||
test.describe('with metadata (v1.0.0)', () => {
|
||||
test.skip(!METADATA, 'team templates and templates in the account need Stalwart v1.0.0');
|
||||
|
||||
test('org-alice adds a team template in org-team and org-bob uses it', async ({ browser }) => {
|
||||
const alice = await userPage(browser, ORG.alice);
|
||||
const manager = await openManager(alice);
|
||||
await manager.getByTestId('template-source-tab-team').click();
|
||||
await expect(manager.getByTestId('template-source-tab-team')).toContainText(ORG.team);
|
||||
await expect(manager.getByTestId('template-source-note')).toContainText(`Everyone in ${ORG.team}`);
|
||||
await createTemplate(manager, TEAM_TEMPLATE, TEAM_BODY);
|
||||
await shot(alice, 'templates-manager-team');
|
||||
await alice.context().close();
|
||||
|
||||
const stored = await templateEntries(ORG.alice, ORG.team);
|
||||
const entry = Object.entries(stored).find(([, value]) => value.name === TEAM_TEMPLATE);
|
||||
expect(entry, JSON.stringify(Object.keys(stored))).toBeTruthy();
|
||||
expect(entry![1].body).toBe(TEAM_BODY);
|
||||
|
||||
const bob = await userPage(browser, ORG.bob);
|
||||
await startCompose(bob);
|
||||
const picker = await openPicker(bob);
|
||||
const team = picker.getByTestId('template-source-team');
|
||||
await expect(team).toContainText(TEAM_TEMPLATE, { timeout: 20000 });
|
||||
const headings = await picker.getByRole('heading', { level: 2 }).allTextContents();
|
||||
expect(headings).toContain(ORG.team);
|
||||
expect(headings).toContain('Organisation');
|
||||
await shot(bob, 'templates-picker-team');
|
||||
await team.getByText(TEAM_TEMPLATE).click();
|
||||
await expect(picker).toBeHidden();
|
||||
await expect(bob.locator('.ProseMirror').first()).toContainText(TEAM_BODY);
|
||||
await expectOnline(bob);
|
||||
await bob.context().close();
|
||||
});
|
||||
|
||||
test("org-alice's own template is in the account and reaches a second browser", async ({ browser }) => {
|
||||
const first = await userPage(browser, ORG.alice);
|
||||
const manager = await openManager(first);
|
||||
await expect(manager.getByTestId('template-storage-note')).toContainText('kept in your account', { timeout: 20000 });
|
||||
await createTemplate(manager, PERSONAL, `Hello from Alice ${STAMP}`);
|
||||
await shot(first, 'templates-manager-personal');
|
||||
await expect(async () => {
|
||||
const entries = await templateEntries(ORG.alice);
|
||||
expect(Object.values(entries).map((v) => v.name)).toContain(PERSONAL);
|
||||
}).toPass({ timeout: 30000 });
|
||||
await first.context().close();
|
||||
|
||||
const second = await userPage(browser, ORG.alice);
|
||||
await startCompose(second);
|
||||
const picker = await openPicker(second);
|
||||
await expect(picker.getByTestId('template-source-personal')).toContainText(PERSONAL, { timeout: 20000 });
|
||||
await second.context().close();
|
||||
});
|
||||
|
||||
test('templates a browser held from before go into the account, and a second browser keeps both lists', async ({ browser }) => {
|
||||
const idA = `aaaa${STAMP}-0001`;
|
||||
const idB = `bbbb${STAMP}-0002`;
|
||||
const first = await userWithLocalTemplates(browser, ORG.alice, [{ id: idA, name: SEEDED_A }]);
|
||||
await expect(async () => {
|
||||
const names = Object.values(await templateEntries(ORG.alice)).map((v) => v.name);
|
||||
expect(names).toContain(SEEDED_A);
|
||||
}).toPass({ timeout: 45000 });
|
||||
await first.context().close();
|
||||
|
||||
const second = await userWithLocalTemplates(browser, ORG.alice, [{ id: idB, name: SEEDED_B }]);
|
||||
await expect(async () => {
|
||||
const names = Object.values(await templateEntries(ORG.alice)).map((v) => v.name);
|
||||
expect(names).toEqual(expect.arrayContaining([SEEDED_A, SEEDED_B]));
|
||||
}).toPass({ timeout: 45000 });
|
||||
await startCompose(second);
|
||||
const picker = await openPicker(second);
|
||||
await expect(picker.getByTestId('template-source-personal')).toContainText(SEEDED_A, { timeout: 20000 });
|
||||
await expect(picker.getByTestId('template-source-personal')).toContainText(SEEDED_B);
|
||||
await shot(second, 'templates-picker-migrated');
|
||||
await second.context().close();
|
||||
// The marker with the tombstones is there once the lists are in.
|
||||
expect(Object.keys(await templateEntries(ORG.alice))).toContain('org.bulwarkmail.templates');
|
||||
});
|
||||
|
||||
test('a locked team template is read-only for the group', async ({ browser }) => {
|
||||
const admin = await adminContext(browser);
|
||||
const page = await admin.newPage();
|
||||
await page.goto('/admin/org-templates');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.getByRole('button', { name: 'Org team' }).click();
|
||||
await page.getByLabel('Lock').check();
|
||||
await page.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await expect(page.getByText('Saved.')).toBeVisible();
|
||||
await page.getByLabel('Lock').scrollIntoViewIfNeeded();
|
||||
await shot(page, 'templates-admin-lock');
|
||||
const policy = await getPolicy(admin);
|
||||
const team = (policy.scopes as Array<{ id: string; policy: { restrictions?: Record<string, unknown> } }>).find((s) => s.id === 'org-team')!;
|
||||
expect(team.policy.restrictions).toEqual({ teamTemplates: { locked: true } });
|
||||
await admin.close();
|
||||
|
||||
const bob = await userPage(browser, ORG.bob);
|
||||
const manager = await openManager(bob);
|
||||
await manager.getByTestId('template-source-tab-team').click();
|
||||
await expect(manager.getByTestId('template-source-note')).toContainText('Set by your organisation');
|
||||
await expect(manager.getByRole('button', { name: 'New Template' })).toHaveCount(0);
|
||||
const row = manager.getByTestId('template-row').filter({ hasText: TEAM_TEMPLATE });
|
||||
await expect(row).toBeVisible();
|
||||
await expect(row.getByRole('button', { name: 'Edit Template' })).toHaveCount(0);
|
||||
await expect(row.getByRole('button', { name: 'Delete' })).toHaveCount(0);
|
||||
await shot(bob, 'templates-manager-team-locked');
|
||||
await bob.context().close();
|
||||
|
||||
// The lock leaves the template in place; members still use it.
|
||||
expect(Object.values(await templateEntries(ORG.alice, ORG.team)).map((v) => v.name)).toContain(TEAM_TEMPLATE);
|
||||
});
|
||||
|
||||
test('the picker, the manager and the admin page work at phone width', async ({ browser }) => {
|
||||
const bob = await userPage(browser, ORG.bob, true);
|
||||
await startCompose(bob);
|
||||
const picker = await openPicker(bob);
|
||||
await expect(picker.getByTestId('template-source-team')).toContainText(TEAM_TEMPLATE, { timeout: 20000 });
|
||||
const box = await picker.boundingBox();
|
||||
expect(box!.x).toBeGreaterThanOrEqual(0);
|
||||
expect(box!.x + box!.width).toBeLessThanOrEqual(390);
|
||||
await shot(bob, 'templates-picker-phone');
|
||||
await picker.getByText(TEAM_TEMPLATE).click();
|
||||
await expect(bob.locator('.ProseMirror').first()).toContainText(TEAM_BODY);
|
||||
await bob.context().close();
|
||||
|
||||
const alice = await userPage(browser, ORG.alice, true);
|
||||
const manager = await openManager(alice);
|
||||
const managerBox = await manager.boundingBox();
|
||||
expect(managerBox!.x + managerBox!.width).toBeLessThanOrEqual(390);
|
||||
await expect(manager.getByTestId('template-sources')).toBeVisible();
|
||||
await manager.getByTestId('template-source-tab-team').click();
|
||||
await expect(manager.getByTestId('template-row').filter({ hasText: TEAM_TEMPLATE })).toBeVisible();
|
||||
const overflow = await alice.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
|
||||
expect(overflow).toBeLessThanOrEqual(0);
|
||||
await shot(alice, 'templates-manager-phone');
|
||||
await alice.context().close();
|
||||
|
||||
const admin = await adminContext(browser, true);
|
||||
const page = await admin.newPage();
|
||||
await page.goto('/admin/org-templates');
|
||||
await expect(page.getByTestId('org-template-add')).toBeVisible();
|
||||
const adminOverflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
|
||||
expect(adminOverflow).toBeLessThanOrEqual(0);
|
||||
await shot(page, 'templates-admin-page-phone');
|
||||
await admin.close();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('without metadata (0.16, 1.0 test build)', () => {
|
||||
test.skip(METADATA, 'this server keeps templates in the account');
|
||||
|
||||
test('no team section, personal templates stay in the browser, and the app stays online', async ({ browser }) => {
|
||||
// The 1.0 test build advertises metadata and drops the connection on
|
||||
// it: count the metadata requests that went out and failed.
|
||||
const metadataRequests: string[] = [];
|
||||
const context = await browser.newContext(contextOptions());
|
||||
context.on('requestfailed', (request) => {
|
||||
if ((request.postData() ?? '').includes('"properties":["id","metadata"]')) metadataRequests.push(request.failure()?.errorText ?? 'failed');
|
||||
});
|
||||
const alice = await context.newPage();
|
||||
await neutralizeDevOverlay(alice);
|
||||
await alice.goto('/');
|
||||
await alice.fill('#username', ORG.alice);
|
||||
await alice.fill('#password', ORG_PASSWORD);
|
||||
await alice.click('button[type="submit"]');
|
||||
await accountSwitcher(alice).waitFor({ state: 'visible', timeout: 60000 });
|
||||
const manager = await openManager(alice);
|
||||
await expect(manager.getByTestId('template-source-tab-team')).toHaveCount(0);
|
||||
await expect(manager.getByTestId('template-storage-note')).toHaveCount(0);
|
||||
await createTemplate(manager, LOCAL, `Kept here ${STAMP}`);
|
||||
const stored = await alice.evaluate(() => ({
|
||||
browser: localStorage.getItem('template-storage') ?? '',
|
||||
account: localStorage.getItem('account-templates') ?? '',
|
||||
}));
|
||||
expect(stored.browser).toContain(LOCAL);
|
||||
expect(stored.account).not.toContain(LOCAL);
|
||||
await shot(alice, 'templates-manager-no-metadata');
|
||||
await alice.keyboard.press('Escape');
|
||||
|
||||
await alice.goto('/');
|
||||
await startCompose(alice);
|
||||
const picker = await openPicker(alice);
|
||||
await expect(picker.getByTestId('template-source-team')).toHaveCount(0);
|
||||
await expect(picker).toContainText(LOCAL);
|
||||
// Long enough for the keep-alive ping after the metadata request failed.
|
||||
await alice.waitForTimeout(35000);
|
||||
await expectOnline(alice);
|
||||
if (ORG_SERVER === '1.0') expect(metadataRequests.length).toBeGreaterThan(0);
|
||||
else expect(metadataRequests).toEqual([]);
|
||||
await alice.context().close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,424 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Personal templates kept in the user's own Inbox metadata on Stalwart
|
||||
// v1.0.0, one namespace per template plus a marker entry holding the
|
||||
// tombstones of deleted ones. The server below answers with the envelopes
|
||||
// recorded from stw-rel (fixtures/…template-metadata.json) and keeps the
|
||||
// metadata in memory, refusing like the real one once 64 KB are used.
|
||||
|
||||
const config = vi.hoisted(() => ({ value: { settingsSyncEnabled: true } as Record<string, unknown> | null }));
|
||||
vi.mock('@/hooks/use-config', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
getCachedConfig: () => config.value,
|
||||
}));
|
||||
|
||||
import { JMAPClient } from '../jmap/client';
|
||||
import { syncPersonalTemplates, type BrowserList } from '../templates/account-template-sync';
|
||||
import { planPersonalSync, pruneTombstones, storableId } from '../templates/metadata-templates';
|
||||
import { browserListFor, flushAccountTemplates, personalTemplatesFor, syncAccountTemplates } from '../templates/personal-templates';
|
||||
import { useAccountTemplatesStore } from '@/stores/account-templates-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useTemplateStore } from '@/stores/template-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { purgeSignedOutData, runBeforeSignOut } from '@/lib/sign-out-cleanup';
|
||||
import type { EmailTemplate } from '../template-types';
|
||||
import recorded from './fixtures/stalwart-1.0.0-template-metadata.json';
|
||||
|
||||
const METADATA_CAP = {
|
||||
'urn:ietf:params:jmap:metadata': {
|
||||
dataTypes: { Mailbox: { namespaces: [], supportsVendorNamespaces: true, supportsPrivate: true, maxDepth: 8 } },
|
||||
},
|
||||
};
|
||||
|
||||
function session(withMetadata = true) {
|
||||
return {
|
||||
capabilities: { 'urn:ietf:params:jmap:core': {}, 'urn:ietf:params:jmap:mail': {}, ...(withMetadata ? { 'urn:ietf:params:jmap:metadata': {} } : {}) },
|
||||
accounts: {
|
||||
bl: { name: 'carol@example.org', isPersonal: true, isReadOnly: false, accountCapabilities: { 'urn:ietf:params:jmap:mail': {}, ...(withMetadata ? METADATA_CAP : {}) } },
|
||||
},
|
||||
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'bl' },
|
||||
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 server: Record<string, unknown>;
|
||||
let sets: Array<{ key: string; value: unknown }>;
|
||||
let drop: boolean;
|
||||
let omitMetadata: boolean;
|
||||
let onSet: (() => void) | null;
|
||||
const SHARED_MAX = 65536;
|
||||
|
||||
function answer(body: { methodCalls: Call[] }): Response {
|
||||
if (drop) throw new TypeError('Failed to fetch');
|
||||
const [method, args] = body.methodCalls[body.methodCalls.length - 1];
|
||||
if (method === 'Mailbox/get') {
|
||||
const out = clone(recorded.getEmpty);
|
||||
const inbox = (out.methodResponses[1][1] as { list: Array<Record<string, unknown>> }).list[0];
|
||||
if (omitMetadata) delete inbox.metadata;
|
||||
else inbox.metadata = clone(server);
|
||||
return json(out);
|
||||
}
|
||||
if (method === 'Mailbox/set') {
|
||||
onSet?.();
|
||||
const patch = (args.update as Record<string, Record<string, unknown>>).a;
|
||||
const [path, value] = Object.entries(patch)[0];
|
||||
const namespace = path.replace(/^metadata\//, '');
|
||||
const next = { ...server, [namespace]: value };
|
||||
if (value === null) delete next[namespace];
|
||||
if (new TextEncoder().encode(JSON.stringify(next)).length > SHARED_MAX) return json(clone(recorded.setNoRoom));
|
||||
server = next;
|
||||
sets.push({ key: namespace, value });
|
||||
return json(clone(value === null ? recorded.deleteOne : recorded.setOne));
|
||||
}
|
||||
throw new Error(`unexpected ${method}`);
|
||||
}
|
||||
|
||||
const tpl = (id: string, name: string, updatedAt = '2026-10-01T00:00:00.000Z', extra: Partial<EmailTemplate> = {}): EmailTemplate => ({
|
||||
id, name, subject: '', body: `${name} body`, isHTML: false, category: '', isFavorite: false,
|
||||
createdAt: '2026-10-01T00:00:00.000Z', updatedAt, ...extra,
|
||||
});
|
||||
const ns = (id: string) => `org.bulwarkmail.template.${id}`;
|
||||
const MARKER = 'org.bulwarkmail.templates';
|
||||
const ago = (ms: number) => new Date(Date.now() - ms).toISOString();
|
||||
|
||||
let client: JMAPClient;
|
||||
let connection: ReturnType<typeof vi.fn<(connected: boolean) => void>>;
|
||||
let browser: BrowserList;
|
||||
const sync = () => syncPersonalTemplates('carol@mail', client, () => browser);
|
||||
const list = (templates: EmailTemplate[]): BrowserList => ({ status: 'list', templates });
|
||||
const partition = () => useAccountTemplatesStore.getState().personal['carol@mail'];
|
||||
const names = () => partition().templates.map((t) => t.name).sort();
|
||||
const edit = (fn: (templates: EmailTemplate[]) => EmailTemplate[]) =>
|
||||
useAccountTemplatesStore.getState().updatePersonal('carol@mail', (p) => ({ ...p, templates: fn(p.templates) }));
|
||||
|
||||
async function connect(withMetadata = true) {
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||
fetchSpy.mockResolvedValueOnce(json(session(withMetadata)));
|
||||
client = new JMAPClient('https://mail.example.com', 'carol@example.org', 'pw');
|
||||
await client.connect();
|
||||
fetchSpy.mockReset();
|
||||
fetchSpy.mockImplementation(async (_url: RequestInfo | URL, init?: RequestInit) => answer(JSON.parse(String(init?.body ?? '{}'))));
|
||||
connection = vi.fn<(connected: boolean) => void>();
|
||||
client.onConnectionChange(connection);
|
||||
}
|
||||
|
||||
const original = useAuthStore.getState();
|
||||
const asCarol = () => useAuthStore.setState({
|
||||
activeAccountId: 'carol@mail',
|
||||
getClientForAccount: (id: string) => (id === 'carol@mail' ? client : undefined),
|
||||
} as never);
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.useRealTimers();
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
server = {};
|
||||
sets = [];
|
||||
drop = false;
|
||||
omitMetadata = false;
|
||||
onSet = null;
|
||||
browser = list([]);
|
||||
config.value = { settingsSyncEnabled: true };
|
||||
localStorage.removeItem('settings-sync-owner');
|
||||
useAccountTemplatesStore.setState({ personal: {}, teams: {}, active: null, browserListOwner: null });
|
||||
useTemplateStore.setState({ templates: [], deletedTemplateIds: {}, recentTemplateIds: [] });
|
||||
useSettingsStore.setState({ settingsSyncDisabled: false });
|
||||
await connect();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
useAuthStore.setState({ activeAccountId: original.activeAccountId, getClientForAccount: original.getClientForAccount } as never);
|
||||
});
|
||||
|
||||
describe('the browser\'s own list', () => {
|
||||
it('goes into an empty account, with the marker after it', async () => {
|
||||
browser = list([tpl('aaaa-1', 'Hello'), tpl('aaaa-2', 'Thanks')]);
|
||||
expect(await sync()).toBe('synced');
|
||||
expect(sets.map((s) => s.key)).toEqual([ns('aaaa-1'), ns('aaaa-2'), MARKER]);
|
||||
expect(server[MARKER]).toEqual({ version: 1, deleted: {} });
|
||||
expect(names()).toEqual(['Hello', 'Thanks']);
|
||||
expect(partition().markerOnServer).toBe(true);
|
||||
|
||||
// Each version is taken once: the next round writes nothing.
|
||||
sets = [];
|
||||
await sync();
|
||||
expect(sets).toEqual([]);
|
||||
});
|
||||
|
||||
it('doesn\'t bring back a template deleted in the account once its tombstone has expired', async () => {
|
||||
browser = list([tpl('aaaa-1', 'Hello'), tpl('aaaa-2', 'Thanks')]);
|
||||
await sync();
|
||||
personalTemplatesFor('carol@mail').remove('aaaa-1');
|
||||
await sync();
|
||||
expect(server[ns('aaaa-1')]).toBeUndefined();
|
||||
// 90 days later the tombstone is gone; the browser's list still has the old copy.
|
||||
useAccountTemplatesStore.getState().updatePersonal('carol@mail', (p) => ({ ...p, deletedTemplateIds: {}, serverDeleted: {} }));
|
||||
server[MARKER] = { version: 1, deleted: {} };
|
||||
await sync();
|
||||
expect(names()).toEqual(['Thanks']);
|
||||
expect(server[ns('aaaa-1')]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('is merged into an account that holds templates already, so a second browser keeps both lists', async () => {
|
||||
server = { [ns('bbbb-1')]: tpl('bbbb-1', 'From the other browser'), [MARKER]: { version: 1, deleted: {} } };
|
||||
browser = list([tpl('aaaa-1', 'Only here')]);
|
||||
await sync();
|
||||
expect(names()).toEqual(['From the other browser', 'Only here']);
|
||||
expect(sets.map((s) => s.key)).toEqual([ns('aaaa-1')]);
|
||||
});
|
||||
|
||||
it('leaves out a template that a newer tombstone deleted', async () => {
|
||||
server = { [MARKER]: { version: 1, deleted: { 'aaaa-1': ago(1000) } } };
|
||||
browser = list([tpl('aaaa-1', 'Deleted elsewhere', ago(60_000)), tpl('aaaa-2', 'Kept', ago(60_000))]);
|
||||
await sync();
|
||||
expect(names()).toEqual(['Kept']);
|
||||
expect(server[ns('aaaa-1')]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('is not used, and no marker written, while it isn\'t known whose it is', async () => {
|
||||
browser = { status: 'unknown' };
|
||||
useTemplateStore.setState({ templates: [tpl('aaaa-9', 'Someone else’s')] });
|
||||
await sync();
|
||||
expect(sets).toEqual([]);
|
||||
expect(partition().templates).toEqual([]);
|
||||
expect(partition().markerOnServer).toBe(false);
|
||||
});
|
||||
|
||||
it('gives an id that doesn\'t fit a metadata key the same new id on every device', async () => {
|
||||
const id = storableId('Demo_Template 1');
|
||||
expect(id).toMatch(/^[0-9a-f]{28}$/);
|
||||
expect(storableId('Demo_Template 1')).toBe(id);
|
||||
expect(storableId('Demo_Template 2')).not.toBe(id);
|
||||
browser = list([tpl('Demo_Template 1', 'Old id')]);
|
||||
await sync();
|
||||
expect(partition().templates[0].id).toBe(id);
|
||||
expect(server[ns(id)]).toBeTruthy();
|
||||
});
|
||||
|
||||
it('keeps a template over 8 KB and one the server has no room for in this browser, with why', async () => {
|
||||
server = { 'org.bulwarkmail.other': { fill: 'y'.repeat(SHARED_MAX - 4000) } };
|
||||
browser = list([tpl('aaaa-big', 'Big', undefined, { body: 'x'.repeat(9000) }), tpl('aaaa-mid', 'Mid', undefined, { body: 'z'.repeat(5000) }), tpl('aaaa-ok', 'Small')]);
|
||||
await sync();
|
||||
expect(partition().localOnly).toEqual({ 'aaaa-big': 'too-large', 'aaaa-mid': 'no-room' });
|
||||
expect(names()).toEqual(['Big', 'Mid', 'Small']);
|
||||
expect(server[ns('aaaa-ok')]).toBeTruthy();
|
||||
expect(sets.some((s) => s.key === ns('aaaa-big'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('later rounds', () => {
|
||||
beforeEach(async () => {
|
||||
browser = list([tpl('aaaa-1', 'One'), tpl('aaaa-2', 'Two')]);
|
||||
await sync();
|
||||
sets = [];
|
||||
});
|
||||
|
||||
it('take a newer server version and upload a newer local one', async () => {
|
||||
server[ns('aaaa-1')] = tpl('aaaa-1', 'One, edited elsewhere', '2026-10-05T00:00:00.000Z');
|
||||
edit((templates) => templates.map((t) => (t.id === 'aaaa-2' ? { ...t, name: 'Two, edited here', updatedAt: '2026-10-06T00:00:00.000Z' } : t)));
|
||||
await sync();
|
||||
expect(names()).toEqual(['One, edited elsewhere', 'Two, edited here']);
|
||||
expect(sets.map((s) => s.key)).toEqual([ns('aaaa-2')]);
|
||||
});
|
||||
|
||||
it('upload an entry that is missing without a tombstone, never take it as deleted', async () => {
|
||||
delete server[ns('aaaa-1')];
|
||||
await sync();
|
||||
expect(names()).toEqual(['One', 'Two']);
|
||||
expect(server[ns('aaaa-1')]).toBeTruthy();
|
||||
});
|
||||
|
||||
it('delete through the tombstone: the marker first, then the entry', async () => {
|
||||
personalTemplatesFor('carol@mail').remove('aaaa-2');
|
||||
await sync();
|
||||
expect(sets.map((s) => s.key)).toEqual([MARKER, ns('aaaa-2')]);
|
||||
expect((server[MARKER] as { deleted: Record<string, string> }).deleted['aaaa-2']).toBeTruthy();
|
||||
expect(partition().serverDeleted['aaaa-2']).toBeTruthy();
|
||||
});
|
||||
|
||||
it('drop a template another device deleted, also when the store changes during the round (review A)', async () => {
|
||||
server[MARKER] = { version: 1, deleted: { 'aaaa-1': ago(0) } };
|
||||
delete server[ns('aaaa-1')];
|
||||
edit((templates) => templates.map((t) => (t.id === 'aaaa-2' ? { ...t, name: 'Two!', updatedAt: ago(500) } : t)));
|
||||
onSet = () => {
|
||||
// While the upload runs, a new template arrives (this tab or another one).
|
||||
edit((templates) => [...templates, tpl('aaaa-3', 'Three', ago(100))]);
|
||||
onSet = null;
|
||||
};
|
||||
await sync();
|
||||
await sync();
|
||||
expect(partition().templates.map((t) => t.id).sort()).toEqual(['aaaa-2', 'aaaa-3']);
|
||||
expect(server[ns('aaaa-1')]).toBeUndefined();
|
||||
expect(server[ns('aaaa-3')]).toBeTruthy();
|
||||
});
|
||||
|
||||
it('keep what another tab wrote while the round ran', async () => {
|
||||
onSet = () => {
|
||||
// Another tab's write replaces the whole partition (storage rehydrate).
|
||||
const p = partition();
|
||||
useAccountTemplatesStore.setState({
|
||||
personal: {
|
||||
'carol@mail': {
|
||||
...p,
|
||||
templates: [...p.templates.filter((t) => t.id !== 'aaaa-1'), tpl('aaaa-4', 'From the other tab', ago(50))],
|
||||
deletedTemplateIds: { 'aaaa-1': ago(10) },
|
||||
},
|
||||
},
|
||||
});
|
||||
onSet = null;
|
||||
};
|
||||
edit((templates) => templates.map((t) => (t.id === 'aaaa-2' ? { ...t, updatedAt: ago(1000) } : t)));
|
||||
await sync();
|
||||
expect(partition().templates.map((t) => t.id).sort()).toEqual(['aaaa-2', 'aaaa-4']);
|
||||
expect(server[ns('aaaa-1')]).toBeUndefined();
|
||||
expect(server[ns('aaaa-4')]).toBeTruthy();
|
||||
});
|
||||
|
||||
it('keep the copy when an answer comes without the metadata property (review B)', async () => {
|
||||
omitMetadata = true;
|
||||
expect(await sync()).toBe('unsupported');
|
||||
expect(names()).toEqual(['One', 'Two']);
|
||||
});
|
||||
|
||||
it('keep an edit made on a device whose clock is behind (review C)', async () => {
|
||||
server[ns('aaaa-1')] = tpl('aaaa-1', 'Edited on a fast clock', new Date(Date.now() + 3_600_000).toISOString());
|
||||
await sync();
|
||||
personalTemplatesFor('carol@mail').update('aaaa-1', { name: 'Edited here later' });
|
||||
await sync();
|
||||
expect(partition().templates.find((t) => t.id === 'aaaa-1')?.name).toBe('Edited here later');
|
||||
expect((server[ns('aaaa-1')] as EmailTemplate).name).toBe('Edited here later');
|
||||
});
|
||||
});
|
||||
|
||||
describe('whose the browser\'s list is', () => {
|
||||
beforeEach(asCarol);
|
||||
|
||||
it('is unknown until settings sync loaded the account\'s settings, whose templates then reach the account (review D)', async () => {
|
||||
expect(browserListFor('carol@mail')).toEqual({ status: 'unknown' });
|
||||
await syncAccountTemplates('carol@mail');
|
||||
expect(server[MARKER]).toBeUndefined();
|
||||
expect(partition().markerOnServer).toBe(false);
|
||||
|
||||
// The settings blob loads: its templates go into the account's copy too.
|
||||
localStorage.setItem('settings-sync-owner', 'carol@mail');
|
||||
useSettingsStore.getState().importSettings(JSON.stringify({ templates: [tpl('blob-1', 'Only in the blob')], deletedTemplateIds: {} }), { serverAccountId: 'carol@mail' });
|
||||
expect(partition().templates.map((t) => t.id)).toEqual(['blob-1']);
|
||||
await syncAccountTemplates('carol@mail');
|
||||
expect(server[ns('blob-1')]).toBeTruthy();
|
||||
expect(server[MARKER]).toEqual({ version: 1, deleted: {} });
|
||||
});
|
||||
|
||||
it('is another account\'s while that account\'s settings are loaded', () => {
|
||||
localStorage.setItem('settings-sync-owner', 'alice@mail');
|
||||
expect(browserListFor('carol@mail')).toEqual({ status: 'unknown' });
|
||||
expect(browserListFor('dave@mail')).toEqual({ status: 'none' });
|
||||
});
|
||||
|
||||
it('goes into the first account only without settings sync, and others start empty', async () => {
|
||||
config.value = { settingsSyncEnabled: false };
|
||||
useTemplateStore.setState({ templates: [tpl('aaaa-1', 'Browser')] });
|
||||
expect(browserListFor('carol@mail').status).toBe('list');
|
||||
await syncAccountTemplates('carol@mail');
|
||||
expect(useAccountTemplatesStore.getState().browserListOwner).toBe('carol@mail');
|
||||
expect(names()).toEqual(['Browser']);
|
||||
expect(browserListFor('dave@mail')).toEqual({ status: 'none' });
|
||||
expect(browserListFor('carol@mail').status).toBe('list');
|
||||
});
|
||||
});
|
||||
|
||||
describe('servers without metadata', () => {
|
||||
it('leave an account without the capability alone', async () => {
|
||||
vi.restoreAllMocks();
|
||||
await connect(false);
|
||||
browser = list([tpl('aaaa-1', 'Local')]);
|
||||
const fetchSpy = vi.mocked(globalThis.fetch);
|
||||
fetchSpy.mockClear();
|
||||
expect(await sync()).toBe('unsupported');
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(partition()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keep the app online when the server drops the connection on metadata (the 1.0 test build)', async () => {
|
||||
drop = true;
|
||||
browser = list([tpl('aaaa-1', 'Local')]);
|
||||
expect(await sync()).toBe('unsupported');
|
||||
expect(partition()).toBeUndefined();
|
||||
expect(connection).not.toHaveBeenCalledWith(false);
|
||||
const fetchSpy = vi.mocked(globalThis.fetch);
|
||||
fetchSpy.mockClear();
|
||||
expect(await sync()).toBe('unsupported');
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('signing out', () => {
|
||||
beforeEach(async () => {
|
||||
asCarol();
|
||||
localStorage.setItem('settings-sync-owner', 'carol@mail');
|
||||
useTemplateStore.setState({ templates: [tpl('aaaa-1', 'One'), tpl('aaaa-2', 'Two')] });
|
||||
await syncAccountTemplates('carol@mail');
|
||||
sets = [];
|
||||
});
|
||||
|
||||
it('sends a deletion that is still queued before the account signs out', async () => {
|
||||
personalTemplatesFor('carol@mail').remove('aaaa-1');
|
||||
await runBeforeSignOut('carol@mail');
|
||||
expect(sets.map((s) => s.key)).toEqual([MARKER, ns('aaaa-1')]);
|
||||
expect(server[ns('aaaa-1')]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps only unsent templates and deletions after a full sign-out', async () => {
|
||||
drop = true;
|
||||
personalTemplatesFor('carol@mail').remove('aaaa-1');
|
||||
personalTemplatesFor('carol@mail').add({ name: 'Unsent', subject: '', body: '', category: '', isFavorite: false });
|
||||
await flushAccountTemplates('carol@mail');
|
||||
purgeSignedOutData();
|
||||
const kept = useAccountTemplatesStore.getState().personal['carol@mail'];
|
||||
expect(kept.templates.map((t) => t.name)).toEqual(['Unsent']);
|
||||
expect(Object.keys(kept.deletedTemplateIds)).toEqual(['aaaa-1']);
|
||||
});
|
||||
|
||||
it('keeps a deletion the server hasn\'t heard of, also with nothing else left to send', async () => {
|
||||
drop = true;
|
||||
personalTemplatesFor('carol@mail').remove('aaaa-1');
|
||||
await flushAccountTemplates('carol@mail');
|
||||
purgeSignedOutData();
|
||||
const kept = useAccountTemplatesStore.getState().personal['carol@mail'];
|
||||
expect(kept.templates).toEqual([]);
|
||||
expect(Object.keys(kept.deletedTemplateIds)).toEqual(['aaaa-1']);
|
||||
});
|
||||
|
||||
it('drops an account copy that the server holds in full', () => {
|
||||
purgeSignedOutData();
|
||||
expect(useAccountTemplatesStore.getState().personal['carol@mail']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('tombstones', () => {
|
||||
it('keep 90 days and at most 8 KB, the oldest go first', () => {
|
||||
const deleted: Record<string, string> = { old: ago(91 * 86_400_000) };
|
||||
for (let i = 0; i < 400; i++) deleted[`id-${String(i).padStart(4, '0')}-${'x'.repeat(20)}`] = ago(i * 1000);
|
||||
const kept = pruneTombstones(deleted);
|
||||
expect(kept.old).toBeUndefined();
|
||||
expect(new TextEncoder().encode(JSON.stringify({ version: 1, deleted: kept })).length).toBeLessThanOrEqual(8192);
|
||||
expect(kept[`id-0000-${'x'.repeat(20)}`]).toBeDefined();
|
||||
expect(kept[`id-0399-${'x'.repeat(20)}`]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('a deletion here wins over an older server copy, an edit there over an older deletion', () => {
|
||||
const plan = planPersonalSync(
|
||||
{ templates: [], deletedTemplateIds: { a: ago(5000), b: ago(5000) } },
|
||||
{ a: tpl('a', 'A', ago(10_000)), b: tpl('b', 'B', ago(1000)) },
|
||||
{},
|
||||
);
|
||||
expect(plan.deletes).toEqual(['a']);
|
||||
expect(plan.templates.map((t) => t.id)).toEqual(['b']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
{
|
||||
"_comment": "Recorded from Stalwart v1.0.0 (stw-rel, http://10.10.10.62:8120) on 2026-10-28, org-carol@example.org own Inbox: Bulwark template metadata. Removed again after recording. setForbidden: org-carol writing to the Inbox org-alice shared with her read-only.",
|
||||
"getEmpty": {
|
||||
"methodResponses": [
|
||||
[
|
||||
"Mailbox/query",
|
||||
{
|
||||
"accountId": "bl",
|
||||
"queryState": "saq",
|
||||
"canCalculateChanges": true,
|
||||
"position": 0,
|
||||
"ids": [
|
||||
"a"
|
||||
]
|
||||
},
|
||||
"q"
|
||||
],
|
||||
[
|
||||
"Mailbox/get",
|
||||
{
|
||||
"accountId": "bl",
|
||||
"state": "saq",
|
||||
"list": [
|
||||
{
|
||||
"id": "a",
|
||||
"metadata": {}
|
||||
}
|
||||
],
|
||||
"notFound": []
|
||||
},
|
||||
"g"
|
||||
]
|
||||
],
|
||||
"sessionState": "c499329"
|
||||
},
|
||||
"setOne": {
|
||||
"methodResponses": [
|
||||
[
|
||||
"Mailbox/set",
|
||||
{
|
||||
"accountId": "bl",
|
||||
"oldState": "saq",
|
||||
"newState": "sau",
|
||||
"updated": {
|
||||
"a": null
|
||||
}
|
||||
},
|
||||
"0"
|
||||
]
|
||||
],
|
||||
"sessionState": "c499329"
|
||||
},
|
||||
"setMarker": {
|
||||
"methodResponses": [
|
||||
[
|
||||
"Mailbox/set",
|
||||
{
|
||||
"accountId": "bl",
|
||||
"oldState": "sau",
|
||||
"newState": "say",
|
||||
"updated": {
|
||||
"a": null
|
||||
}
|
||||
},
|
||||
"0"
|
||||
]
|
||||
],
|
||||
"sessionState": "c499329"
|
||||
},
|
||||
"getOne": {
|
||||
"methodResponses": [
|
||||
[
|
||||
"Mailbox/query",
|
||||
{
|
||||
"accountId": "bl",
|
||||
"queryState": "say",
|
||||
"canCalculateChanges": true,
|
||||
"position": 0,
|
||||
"ids": [
|
||||
"a"
|
||||
]
|
||||
},
|
||||
"q"
|
||||
],
|
||||
[
|
||||
"Mailbox/get",
|
||||
{
|
||||
"accountId": "bl",
|
||||
"state": "say",
|
||||
"list": [
|
||||
{
|
||||
"id": "a",
|
||||
"metadata": {
|
||||
"org.bulwarkmail.template.rec-1": {
|
||||
"id": "rec-1",
|
||||
"name": "Recorded",
|
||||
"subject": "Hi",
|
||||
"body": "Body",
|
||||
"isHTML": false,
|
||||
"category": "",
|
||||
"isFavorite": false,
|
||||
"createdAt": "2026-10-28T10:00:00.000Z",
|
||||
"updatedAt": "2026-10-28T10:00:00.000Z"
|
||||
},
|
||||
"org.bulwarkmail.templates": {
|
||||
"v": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"notFound": []
|
||||
},
|
||||
"g"
|
||||
]
|
||||
],
|
||||
"sessionState": "c499329"
|
||||
},
|
||||
"setNoRoom": {
|
||||
"methodResponses": [
|
||||
[
|
||||
"Mailbox/set",
|
||||
{
|
||||
"accountId": "bl",
|
||||
"oldState": "sb2",
|
||||
"newState": "sb2",
|
||||
"notUpdated": {
|
||||
"a": {
|
||||
"type": "invalidProperties",
|
||||
"description": "Metadata size 72076 exceeds the maximum of 65536 bytes.",
|
||||
"properties": [
|
||||
"metadata"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"0"
|
||||
]
|
||||
],
|
||||
"sessionState": "c499329"
|
||||
},
|
||||
"deleteOne": {
|
||||
"methodResponses": [
|
||||
[
|
||||
"Mailbox/set",
|
||||
{
|
||||
"accountId": "bl",
|
||||
"oldState": "sb2",
|
||||
"newState": "sca",
|
||||
"updated": {
|
||||
"a": null
|
||||
}
|
||||
},
|
||||
"0"
|
||||
]
|
||||
],
|
||||
"sessionState": "c499329"
|
||||
},
|
||||
"setForbidden": {
|
||||
"methodResponses": [
|
||||
[
|
||||
"Mailbox/set",
|
||||
{
|
||||
"accountId": "bj",
|
||||
"oldState": "seu",
|
||||
"newState": "seu",
|
||||
"notUpdated": {
|
||||
"a": {
|
||||
"type": "forbidden",
|
||||
"description": "Not allowed to modify metadata.",
|
||||
"properties": [
|
||||
"metadata"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"0"
|
||||
]
|
||||
],
|
||||
"sessionState": "e6894019"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { JMAPClient } from '../jmap/client';
|
||||
import { MetadataTooLargeError, MetadataUnsupportedError, bulwarkEntries, isValidMetadataKey } from '../jmap/bulwark-metadata';
|
||||
import { MetadataRefusedError, MetadataTooLargeError, MetadataUnsupportedError, bulwarkEntries, isValidMetadataKey } from '../jmap/bulwark-metadata';
|
||||
import recorded from './fixtures/stalwart-1.0.0-template-metadata.json';
|
||||
|
||||
// Bulwark keeps shared tags and templates in the Inbox metadata of an
|
||||
// account, one namespace per item (each namespace holds at most 8 KB on
|
||||
@@ -121,6 +122,29 @@ describe('setBulwarkMetadata', () => {
|
||||
it('says unsupported for an account without metadata', async () => {
|
||||
await expect(client.setBulwarkMetadata('old', 'tags', [])).rejects.toBeInstanceOf(MetadataUnsupportedError);
|
||||
});
|
||||
|
||||
it('passes on the type of Stalwart\'s refusal (recorded on v1.0.0)', async () => {
|
||||
answer = (body) => body.methodCalls[0][0] === 'Mailbox/set'
|
||||
? json({ methodResponses: [['Mailbox/set', { ...(recorded.setForbidden.methodResponses[0][1] as object), notUpdated: { 'inbox-1': (recorded.setForbidden.methodResponses[0][1] as { notUpdated: { a: unknown } }).notUpdated.a } }, '0']] })
|
||||
: inboxAnswer({});
|
||||
const error = await client.setBulwarkMetadata('team', 'template.x', { name: 'x' }).catch((e) => e);
|
||||
expect(error).toBeInstanceOf(MetadataRefusedError);
|
||||
expect(error.type).toBe('forbidden');
|
||||
expect(error.message).toBe('Not allowed to modify metadata.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('an answer without the metadata property', () => {
|
||||
it('means unsupported, never "every entry was removed"', async () => {
|
||||
answer = () => json({
|
||||
methodResponses: [
|
||||
['Mailbox/query', { ids: ['inbox-1'] }, 'q'],
|
||||
['Mailbox/get', { list: [{ id: 'inbox-1' }] }, 'g'],
|
||||
],
|
||||
});
|
||||
expect(await client.getBulwarkMetadata('team')).toBeNull();
|
||||
expect(client.supportsBulwarkMetadata('team')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('helpers', () => {
|
||||
|
||||
@@ -6,6 +6,9 @@ import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useTemplateStore } from '@/stores/template-store';
|
||||
import { useIdentityStore } from '@/stores/identity-store';
|
||||
import { useVacationStore } from '@/stores/vacation-store';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { emptyPartition, useAccountTemplatesStore } from '@/stores/account-templates-store';
|
||||
import { DEFAULT_POLICY } from '../admin/types';
|
||||
import { HIGH_RISK_PERMISSIONS } from '../plugin-sandbox/permission-text';
|
||||
import { clearAllHooks, identityHooks } from '../plugin-hooks';
|
||||
|
||||
@@ -53,7 +56,9 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
useAuthStore.setState({ client: null });
|
||||
useAuthStore.setState({ client: null, activeAccountId: null });
|
||||
useAccountTemplatesStore.setState({ personal: {} });
|
||||
usePolicyStore.setState({ policy: DEFAULT_POLICY });
|
||||
useTemplateStore.setState({ templates: [], deletedTemplateIds: {} });
|
||||
useIdentityStore.setState({ identities: [] });
|
||||
useVacationStore.getState().clearState();
|
||||
@@ -101,6 +106,33 @@ describe('api.templates', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('api.templates and organisation settings', () => {
|
||||
it('works on the account\'s own copy when the account keeps its templates (Stalwart 1.0)', async () => {
|
||||
const inAccount = { ...greeting, id: 'acc-1', name: 'In the account' };
|
||||
useAuthStore.setState({ activeAccountId: 'ann@mail' });
|
||||
useAccountTemplatesStore.setState({ personal: { 'ann@mail': { ...emptyPartition(), templates: [inAccount] } } });
|
||||
expect((await call(ALL, 'templates.list') as Array<{ name: string }>).map((t) => t.name)).toEqual(['In the account']);
|
||||
const created = await call(ALL, 'templates.create', { name: 'From a plugin' }) as { id: string };
|
||||
await call(ALL, 'templates.update', 'acc-1', { subject: 'Changed' });
|
||||
await call(ALL, 'templates.delete', created.id);
|
||||
const copy = useAccountTemplatesStore.getState().personal['ann@mail'];
|
||||
expect(copy.templates.map((t) => t.subject)).toEqual(['Changed']);
|
||||
expect(copy.deletedTemplateIds).toHaveProperty(created.id);
|
||||
// The browser's list is left alone.
|
||||
expect(useTemplateStore.getState().templates.map((t) => t.id)).toEqual(['tpl-1']);
|
||||
});
|
||||
|
||||
it('shows and accepts nothing when the admin hid personal templates', async () => {
|
||||
usePolicyStore.setState({ policy: { ...DEFAULT_POLICY, restrictions: { personalTemplates: { hidden: true } } } });
|
||||
expect(await call(ALL, 'templates.list')).toEqual([]);
|
||||
expect(await call(ALL, 'templates.get', 'tpl-1')).toBeNull();
|
||||
await expect(call(ALL, 'templates.create', { name: 'x' })).rejects.toThrow(/turned personal templates off/);
|
||||
await expect(call(ALL, 'templates.update', 'tpl-1', { name: 'x' })).rejects.toThrow(/turned personal templates off/);
|
||||
await expect(call(ALL, 'templates.delete', 'tpl-1')).rejects.toThrow(/turned personal templates off/);
|
||||
expect(useTemplateStore.getState().templates.map((t) => t.name)).toEqual(['Greeting']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('api.identities.update', () => {
|
||||
it('changes name, reply-to and signatures and never the address', async () => {
|
||||
const view = await call(ALL, 'identities.update', 'id1', {
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
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: boolean;
|
||||
|
||||
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') {
|
||||
// Stalwart v1.0.0's refusal for a user who may read the Inbox but not set its metadata.
|
||||
if (refuse) return json(clone(recorded.setForbidden));
|
||||
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 = false;
|
||||
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, favourite or blind copies, and deletes', async () => {
|
||||
await loadTeamTemplates('alice@mail', client);
|
||||
const result = await saveTeamTemplate('alice@mail', 'bi', tpl('t2', 'Offer', {
|
||||
identityId: 'alice-identity', isFavorite: true, defaultRecipients: { to: ['a@x.test'], cc: ['c@x.test'], bcc: ['boss@x.test'] },
|
||||
}), null);
|
||||
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(saved.defaultRecipients).toEqual({ to: ['a@x.test'], cc: ['c@x.test'] });
|
||||
expect(useAccountTemplatesStore.getState().teams['alice@mail'][0].templates.map((t) => t.id).sort()).toEqual(['t1', 't2']);
|
||||
|
||||
expect(await deleteTeamTemplate('alice@mail', 'bi', 't1', tpl('t1', 'Team reply').updatedAt)).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) }), null)).toBe('too-large');
|
||||
refuse = true;
|
||||
expect(await saveTeamTemplate('alice@mail', 'bi', tpl('t4', 'Small'), null)).toBe('forbidden');
|
||||
expect(metadata.bi['org.bulwarkmail.template.t4']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('refuses a change to a template someone else changed since, and shows their version', async () => {
|
||||
await loadTeamTemplates('alice@mail', client);
|
||||
const base = tpl('t1', 'Team reply').updatedAt;
|
||||
// Bob saves first.
|
||||
metadata.bi['org.bulwarkmail.template.t1'] = tpl('t1', 'Version of Bob', { updatedAt: '2026-10-20T00:00:00.000Z' });
|
||||
const result = await saveTeamTemplate('alice@mail', 'bi', tpl('t1', 'Version of Alice', { updatedAt: '2026-10-21T00:00:00.000Z' }), base);
|
||||
expect(result).toBe('conflict');
|
||||
expect((metadata.bi['org.bulwarkmail.template.t1'] as EmailTemplate).name).toBe('Version of Bob');
|
||||
expect(useAccountTemplatesStore.getState().teams['alice@mail'][0].templates[0].name).toBe('Version of Bob');
|
||||
expect(await deleteTeamTemplate('alice@mail', 'bi', 't1', base)).toBe('conflict');
|
||||
// A new template whose id someone took meanwhile is refused too.
|
||||
expect(await saveTeamTemplate('alice@mail', 'bi', tpl('t1', 'New'), null)).toBe('conflict');
|
||||
// Starting from Bob's version, the change goes through.
|
||||
expect(await saveTeamTemplate('alice@mail', 'bi', tpl('t1', 'Alice again', { updatedAt: '2026-10-22T00:00:00.000Z' }), '2026-10-20T00:00:00.000Z')).toBe('ok');
|
||||
});
|
||||
});
|
||||
@@ -51,6 +51,24 @@ export const RESTRICTION_KEYS: readonly RestrictionKeyDef[] = [
|
||||
page: 'org-signatures',
|
||||
modes: ['locked', 'hidden'],
|
||||
},
|
||||
{
|
||||
key: 'personalTemplates',
|
||||
label: 'Personal templates',
|
||||
description: "Hide: users get only the organisation and team templates and can't save templates of their own. The ones they have are kept, not deleted.",
|
||||
category: 'Composer',
|
||||
type: 'boolean',
|
||||
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'],
|
||||
},
|
||||
{ key: 'delegation', label: 'Delegates', description: 'Hide removes Settings > Sharing > Delegates, so users can no longer give someone access to mail, calendars and contacts in one step.', category: 'Sharing', type: 'boolean', page: 'org-sharing', modes: ['hidden'] },
|
||||
{ key: 'sharingRights', label: 'Rights users can give', description: 'Untick "write" and every share dialog in Bulwark (folders, calendars, address books, files, Delegates) offers read access only.', category: 'Sharing', type: 'enum', allowedValues: ['read', 'write'], page: 'org-sharing', modes: ['allowedValues'] },
|
||||
];
|
||||
|
||||
@@ -31,6 +31,14 @@ export class MetadataTooLargeError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/** The server refused a metadata change; `type` is its SetError type (`forbidden`, `invalidProperties`, ...). */
|
||||
export class MetadataRefusedError extends Error {
|
||||
constructor(readonly type: string, message: string) {
|
||||
super(message);
|
||||
this.name = 'MetadataRefusedError';
|
||||
}
|
||||
}
|
||||
|
||||
export class MetadataUnsupportedError extends Error {
|
||||
constructor(message = 'This server keeps no metadata for Bulwark') {
|
||||
super(message);
|
||||
|
||||
+5
-3
@@ -1,4 +1,4 @@
|
||||
import { advertisesMailboxMetadata, bulwarkEntries, MAX_METADATA_ENTRY_BYTES, METADATA_CAPABILITY, MetadataTooLargeError, MetadataUnsupportedError, metadataValueBytes, namespaceFor } from "./bulwark-metadata";
|
||||
import { advertisesMailboxMetadata, bulwarkEntries, MAX_METADATA_ENTRY_BYTES, METADATA_CAPABILITY, MetadataRefusedError, MetadataTooLargeError, MetadataUnsupportedError, metadataValueBytes, namespaceFor } from "./bulwark-metadata";
|
||||
import { IncompleteDeleteError } from "./delete-error";
|
||||
import { ArchiveEmailsError } from "./archive-error";
|
||||
import { generateUUID } from '@/lib/utils';
|
||||
@@ -7132,7 +7132,9 @@ export class JMAPClient implements IJMAPClient {
|
||||
], ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail", METADATA_CAPABILITY]);
|
||||
const get = response.methodResponses?.find((r) => r[0] === "Mailbox/get");
|
||||
const inbox = (get?.[1] as { list?: Array<Record<string, unknown>> } | undefined)?.list?.[0];
|
||||
if (!inbox) throw new MetadataUnsupportedError("No Inbox metadata in the answer");
|
||||
// An answer without the property is a server that doesn't keep it,
|
||||
// never "every entry was removed".
|
||||
if (!inbox || !(property in inbox)) throw new MetadataUnsupportedError("No Inbox metadata in the answer");
|
||||
this.metadataInboxIds.set(id, inbox.id as string);
|
||||
this.metadataUnsupported.delete(id);
|
||||
return bulwarkEntries(inbox[property]);
|
||||
@@ -7174,7 +7176,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
const notUpdated = (result as { notUpdated?: Record<string, { type?: string; description?: string }> }).notUpdated?.[inboxId];
|
||||
if (notUpdated) {
|
||||
if (/exceeds the maximum/i.test(notUpdated.description ?? "")) throw new MetadataTooLargeError(notUpdated.description!);
|
||||
throw new Error(notUpdated.description || `Failed to save metadata (${notUpdated.type ?? "unknown"})`);
|
||||
throw new MetadataRefusedError(notUpdated.type ?? "unknown", notUpdated.description || `Failed to save metadata (${notUpdated.type ?? "unknown"})`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Host side of api.templates, api.identities and api.vacation: the message
|
||||
// templates (kept in the browser and synced with the settings), the sender
|
||||
// templates (the active account's own: in the account on Stalwart 1.0, in the
|
||||
// browser and synced with the settings elsewhere), the sender
|
||||
// identities and the out-of-office reply. Changes go through the same stores
|
||||
// and the same HTML sanitising as Settings, so what a plugin saves is held to
|
||||
// the rules a user's own edit is.
|
||||
@@ -8,7 +9,8 @@ import type {
|
||||
IdentityView, TemplateInput, VacationInput, VacationView, TemplateView,
|
||||
} from '../plugin-types';
|
||||
import type { EmailAddress, Identity } from '../jmap/types';
|
||||
import { useTemplateStore } from '@/stores/template-store';
|
||||
import { personalTemplatesFor } from '@/lib/templates/personal-templates';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { updateIdentityWithHooks, useIdentityStore } from '@/stores/identity-store';
|
||||
import { useVacationStore } from '@/stores/vacation-store';
|
||||
import { identityToView, templateToView, vacationToView } from '../plugin-projection';
|
||||
@@ -29,13 +31,27 @@ const MAX_TEMPLATE_SUBJECT = 998;
|
||||
const MAX_TEMPLATE_BODY = 100_000;
|
||||
const TEMPLATE_FIELDS = ['name', 'subject', 'body', 'isHTML', 'category'] as const;
|
||||
|
||||
// The active account's own templates: kept in the account on Stalwart 1.0,
|
||||
// in the browser's list otherwise (lib/templates/personal-templates.ts).
|
||||
const personal = () => personalTemplatesFor();
|
||||
|
||||
// The admin hid personal templates for the active account
|
||||
// (`personalTemplates`): plugins see none and can't make any either.
|
||||
const personalHidden = () => usePolicyStore.getState().policy.restrictions?.personalTemplates?.hidden === true;
|
||||
|
||||
function refuseIfHidden(method: string): void {
|
||||
if (personalHidden()) throw new Error(`${method}: the administrator turned personal templates off`);
|
||||
}
|
||||
|
||||
export function doTemplatesList(): TemplateView[] {
|
||||
return useTemplateStore.getState().templates.map(templateToView);
|
||||
if (personalHidden()) return [];
|
||||
return personal().templates().map(templateToView);
|
||||
}
|
||||
|
||||
export function doTemplatesGet(id: unknown): TemplateView | null {
|
||||
const templateId = readId(id, 'templates.get');
|
||||
const template = useTemplateStore.getState().templates.find((t) => t.id === templateId);
|
||||
if (personalHidden()) return null;
|
||||
const template = personal().templates().find((t) => t.id === templateId);
|
||||
return template ? templateToView(template) : null;
|
||||
}
|
||||
|
||||
@@ -59,13 +75,14 @@ export function doTemplatesCreate(input: unknown): TemplateView {
|
||||
const method = 'templates.create';
|
||||
const data = readObject(input, method, 'template', TEMPLATE_FIELDS) as TemplateInput & Record<string, unknown>;
|
||||
if (data.name === undefined) throw new Error(`${method}: name is required`);
|
||||
const store = useTemplateStore.getState();
|
||||
if (store.templates.length >= MAX_PLUGIN_TEMPLATES) {
|
||||
refuseIfHidden(method);
|
||||
const store = personal();
|
||||
if (store.templates().length >= MAX_PLUGIN_TEMPLATES) {
|
||||
throw new Error(`${method}: there are already ${MAX_PLUGIN_TEMPLATES} templates`);
|
||||
}
|
||||
const isHTML = data.isHTML === undefined ? false : readBoolean(data.isHTML, method, 'isHTML');
|
||||
const fields = templateFields(method, data, isHTML);
|
||||
const created = store.addTemplate({
|
||||
const created = store.add({
|
||||
name: fields.name!,
|
||||
subject: fields.subject ?? '',
|
||||
body: fields.body ?? '',
|
||||
@@ -81,23 +98,27 @@ export function doTemplatesUpdate(id: unknown, patch: unknown): TemplateView {
|
||||
const templateId = readId(id, method);
|
||||
const data = readObject(patch, method, 'patch', TEMPLATE_FIELDS);
|
||||
if (Object.keys(data).length === 0) throw new Error(`${method}: patch is empty`);
|
||||
const existing = useTemplateStore.getState().templates.find((t) => t.id === templateId);
|
||||
refuseIfHidden(method);
|
||||
const store = personal();
|
||||
const existing = store.templates().find((t) => t.id === templateId);
|
||||
if (!existing) throw new Error(`${method}: no template with id "${templateId}"`);
|
||||
const isHTML = data.isHTML === undefined ? !!existing.isHTML : readBoolean(data.isHTML, method, 'isHTML');
|
||||
const fields = templateFields(method, data, isHTML);
|
||||
// Turning an existing body into HTML must not let unsanitised text through.
|
||||
if (isHTML && !existing.isHTML && fields.body === undefined) fields.body = sanitizePluginBodyHtml(existing.body || '');
|
||||
useTemplateStore.getState().updateTemplate(templateId, fields);
|
||||
return templateToView(useTemplateStore.getState().templates.find((t) => t.id === templateId)!);
|
||||
store.update(templateId, fields);
|
||||
return templateToView(store.templates().find((t) => t.id === templateId)!);
|
||||
}
|
||||
|
||||
export function doTemplatesDelete(id: unknown): void {
|
||||
const method = 'templates.delete';
|
||||
const templateId = readId(id, method);
|
||||
if (!useTemplateStore.getState().templates.some((t) => t.id === templateId)) {
|
||||
refuseIfHidden(method);
|
||||
const store = personal();
|
||||
if (!store.templates().some((t) => t.id === templateId)) {
|
||||
throw new Error(`${method}: no template with id "${templateId}"`);
|
||||
}
|
||||
useTemplateStore.getState().deleteTemplate(templateId);
|
||||
store.remove(templateId);
|
||||
}
|
||||
|
||||
// ─── Identities ───────────────────────────────────────────────
|
||||
|
||||
@@ -38,6 +38,21 @@ export function onFullSignOut(cleanup: () => void): void {
|
||||
signedOutCleanups.add(cleanup);
|
||||
}
|
||||
|
||||
// Work that has to reach the server before an account signs out (changes
|
||||
// still queued for it). The auth store waits for it within its sign-out
|
||||
// budget, next to the plugins' onBeforeLogout.
|
||||
const beforeSignOutTasks = new Set<(accountId: string) => Promise<void>>();
|
||||
|
||||
/** Run `task` before an account signs out; `accountId` is 'all' when every account does. */
|
||||
export function onBeforeSignOut(task: (accountId: string) => Promise<void>): () => void {
|
||||
beforeSignOutTasks.add(task);
|
||||
return () => beforeSignOutTasks.delete(task);
|
||||
}
|
||||
|
||||
export async function runBeforeSignOut(accountId: string): Promise<void> {
|
||||
await Promise.all([...beforeSignOutTasks].map((task) => task(accountId).catch(() => {})));
|
||||
}
|
||||
|
||||
/** Remove the accounts' leftovers from browser storage. */
|
||||
export function purgeSignedOutData(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
@@ -98,6 +98,16 @@ export function mergeStoredTemplates(
|
||||
stored: Record<string, unknown> | null,
|
||||
incoming: Record<string, unknown>
|
||||
): Record<string, unknown> {
|
||||
// The account keeps its templates in its own server storage (JMAP
|
||||
// metadata, Stalwart 1.0): the blob drops its copy. A push from a build
|
||||
// that doesn't know this brings them back, and the next push from one
|
||||
// that does drops them again.
|
||||
if (incoming.templatesInMetadata === true) {
|
||||
const rest = { ...incoming };
|
||||
delete rest.templates;
|
||||
delete rest.deletedTemplateIds;
|
||||
return rest;
|
||||
}
|
||||
const storedTemplates = stored ? rawTemplates(stored.templates) : null;
|
||||
if (!storedTemplates) return incoming;
|
||||
|
||||
|
||||
@@ -191,6 +191,9 @@ export {
|
||||
type SyncedTemplateState,
|
||||
} from './template-merge';
|
||||
|
||||
/** `createdAt` / `updatedAt` of a synced template that has none. */
|
||||
const UNKNOWN_TIME = new Date(0).toISOString();
|
||||
|
||||
/**
|
||||
* Structurally validates a `templates` value from a synced settings blob or
|
||||
* settings-file import. Returns null when the value is not an array (a blob
|
||||
@@ -210,7 +213,9 @@ export function parseSyncedTemplates(value: unknown): EmailTemplate[] | null {
|
||||
seen.add(t.id);
|
||||
|
||||
const recipients = t.defaultRecipients as Record<string, unknown> | undefined;
|
||||
const now = new Date().toISOString();
|
||||
// A fixed time, not now: a copy without a date must never win a merge
|
||||
// by `updatedAt` against one that has a date.
|
||||
const now = UNKNOWN_TIME;
|
||||
|
||||
templates.push({
|
||||
id: t.id,
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
|
||||
/** What the template code asks of a signed-in account's client. */
|
||||
export type TemplateClient = Pick<IJMAPClient, 'getAccountId'> & Partial<Pick<IJMAPClient,
|
||||
'supportsBulwarkMetadata' | 'getBulwarkMetadata' | 'setBulwarkMetadata'
|
||||
| 'getSharedAccounts' | 'getAccountInfo' | 'getPrincipalTypes' | 'getMailboxes'>>;
|
||||
|
||||
/** The client of the signed-in account `localAccountId`, or null when it isn't connected. */
|
||||
export function clientFor(localAccountId: string): TemplateClient | null {
|
||||
const auth = useAuthStore.getState();
|
||||
const client = auth.getClientForAccount?.(localAccountId) ?? (localAccountId === auth.activeAccountId ? auth.client : null);
|
||||
if (!client || typeof (client as { getAccountId?: unknown }).getAccountId !== 'function') return null;
|
||||
return client as unknown as TemplateClient;
|
||||
}
|
||||
|
||||
/** Whether Bulwark may use the metadata of this account of the client. */
|
||||
export function hasMetadata(
|
||||
client: TemplateClient | null | undefined,
|
||||
accountId: string,
|
||||
): client is TemplateClient & Required<Pick<TemplateClient, 'getBulwarkMetadata' | 'setBulwarkMetadata'>> {
|
||||
return !!client
|
||||
&& typeof client.getBulwarkMetadata === 'function'
|
||||
&& typeof client.setBulwarkMetadata === 'function'
|
||||
&& client.supportsBulwarkMetadata?.(accountId) === true;
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* Keeps each signed-in account's personal templates in its own Inbox
|
||||
* metadata (Stalwart v1.0.0 and later; lib/templates/metadata-templates.ts).
|
||||
*
|
||||
* Every round:
|
||||
* - reads the account's template entries and the marker entry with the
|
||||
* tombstones of deleted templates;
|
||||
* - merges in the browser's own list (template-store, which settings sync
|
||||
* fills) when it is this account's, each version once, minus templates
|
||||
* a newer tombstone deleted;
|
||||
* - merges this browser's copy and the server copy by id and `updatedAt`;
|
||||
* - uploads what the server lacks, writes the marker (tombstones first),
|
||||
* then removes the entries the tombstones cover.
|
||||
* A missing entry is uploaded again, never taken as a deletion. A template
|
||||
* the server refuses for its size stays in this browser.
|
||||
*
|
||||
* While it isn't known whose the browser's list is (settings sync is on
|
||||
* and hasn't loaded this account's settings yet), nothing is taken from
|
||||
* it and no marker is written, since the marker lets the settings blob
|
||||
* drop its copy.
|
||||
*
|
||||
* Servers without metadata, and the pre-release 1.0 build that drops the
|
||||
* connection on metadata requests, never answer: their accounts keep using
|
||||
* the browser's list, and none of this touches the connection state
|
||||
* (client.getBulwarkMetadata catches every failure, and pauses the account
|
||||
* for ten minutes).
|
||||
*/
|
||||
|
||||
import { MetadataTooLargeError } from '@/lib/jmap/bulwark-metadata';
|
||||
import type { EmailTemplate } from '@/lib/template-types';
|
||||
import { mergeSyncedTemplates } from '@/lib/template-utils';
|
||||
import { debug } from '@/lib/debug';
|
||||
import { hasMetadata, type TemplateClient } from './account-clients';
|
||||
import {
|
||||
MARKER_KEY,
|
||||
MARKER_VERSION,
|
||||
isTooLargeForMetadata,
|
||||
markerFromMetadata,
|
||||
planPersonalSync,
|
||||
sameTombstones,
|
||||
templateKey,
|
||||
templateValue,
|
||||
templatesFromMetadata,
|
||||
unionTombstones,
|
||||
withStorableId,
|
||||
} from './metadata-templates';
|
||||
import {
|
||||
emptyPartition,
|
||||
useAccountTemplatesStore,
|
||||
type LocalOnlyReason,
|
||||
type PersonalPartition,
|
||||
} from '@/stores/account-templates-store';
|
||||
|
||||
/** The browser's own template list, as far as this account is concerned. */
|
||||
export type BrowserList =
|
||||
/** Not known yet whose it is: take nothing, write no marker. */
|
||||
| { status: 'unknown' }
|
||||
/** It belongs to another account (or to none in particular and was given to another). */
|
||||
| { status: 'none' }
|
||||
/** It is this account's. `claim` records that it went into this account. */
|
||||
| { status: 'list'; templates: EmailTemplate[]; claim?: () => void };
|
||||
|
||||
export type SyncOutcome = 'synced' | 'unsupported';
|
||||
|
||||
const running = new Map<string, Promise<SyncOutcome>>();
|
||||
const again = new Set<string>();
|
||||
|
||||
/**
|
||||
* One round for the signed-in account `localAccountId`. Rounds for the
|
||||
* same account don't overlap; asking during one runs another afterwards.
|
||||
*/
|
||||
export function syncPersonalTemplates(localAccountId: string, client: TemplateClient, browserList: () => BrowserList): Promise<SyncOutcome> {
|
||||
const current = running.get(localAccountId);
|
||||
if (current) {
|
||||
again.add(localAccountId);
|
||||
return current;
|
||||
}
|
||||
const round = (async (): Promise<SyncOutcome> => {
|
||||
try {
|
||||
let outcome = await syncRound(localAccountId, client, browserList);
|
||||
while (again.delete(localAccountId) && outcome === 'synced') {
|
||||
outcome = await syncRound(localAccountId, client, browserList);
|
||||
}
|
||||
return outcome;
|
||||
} finally {
|
||||
running.delete(localAccountId);
|
||||
again.delete(localAccountId);
|
||||
}
|
||||
})();
|
||||
running.set(localAccountId, round);
|
||||
return round;
|
||||
}
|
||||
|
||||
/** Waits for a round of the account that is under way. */
|
||||
export function pendingRound(localAccountId: string): Promise<SyncOutcome> | undefined {
|
||||
return running.get(localAccountId);
|
||||
}
|
||||
|
||||
async function syncRound(localAccountId: string, client: TemplateClient, browserList: () => BrowserList): Promise<SyncOutcome> {
|
||||
const accountId = client.getAccountId();
|
||||
if (!accountId || !hasMetadata(client, accountId)) return 'unsupported';
|
||||
const entries = await client.getBulwarkMetadata(accountId);
|
||||
if (!entries) return 'unsupported';
|
||||
|
||||
const server = templatesFromMetadata(entries);
|
||||
const marker = markerFromMetadata(entries);
|
||||
const serverTombstones = marker?.deleted ?? {};
|
||||
const browser = browserList();
|
||||
const store = useAccountTemplatesStore.getState();
|
||||
|
||||
let start: PersonalPartition = store.personal[localAccountId] ?? emptyPartition();
|
||||
// Every id has to fit a metadata key.
|
||||
if (start.templates.some((t) => withStorableId(t) !== t)) {
|
||||
start = { ...start, templates: start.templates.map(withStorableId) };
|
||||
}
|
||||
if (browser.status === 'list') {
|
||||
const fresh = browser.templates.map(withStorableId).filter((t) => start.fromBrowser[t.id] !== t.updatedAt);
|
||||
if (fresh.length > 0) {
|
||||
const merged = mergeSyncedTemplates(
|
||||
{ templates: start.templates, deletedTemplateIds: unionTombstones(start.deletedTemplateIds, serverTombstones) },
|
||||
{ templates: fresh, deletedTemplateIds: {} },
|
||||
);
|
||||
const fromBrowser = { ...start.fromBrowser };
|
||||
for (const t of fresh) fromBrowser[t.id] = t.updatedAt;
|
||||
start = { ...start, templates: merged.templates, deletedTemplateIds: merged.deletedTemplateIds, fromBrowser };
|
||||
}
|
||||
browser.claim?.();
|
||||
}
|
||||
if (start !== store.personal[localAccountId]) store.setPersonal(localAccountId, start);
|
||||
|
||||
const plan = planPersonalSync(start, server, serverTombstones);
|
||||
const serverAfter: Record<string, EmailTemplate> = { ...server };
|
||||
const localOnly: Record<string, LocalOnlyReason> = {};
|
||||
let reachable = true;
|
||||
|
||||
for (const template of plan.uploads) {
|
||||
if (isTooLargeForMetadata(template)) {
|
||||
localOnly[template.id] = 'too-large';
|
||||
continue;
|
||||
}
|
||||
if (!reachable) continue;
|
||||
try {
|
||||
await client.setBulwarkMetadata(accountId, templateKey(template.id), templateValue(template));
|
||||
serverAfter[template.id] = template;
|
||||
} catch (error) {
|
||||
if (error instanceof MetadataTooLargeError) {
|
||||
localOnly[template.id] = 'no-room';
|
||||
} else {
|
||||
// Offline or refused: what is left goes up next time.
|
||||
debug.warn('templates', 'Could not save a template in the account:', error);
|
||||
reachable = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The tombstones go up before the entries they delete come off.
|
||||
let serverDeleted = serverTombstones;
|
||||
let markerOnServer = marker !== null;
|
||||
const markerAllowed = browser.status !== 'unknown';
|
||||
if (reachable && markerAllowed && (!marker || !sameTombstones(marker.deleted, plan.deletedTemplateIds))) {
|
||||
try {
|
||||
await client.setBulwarkMetadata(accountId, MARKER_KEY, { version: MARKER_VERSION, deleted: plan.deletedTemplateIds });
|
||||
serverDeleted = plan.deletedTemplateIds;
|
||||
markerOnServer = true;
|
||||
} catch (error) {
|
||||
debug.warn('templates', 'Could not save the deleted templates in the account:', error);
|
||||
reachable = false;
|
||||
}
|
||||
}
|
||||
for (const id of plan.deletes) {
|
||||
if (!reachable || serverDeleted[id] === undefined) continue;
|
||||
try {
|
||||
await client.setBulwarkMetadata(accountId, templateKey(id), null);
|
||||
delete serverAfter[id];
|
||||
} catch (error) {
|
||||
debug.warn('templates', 'Could not remove a template from the account:', error);
|
||||
reachable = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Changes made here while the round ran come after it: plan them again
|
||||
// against the server as it is now (nothing is written), and go round
|
||||
// again for what that leaves to do.
|
||||
const latest = useAccountTemplatesStore.getState().personal[localAccountId] ?? start;
|
||||
const final = planPersonalSync(latest, serverAfter, serverDeleted);
|
||||
const serverVersions: Record<string, string> = {};
|
||||
for (const [id, template] of Object.entries(serverAfter)) serverVersions[id] = template.updatedAt;
|
||||
const keptLocalOnly: Record<string, LocalOnlyReason> = {};
|
||||
for (const template of final.templates) {
|
||||
if (serverVersions[template.id] === template.updatedAt) continue;
|
||||
const reason = localOnly[template.id] ?? latest.localOnly[template.id];
|
||||
if (reason) keptLocalOnly[template.id] = reason;
|
||||
}
|
||||
useAccountTemplatesStore.getState().setPersonal(localAccountId, {
|
||||
...latest,
|
||||
templates: final.templates,
|
||||
deletedTemplateIds: final.deletedTemplateIds,
|
||||
serverVersions,
|
||||
serverDeleted,
|
||||
markerOnServer,
|
||||
localOnly: keptLocalOnly,
|
||||
});
|
||||
if (latest !== start) again.add(localAccountId);
|
||||
return 'synced';
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Templates kept in an account's Inbox metadata (Stalwart v1.0.0 and
|
||||
* later): one namespace per template, `org.bulwarkmail.template.<id>`,
|
||||
* because Stalwart takes at most 8 KB per namespace and 64 KB of shared
|
||||
* metadata per mailbox (local-data/org-features-facts.md §2). Personal
|
||||
* templates go in the shared metadata of the user's own Inbox (the private
|
||||
* part holds only 16 KB), team templates in the group account's Inbox.
|
||||
*
|
||||
* Deletions of personal templates are recorded as tombstones in the marker
|
||||
* entry `org.bulwarkmail.templates` ({ version, deleted: { id: deletedAt } }),
|
||||
* and only tombstones delete: an entry that is simply missing (a request
|
||||
* that failed half way, another client) is uploaded again, never taken as
|
||||
* a deletion.
|
||||
*
|
||||
* Pure: reading the entries, and planning how the copy in this browser and
|
||||
* the server copy come together. lib/templates/account-template-sync.ts
|
||||
* does the requests.
|
||||
*/
|
||||
|
||||
import type { EmailTemplate } from '@/lib/template-types';
|
||||
import { mergeSyncedTemplates, parseSyncedTemplates, parseTemplateTombstones, TEMPLATE_TOMBSTONE_TTL_MS } from '@/lib/template-utils';
|
||||
import { MAX_METADATA_ENTRY_BYTES, isValidMetadataKey, metadataValueBytes } from '@/lib/jmap/bulwark-metadata';
|
||||
|
||||
const KEY_PREFIX = 'template.';
|
||||
/** The marker entry: tombstones of deleted personal templates. */
|
||||
export const MARKER_KEY = 'templates';
|
||||
export const MARKER_VERSION = 1;
|
||||
|
||||
/** The metadata key of a template (`template.<id>`). */
|
||||
export function templateKey(id: string): string {
|
||||
return `${KEY_PREFIX}${id}`;
|
||||
}
|
||||
|
||||
/** Ids Bulwark generates (UUIDs) fit a metadata key; older or imported ones may not. */
|
||||
export function isStorableTemplateId(id: string): boolean {
|
||||
return isValidMetadataKey(templateKey(id));
|
||||
}
|
||||
|
||||
/** 53-bit string hash (cyrb53), as 14 hex digits. */
|
||||
function hash53(text: string, seed: number): string {
|
||||
let h1 = 0xdeadbeef ^ seed;
|
||||
let h2 = 0x41c6ce57 ^ seed;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const ch = text.charCodeAt(i);
|
||||
h1 = Math.imul(h1 ^ ch, 2654435761);
|
||||
h2 = Math.imul(h2 ^ ch, 1597334677);
|
||||
}
|
||||
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
|
||||
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
|
||||
return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(16).padStart(14, '0');
|
||||
}
|
||||
|
||||
/**
|
||||
* An id that fits a metadata key, derived from the old one: the same old
|
||||
* id gives the same new id on every device and on every import, so the
|
||||
* copies still meet by id.
|
||||
*/
|
||||
export function storableId(id: string): string {
|
||||
return isStorableTemplateId(id) ? id : `${hash53(id, 1)}${hash53(id, 2)}`;
|
||||
}
|
||||
|
||||
/** The same template with an id that fits a metadata key. */
|
||||
export function withStorableId(template: EmailTemplate): EmailTemplate {
|
||||
const id = storableId(template.id);
|
||||
return id === template.id ? template : { ...template, id };
|
||||
}
|
||||
|
||||
/** The value stored for a template: the template itself, which carries its id and `updatedAt`. */
|
||||
export function templateValue(template: EmailTemplate): EmailTemplate {
|
||||
return { ...template };
|
||||
}
|
||||
|
||||
/** Whether a template is larger than one metadata entry may be. */
|
||||
export function isTooLargeForMetadata(template: EmailTemplate): boolean {
|
||||
return metadataValueBytes(templateValue(template)) > MAX_METADATA_ENTRY_BYTES;
|
||||
}
|
||||
|
||||
/**
|
||||
* The templates among an Inbox's Bulwark metadata entries (`template.<id>`),
|
||||
* by id, validated and sanitised like a synced template. The key decides
|
||||
* the id.
|
||||
*/
|
||||
export function templatesFromMetadata(entries: Record<string, unknown> | null | undefined): Record<string, EmailTemplate> {
|
||||
const out: Record<string, EmailTemplate> = {};
|
||||
if (!entries) return out;
|
||||
for (const [key, value] of Object.entries(entries)) {
|
||||
if (!key.startsWith(KEY_PREFIX) || !value || typeof value !== 'object' || Array.isArray(value)) continue;
|
||||
const id = key.slice(KEY_PREFIX.length);
|
||||
const [template] = parseSyncedTemplates([{ ...(value as Record<string, unknown>), id }]) ?? [];
|
||||
if (template) out[id] = template;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface Marker {
|
||||
version: number;
|
||||
deleted: Record<string, string>;
|
||||
}
|
||||
|
||||
/** The marker entry, or null when there is none. */
|
||||
export function markerFromMetadata(entries: Record<string, unknown> | null | undefined): Marker | null {
|
||||
const value = entries?.[MARKER_KEY];
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
||||
const raw = value as Record<string, unknown>;
|
||||
return { version: typeof raw.version === 'number' ? raw.version : MARKER_VERSION, deleted: parseTemplateTombstones(raw.deleted) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Tombstones of the last 90 days, newest first as long as the marker entry
|
||||
* stays within 8 KB: the oldest go first.
|
||||
*/
|
||||
export function pruneTombstones(deleted: Record<string, string>, now: Date = new Date()): Record<string, string> {
|
||||
const cutoff = now.getTime() - TEMPLATE_TOMBSTONE_TTL_MS;
|
||||
const fresh = Object.entries(deleted)
|
||||
.filter(([, at]) => Date.parse(at) >= cutoff)
|
||||
.sort((a, b) => Date.parse(b[1]) - Date.parse(a[1]));
|
||||
const kept: Record<string, string> = {};
|
||||
for (const [id, at] of fresh) {
|
||||
kept[id] = at;
|
||||
if (metadataValueBytes({ version: MARKER_VERSION, deleted: kept }) > MAX_METADATA_ENTRY_BYTES) {
|
||||
delete kept[id];
|
||||
break;
|
||||
}
|
||||
}
|
||||
return kept;
|
||||
}
|
||||
|
||||
/** The newest tombstone per id of both sets. */
|
||||
export function unionTombstones(a: Record<string, string>, b: Record<string, string>): Record<string, string> {
|
||||
const out = { ...a };
|
||||
for (const [id, at] of Object.entries(b)) {
|
||||
if (!out[id] || Date.parse(at) > Date.parse(out[id])) out[id] = at;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function sameTombstones(a: Record<string, string>, b: Record<string, string>): boolean {
|
||||
const ka = Object.keys(a);
|
||||
return ka.length === Object.keys(b).length && ka.every((id) => a[id] === b[id]);
|
||||
}
|
||||
|
||||
/** This browser's copy of an account's personal templates. */
|
||||
export interface PersonalCopy {
|
||||
templates: EmailTemplate[];
|
||||
/** Tombstones known here: id -> ISO time it was deleted. */
|
||||
deletedTemplateIds: Record<string, string>;
|
||||
/** `updatedAt` of each template as the server held it at the last sync. */
|
||||
serverVersions: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface PersonalSyncPlan {
|
||||
/** What this browser keeps after the merge. */
|
||||
templates: EmailTemplate[];
|
||||
/** All tombstones, pruned: what the marker should hold. */
|
||||
deletedTemplateIds: Record<string, string>;
|
||||
/** Templates the server lacks or holds an older version of. */
|
||||
uploads: EmailTemplate[];
|
||||
/** Ids to remove from the server, each covered by a tombstone. */
|
||||
deletes: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Brings this browser's copy and the server copy together, per id with
|
||||
* the newer `updatedAt` winning (mergeSyncedTemplates), with the
|
||||
* tombstones of both. A tombstone deletes a template it is newer than; an
|
||||
* edit newer than the tombstone brings the template back.
|
||||
*/
|
||||
export function planPersonalSync(
|
||||
local: Pick<PersonalCopy, 'templates' | 'deletedTemplateIds'>,
|
||||
server: Record<string, EmailTemplate>,
|
||||
serverTombstones: Record<string, string>,
|
||||
now: Date = new Date(),
|
||||
): PersonalSyncPlan {
|
||||
const tombstones = unionTombstones(local.deletedTemplateIds, serverTombstones);
|
||||
const merged = mergeSyncedTemplates(
|
||||
{ templates: local.templates, deletedTemplateIds: tombstones },
|
||||
{ templates: Object.values(server), deletedTemplateIds: {} },
|
||||
now,
|
||||
);
|
||||
const keptIds = new Set(merged.templates.map((t) => t.id));
|
||||
const uploads = merged.templates.filter((template) => {
|
||||
const there = server[template.id];
|
||||
return !there || Date.parse(template.updatedAt) > Date.parse(there.updatedAt);
|
||||
});
|
||||
const deletes = Object.keys(server).filter((id) => !keptIds.has(id) && merged.deletedTemplateIds[id] !== undefined);
|
||||
return { templates: merged.templates, deletedTemplateIds: pruneTombstones(merged.deletedTemplateIds, now), uploads, deletes };
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* The admin's template library (policy.json `organisation.templates.library`)
|
||||
* as templates the picker and the template manager show under
|
||||
* "Organisation". Users can't change them; scopes add to Everyone's library
|
||||
* by id (mergeOrganisation), so the effective policy already holds the
|
||||
* list for the user's domain and groups.
|
||||
*/
|
||||
|
||||
import type { SettingsPolicy } from '@/lib/admin/types';
|
||||
import type { EmailTemplate } from '@/lib/template-types';
|
||||
import { parseSyncedTemplates } from '@/lib/template-utils';
|
||||
|
||||
export function orgLibraryTemplates(policy: Pick<SettingsPolicy, 'organisation'> | null | undefined): EmailTemplate[] {
|
||||
const library = policy?.organisation?.templates?.library ?? [];
|
||||
if (library.length === 0) return [];
|
||||
// The same validation and HTML sanitising as a synced template of the
|
||||
// user's own, so an admin's HTML body reaches the editor cleaned too.
|
||||
return (parseSyncedTemplates(library) ?? []).map((template) => ({
|
||||
...template,
|
||||
defaultRecipients: undefined,
|
||||
identityId: undefined,
|
||||
isFavorite: false,
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* A signed-in account's own templates, wherever they live: in the account
|
||||
* (account-templates-store, kept in the Inbox metadata on Stalwart v1.0.0
|
||||
* and later), or, for accounts without, the browser's list in
|
||||
* template-store as before. Everything that changes personal templates
|
||||
* (the template manager, the composer's "Save as template", plugins) goes
|
||||
* through here, so an account's templates stay that account's.
|
||||
*/
|
||||
|
||||
import type { EmailTemplate } from '@/lib/template-types';
|
||||
import { exportTemplates, filterTemplates, importTemplates as parseImport } from '@/lib/template-utils';
|
||||
import { templateHooks } from '@/lib/plugin-hooks';
|
||||
import { templateToView } from '@/lib/plugin-projection';
|
||||
import { onBeforeSignOut, onFullSignOut } from '@/lib/sign-out-cleanup';
|
||||
import { IS_LITE } from '@/lib/lite';
|
||||
import { getCachedConfig } from '@/hooks/use-config';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useTemplateStore } from '@/stores/template-store';
|
||||
import { isSettingsLoadSettled, readSettingsOwner, useSettingsStore } from '@/stores/settings-store';
|
||||
import {
|
||||
notifyPersonalChange,
|
||||
onPersonalChange,
|
||||
useAccountTemplatesStore,
|
||||
type LocalOnlyReason,
|
||||
type PersonalPartition,
|
||||
} from '@/stores/account-templates-store';
|
||||
import * as reducer from './template-reducer';
|
||||
import { clientFor } from './account-clients';
|
||||
import { pendingRound, syncPersonalTemplates, type BrowserList, type SyncOutcome } from './account-template-sync';
|
||||
|
||||
export interface PersonalTemplateOps {
|
||||
/** The account keeps its templates (in the server's metadata), not just this browser. */
|
||||
storedInAccount: boolean;
|
||||
templates: () => EmailTemplate[];
|
||||
localOnly: () => Record<string, LocalOnlyReason>;
|
||||
add: (data: reducer.TemplateData) => EmailTemplate;
|
||||
update: (id: string, updates: reducer.TemplateUpdate) => void;
|
||||
remove: (id: string) => void;
|
||||
duplicate: (id: string, nameSuffix?: string) => EmailTemplate | null;
|
||||
toggleFavorite: (id: string) => void;
|
||||
search: (query: string) => EmailTemplate[];
|
||||
exportAll: () => string;
|
||||
importJson: (json: string) => { count: number; errors: string[] };
|
||||
}
|
||||
|
||||
/** The account a call is about: the given one, or the active one. */
|
||||
export function resolveAccountId(accountId?: string | null): string | null {
|
||||
return accountId || useAuthStore.getState().activeAccountId || null;
|
||||
}
|
||||
|
||||
/** Whether the account's templates live in the account. */
|
||||
export function isStoredInAccount(accountId?: string | null): boolean {
|
||||
const id = resolveAccountId(accountId);
|
||||
return !!id && !!useAccountTemplatesStore.getState().personal[id];
|
||||
}
|
||||
|
||||
function legacyOps(): PersonalTemplateOps {
|
||||
const store = () => useTemplateStore.getState();
|
||||
return {
|
||||
storedInAccount: false,
|
||||
templates: () => store().templates,
|
||||
localOnly: () => ({}),
|
||||
add: (data) => store().addTemplate(data),
|
||||
update: (id, updates) => store().updateTemplate(id, updates),
|
||||
remove: (id) => store().deleteTemplate(id),
|
||||
duplicate: (id, suffix) => store().duplicateTemplate(id, suffix),
|
||||
toggleFavorite: (id) => store().toggleFavorite(id),
|
||||
search: (query) => store().searchTemplates(query),
|
||||
exportAll: () => store().exportAllTemplates(),
|
||||
importJson: (json) => store().importTemplates(json),
|
||||
};
|
||||
}
|
||||
|
||||
function accountOps(accountId: string): PersonalTemplateOps {
|
||||
const partition = (): PersonalPartition | undefined => useAccountTemplatesStore.getState().personal[accountId];
|
||||
const templates = () => partition()?.templates ?? [];
|
||||
const change = (fn: (list: reducer.TemplateListState) => reducer.TemplateListState) => {
|
||||
useAccountTemplatesStore.getState().updatePersonal(accountId, (p) => ({ ...p, ...fn({ templates: p.templates, deletedTemplateIds: p.deletedTemplateIds }) }));
|
||||
notifyPersonalChange(accountId);
|
||||
};
|
||||
const find = (id: string) => templates().find((t) => t.id === id);
|
||||
const emitUpdate = (id: string) => {
|
||||
const template = find(id);
|
||||
if (template) void templateHooks.onTemplateUpdate.emit(templateToView(template));
|
||||
};
|
||||
const add = (data: reducer.TemplateData): EmailTemplate => {
|
||||
let created: EmailTemplate | null = null;
|
||||
change((list) => {
|
||||
const result = reducer.addTemplate(list, data);
|
||||
created = result.template;
|
||||
return result.state;
|
||||
});
|
||||
void templateHooks.onTemplateCreate.emit(templateToView(created!));
|
||||
return created!;
|
||||
};
|
||||
return {
|
||||
storedInAccount: true,
|
||||
templates,
|
||||
localOnly: () => partition()?.localOnly ?? {},
|
||||
add,
|
||||
update: (id, updates) => {
|
||||
if (!find(id)) return;
|
||||
change((list) => reducer.updateTemplate(list, id, updates));
|
||||
emitUpdate(id);
|
||||
},
|
||||
remove: (id) => {
|
||||
if (!find(id)) return;
|
||||
change((list) => reducer.deleteTemplate(list, id));
|
||||
void templateHooks.onTemplateDelete.emit({ templateId: id });
|
||||
},
|
||||
duplicate: (id, suffix) => {
|
||||
let created: EmailTemplate | null = null;
|
||||
change((list) => {
|
||||
const result = reducer.duplicateTemplate(list, id, suffix);
|
||||
if (!result) return list;
|
||||
created = result.template;
|
||||
return result.state;
|
||||
});
|
||||
if (created) void templateHooks.onTemplateCreate.emit(templateToView(created));
|
||||
return created;
|
||||
},
|
||||
toggleFavorite: (id) => {
|
||||
if (!find(id)) return;
|
||||
change((list) => reducer.toggleFavorite(list, id));
|
||||
emitUpdate(id);
|
||||
},
|
||||
search: (query) => filterTemplates(templates(), query),
|
||||
exportAll: () => exportTemplates(templates()),
|
||||
importJson: (json) => {
|
||||
const result = parseImport(json);
|
||||
if (result.templates.length > 0) {
|
||||
change((list) => reducer.appendTemplates(list, result.templates));
|
||||
void templateHooks.onTemplatesImport.emit({ count: result.templates.length });
|
||||
}
|
||||
return { count: result.templates.length, errors: result.errors };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The personal templates of the signed-in account `accountId` (the active one when left out). */
|
||||
export function personalTemplatesFor(accountId?: string | null): PersonalTemplateOps {
|
||||
const id = resolveAccountId(accountId);
|
||||
return id && useAccountTemplatesStore.getState().personal[id] ? accountOps(id) : legacyOps();
|
||||
}
|
||||
|
||||
// ─── Whose is the browser's list ──────────────────────────────
|
||||
|
||||
/**
|
||||
* The browser's template list as far as `accountId` is concerned.
|
||||
*
|
||||
* With settings sync, the list mirrors the settings of the account that
|
||||
* loaded last (settings-sync-owner): it is this account's once its settings
|
||||
* have loaded, and unknown before that; a load that ended without making
|
||||
* any account the owner (the server refused sync) leaves the list to no
|
||||
* one, as without sync.
|
||||
*
|
||||
* Without settings sync (Lite, sync off), the list belongs to no account:
|
||||
* it goes into the first account whose metadata answers, and only that
|
||||
* one, so one person's templates don't turn up in every mailbox signed in
|
||||
* in this browser.
|
||||
*/
|
||||
export function browserListFor(accountId: string): BrowserList {
|
||||
const templates = useTemplateStore.getState().templates;
|
||||
const config = getCachedConfig();
|
||||
if (!IS_LITE && !config) return { status: 'unknown' };
|
||||
const syncOn = !IS_LITE && config?.settingsSyncEnabled === true && !useSettingsStore.getState().settingsSyncDisabled;
|
||||
if (syncOn) {
|
||||
const owner = readSettingsOwner();
|
||||
const active = useAuthStore.getState().activeAccountId;
|
||||
if (owner !== null) {
|
||||
if (owner === accountId && accountId === active) return { status: 'list', templates };
|
||||
return accountId === active ? { status: 'unknown' } : { status: 'none' };
|
||||
}
|
||||
if (!isSettingsLoadSettled(accountId)) return accountId === active ? { status: 'unknown' } : { status: 'none' };
|
||||
}
|
||||
const given = useAccountTemplatesStore.getState().browserListOwner;
|
||||
if (given !== null && given !== accountId) return { status: 'none' };
|
||||
return {
|
||||
status: 'list',
|
||||
templates,
|
||||
claim: () => {
|
||||
if (useAccountTemplatesStore.getState().browserListOwner === null) useAccountTemplatesStore.setState({ browserListOwner: accountId });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Syncing ──────────────────────────────────────────────────
|
||||
|
||||
/** Sync the account's personal templates now. */
|
||||
export function syncAccountTemplates(accountId: string): Promise<SyncOutcome> {
|
||||
const client = clientFor(accountId);
|
||||
if (!client) return Promise.resolve('unsupported');
|
||||
return syncPersonalTemplates(accountId, client, () => browserListFor(accountId));
|
||||
}
|
||||
|
||||
const SYNC_DELAY_MS = 800;
|
||||
const timers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
|
||||
/** Sync shortly after a change, once for a burst of changes. */
|
||||
export function scheduleAccountSync(accountId: string): void {
|
||||
const pending = timers.get(accountId);
|
||||
if (pending) clearTimeout(pending);
|
||||
timers.set(accountId, setTimeout(() => {
|
||||
timers.delete(accountId);
|
||||
void syncAccountTemplates(accountId);
|
||||
}, SYNC_DELAY_MS));
|
||||
}
|
||||
|
||||
onPersonalChange(scheduleAccountSync);
|
||||
|
||||
const lastSync = new Map<string, number>();
|
||||
|
||||
/** Sync when the last round is older than `maxAgeMs` (opening the picker or the manager, coming back to the tab). */
|
||||
export function refreshAccountTemplates(accountId: string | null | undefined, maxAgeMs = 15_000): void {
|
||||
const id = resolveAccountId(accountId);
|
||||
if (!id) return;
|
||||
const last = lastSync.get(id) ?? 0;
|
||||
if (Date.now() - last < maxAgeMs) return;
|
||||
lastSync.set(id, Date.now());
|
||||
void syncAccountTemplates(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Before an account signs out: send what is queued for it (above all
|
||||
* deletions, which otherwise come back from the server next time). The
|
||||
* auth store bounds the wait.
|
||||
*/
|
||||
export async function flushAccountTemplates(accountId: string): Promise<void> {
|
||||
const ids = accountId === 'all' ? Object.keys(useAccountTemplatesStore.getState().personal) : [accountId];
|
||||
await Promise.all(ids.map(async (id) => {
|
||||
if (!useAccountTemplatesStore.getState().personal[id]) return;
|
||||
const timer = timers.get(id);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timers.delete(id);
|
||||
}
|
||||
await pendingRound(id);
|
||||
await syncAccountTemplates(id);
|
||||
}));
|
||||
}
|
||||
|
||||
onBeforeSignOut(flushAccountTemplates);
|
||||
|
||||
// At a full sign-out only what exists nowhere else stays in this browser:
|
||||
// templates and deletions the server hasn't taken yet.
|
||||
onFullSignOut(() => {
|
||||
lastSync.clear();
|
||||
useAccountTemplatesStore.getState().forgetServerCopies();
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* Two members may edit the same template. A change is written only if the
|
||||
* entry is still the version the edit started from; otherwise it is
|
||||
* refused ('conflict') and the list is read again.
|
||||
*
|
||||
* Only group principals count, not a mailbox another person shared
|
||||
* (facts §5); on servers without metadata there are no team templates.
|
||||
*/
|
||||
|
||||
import { MetadataRefusedError, MetadataTooLargeError } from '@/lib/jmap/bulwark-metadata';
|
||||
import type { EmailTemplate } from '@/lib/template-types';
|
||||
import { debug } from '@/lib/debug';
|
||||
import { useAccountTemplatesStore, type TeamTemplates } from '@/stores/account-templates-store';
|
||||
import { clientFor, hasMetadata, type TemplateClient } from './account-clients';
|
||||
import { isTooLargeForMetadata, templateKey, templateValue, templatesFromMetadata } from './metadata-templates';
|
||||
import type { TemplateSaveResult } from './template-sources';
|
||||
|
||||
/**
|
||||
* 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: TemplateClient): 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: TemplateClient, 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: TemplateClient): 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;
|
||||
}
|
||||
|
||||
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) return;
|
||||
lastLoad.set(localAccountId, Date.now());
|
||||
void loadTeamTemplates(localAccountId, client).catch((error) => {
|
||||
debug.warn('templates', 'Could not read team templates:', error);
|
||||
});
|
||||
}
|
||||
|
||||
function setTeamTemplates(localAccountId: string, groupAccountId: string, templates: EmailTemplate[]) {
|
||||
const store = useAccountTemplatesStore.getState();
|
||||
const teams = store.teams[localAccountId] ?? [];
|
||||
store.setTeams(localAccountId, teams.map((team) => (team.accountId === groupAccountId ? { ...team, templates } : team)));
|
||||
}
|
||||
|
||||
function refusal(error: unknown, template: EmailTemplate | null): TemplateSaveResult {
|
||||
if (error instanceof MetadataTooLargeError) return template && isTooLargeForMetadata(template) ? 'too-large' : 'no-room';
|
||||
if (error instanceof MetadataRefusedError && error.type === 'forbidden') return 'forbidden';
|
||||
debug.warn('templates', 'Could not change a team template:', error);
|
||||
return 'failed';
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the group's templates and checks that the entry `id` is still at
|
||||
* `base` (the `updatedAt` the edit started from; null for a new one).
|
||||
*/
|
||||
async function checkUnchanged(
|
||||
localAccountId: string,
|
||||
client: TemplateClient & Required<Pick<TemplateClient, 'getBulwarkMetadata'>>,
|
||||
groupAccountId: string,
|
||||
id: string,
|
||||
base: string | null,
|
||||
): Promise<Record<string, EmailTemplate> | TemplateSaveResult> {
|
||||
const entries = await client.getBulwarkMetadata(groupAccountId);
|
||||
if (!entries) return 'failed';
|
||||
const current = templatesFromMetadata(entries);
|
||||
if ((current[id]?.updatedAt ?? null) !== base) {
|
||||
setTeamTemplates(localAccountId, groupAccountId, Object.values(current));
|
||||
return 'conflict';
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves a team template in the group's Inbox. `base` is the `updatedAt`
|
||||
* of the version the edit started from, null for a new template.
|
||||
*/
|
||||
export async function saveTeamTemplate(
|
||||
localAccountId: string,
|
||||
groupAccountId: string,
|
||||
template: EmailTemplate,
|
||||
base: string | null,
|
||||
): Promise<TemplateSaveResult> {
|
||||
const client = clientFor(localAccountId);
|
||||
if (!client || !hasMetadata(client, groupAccountId)) return 'failed';
|
||||
// Identities are each member's own, a favourite is a personal choice,
|
||||
// and blind copies don't belong in something the whole team uses.
|
||||
const recipients = template.defaultRecipients
|
||||
? { to: template.defaultRecipients.to, cc: template.defaultRecipients.cc }
|
||||
: undefined;
|
||||
const shared: EmailTemplate = {
|
||||
...template,
|
||||
identityId: undefined,
|
||||
isFavorite: false,
|
||||
defaultRecipients: recipients && (recipients.to?.length || recipients.cc?.length) ? recipients : undefined,
|
||||
};
|
||||
if (isTooLargeForMetadata(shared)) return 'too-large';
|
||||
const current = await checkUnchanged(localAccountId, client, groupAccountId, shared.id, base);
|
||||
if (typeof current === 'string') return current;
|
||||
try {
|
||||
await client.setBulwarkMetadata(groupAccountId, templateKey(shared.id), templateValue(shared));
|
||||
} catch (error) {
|
||||
return refusal(error, shared);
|
||||
}
|
||||
setTeamTemplates(localAccountId, groupAccountId, [...Object.values(current).filter((t) => t.id !== shared.id), shared]);
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
/** Removes a team template from the group's Inbox, if it is still the version `base`. */
|
||||
export async function deleteTeamTemplate(localAccountId: string, groupAccountId: string, id: string, base: string): Promise<TemplateSaveResult> {
|
||||
const client = clientFor(localAccountId);
|
||||
if (!client || !hasMetadata(client, groupAccountId)) return 'failed';
|
||||
const current = await checkUnchanged(localAccountId, client, groupAccountId, id, base);
|
||||
if (typeof current === 'string') return current;
|
||||
try {
|
||||
await client.setBulwarkMetadata(groupAccountId, templateKey(id), null);
|
||||
} catch (error) {
|
||||
return refusal(error, null);
|
||||
}
|
||||
setTeamTemplates(localAccountId, groupAccountId, Object.values(current).filter((t) => t.id !== id));
|
||||
return 'ok';
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* The changes a user makes to a list of their own templates, as pure
|
||||
* functions over `{ templates, deletedTemplateIds }`. The browser's list
|
||||
* (template-store) and an account's copy (account-templates-store) both use
|
||||
* them, so both behave the same.
|
||||
*
|
||||
* Every change moves `updatedAt` forward from the template's previous value
|
||||
* even when this device's clock is behind the one that wrote it last;
|
||||
* otherwise the merge by `updatedAt` would throw the change away.
|
||||
*/
|
||||
|
||||
import type { EmailTemplate } from '@/lib/template-types';
|
||||
import { generateUUID } from '@/lib/utils';
|
||||
|
||||
export interface TemplateListState {
|
||||
templates: EmailTemplate[];
|
||||
deletedTemplateIds: Record<string, string>;
|
||||
}
|
||||
|
||||
export type TemplateData = Omit<EmailTemplate, 'id' | 'createdAt' | 'updatedAt'>;
|
||||
export type TemplateUpdate = Partial<Omit<EmailTemplate, 'id' | 'createdAt'>>;
|
||||
|
||||
/** `updatedAt` for a change: now, or just after the previous value if that is later. */
|
||||
export function nextUpdatedAt(previous?: string, now: number = Date.now()): string {
|
||||
const before = previous ? Date.parse(previous) : NaN;
|
||||
return new Date(Number.isNaN(before) ? now : Math.max(now, before + 1)).toISOString();
|
||||
}
|
||||
|
||||
export function addTemplate(state: TemplateListState, data: TemplateData, now = Date.now()): { state: TemplateListState; template: EmailTemplate } {
|
||||
const at = new Date(now).toISOString();
|
||||
const template: EmailTemplate = { ...data, id: generateUUID(), createdAt: at, updatedAt: at };
|
||||
return { state: { ...state, templates: [...state.templates, template] }, template };
|
||||
}
|
||||
|
||||
export function updateTemplate(state: TemplateListState, id: string, updates: TemplateUpdate, now = Date.now()): TemplateListState {
|
||||
return {
|
||||
...state,
|
||||
templates: state.templates.map((t) => (t.id === id ? { ...t, ...updates, updatedAt: nextUpdatedAt(t.updatedAt, now) } : t)),
|
||||
};
|
||||
}
|
||||
|
||||
export function toggleFavorite(state: TemplateListState, id: string, now = Date.now()): TemplateListState {
|
||||
return {
|
||||
...state,
|
||||
templates: state.templates.map((t) => (t.id === id ? { ...t, isFavorite: !t.isFavorite, updatedAt: nextUpdatedAt(t.updatedAt, now) } : t)),
|
||||
};
|
||||
}
|
||||
|
||||
/** Removes a template and leaves a tombstone newer than the template, so the deletion wins the merge. */
|
||||
export function deleteTemplate(state: TemplateListState, id: string, now = Date.now()): TemplateListState {
|
||||
const template = state.templates.find((t) => t.id === id);
|
||||
if (!template) return state;
|
||||
return {
|
||||
templates: state.templates.filter((t) => t.id !== id),
|
||||
deletedTemplateIds: { ...state.deletedTemplateIds, [id]: nextUpdatedAt(template.updatedAt, now) },
|
||||
};
|
||||
}
|
||||
|
||||
export function duplicateTemplate(
|
||||
state: TemplateListState,
|
||||
id: string,
|
||||
nameSuffix?: string,
|
||||
now = Date.now(),
|
||||
): { state: TemplateListState; template: EmailTemplate } | null {
|
||||
const original = state.templates.find((t) => t.id === id);
|
||||
if (!original) return null;
|
||||
const { id: _id, createdAt: _c, updatedAt: _u, ...data } = original;
|
||||
void _id; void _c; void _u;
|
||||
return addTemplate(state, { ...data, name: `${original.name} ${nameSuffix || '(copy)'}`, isFavorite: false }, now);
|
||||
}
|
||||
|
||||
export function appendTemplates(state: TemplateListState, templates: EmailTemplate[]): TemplateListState {
|
||||
return { ...state, templates: [...state.templates, ...templates] };
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Where the templates a user can pick come from: their own (personal), a
|
||||
* group mailbox they are a member of (team), and the admin's library
|
||||
* (organisation). The picker shows each under its own heading; the template
|
||||
* manager edits the ones the user may change.
|
||||
*/
|
||||
|
||||
import type { EmailTemplate } from '@/lib/template-types';
|
||||
|
||||
export type TemplateSourceKind = 'personal' | 'team' | 'organisation';
|
||||
|
||||
export interface TemplateSource {
|
||||
/** 'personal', 'organisation' or `team:<JMAP account id>`. */
|
||||
key: string;
|
||||
kind: TemplateSourceKind;
|
||||
/** A team's name (the group's address); personal and organisation are named by the UI. */
|
||||
name?: string;
|
||||
templates: EmailTemplate[];
|
||||
/** The user may add, change and delete templates here. */
|
||||
editable: boolean;
|
||||
/** Read-only because the admin locked it: "Set by your organisation". */
|
||||
locked: boolean;
|
||||
}
|
||||
|
||||
export const PERSONAL_SOURCE = 'personal';
|
||||
export const ORGANISATION_SOURCE = 'organisation';
|
||||
export const teamSourceKey = (accountId: string) => `team:${accountId}`;
|
||||
|
||||
/** Sorted by name, the way the picker and the manager list a source. */
|
||||
export function byName(templates: EmailTemplate[]): EmailTemplate[] {
|
||||
return [...templates].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
/** What a change to a template came to; anything but 'ok' keeps the form open. */
|
||||
export type TemplateSaveResult = 'ok' | 'too-large' | 'no-room' | 'forbidden' | 'conflict' | 'failed';
|
||||
@@ -2910,6 +2910,26 @@
|
||||
"sender_name": "Ihr Name"
|
||||
}
|
||||
},
|
||||
"org_templates": {
|
||||
"section_personal": "Meine Vorlagen",
|
||||
"section_organisation": "Organisation",
|
||||
"sources_label": "Vorlagenlisten",
|
||||
"organisation_note": "Von Ihrer Organisation. Sie können diese Vorlagen nicht ändern, aber in Ihre eigenen kopieren.",
|
||||
"copy_to_personal": "In meine Vorlagen kopieren",
|
||||
"duplicate": "Duplizieren",
|
||||
"save_failed": "Die Vorlage konnte nicht gespeichert werden. Versuchen Sie es erneut.",
|
||||
"save_failed_forbidden": "Sie können diese Vorlagen nicht ändern.",
|
||||
"save_failed_too_large": "Diese Vorlage ist größer als die 8 KB, die der Server pro Vorlage speichert. Kürzen Sie sie und speichern Sie erneut.",
|
||||
"save_failed_no_room": "Im Postfach ist kein Platz für weitere Vorlagen. Löschen Sie eine und versuchen Sie es erneut.",
|
||||
"local_only_too_large": "Nur in diesem Browser: größer als die 8 KB, die der Server pro Vorlage speichert.",
|
||||
"local_only_no_room": "Nur in diesem Browser: In Ihrem Konto ist kein Platz für weitere Vorlagen.",
|
||||
"personal_in_account": "Ihre Vorlagen werden in Ihrem Konto gespeichert, Sie haben sie also auf jedem Gerät, auf dem Sie sich anmelden. Alle, für die Sie Ihren Posteingang freigeben, können sie lesen.",
|
||||
"team_note": "Alle Mitglieder von {name} sehen diese Vorlagen und können sie ändern.",
|
||||
"team_read_only": "Sie können diese Vorlagen verwenden, aber nicht ändern.",
|
||||
"save_failed_conflict": "Von jemand anderem geändert. Laden Sie neu und versuchen Sie es erneut.",
|
||||
"preview_to": "An: {list}",
|
||||
"preview_cc": "Cc: {list}"
|
||||
},
|
||||
"contacts": {
|
||||
"directory": {
|
||||
"title": "Verzeichnis",
|
||||
|
||||
@@ -2910,6 +2910,26 @@
|
||||
"sender_name": "Your name"
|
||||
}
|
||||
},
|
||||
"org_templates": {
|
||||
"section_personal": "My templates",
|
||||
"section_organisation": "Organisation",
|
||||
"sources_label": "Template lists",
|
||||
"organisation_note": "From your organisation. You can't change these, but you can copy one into your own templates.",
|
||||
"copy_to_personal": "Copy to my templates",
|
||||
"duplicate": "Duplicate",
|
||||
"save_failed": "The template could not be saved. Try again.",
|
||||
"save_failed_forbidden": "You can't change these templates.",
|
||||
"save_failed_too_large": "This template is larger than the 8 KB the server keeps per template. Shorten it and save again.",
|
||||
"save_failed_no_room": "The mailbox has no room for more templates. Delete one and try again.",
|
||||
"local_only_too_large": "Only in this browser: larger than the 8 KB the server keeps per template.",
|
||||
"local_only_no_room": "Only in this browser: your account has no room for more templates.",
|
||||
"personal_in_account": "Your templates are kept in your account, so you have them on every device you sign in on. Anyone you share your Inbox with can read them.",
|
||||
"team_note": "Everyone in {name} sees these templates and can change them.",
|
||||
"team_read_only": "You can use these templates but not change them.",
|
||||
"save_failed_conflict": "Changed by someone else. Reload and try again.",
|
||||
"preview_to": "To: {list}",
|
||||
"preview_cc": "Cc: {list}"
|
||||
},
|
||||
"contacts": {
|
||||
"directory": {
|
||||
"title": "Directory",
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { useSettingsStore } from '../settings-store';
|
||||
import { useTemplateStore } from '../template-store';
|
||||
import { emptyPartition, useAccountTemplatesStore, type PersonalPartition } from '../account-templates-store';
|
||||
import { mergeStoredTemplates } from '@/lib/template-merge';
|
||||
import { parseSyncedTemplates } from '@/lib/template-utils';
|
||||
import type { EmailTemplate } from '@/lib/template-types';
|
||||
|
||||
// An account that keeps its templates in its own Inbox metadata (Stalwart
|
||||
// 1.0) takes them out of the settings-sync blob once its marker entry is on
|
||||
// the server; until then, and for accounts without, the blob syncs the
|
||||
// browser's list as before.
|
||||
|
||||
const tpl = (id: string, name = id, updatedAt = '2026-10-01T00:00:00.000Z'): EmailTemplate => ({
|
||||
id, name, subject: '', body: '', isHTML: false, category: '', isFavorite: false,
|
||||
createdAt: '2026-10-01T00:00:00.000Z', updatedAt,
|
||||
});
|
||||
|
||||
const copy = (templates: EmailTemplate[], extra: Partial<PersonalPartition> = {}): PersonalPartition => ({ ...emptyPartition(), templates, ...extra });
|
||||
|
||||
beforeEach(() => {
|
||||
useTemplateStore.setState({ templates: [tpl('browser-1', 'Browser')], recentTemplateIds: [], deletedTemplateIds: {} });
|
||||
useAccountTemplatesStore.setState({ personal: {}, teams: {}, active: null, browserListOwner: null });
|
||||
});
|
||||
|
||||
const push = () => JSON.parse(useSettingsStore.getState().exportSettings({ forSync: true }));
|
||||
|
||||
describe('the settings blob', () => {
|
||||
it('carries the browser\'s templates for an account without its own copy, as before', () => {
|
||||
useAccountTemplatesStore.setState({ active: 'old@server' });
|
||||
expect(push().templates.map((t: EmailTemplate) => t.name)).toEqual(['Browser']);
|
||||
expect(push().templatesInMetadata).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps carrying them until the account\'s marker is on the server', () => {
|
||||
useAccountTemplatesStore.setState({ active: 'new@server', personal: { 'new@server': copy([tpl('acc-1', 'In the account')]) } });
|
||||
expect(push().templates.map((t: EmailTemplate) => t.name)).toEqual(['Browser']);
|
||||
expect(push().templatesInMetadata).toBeUndefined();
|
||||
});
|
||||
|
||||
it('leaves them out once the marker is on the server, and the stored blob drops its copy', () => {
|
||||
useAccountTemplatesStore.setState({ active: 'new@server', personal: { 'new@server': copy([tpl('acc-1', 'In the account')], { markerOnServer: true }) } });
|
||||
const blob = push();
|
||||
expect(blob.templates).toBeUndefined();
|
||||
expect(blob.templatesInMetadata).toBe(true);
|
||||
|
||||
const stored = { theme: 'dark', templates: [tpl('old', 'Old copy')], deletedTemplateIds: {} };
|
||||
const saved = mergeStoredTemplates(stored, blob);
|
||||
expect(saved.templates).toBeUndefined();
|
||||
expect(saved.deletedTemplateIds).toBeUndefined();
|
||||
expect(saved.templatesInMetadata).toBe(true);
|
||||
});
|
||||
|
||||
it('exports the account\'s own templates to a settings file', () => {
|
||||
useAccountTemplatesStore.setState({ active: 'new@server', personal: { 'new@server': copy([tpl('acc-1', 'In the account')], { markerOnServer: true }) } });
|
||||
const file = JSON.parse(useSettingsStore.getState().exportSettings());
|
||||
expect(file.templates.map((t: EmailTemplate) => t.name)).toEqual(['In the account']);
|
||||
});
|
||||
|
||||
it('merges a blob loaded for an account with its own copy into that copy too, tombstones applied', () => {
|
||||
useAccountTemplatesStore.setState({
|
||||
personal: { 'new@server': copy([tpl('acc-1', 'In the account'), tpl('gone', 'Deleted elsewhere')]) },
|
||||
});
|
||||
useSettingsStore.getState().importSettings(JSON.stringify({
|
||||
templates: [tpl('blob-1', 'From the blob'), tpl('blob-dead', 'Deleted later')],
|
||||
deletedTemplateIds: { gone: '2026-10-09T00:00:00.000Z', 'blob-dead': '2026-10-09T00:00:00.000Z' },
|
||||
}), { serverAccountId: 'new@server' });
|
||||
expect(useAccountTemplatesStore.getState().personal['new@server'].templates.map((t) => t.id).sort()).toEqual(['acc-1', 'blob-1']);
|
||||
// The browser's list merges it as before.
|
||||
expect(useTemplateStore.getState().templates.map((t) => t.name).sort()).toEqual(['Browser', 'From the blob']);
|
||||
});
|
||||
|
||||
it('merges a blob into the browser\'s list for an account without, as before', () => {
|
||||
useSettingsStore.getState().importSettings(JSON.stringify({ templates: [tpl('blob-1', 'From the blob')], deletedTemplateIds: {} }), { serverAccountId: 'old@server' });
|
||||
expect(useTemplateStore.getState().templates.map((t) => t.name).sort()).toEqual(['Browser', 'From the blob']);
|
||||
});
|
||||
|
||||
it('adds the templates of an imported settings file to the active account\'s copy, never replacing it', () => {
|
||||
useAccountTemplatesStore.setState({ active: 'new@server', personal: { 'new@server': copy([tpl('acc-1', 'Kept')]) } });
|
||||
useSettingsStore.getState().importSettings(JSON.stringify({ templates: [tpl('file-1', 'From the file')], deletedTemplateIds: {} }));
|
||||
const names = useAccountTemplatesStore.getState().personal['new@server'].templates.map((t) => t.name).sort();
|
||||
expect(names).toEqual(['From the file', 'Kept']);
|
||||
expect(useTemplateStore.getState().templates.map((t) => t.name)).toEqual(['Browser']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('changes', () => {
|
||||
it('move updatedAt past the previous value when this clock is behind', () => {
|
||||
const ahead = new Date(Date.now() + 3_600_000).toISOString();
|
||||
useTemplateStore.setState({ templates: [tpl('a', 'A', ahead)] });
|
||||
useTemplateStore.getState().updateTemplate('a', { name: 'Edited' });
|
||||
expect(Date.parse(useTemplateStore.getState().templates[0].updatedAt)).toBe(Date.parse(ahead) + 1);
|
||||
useTemplateStore.getState().deleteTemplate('a');
|
||||
expect(Date.parse(useTemplateStore.getState().deletedTemplateIds.a)).toBe(Date.parse(ahead) + 2);
|
||||
});
|
||||
|
||||
it('date a synced template without updatedAt at a fixed early time, so it never beats a dated copy', () => {
|
||||
const [parsed] = parseSyncedTemplates([{ id: 'x', name: 'No date' }])!;
|
||||
expect(parsed.updatedAt).toBe('1970-01-01T00:00:00.000Z');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Templates kept in a mail account rather than only in this browser
|
||||
* (Stalwart v1.0.0 and later, JMAP metadata; lib/templates/metadata-templates.ts):
|
||||
*
|
||||
* - `personal`: this browser's copy of each signed-in account's own
|
||||
* templates, by local account id. An account gets one the first time
|
||||
* its Inbox metadata answers (lib/templates/account-template-sync.ts);
|
||||
* until then, and on servers without metadata, its templates are the
|
||||
* browser's own list in template-store, as they always were.
|
||||
* - `teams`: the templates of the group mailboxes each signed-in account
|
||||
* is a member of, as last read. Not kept across reloads: the server
|
||||
* copy is the only one.
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import type { EmailTemplate } from '@/lib/template-types';
|
||||
import type { PersonalCopy } from '@/lib/templates/metadata-templates';
|
||||
|
||||
/** Why a personal template could not go to the server and stays in this browser. */
|
||||
export type LocalOnlyReason = 'too-large' | 'no-room';
|
||||
|
||||
export interface PersonalPartition extends PersonalCopy {
|
||||
/** Templates the server refused, with why. */
|
||||
localOnly: Record<string, LocalOnlyReason>;
|
||||
/** Tombstones the server's marker entry held after the last round. Others are still to be sent. */
|
||||
serverDeleted: Record<string, string>;
|
||||
/** The marker entry (`org.bulwarkmail.templates`) is on the server: the settings blob may drop its copy. */
|
||||
markerOnServer: boolean;
|
||||
/** Versions of the browser's own templates already merged in, so each is taken once. */
|
||||
fromBrowser: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface TeamTemplates {
|
||||
/** The group's JMAP account id in the session of the signed-in account. */
|
||||
accountId: string;
|
||||
/** The group's address, as Stalwart names the account. */
|
||||
name: string;
|
||||
templates: EmailTemplate[];
|
||||
/** The member may change the group Inbox's metadata (myRights.maySetMetadata). */
|
||||
mayWrite: boolean;
|
||||
}
|
||||
|
||||
interface AccountTemplatesState {
|
||||
personal: Record<string, PersonalPartition>;
|
||||
/**
|
||||
* Without settings sync (Lite, sync off) the browser's list belongs to no
|
||||
* account: it goes into the first account whose metadata answers, this one.
|
||||
*/
|
||||
browserListOwner: string | null;
|
||||
/** The active signed-in account, kept here by the sync driver for the settings bridge. */
|
||||
active: string | null;
|
||||
teams: Record<string, TeamTemplates[]>;
|
||||
setPersonal: (accountId: string, partition: PersonalPartition) => void;
|
||||
updatePersonal: (accountId: string, change: (partition: PersonalPartition) => PersonalPartition) => void;
|
||||
setTeams: (accountId: string, teams: TeamTemplates[]) => void;
|
||||
/**
|
||||
* At a full sign-out: keep only what exists nowhere else, the templates
|
||||
* the server never took and deletions it hasn't heard of.
|
||||
*/
|
||||
forgetServerCopies: () => void;
|
||||
}
|
||||
|
||||
export const emptyPartition = (): PersonalPartition => ({
|
||||
templates: [],
|
||||
deletedTemplateIds: {},
|
||||
serverVersions: {},
|
||||
localOnly: {},
|
||||
serverDeleted: {},
|
||||
markerOnServer: false,
|
||||
fromBrowser: {},
|
||||
});
|
||||
|
||||
/** What a full sign-out keeps of a partition; null when everything is on the server. */
|
||||
export function unsentPart(partition: PersonalPartition): PersonalPartition | null {
|
||||
const templates = partition.templates.filter((t) => partition.serverVersions[t.id] !== t.updatedAt);
|
||||
const deletedTemplateIds: Record<string, string> = {};
|
||||
for (const [id, at] of Object.entries(partition.deletedTemplateIds)) {
|
||||
if (partition.serverDeleted[id] !== at) deletedTemplateIds[id] = at;
|
||||
}
|
||||
if (templates.length === 0 && Object.keys(deletedTemplateIds).length === 0) return null;
|
||||
const localOnly: Record<string, LocalOnlyReason> = {};
|
||||
for (const t of templates) if (partition.localOnly[t.id]) localOnly[t.id] = partition.localOnly[t.id];
|
||||
return { ...emptyPartition(), templates, deletedTemplateIds, localOnly, fromBrowser: partition.fromBrowser };
|
||||
}
|
||||
|
||||
export const useAccountTemplatesStore = create<AccountTemplatesState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
personal: {},
|
||||
browserListOwner: null,
|
||||
active: null,
|
||||
teams: {},
|
||||
setPersonal: (accountId, partition) => set((state) => ({ personal: { ...state.personal, [accountId]: partition } })),
|
||||
updatePersonal: (accountId, change) => set((state) => {
|
||||
const current = state.personal[accountId];
|
||||
if (!current) return state;
|
||||
return { personal: { ...state.personal, [accountId]: change(current) } };
|
||||
}),
|
||||
setTeams: (accountId, teams) => set((state) => ({ teams: { ...state.teams, [accountId]: teams } })),
|
||||
forgetServerCopies: () => set((state) => {
|
||||
const personal: Record<string, PersonalPartition> = {};
|
||||
for (const [accountId, partition] of Object.entries(state.personal)) {
|
||||
const kept = unsentPart(partition);
|
||||
if (kept) personal[accountId] = kept;
|
||||
}
|
||||
return { personal, teams: {} };
|
||||
}),
|
||||
}),
|
||||
{
|
||||
name: 'account-templates',
|
||||
version: 2,
|
||||
partialize: (state) => ({ personal: state.personal, browserListOwner: state.browserListOwner }),
|
||||
migrate: (persisted, version) => {
|
||||
const state = (persisted ?? {}) as { personal?: Record<string, Partial<PersonalPartition>>; browserListOwner?: string | null };
|
||||
if (version < 2) {
|
||||
const personal: Record<string, PersonalPartition> = {};
|
||||
for (const [id, partition] of Object.entries(state.personal ?? {})) personal[id] = { ...emptyPartition(), ...partition };
|
||||
return { personal, browserListOwner: state.browserListOwner ?? null } as unknown as AccountTemplatesState;
|
||||
}
|
||||
return state as unknown as AccountTemplatesState;
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// A change made here (not one a sync wrote) asks for a sync. The sync
|
||||
// module registers the listener, so this store doesn't import it.
|
||||
const changeListeners = new Set<(accountId: string) => void>();
|
||||
|
||||
export function onPersonalChange(listener: (accountId: string) => void): () => void {
|
||||
changeListeners.add(listener);
|
||||
return () => changeListeners.delete(listener);
|
||||
}
|
||||
|
||||
export function notifyPersonalChange(accountId: string): void {
|
||||
for (const listener of changeListeners) listener(accountId);
|
||||
}
|
||||
|
||||
// Every tab writes the whole state back on a change; pick up another tab's.
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('storage', (event) => {
|
||||
if (event.key !== 'account-templates' || event.storageArea !== window.localStorage) return;
|
||||
void useAccountTemplatesStore.persist.rehydrate();
|
||||
});
|
||||
}
|
||||
@@ -19,7 +19,7 @@ import { replaceWindowLocation, getPathPrefix, getLocaleFromPath, apiFetch } fro
|
||||
import { isEmbedded, notifyParent } from '@/lib/iframe-bridge';
|
||||
import { snapshotAccount, restoreAccount, clearAllStores, evictAccount, evictAll, forgetAccountRows } from '@/lib/account-state-manager';
|
||||
import { clearAllPluginStorage, clearPluginStorageForAccount } from '@/lib/plugin-sandbox/storage-scope';
|
||||
import { broadcastSignOut, onSignedOutElsewhere, purgeSignedOutData } from '@/lib/sign-out-cleanup';
|
||||
import { broadcastSignOut, onSignedOutElsewhere, purgeSignedOutData, runBeforeSignOut } from '@/lib/sign-out-cleanup';
|
||||
import { forgetPrincipals } from '@/lib/delegation/known-principals';
|
||||
import { useSearchHistoryStore } from './search-history-store';
|
||||
import { useCalendarNotificationStore } from './calendar-notification-store';
|
||||
@@ -1329,7 +1329,7 @@ function notifySignedIn(state: { activeAccountId: string | null; username: strin
|
||||
|
||||
function beforeLogoutHooks(accountId: string): Promise<void> {
|
||||
return Promise.race([
|
||||
authHooks.onBeforeLogout.emit({ accountId }),
|
||||
Promise.all([authHooks.onBeforeLogout.emit({ accountId }), runBeforeSignOut(accountId)]).then(() => {}),
|
||||
new Promise<void>((resolve) => setTimeout(resolve, LOGOUT_HOOK_BUDGET_MS)),
|
||||
]);
|
||||
}
|
||||
|
||||
+52
-16
@@ -56,7 +56,25 @@ let blockedSyncAccountId: string | null = null;
|
||||
// device settings and the first sign-in adopts them.
|
||||
const SETTINGS_OWNER_KEY = 'settings-sync-owner';
|
||||
|
||||
function readSettingsOwner(): string | null {
|
||||
// Accounts whose settings load from the server has finished (with or
|
||||
// without a copy) since this page loaded.
|
||||
const settledLoads = new Set<string>();
|
||||
|
||||
/** Whether this account's settings load has finished in this page. */
|
||||
export function isSettingsLoadSettled(accountId: string): boolean {
|
||||
return settledLoads.has(accountId);
|
||||
}
|
||||
|
||||
const settledListeners = new Set<(accountId: string) => void>();
|
||||
|
||||
/** Called whenever an account's settings load finishes (with or without a copy). */
|
||||
export function onSettingsLoadSettled(listener: (accountId: string) => void): () => void {
|
||||
settledListeners.add(listener);
|
||||
return () => settledListeners.delete(listener);
|
||||
}
|
||||
|
||||
/** The account whose server copy the local settings (and template list) mirror; null if none. */
|
||||
export function readSettingsOwner(): string | null {
|
||||
try {
|
||||
return typeof window === 'undefined' ? null : window.localStorage.getItem(SETTINGS_OWNER_KEY);
|
||||
} catch {
|
||||
@@ -89,14 +107,21 @@ interface SettingsSyncJob {
|
||||
// bindings uninitialized depending on which module loads first. Instead,
|
||||
// template-store registers itself here during its own module init.
|
||||
interface TemplateSyncBridge {
|
||||
getSyncedState: () => {
|
||||
/**
|
||||
* The templates of the account `accountId` (the one being synced, or the
|
||||
* active one). `storedInAccount`: they live in the account's own server
|
||||
* storage (JMAP metadata, Stalwart 1.0), so a sync push leaves them out
|
||||
* and the stored blob drops them (`templatesInMetadata`).
|
||||
*/
|
||||
getSyncedState: (opts?: { accountId?: string }) => {
|
||||
templates: EmailTemplate[];
|
||||
deletedTemplateIds: Record<string, string>;
|
||||
storedInAccount?: boolean;
|
||||
};
|
||||
applySyncedState: (
|
||||
templates: unknown,
|
||||
deletedTemplateIds: unknown,
|
||||
opts: { merge: boolean }
|
||||
opts: { merge: boolean; accountId?: string }
|
||||
) => void;
|
||||
}
|
||||
|
||||
@@ -129,9 +154,11 @@ function templateStateKey(templates: unknown, deletedTemplateIds: unknown): stri
|
||||
* Whether the local template state differs from what a server blob carries,
|
||||
* so the blob needs a push. False when there is nothing local to push.
|
||||
*/
|
||||
function localTemplatesAhead(serverSettings: Record<string, unknown> | null): boolean {
|
||||
const local = templateSyncBridge?.getSyncedState();
|
||||
function localTemplatesAhead(serverSettings: Record<string, unknown> | null, accountId?: string): boolean {
|
||||
const local = templateSyncBridge?.getSyncedState({ accountId });
|
||||
if (!local) return false;
|
||||
// Templates kept in the account leave the blob: push once to drop them.
|
||||
if (local.storedInAccount) return !!serverSettings && Array.isArray(serverSettings.templates);
|
||||
if (local.templates.length === 0 && Object.keys(local.deletedTemplateIds).length === 0) return false;
|
||||
if (!serverSettings) return true;
|
||||
return templateStateKey(local.templates, local.deletedTemplateIds) !==
|
||||
@@ -1204,9 +1231,11 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
}
|
||||
};
|
||||
|
||||
const buildExport = (opts?: { subscriptionOwner?: string }): string => {
|
||||
const buildExport = (opts?: { forSync?: boolean; subscriptionOwner?: string }): string => {
|
||||
const state = get();
|
||||
const templateSync = templateSyncBridge?.getSyncedState();
|
||||
const templateSync = templateSyncBridge?.getSyncedState({
|
||||
accountId: opts?.forSync && syncUsername && syncServerUrl ? generateAccountId(syncUsername, syncServerUrl) : undefined,
|
||||
});
|
||||
const subscriptionSync = calendarSubscriptionSyncBridge?.getSyncedState();
|
||||
const settings = {
|
||||
fontSize: state.fontSize,
|
||||
@@ -1338,12 +1367,16 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
locale: useLocaleStore.getState().locale,
|
||||
// Omitted entirely (rather than emitted empty) if the bridge has
|
||||
// not registered, so an importing device leaves its templates alone.
|
||||
...(templateSync
|
||||
? {
|
||||
templates: templateSync.templates,
|
||||
deletedTemplateIds: templateSync.deletedTemplateIds,
|
||||
}
|
||||
: {}),
|
||||
// Templates the account keeps itself are left out of a sync push;
|
||||
// the flag has the stored blob drop its old copy (mergeStoredTemplates).
|
||||
...(templateSync && opts?.forSync && templateSync.storedInAccount
|
||||
? { templatesInMetadata: true }
|
||||
: templateSync
|
||||
? {
|
||||
templates: templateSync.templates,
|
||||
deletedTemplateIds: templateSync.deletedTemplateIds,
|
||||
}
|
||||
: {}),
|
||||
// Omitted entirely if the bridge has not registered, so an importing
|
||||
// device leaves its calendar subscriptions alone.
|
||||
...(subscriptionSync
|
||||
@@ -1545,7 +1578,7 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
templateSyncBridge?.applySyncedState(
|
||||
settings.templates,
|
||||
settings.deletedTemplateIds,
|
||||
{ merge: Boolean(opts?.serverAccountId) }
|
||||
{ merge: Boolean(opts?.serverAccountId), accountId: opts?.serverAccountId }
|
||||
);
|
||||
calendarSubscriptionSyncBridge?.applySyncedState(
|
||||
settings.icalSubscriptions,
|
||||
@@ -1760,7 +1793,7 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
// previous account left in this browser (#1185).
|
||||
if (heldByOther) resetSyncedState({ keepCrossAccountViews });
|
||||
writeSettingsOwner(accountId);
|
||||
pushTemplatesAfterLoad = localTemplatesAhead(null);
|
||||
pushTemplatesAfterLoad = localTemplatesAhead(null, accountId);
|
||||
return false;
|
||||
}
|
||||
if (settings && typeof settings === 'object') {
|
||||
@@ -1778,7 +1811,7 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
});
|
||||
isLoadingFromServer = false;
|
||||
writeSettingsOwner(accountId);
|
||||
pushTemplatesAfterLoad = localTemplatesAhead(settings);
|
||||
pushTemplatesAfterLoad = localTemplatesAhead(settings, accountId);
|
||||
syncLog('Settings loaded from server successfully');
|
||||
void settingsHooks.onSettingsSync.emit({ direction: 'pulled' });
|
||||
// The per-account preferred sender identity (#507) is re-applied by
|
||||
@@ -1792,6 +1825,9 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
syncError('Failed to load settings from server:', error);
|
||||
isLoadingFromServer = false;
|
||||
return failWithoutForeignState();
|
||||
} finally {
|
||||
settledLoads.add(accountId);
|
||||
for (const listener of settledListeners) listener(accountId);
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
+72
-54
@@ -9,13 +9,18 @@ import {
|
||||
parseSyncedTemplates,
|
||||
parseTemplateTombstones,
|
||||
} from '@/lib/template-utils';
|
||||
import { generateUUID } from '@/lib/utils';
|
||||
import * as reducer from '@/lib/templates/template-reducer';
|
||||
import { registerTemplateSyncBridge } from './settings-store';
|
||||
import { notifyPersonalChange, useAccountTemplatesStore } from './account-templates-store';
|
||||
import { withStorableId } from '@/lib/templates/metadata-templates';
|
||||
import { templateHooks } from '@/lib/plugin-hooks';
|
||||
import { templateToView } from '@/lib/plugin-projection';
|
||||
|
||||
const MAX_RECENT = 5;
|
||||
|
||||
const listOf = (state: { templates: EmailTemplate[]; deletedTemplateIds: Record<string, string> }): reducer.TemplateListState =>
|
||||
({ templates: state.templates, deletedTemplateIds: state.deletedTemplateIds });
|
||||
|
||||
interface TemplateStore {
|
||||
templates: EmailTemplate[];
|
||||
recentTemplateIds: string[];
|
||||
@@ -63,72 +68,36 @@ export const useTemplateStore = create<TemplateStore>()(
|
||||
deletedTemplateIds: {},
|
||||
|
||||
addTemplate: (data) => {
|
||||
const now = new Date().toISOString();
|
||||
const template: EmailTemplate = {
|
||||
...data,
|
||||
id: generateUUID(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
set((state) => ({
|
||||
templates: [...state.templates, template],
|
||||
}));
|
||||
const { state, template } = reducer.addTemplate(listOf(get()), data);
|
||||
set(state);
|
||||
void templateHooks.onTemplateCreate.emit(templateToView(template));
|
||||
return template;
|
||||
},
|
||||
|
||||
updateTemplate: (id, updates) => {
|
||||
set((state) => ({
|
||||
templates: state.templates.map((t) =>
|
||||
t.id === id
|
||||
? { ...t, ...updates, updatedAt: new Date().toISOString() }
|
||||
: t
|
||||
),
|
||||
}));
|
||||
set(reducer.updateTemplate(listOf(get()), id, updates));
|
||||
emitTemplateUpdate(id);
|
||||
},
|
||||
|
||||
deleteTemplate: (id) => {
|
||||
if (!get().templates.some((t) => t.id === id)) return;
|
||||
set((state) => ({
|
||||
templates: state.templates.filter((t) => t.id !== id),
|
||||
...reducer.deleteTemplate(listOf(state), id),
|
||||
recentTemplateIds: state.recentTemplateIds.filter((rid) => rid !== id),
|
||||
deletedTemplateIds: {
|
||||
...state.deletedTemplateIds,
|
||||
[id]: new Date().toISOString(),
|
||||
},
|
||||
}));
|
||||
void templateHooks.onTemplateDelete.emit({ templateId: id });
|
||||
},
|
||||
|
||||
duplicateTemplate: (id, nameSuffix) => {
|
||||
const original = get().templates.find((t) => t.id === id);
|
||||
if (!original) return null;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const duplicate: EmailTemplate = {
|
||||
...original,
|
||||
id: generateUUID(),
|
||||
name: `${original.name} ${nameSuffix || '(copy)'}`,
|
||||
|
||||
isFavorite: false,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
set((state) => ({
|
||||
templates: [...state.templates, duplicate],
|
||||
}));
|
||||
void templateHooks.onTemplateCreate.emit(templateToView(duplicate));
|
||||
return duplicate;
|
||||
const result = reducer.duplicateTemplate(listOf(get()), id, nameSuffix);
|
||||
if (!result) return null;
|
||||
set(result.state);
|
||||
void templateHooks.onTemplateCreate.emit(templateToView(result.template));
|
||||
return result.template;
|
||||
},
|
||||
|
||||
toggleFavorite: (id) => {
|
||||
set((state) => ({
|
||||
templates: state.templates.map((t) =>
|
||||
t.id === id ? { ...t, isFavorite: !t.isFavorite, updatedAt: new Date().toISOString() } : t
|
||||
),
|
||||
}));
|
||||
set(reducer.toggleFavorite(listOf(get()), id));
|
||||
emitTemplateUpdate(id);
|
||||
},
|
||||
|
||||
@@ -174,9 +143,7 @@ export const useTemplateStore = create<TemplateStore>()(
|
||||
importTemplates: (json) => {
|
||||
const result = importUtil(json);
|
||||
if (result.templates.length > 0) {
|
||||
set((state) => ({
|
||||
templates: [...state.templates, ...result.templates],
|
||||
}));
|
||||
set(reducer.appendTemplates(listOf(get()), result.templates));
|
||||
void templateHooks.onTemplatesImport.emit({ count: result.templates.length });
|
||||
}
|
||||
return { count: result.templates.length, errors: result.errors };
|
||||
@@ -260,14 +227,65 @@ if (typeof window !== 'undefined') {
|
||||
// and file imports, and template changes schedule a sync push. Registration
|
||||
// (rather than settings-store importing this module) avoids an import cycle -
|
||||
// see registerTemplateSyncBridge.
|
||||
//
|
||||
// An account that keeps its templates in its own Inbox metadata (Stalwart
|
||||
// 1.0, account-templates-store) leaves the blob once its marker entry is on
|
||||
// the server: from then on its pushes carry no templates, and the flag has
|
||||
// the stored blob drop its copy. Until then the blob syncs the browser's
|
||||
// list as before. A blob loaded for such an account is merged into the
|
||||
// browser's list as always and into the account's copy as well, so
|
||||
// templates that only the blob has still reach the account.
|
||||
const accountCopy = (accountId: string | undefined) => {
|
||||
const accounts = useAccountTemplatesStore.getState();
|
||||
const id = accountId ?? accounts.active ?? undefined;
|
||||
return id ? accounts.personal[id] : undefined;
|
||||
};
|
||||
|
||||
function mergeIntoAccount(accountId: string, templates: unknown, deletedTemplateIds: unknown): void {
|
||||
const incoming = parseSyncedTemplates(templates);
|
||||
if (!incoming || incoming.length === 0) return;
|
||||
useAccountTemplatesStore.getState().updatePersonal(accountId, (copy) => ({
|
||||
...copy,
|
||||
...mergeSyncedTemplates(
|
||||
{ templates: copy.templates, deletedTemplateIds: copy.deletedTemplateIds },
|
||||
{ templates: incoming.map(withStorableId), deletedTemplateIds: parseTemplateTombstones(deletedTemplateIds) },
|
||||
),
|
||||
}));
|
||||
notifyPersonalChange(accountId);
|
||||
}
|
||||
|
||||
registerTemplateSyncBridge(
|
||||
{
|
||||
getSyncedState: () => {
|
||||
getSyncedState: (opts) => {
|
||||
const copy = accountCopy(opts?.accountId);
|
||||
if (copy?.markerOnServer) return { templates: copy.templates, deletedTemplateIds: copy.deletedTemplateIds, storedInAccount: true };
|
||||
const { templates, deletedTemplateIds } = useTemplateStore.getState();
|
||||
return { templates, deletedTemplateIds };
|
||||
},
|
||||
applySyncedState: (templates, deletedTemplateIds, opts) =>
|
||||
useTemplateStore.getState().applySyncedState(templates, deletedTemplateIds, opts),
|
||||
applySyncedState: (templates, deletedTemplateIds, opts) => {
|
||||
if (opts.accountId) {
|
||||
useTemplateStore.getState().applySyncedState(templates, deletedTemplateIds, opts);
|
||||
if (accountCopy(opts.accountId)) mergeIntoAccount(opts.accountId, templates, deletedTemplateIds);
|
||||
return;
|
||||
}
|
||||
// A settings file the user imports: into the active account's copy
|
||||
// when it keeps one, added by id (never a wholesale replace, which
|
||||
// would delete server templates).
|
||||
const active = useAccountTemplatesStore.getState().active;
|
||||
if (active && accountCopy(active)) {
|
||||
mergeIntoAccount(active, templates, {});
|
||||
return;
|
||||
}
|
||||
useTemplateStore.getState().applySyncedState(templates, deletedTemplateIds, opts);
|
||||
},
|
||||
},
|
||||
(listener) => useTemplateStore.subscribe(listener)
|
||||
(listener) => {
|
||||
useTemplateStore.subscribe(listener);
|
||||
// An account whose marker just reached the server pushes once, so the
|
||||
// blob drops its copy.
|
||||
useAccountTemplatesStore.subscribe((state, prev) => {
|
||||
const newlyMarked = Object.entries(state.personal).some(([id, copy]) => copy.markerOnServer && !prev.personal[id]?.markerOnServer);
|
||||
if (newlyMarked) listener();
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user