feat: keep personal templates in the account on Stalwart 1.0

This commit is contained in:
Linus Rath
2026-10-28 06:13:47 +01:00
parent 57065d7ef2
commit 47c47b4f08
21 changed files with 1376 additions and 62 deletions
+2
View File
@@ -20,6 +20,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";
@@ -63,6 +64,7 @@ export default async function LocaleLayout({
<ImpersonationReconciler />
<PushBindingsHost />
<SessionTimeoutGuard />
<AccountTemplateSync />
{children}
<PluginDialogHost />
<CommandPaletteHost />
+7 -2
View File
@@ -83,8 +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";
@@ -1554,7 +1555,11 @@ 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 personalTemplatesHidden = useTemplateSources(composingAccountId).personalHidden;
// Sign/encrypt is provided by crypto plugins (S/MIME, PGP) via the
+5 -5
View File
@@ -5,7 +5,7 @@ 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';
@@ -21,15 +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');
@@ -59,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'));
@@ -105,7 +105,7 @@ export function TemplateSettings() {
variant="outline"
size="sm"
onClick={handleExport}
disabled={templates.length === 0}
disabled={personal.length === 0}
>
<Download className="w-4 h-4 me-1" />
{t('export')}
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { fireEvent, render, screen, within } from '@testing-library/react';
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';
@@ -8,6 +8,8 @@ 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
@@ -35,8 +37,12 @@ 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);
});
afterEach(() => resetAccountSubjects());
describe('template manager sources', () => {
it('shows the organisation library read-only, with a preview and a copy into the user\'s own', () => {
@@ -69,4 +75,24 @@ describe('template manager sources', () => {
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']);
});
});
});
@@ -9,6 +9,7 @@ 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
@@ -49,6 +50,7 @@ beforeEach(() => {
} as never);
setPolicy(RAW);
useTemplateStore.setState({ templates: [own('mine', 'Mine')], recentTemplateIds: [], deletedTemplateIds: {} });
useAccountTemplatesStore.setState({ personal: {}, teams: {}, active: null });
});
afterEach(() => {
resetAccountSubjects();
@@ -109,4 +111,16 @@ describe('template picker sources', () => {
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();
});
});
@@ -0,0 +1,34 @@
'use client';
import { useEffect } from 'react';
import { useAuthStore } from '@/stores/auth-store';
import { useAccountTemplatesStore } from '@/stores/account-templates-store';
import { refreshAccountTemplates } from '@/lib/templates/personal-templates';
/**
* Syncs the active account's templates with the account (Stalwart 1.0
* metadata) when it signs in or becomes active, and again when the tab
* comes back into view. Renders nothing; does nothing on servers without
* metadata.
*/
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]);
useEffect(() => {
if (!isAuthenticated || !activeAccountId || !client) return;
refreshAccountTemplates(activeAccountId, 0);
const onVisible = () => {
if (document.visibilityState === 'visible') refreshAccountTemplates(activeAccountId, 60_000);
};
document.addEventListener('visibilitychange', onVisible);
return () => document.removeEventListener('visibilitychange', onVisible);
}, [activeAccountId, client, isAuthenticated]);
return null;
}
@@ -1,6 +1,6 @@
'use client';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { X, FileText, Pencil, Trash2, Star, Copy, Search, Eye } from '@/components/icons';
import { cn } from '@/lib/utils';
@@ -14,6 +14,7 @@ import { useTemplateSourceActions, type TemplateSaveResult } from '@/hooks/use-t
import { OrgLockedNote } from '@/components/settings/org-locked-note';
import { filterTemplates } from '@/lib/template-utils';
import { PERSONAL_SOURCE, type TemplateSource } from '@/lib/templates/template-sources';
import { refreshAccountTemplates } from '@/lib/templates/personal-templates';
import type { EmailTemplate } from '@/lib/template-types';
interface TemplateManagerModalProps {
@@ -51,6 +52,10 @@ export function TemplateManagerModal({ isOpen, onClose, accountId }: TemplateMan
setError(null);
};
useEffect(() => {
if (isOpen) refreshAccountTemplates(accountId);
}, [isOpen, accountId]);
const modalRef = useFocusTrap({
isActive: isOpen,
onEscape: () => {
+7 -1
View File
@@ -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';
@@ -15,6 +15,7 @@ import {
getAutoFilledPlaceholders,
} from '@/lib/template-utils';
import type { TemplateSource } from '@/lib/templates/template-sources';
import { refreshAccountTemplates } from '@/lib/templates/personal-templates';
import { PlaceholderFillModal } from './placeholder-fill-modal';
import type { EmailTemplate } from '@/lib/template-types';
@@ -44,6 +45,11 @@ export function TemplatePicker({ isOpen, onClose, onSelect, accountId }: Templat
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) refreshAccountTemplates(accountId);
}, [isOpen, accountId]);
const modalRef = useFocusTrap({
isActive: isOpen && !showFillModal,
onEscape: onClose,
+23 -17
View File
@@ -1,13 +1,14 @@
import { useMemo } from 'react';
import type { EmailTemplate } from '@/lib/template-types';
import type { TemplateSource } from '@/lib/templates/template-sources';
import { useTemplateStore } from '@/stores/template-store';
import { personalTemplatesFor, resolveAccountId } from '@/lib/templates/personal-templates';
import { useAuthStore } from '@/stores/auth-store';
import { useAccountTemplatesStore, type LocalOnlyReason } from '@/stores/account-templates-store';
/** What a change to a template came to; anything but 'ok' keeps the form open. */
export type TemplateSaveResult = 'ok' | 'too-large' | 'no-room' | 'forbidden' | 'failed';
/** Why a personal template could not go to the server and stays in this browser. */
export type LocalOnlyReason = 'too-large' | 'no-room';
export type { LocalOnlyReason };
type TemplateData = Omit<EmailTemplate, 'id' | 'createdAt' | 'updatedAt'>;
@@ -25,36 +26,41 @@ export interface TemplateSourceActions {
storedInAccount: boolean;
}
const readOnly = async (): Promise<TemplateSaveResult> => 'forbidden';
const NO_LOCAL_ONLY: Record<string, LocalOnlyReason> = {};
/**
* Changes to the templates of each source the manager shows, for the
* signed-in account `accountId`. Read-only sources refuse every change.
* 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; read-only sources refuse every change.
*/
export function useTemplateSourceActions(accountId?: string | null): TemplateSourceActions {
void accountId;
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 store = () => useTemplateStore.getState();
const ops = () => personalTemplatesFor(resolveAccountId(resolvedId));
const personalOnly = <A extends unknown[]>(fn: (...args: A) => void) =>
async (source: TemplateSource, ...args: A): Promise<TemplateSaveResult> => {
if (source.kind !== 'personal' || !source.editable) return readOnly();
if (source.kind !== 'personal' || !source.editable) return 'forbidden';
fn(...args);
return 'ok';
};
return {
add: personalOnly((data: TemplateData) => { store().addTemplate(data); }),
update: personalOnly((id: string, data: TemplateData) => store().updateTemplate(id, data)),
remove: personalOnly((id: string) => store().deleteTemplate(id)),
duplicate: personalOnly((id: string, suffix: string) => { store().duplicateTemplate(id, suffix); }),
toggleFavorite: personalOnly((id: string) => store().toggleFavorite(id)),
add: personalOnly((data: TemplateData) => { ops().add(data); }),
update: personalOnly((id: string, data: TemplateData) => ops().update(id, data)),
remove: personalOnly((id: string) => ops().remove(id)),
duplicate: personalOnly((id: string, suffix: string) => { ops().duplicate(id, suffix); }),
toggleFavorite: personalOnly((id: string) => ops().toggleFavorite(id)),
copyToPersonal: async (template) => {
store().addTemplate(copyOf(template));
ops().add(copyOf(template));
return 'ok';
},
localOnly: {},
storedInAccount: false,
localOnly,
storedInAccount,
};
}, []);
}, [resolvedId, localOnly, storedInAccount]);
}
/** The fields a copy of a shared template takes into the user's own list. */
+9 -2
View File
@@ -8,6 +8,8 @@ import {
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. */
@@ -20,11 +22,16 @@ export interface TemplateSources {
* The templates a user can pick when writing from the signed-in account
* `accountId` (the active one when left out): their own, and the admin
* library of that account's policy, so a composer sending from another
* account shows that account's organisation templates.
* account shows that account's own and organisation templates.
*/
export function useTemplateSources(accountId?: string | null): TemplateSources {
const policy = useAccountPolicy(accountId);
const personal = useTemplateStore((s) => s.templates);
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 personalHidden = policy.restrictions?.personalTemplates?.hidden === true;
return useMemo(() => {
const sources: TemplateSource[] = [];
+270
View File
@@ -0,0 +1,270 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { JMAPClient } from '../jmap/client';
import { syncPersonalTemplates, PERSONAL_MARKER_KEY } from '../templates/account-template-sync';
import { planPersonalSync } from '../templates/metadata-templates';
import { useAccountTemplatesStore } from '@/stores/account-templates-store';
import type { EmailTemplate } from '../template-types';
import recorded from './fixtures/stalwart-1.0.0-template-metadata.json';
import { syncAccountTemplates } from '../templates/personal-templates';
import { useAuthStore } from '@/stores/auth-store';
import { useTemplateStore } from '@/stores/template-store';
// Personal templates kept in the user's own Inbox metadata on Stalwart
// v1.0.0, one namespace per template. 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 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;
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);
(out.methodResponses[1][1] as { list: Array<{ metadata: unknown }> }).list[0].metadata = clone(server);
return json(out);
}
if (method === 'Mailbox/set') {
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}`;
let client: JMAPClient;
let connection: ReturnType<typeof vi.fn<(connected: boolean) => void>>;
let legacy: EmailTemplate[];
const sync = () => syncPersonalTemplates('carol@mail', client, () => legacy);
const partition = () => useAccountTemplatesStore.getState().personal['carol@mail'];
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);
}
beforeEach(async () => {
vi.useRealTimers();
vi.spyOn(console, 'error').mockImplementation(() => {});
vi.spyOn(console, 'warn').mockImplementation(() => {});
server = {};
sets = [];
drop = false;
legacy = [];
useAccountTemplatesStore.setState({ personal: {}, teams: {}, active: null });
await connect();
});
afterEach(() => vi.restoreAllMocks());
describe('the first load with metadata', () => {
it('uploads the browser\'s templates once into an empty account and marks it', async () => {
legacy = [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'), `org.bulwarkmail.${PERSONAL_MARKER_KEY}`]);
expect((server[ns('aaaa-1')] as EmailTemplate).name).toBe('Hello');
expect(partition().templates.map((t) => t.name).sort()).toEqual(['Hello', 'Thanks']);
// Once: the next round has nothing to write.
sets = [];
expect(await sync()).toBe('synced');
expect(sets).toEqual([]);
});
it('doesn\'t upload the browser\'s list to an account set up before, even an empty one', async () => {
server = { [`org.bulwarkmail.${PERSONAL_MARKER_KEY}`]: { v: 1 } };
legacy = [tpl('aaaa-1', 'Deleted elsewhere long ago')];
await sync();
expect(sets).toEqual([]);
expect(partition().templates).toEqual([]);
});
it('takes the server copy in a second browser instead of the local list', async () => {
server = { [ns('bbbb-1')]: tpl('bbbb-1', 'From the other browser'), [`org.bulwarkmail.${PERSONAL_MARKER_KEY}`]: { v: 1 } };
legacy = [tpl('aaaa-1', 'Only here')];
await sync();
expect(partition().templates.map((t) => t.name)).toEqual(['From the other browser']);
expect(sets).toEqual([]);
});
it('keeps a template over 8 KB and one the server has no room for in this browser, with why', async () => {
// 64 KB minus a bit is in use by something else already.
server = { 'org.bulwarkmail.other': { fill: 'y'.repeat(SHARED_MAX - 4000) } };
legacy = [tpl('aaaa-big', 'Big', undefined, { body: 'x'.repeat(9000) }), tpl('aaaa-mid', 'Mid', undefined, { body: 'z'.repeat(5000) }), tpl('aaaa-ok', 'Small')];
await sync();
const p = partition();
expect(p.localOnly).toEqual({ 'aaaa-big': 'too-large', 'aaaa-mid': 'no-room' });
expect(p.templates.map((t) => t.name).sort()).toEqual(['Big', 'Mid', 'Small']);
expect(server[ns('aaaa-ok')]).toBeTruthy();
expect(server[ns('aaaa-mid')]).toBeUndefined();
// The 9 KB one never went out.
expect(sets.some((s) => s.key === ns('aaaa-big'))).toBe(false);
});
it('gives an id that doesn\'t fit a metadata key a new one', async () => {
legacy = [tpl('Demo_Template 1', 'Old id')];
await sync();
const [template] = partition().templates;
expect(template.id).not.toBe('Demo_Template 1');
expect(server[ns(template.id)]).toBeTruthy();
});
});
describe('whose list is uploaded', () => {
const original = useAuthStore.getState();
afterEach(() => {
localStorage.removeItem('settings-sync-owner');
useAuthStore.setState({ activeAccountId: original.activeAccountId, getClientForAccount: original.getClientForAccount } as never);
});
it("waits while the browser's list still holds another account's synced templates", async () => {
useTemplateStore.setState({ templates: [tpl('aaaa-9', "Alice's")] });
useAuthStore.setState({ activeAccountId: 'carol@mail', getClientForAccount: (id: string) => (id === 'carol@mail' ? client : undefined) } as never);
localStorage.setItem('settings-sync-owner', 'alice@mail');
expect(await syncAccountTemplates('carol@mail')).toBe('unsupported');
expect(sets).toEqual([]);
expect(partition()).toBeUndefined();
// Carol's settings have loaded: now the list is hers.
useTemplateStore.setState({ templates: [tpl('cccc-1', "Carol's")] });
localStorage.setItem('settings-sync-owner', 'carol@mail');
expect(await syncAccountTemplates('carol@mail')).toBe('synced');
expect(partition().templates.map((t) => t.name)).toEqual(["Carol's"]);
});
it("never uploads the browser's list into an account that isn't the active one", async () => {
useTemplateStore.setState({ templates: [tpl('aaaa-9', "Active account's")] });
useAuthStore.setState({ activeAccountId: 'alice@mail', getClientForAccount: (id: string) => (id === 'carol@mail' ? client : undefined) } as never);
expect(await syncAccountTemplates('carol@mail')).toBe('unsupported');
expect(sets).toEqual([]);
// Once set up elsewhere, its own copy is read from the server.
server = { [ns('cccc-2')]: tpl('cccc-2', 'Set up elsewhere'), [`org.bulwarkmail.${PERSONAL_MARKER_KEY}`]: { v: 1 } };
expect(await syncAccountTemplates('carol@mail')).toBe('synced');
expect(partition().templates.map((t) => t.name)).toEqual(['Set up elsewhere']);
});
});
describe('later rounds merge by id and updatedAt', () => {
beforeEach(async () => {
legacy = [tpl('aaaa-1', 'One'), tpl('aaaa-2', 'Two')];
await sync();
sets = [];
});
it('takes a newer server version and uploads a newer local one', async () => {
server[ns('aaaa-1')] = tpl('aaaa-1', 'One, edited elsewhere', '2026-10-05T00:00:00.000Z');
useAccountTemplatesStore.getState().updatePersonal('carol@mail', (p) => ({
...p, templates: p.templates.map((t) => (t.id === 'aaaa-2' ? { ...t, name: 'Two, edited here', updatedAt: '2026-10-06T00:00:00.000Z' } : t)),
}));
await sync();
expect(partition().templates.find((t) => t.id === 'aaaa-1')?.name).toBe('One, edited elsewhere');
expect(sets.map((s) => s.key)).toEqual([ns('aaaa-2')]);
expect((server[ns('aaaa-2')] as EmailTemplate).name).toBe('Two, edited here');
});
it('drops a template deleted elsewhere, and removes one deleted here from the server', async () => {
delete server[ns('aaaa-1')];
useAccountTemplatesStore.getState().updatePersonal('carol@mail', (p) => ({
...p,
templates: p.templates.filter((t) => t.id !== 'aaaa-2'),
deletedTemplateIds: { 'aaaa-2': '2026-10-07T00:00:00.000Z' },
}));
await sync();
expect(partition().templates).toEqual([]);
expect(sets).toEqual([{ key: ns('aaaa-2'), value: null }]);
expect(partition().deletedTemplateIds).toEqual({});
});
it('keeps a template edited here after another browser deleted it', async () => {
delete server[ns('aaaa-1')];
useAccountTemplatesStore.getState().updatePersonal('carol@mail', (p) => ({
...p, templates: p.templates.map((t) => (t.id === 'aaaa-1' ? { ...t, updatedAt: '2026-10-09T00:00:00.000Z' } : t)),
}));
await sync();
expect(partition().templates.map((t) => t.id).sort()).toEqual(['aaaa-1', 'aaaa-2']);
expect(sets.map((s) => s.key)).toEqual([ns('aaaa-1')]);
});
});
describe('servers without metadata', () => {
it('leaves an account without the capability alone', async () => {
vi.restoreAllMocks();
await connect(false);
legacy = [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('keeps the app online when the server drops the connection on metadata (the 1.0 test build)', async () => {
drop = true;
legacy = [tpl('aaaa-1', 'Local')];
expect(await sync()).toBe('unsupported');
expect(partition()).toBeUndefined();
expect(connection).not.toHaveBeenCalledWith(false);
// And it isn't asked again for a while.
const fetchSpy = vi.mocked(globalThis.fetch);
fetchSpy.mockClear();
expect(await sync()).toBe('unsupported');
expect(fetchSpy).not.toHaveBeenCalled();
});
});
describe('planPersonalSync', () => {
it('keeps a deletion here over an older server copy, and an edit there over an older deletion', () => {
const plan = planPersonalSync(
{ templates: [], deletedTemplateIds: { a: '2026-10-05T00:00:00.000Z', b: '2026-10-05T00:00:00.000Z' }, serverVersions: { a: '2026-10-01T00:00:00.000Z', b: '2026-10-01T00:00:00.000Z' } },
{ a: tpl('a', 'A', '2026-10-01T00:00:00.000Z'), b: tpl('b', 'B', '2026-10-06T00:00:00.000Z') },
);
expect(plan.deletes).toEqual(['a']);
expect(plan.templates.map((t) => t.id)).toEqual(['b']);
expect(plan.uploads).toEqual([]);
});
});
@@ -0,0 +1,158 @@
{
"_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.",
"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"
}
}
+19 -12
View File
@@ -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,7 @@ 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 { updateIdentityWithHooks, useIdentityStore } from '@/stores/identity-store';
import { useVacationStore } from '@/stores/vacation-store';
import { identityToView, templateToView, vacationToView } from '../plugin-projection';
@@ -29,13 +30,17 @@ 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();
export function doTemplatesList(): TemplateView[] {
return useTemplateStore.getState().templates.map(templateToView);
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);
const template = personal().templates().find((t) => t.id === templateId);
return template ? templateToView(template) : null;
}
@@ -59,13 +64,13 @@ 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) {
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 +86,25 @@ 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);
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)) {
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 ───────────────────────────────────────────────
+10
View File
@@ -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;
+203
View File
@@ -0,0 +1,203 @@
/**
* Keeps each signed-in account's personal templates in its own Inbox
* metadata, and reads the team templates of its group mailboxes
* (Stalwart v1.0.0 and later; lib/templates/metadata-templates.ts).
*
* Personal templates: the first time an account's metadata answers in this
* browser and the server holds no templates for it yet, the browser's own
* list (template-store, which settings sync also fills) is uploaded once,
* and a marker entry says the account has been set up, so a later empty
* server copy means "all deleted", not "never uploaded". After that, this
* browser's copy (account-templates-store) and the server copy are merged
* on every sync. A template the server refuses for its size stays here.
*
* 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 type { IJMAPClient } from '@/lib/jmap/client-interface';
import { MetadataTooLargeError } from '@/lib/jmap/bulwark-metadata';
import type { EmailTemplate } from '@/lib/template-types';
import { debug } from '@/lib/debug';
import {
isTooLargeForMetadata,
planPersonalSync,
templateKey,
templateValue,
templatesFromMetadata,
withStorableId,
} from './metadata-templates';
import {
emptyPartition,
useAccountTemplatesStore,
type LocalOnlyReason,
type PersonalPartition,
} from '@/stores/account-templates-store';
/** The entry that says an account's personal templates were set up on the server. */
export const PERSONAL_MARKER_KEY = 'templates';
export type MetadataClient = Pick<IJMAPClient, 'getAccountId'> & Partial<Pick<IJMAPClient,
'supportsBulwarkMetadata' | 'getBulwarkMetadata' | 'setBulwarkMetadata'>>;
export type SyncOutcome = 'synced' | 'unsupported' | 'busy';
/**
* The browser's own list, for the one-time upload; null while it isn't
* this account's (another account's settings are still loaded), and then
* a server that was never set up waits for a later round.
*/
type LegacySource = () => EmailTemplate[] | null;
const running = new Map<string, Promise<SyncOutcome>>();
const again = new Set<string>();
export function hasMetadata(client: MetadataClient | null | undefined, accountId: string): client is MetadataClient & Required<Pick<MetadataClient, 'getBulwarkMetadata' | 'setBulwarkMetadata'>> {
return !!client
&& typeof client.getBulwarkMetadata === 'function'
&& typeof client.setBulwarkMetadata === 'function'
&& client.supportsBulwarkMetadata?.(accountId) === true;
}
/**
* One round for the signed-in account `localAccountId`: read its Inbox
* metadata, merge, write what changed. Rounds for the same account don't
* overlap; asking during one runs another afterwards.
*/
export function syncPersonalTemplates(localAccountId: string, client: MetadataClient, legacy: LegacySource): 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, legacy);
while (again.delete(localAccountId) && outcome === 'synced') {
outcome = await syncRound(localAccountId, client, legacy);
}
return outcome;
} finally {
running.delete(localAccountId);
again.delete(localAccountId);
}
})();
running.set(localAccountId, round);
return round;
}
async function syncRound(localAccountId: string, client: MetadataClient, legacy: LegacySource): Promise<SyncOutcome> {
const accountId = client.getAccountId();
if (!accountId || !hasMetadata(client, accountId)) return 'unsupported';
const entries = await client.getBulwarkMetadata(accountId);
if (!entries) return 'unsupported';
const store = useAccountTemplatesStore.getState();
const server = templatesFromMetadata(entries);
const setUp = PERSONAL_MARKER_KEY in entries || Object.keys(server).length > 0;
let partition = store.personal[localAccountId];
if (!partition) {
// First answer in this browser. A server that has never held this
// account's templates gets the browser's own list once.
partition = emptyPartition();
if (!setUp) {
const own = legacy();
if (!own) return 'unsupported';
partition.templates = own.map(withStorableId);
}
store.setPersonal(localAccountId, partition);
}
// Every id has to fit a metadata key; give the odd old one a new id.
if (partition.templates.some((t) => withStorableId(t) !== t)) {
partition = { ...partition, templates: partition.templates.map(withStorableId) };
store.setPersonal(localAccountId, partition);
}
const plan = planPersonalSync(partition, server);
const serverVersions: Record<string, string> = {};
for (const [id, template] of Object.entries(server)) serverVersions[id] = template.updatedAt;
const localOnly: Record<string, LocalOnlyReason> = {};
const failedDeletes = new Set<string>();
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));
serverVersions[template.id] = template.updatedAt;
} 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;
}
}
}
for (const id of plan.deletes) {
if (!reachable) {
failedDeletes.add(id);
continue;
}
try {
await client.setBulwarkMetadata(accountId, templateKey(id), null);
delete serverVersions[id];
} catch (error) {
debug.warn('templates', 'Could not remove a template from the account:', error);
failedDeletes.add(id);
reachable = false;
}
}
if (!setUp && reachable) {
try {
await client.setBulwarkMetadata(accountId, PERSONAL_MARKER_KEY, { v: 1 });
} catch (error) {
debug.warn('templates', 'Could not mark the account as set up:', error);
}
}
// Changes made here while the round ran are newer than the plan: keep
// them and go round again.
const latest = useAccountTemplatesStore.getState().personal[localAccountId] ?? partition;
const changedMeanwhile = latest !== partition;
const deletedTemplateIds: Record<string, string> = {};
for (const [id, at] of Object.entries(plan.deletedTemplateIds)) {
if (failedDeletes.has(id)) deletedTemplateIds[id] = at;
}
const next: PersonalPartition = changedMeanwhile
? mergeLater(latest, plan.templates, serverVersions, localOnly)
: { templates: plan.templates, deletedTemplateIds, serverVersions, localOnly };
useAccountTemplatesStore.getState().setPersonal(localAccountId, next);
if (changedMeanwhile) again.add(localAccountId);
return 'synced';
}
/** The round's result under edits made while it ran: those win. */
function mergeLater(
latest: PersonalPartition,
synced: EmailTemplate[],
serverVersions: Record<string, string>,
localOnly: Record<string, LocalOnlyReason>,
): PersonalPartition {
const byId = new Map(synced.map((t) => [t.id, t]));
for (const template of latest.templates) {
const known = byId.get(template.id);
if (!known || Date.parse(template.updatedAt) >= Date.parse(known.updatedAt)) byId.set(template.id, template);
}
for (const id of Object.keys(latest.deletedTemplateIds)) byId.delete(id);
return {
templates: [...byId.values()],
deletedTemplateIds: latest.deletedTemplateIds,
serverVersions,
localOnly: { ...latest.localOnly, ...localOnly },
};
}
+114
View File
@@ -0,0 +1,114 @@
/**
* 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.
*
* 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 } from '@/lib/template-utils';
import { MAX_METADATA_ENTRY_BYTES, isValidMetadataKey, metadataValueBytes } from '@/lib/jmap/bulwark-metadata';
import { generateUUID } from '@/lib/utils';
const KEY_PREFIX = 'template.';
/** 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));
}
/** The same template with an id that fits a metadata key. */
export function withStorableId(template: EmailTemplate): EmailTemplate {
return isStorableTemplateId(template.id) ? template : { ...template, id: generateUUID() };
}
/** 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;
}
/** This browser's copy of an account's personal templates. */
export interface PersonalCopy {
templates: EmailTemplate[];
/** Deleted here, waiting to be removed from the server: id -> ISO time. */
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[];
deletedTemplateIds: Record<string, string>;
/** Templates the server lacks or holds an older version of. */
uploads: EmailTemplate[];
/** Ids to remove from the server. */
deletes: string[];
}
/**
* Brings this browser's copy and the server copy together, per id with
* the newer `updatedAt` winning (mergeSyncedTemplates):
* - a template the server had at the last sync and no longer has was
* deleted elsewhere and goes here too, unless it was changed here since;
* - a template deleted here goes from the server, unless it was changed
* there since;
* - everything newer here than on the server is uploaded, which includes
* templates the server never had (new, or kept here for lack of room).
*/
export function planPersonalSync(
local: PersonalCopy,
server: Record<string, EmailTemplate>,
now: Date = new Date(),
): PersonalSyncPlan {
const kept = local.templates.filter((template) => {
const seen = local.serverVersions[template.id];
if (seen === undefined || server[template.id]) return true;
return Date.parse(template.updatedAt) > Date.parse(seen);
});
const merged = mergeSyncedTemplates(
{ templates: kept, deletedTemplateIds: local.deletedTemplateIds },
{ 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));
return { templates: merged.templates, deletedTemplateIds: merged.deletedTemplateIds, uploads, deletes };
}
+211
View File
@@ -0,0 +1,211 @@
/**
* 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 { generateUUID } from '@/lib/utils';
import { onFullSignOut } from '@/lib/sign-out-cleanup';
import { useAuthStore } from '@/stores/auth-store';
import { useTemplateStore } from '@/stores/template-store';
import { readSettingsOwner } from '@/stores/settings-store';
import {
notifyPersonalChange,
onPersonalChange,
useAccountTemplatesStore,
type LocalOnlyReason,
type PersonalPartition,
} from '@/stores/account-templates-store';
import { syncPersonalTemplates, type MetadataClient, type SyncOutcome } from './account-template-sync';
type TemplateData = Omit<EmailTemplate, 'id' | 'createdAt' | 'updatedAt'>;
type TemplateUpdate = Partial<Omit<EmailTemplate, 'id' | 'createdAt'>>;
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: TemplateData) => EmailTemplate;
update: (id: string, updates: 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: (p: PersonalPartition) => PersonalPartition) => {
useAccountTemplatesStore.getState().updatePersonal(accountId, fn);
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: TemplateData): EmailTemplate => {
const now = new Date().toISOString();
const template: EmailTemplate = { ...data, id: generateUUID(), createdAt: now, updatedAt: now };
change((p) => ({ ...p, templates: [...p.templates, template] }));
void templateHooks.onTemplateCreate.emit(templateToView(template));
return template;
};
return {
storedInAccount: true,
templates,
localOnly: () => partition()?.localOnly ?? {},
add,
update: (id, updates) => {
if (!find(id)) return;
const now = new Date().toISOString();
change((p) => ({ ...p, templates: p.templates.map((t) => (t.id === id ? { ...t, ...updates, updatedAt: now } : t)) }));
emitUpdate(id);
},
remove: (id) => {
if (!find(id)) return;
const now = new Date().toISOString();
change((p) => {
const localOnly = { ...p.localOnly };
delete localOnly[id];
return { ...p, templates: p.templates.filter((t) => t.id !== id), deletedTemplateIds: { ...p.deletedTemplateIds, [id]: now }, localOnly };
});
void templateHooks.onTemplateDelete.emit({ templateId: id });
},
duplicate: (id, suffix) => {
const original = find(id);
if (!original) return null;
return add({ ...original, name: `${original.name} ${suffix || '(copy)'}`, isFavorite: false });
},
toggleFavorite: (id) => {
const template = find(id);
if (!template) return;
const now = new Date().toISOString();
change((p) => ({ ...p, templates: p.templates.map((t) => (t.id === id ? { ...t, isFavorite: !t.isFavorite, updatedAt: now } : t)) }));
emitUpdate(id);
},
search: (query) => filterTemplates(templates(), query),
exportAll: () => exportTemplates(templates()),
importJson: (json) => {
const result = parseImport(json);
if (result.templates.length > 0) {
change((p) => ({ ...p, templates: [...p.templates, ...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();
}
// ─── Syncing ──────────────────────────────────────────────────
function clientFor(accountId: string): MetadataClient | null {
const auth = useAuthStore.getState();
const client = auth.getClientForAccount?.(accountId) ?? (accountId === auth.activeAccountId ? auth.client : null);
return (client as unknown as MetadataClient | null) ?? null;
}
/** Sync the account's personal templates now. */
export function syncAccountTemplates(accountId: string): Promise<SyncOutcome> {
const client = clientFor(accountId);
if (!client || typeof client.getAccountId !== 'function') return Promise.resolve('unsupported');
return syncPersonalTemplates(accountId, client, () => browserListOf(accountId));
}
/**
* The browser's template list, if it is this account's: the active
* account's once its synced settings are loaded, or a list no account's
* settings sync owns (sync off, Lite). Never another account's, which
* would copy one account's templates into another's mailbox.
*/
function browserListOf(accountId: string): EmailTemplate[] | null {
if (accountId !== useAuthStore.getState().activeAccountId) return null;
const owner = readSettingsOwner();
if (owner !== null && owner !== accountId) return null;
return useTemplateStore.getState().templates;
}
// The browser's list changing for an account that doesn't keep its own
// copy yet (its synced settings just loaded) is the moment to set it up.
useTemplateStore.subscribe(() => {
const active = useAuthStore.getState().activeAccountId;
if (active && !useAccountTemplatesStore.getState().personal[active]) scheduleAccountSync(active);
});
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);
}
// At a full sign-out only what exists nowhere else stays in this browser.
onFullSignOut(() => {
lastSync.clear();
useAccountTemplatesStore.getState().forgetServerCopies();
});
@@ -0,0 +1,68 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { useSettingsStore } from '../settings-store';
import { useTemplateStore } from '../template-store';
import { emptyPartition, useAccountTemplatesStore } from '../account-templates-store';
import { mergeStoredTemplates } from '@/lib/template-merge';
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; accounts without keep
// syncing 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,
});
beforeEach(() => {
useTemplateStore.setState({ templates: [tpl('browser-1', 'Browser')], recentTemplateIds: [], deletedTemplateIds: {} });
useAccountTemplatesStore.setState({ personal: {}, teams: {}, active: null });
});
describe('the settings blob', () => {
it('carries the browser\'s templates for an account without its own copy, as before', () => {
useAccountTemplatesStore.setState({ active: 'old@server' });
const blob = JSON.parse(useSettingsStore.getState().exportSettings({ forSync: true }));
expect(blob.templates.map((t: EmailTemplate) => t.name)).toEqual(['Browser']);
expect(blob.templatesInMetadata).toBeUndefined();
});
it('leaves the templates out of a push for an account that keeps them, and the stored blob drops its copy', () => {
useAccountTemplatesStore.setState({ active: 'new@server', personal: { 'new@server': { ...emptyPartition(), templates: [tpl('acc-1', 'In the account')] } } });
const blob = JSON.parse(useSettingsStore.getState().exportSettings({ forSync: true }));
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': { ...emptyPartition(), templates: [tpl('acc-1', 'In the account')] } } });
const file = JSON.parse(useSettingsStore.getState().exportSettings());
expect(file.templates.map((t: EmailTemplate) => t.name)).toEqual(['In the account']);
});
it('doesn\'t touch the browser\'s list when a blob loads for an account that keeps its own', () => {
useAccountTemplatesStore.setState({ personal: { 'new@server': { ...emptyPartition(), templates: [tpl('acc-1')] } } });
useSettingsStore.getState().importSettings(JSON.stringify({ templates: [tpl('blob-1', 'From the blob')], deletedTemplateIds: { 'browser-1': '2026-10-09T00:00:00.000Z' } }), { serverAccountId: 'new@server' });
expect(useTemplateStore.getState().templates.map((t) => t.name)).toEqual(['Browser']);
expect(useAccountTemplatesStore.getState().personal['new@server'].templates.map((t) => t.id)).toEqual(['acc-1']);
});
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': { ...emptyPartition(), templates: [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']);
});
});
+107
View File
@@ -0,0 +1,107 @@
/**
* 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>;
}
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>;
/** 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 (templates the server never took). */
forgetServerCopies: () => void;
}
export const emptyPartition = (): PersonalPartition => ({
templates: [],
deletedTemplateIds: {},
serverVersions: {},
localOnly: {},
});
export const useAccountTemplatesStore = create<AccountTemplatesState>()(
persist(
(set) => ({
personal: {},
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 unsent = partition.templates.filter((t) => partition.serverVersions[t.id] !== t.updatedAt);
if (unsent.length === 0) continue;
personal[accountId] = { ...emptyPartition(), templates: unsent, localOnly: partition.localOnly };
}
return { personal, teams: {} };
}),
}),
{
name: 'account-templates',
version: 1,
partialize: (state) => ({ personal: state.personal }),
},
),
);
// 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();
});
}
+32 -16
View File
@@ -56,7 +56,8 @@ 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 {
/** 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 +90,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 +137,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 +1214,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 +1350,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 +1561,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 +1776,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 +1794,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
+49 -4
View File
@@ -11,6 +11,8 @@ import {
} from '@/lib/template-utils';
import { generateUUID } from '@/lib/utils';
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';
@@ -260,14 +262,57 @@ 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 itself (account-templates-store, JMAP
// metadata on Stalwart 1.0) is left out of the blob: its pushes carry no
// templates, and a blob loaded for it doesn't touch this browser's list,
// which belongs to the accounts without. The account's copy is what a
// settings file export of it holds.
const accountCopy = (accountId: string | undefined) => {
const accounts = useAccountTemplatesStore.getState();
const id = accountId ?? accounts.active ?? undefined;
return id ? accounts.personal[id] : undefined;
};
registerTemplateSyncBridge(
{
getSyncedState: () => {
getSyncedState: (opts) => {
const copy = accountCopy(opts?.accountId);
if (copy) 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) {
if (!accountCopy(opts.accountId)) useTemplateStore.getState().applySyncedState(templates, deletedTemplateIds, opts);
return;
}
// A settings file the user imports: into the active account's copy
// when it keeps one, added by id like a sync (never a wholesale
// replace, which would delete server templates).
const active = useAccountTemplatesStore.getState().active;
const incoming = parseSyncedTemplates(templates);
if (active && accountCopy(active)) {
if (!incoming || incoming.length === 0) return;
useAccountTemplatesStore.getState().updatePersonal(active, (copy) => ({
...copy,
...mergeSyncedTemplates(
{ templates: copy.templates, deletedTemplateIds: copy.deletedTemplateIds },
{ templates: incoming.map(withStorableId), deletedTemplateIds: {} },
),
}));
notifyPersonalChange(active);
return;
}
useTemplateStore.getState().applySyncedState(templates, deletedTemplateIds, opts);
},
},
(listener) => useTemplateStore.subscribe(listener)
(listener) => {
useTemplateStore.subscribe(listener);
// An account that starts keeping its templates pushes once, so the
// blob drops its copy.
useAccountTemplatesStore.subscribe((state, prev) => {
if (Object.keys(state.personal).length > Object.keys(prev.personal).length) listener();
});
}
);