From 47c47b4f08445cc346a1e8b372e283fd9e8ebbed Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 28 Oct 2026 06:13:47 +0100
Subject: [PATCH] feat: keep personal templates in the account on Stalwart 1.0
---
app/(main)/[locale]/layout.tsx | 2 +
components/email/email-composer.tsx | 9 +-
components/settings/template-settings.tsx | 10 +-
.../template-manager-sources.test.tsx | 30 +-
.../template-picker-sources.test.tsx | 14 +
.../templates/account-template-sync.tsx | 34 +++
.../templates/template-manager-modal.tsx | 7 +-
components/templates/template-picker.tsx | 8 +-
hooks/use-template-source-actions.ts | 40 +--
hooks/use-template-sources.ts | 11 +-
lib/__tests__/account-template-sync.test.ts | 270 ++++++++++++++++++
.../stalwart-1.0.0-template-metadata.json | 158 ++++++++++
lib/plugin-sandbox/host-api-settings.ts | 31 +-
lib/template-merge.ts | 10 +
lib/templates/account-template-sync.ts | 203 +++++++++++++
lib/templates/metadata-templates.ts | 114 ++++++++
lib/templates/personal-templates.ts | 211 ++++++++++++++
.../template-store-account-copy.test.ts | 68 +++++
stores/account-templates-store.ts | 107 +++++++
stores/settings-store.ts | 48 ++--
stores/template-store.ts | 53 +++-
21 files changed, 1376 insertions(+), 62 deletions(-)
create mode 100644 components/templates/account-template-sync.tsx
create mode 100644 lib/__tests__/account-template-sync.test.ts
create mode 100644 lib/__tests__/fixtures/stalwart-1.0.0-template-metadata.json
create mode 100644 lib/templates/account-template-sync.ts
create mode 100644 lib/templates/metadata-templates.ts
create mode 100644 lib/templates/personal-templates.ts
create mode 100644 stores/__tests__/template-store-account-copy.test.ts
create mode 100644 stores/account-templates-store.ts
diff --git a/app/(main)/[locale]/layout.tsx b/app/(main)/[locale]/layout.tsx
index 31c983c1d..6d63d4ee0 100644
--- a/app/(main)/[locale]/layout.tsx
+++ b/app/(main)/[locale]/layout.tsx
@@ -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({
+
{children}
diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx
index 72a320f85..18688f544 100644
--- a/components/email/email-composer.tsx
+++ b/components/email/email-composer.tsx
@@ -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['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
diff --git a/components/settings/template-settings.tsx b/components/settings/template-settings.tsx
index 5696561f8..77cb7a586 100644
--- a/components/settings/template-settings.tsx
+++ b/components/settings/template-settings.tsx
@@ -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(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}
>
{t('export')}
diff --git a/components/templates/__tests__/template-manager-sources.test.tsx b/components/templates/__tests__/template-manager-sources.test.tsx
index c070cadd7..8674f0cfe 100644
--- a/components/templates/__tests__/template-manager-sources.test.tsx
+++ b/components/templates/__tests__/template-manager-sources.test.tsx
@@ -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( {}} />);
+ 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']);
+ });
+ });
});
diff --git a/components/templates/__tests__/template-picker-sources.test.tsx b/components/templates/__tests__/template-picker-sources.test.tsx
index f691eb96f..c37de0e39 100644
--- a/components/templates/__tests__/template-picker-sources.test.tsx
+++ b/components/templates/__tests__/template-picker-sources.test.tsx
@@ -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( {}} 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( {}} onSelect={() => {}} />);
+ expect(screen.getByText('Mine')).toBeInTheDocument();
+ expect(screen.queryByText("Dave's own")).toBeNull();
+ });
});
diff --git a/components/templates/account-template-sync.tsx b/components/templates/account-template-sync.tsx
new file mode 100644
index 000000000..a99d78290
--- /dev/null
+++ b/components/templates/account-template-sync.tsx
@@ -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;
+}
diff --git a/components/templates/template-manager-modal.tsx b/components/templates/template-manager-modal.tsx
index be55ee319..abb06ca8a 100644
--- a/components/templates/template-manager-modal.tsx
+++ b/components/templates/template-manager-modal.tsx
@@ -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: () => {
diff --git a/components/templates/template-picker.tsx b/components/templates/template-picker.tsx
index 2d84c4d66..0eb766aae 100644
--- a/components/templates/template-picker.tsx
+++ b/components/templates/template-picker.tsx
@@ -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(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,
diff --git a/hooks/use-template-source-actions.ts b/hooks/use-template-source-actions.ts
index 74b3448d1..d1a689b5c 100644
--- a/hooks/use-template-source-actions.ts
+++ b/hooks/use-template-source-actions.ts
@@ -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;
@@ -25,36 +26,41 @@ export interface TemplateSourceActions {
storedInAccount: boolean;
}
-const readOnly = async (): Promise => 'forbidden';
+const NO_LOCAL_ONLY: Record = {};
/**
* 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 = (fn: (...args: A) => void) =>
async (source: TemplateSource, ...args: A): Promise => {
- 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. */
diff --git a/hooks/use-template-sources.ts b/hooks/use-template-sources.ts
index ad77c4c50..08c69fe7c 100644
--- a/hooks/use-template-sources.ts
+++ b/hooks/use-template-sources.ts
@@ -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[] = [];
diff --git a/lib/__tests__/account-template-sync.test.ts b/lib/__tests__/account-template-sync.test.ts
new file mode 100644
index 000000000..0d44d9689
--- /dev/null
+++ b/lib/__tests__/account-template-sync.test.ts
@@ -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 = (v: T): T => JSON.parse(JSON.stringify(v));
+
+type Call = [string, Record, string];
+let server: Record;
+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>).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 => ({
+ 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 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([]);
+ });
+});
diff --git a/lib/__tests__/fixtures/stalwart-1.0.0-template-metadata.json b/lib/__tests__/fixtures/stalwart-1.0.0-template-metadata.json
new file mode 100644
index 000000000..5d3adaccd
--- /dev/null
+++ b/lib/__tests__/fixtures/stalwart-1.0.0-template-metadata.json
@@ -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"
+ }
+}
diff --git a/lib/plugin-sandbox/host-api-settings.ts b/lib/plugin-sandbox/host-api-settings.ts
index 8aafffec1..39c2fa143 100644
--- a/lib/plugin-sandbox/host-api-settings.ts
+++ b/lib/plugin-sandbox/host-api-settings.ts
@@ -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;
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 ───────────────────────────────────────────────
diff --git a/lib/template-merge.ts b/lib/template-merge.ts
index 1377c34aa..cb470b637 100644
--- a/lib/template-merge.ts
+++ b/lib/template-merge.ts
@@ -98,6 +98,16 @@ export function mergeStoredTemplates(
stored: Record | null,
incoming: Record
): Record {
+ // 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;
diff --git a/lib/templates/account-template-sync.ts b/lib/templates/account-template-sync.ts
new file mode 100644
index 000000000..8babcacf6
--- /dev/null
+++ b/lib/templates/account-template-sync.ts
@@ -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 & Partial>;
+
+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>();
+const again = new Set();
+
+export function hasMetadata(client: MetadataClient | null | undefined, accountId: string): client is MetadataClient & Required> {
+ 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 {
+ const current = running.get(localAccountId);
+ if (current) {
+ again.add(localAccountId);
+ return current;
+ }
+ const round = (async (): Promise => {
+ 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 {
+ 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 = {};
+ for (const [id, template] of Object.entries(server)) serverVersions[id] = template.updatedAt;
+ const localOnly: Record = {};
+ const failedDeletes = new Set();
+ 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 = {};
+ 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,
+ localOnly: Record,
+): 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 },
+ };
+}
diff --git a/lib/templates/metadata-templates.ts b/lib/templates/metadata-templates.ts
new file mode 100644
index 000000000..8252b04a5
--- /dev/null
+++ b/lib/templates/metadata-templates.ts
@@ -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.`,
+ * 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.`). */
+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.`),
+ * by id, validated and sanitised like a synced template. The key decides
+ * the id.
+ */
+export function templatesFromMetadata(entries: Record | null | undefined): Record {
+ const out: Record = {};
+ 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), 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;
+ /** `updatedAt` of each template as the server held it at the last sync. */
+ serverVersions: Record;
+}
+
+export interface PersonalSyncPlan {
+ /** What this browser keeps after the merge. */
+ templates: EmailTemplate[];
+ deletedTemplateIds: Record;
+ /** 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,
+ 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 };
+}
diff --git a/lib/templates/personal-templates.ts b/lib/templates/personal-templates.ts
new file mode 100644
index 000000000..027323662
--- /dev/null
+++ b/lib/templates/personal-templates.ts
@@ -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;
+type TemplateUpdate = Partial>;
+
+export interface PersonalTemplateOps {
+ /** The account keeps its templates (in the server's metadata), not just this browser. */
+ storedInAccount: boolean;
+ templates: () => EmailTemplate[];
+ localOnly: () => Record;
+ 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 {
+ 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>();
+
+/** 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();
+
+/** 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();
+});
diff --git a/stores/__tests__/template-store-account-copy.test.ts b/stores/__tests__/template-store-account-copy.test.ts
new file mode 100644
index 000000000..e84d42434
--- /dev/null
+++ b/stores/__tests__/template-store-account-copy.test.ts
@@ -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']);
+ });
+});
diff --git a/stores/account-templates-store.ts b/stores/account-templates-store.ts
new file mode 100644
index 000000000..d0693d2c6
--- /dev/null
+++ b/stores/account-templates-store.ts
@@ -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;
+}
+
+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;
+ /** The active signed-in account, kept here by the sync driver for the settings bridge. */
+ active: string | null;
+ teams: Record;
+ 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()(
+ 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 = {};
+ 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();
+ });
+}
diff --git a/stores/settings-store.ts b/stores/settings-store.ts
index b6f7a484e..5b3214ce2 100644
--- a/stores/settings-store.ts
+++ b/stores/settings-store.ts
@@ -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;
+ 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 | null): boolean {
- const local = templateSyncBridge?.getSyncedState();
+function localTemplatesAhead(serverSettings: Record | 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()(
}
};
- 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()(
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()(
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()(
// 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()(
});
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
diff --git a/stores/template-store.ts b/stores/template-store.ts
index ae3185496..c03f3232e 100644
--- a/stores/template-store.ts
+++ b/stores/template-store.ts
@@ -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();
+ });
+ }
);