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(); + }); + } );