mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
feat: keep personal templates in the account on Stalwart 1.0
This commit is contained in:
@@ -20,6 +20,7 @@ import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
|
||||
import { PushNotificationPrompt } from "@/components/push-notification-prompt";
|
||||
import { AccountArchiveRestorePrompt } from "@/components/account-archive-prompt";
|
||||
import { SessionTimeoutGuard } from "@/components/session/session-timeout-guard";
|
||||
import { AccountTemplateSync } from "@/components/templates/account-template-sync";
|
||||
import { IS_LITE } from "@/lib/lite";
|
||||
import { setRequestLocale } from "next-intl/server";
|
||||
import { locales } from "@/i18n/routing";
|
||||
@@ -63,6 +64,7 @@ export default async function LocaleLayout({
|
||||
<ImpersonationReconciler />
|
||||
<PushBindingsHost />
|
||||
<SessionTimeoutGuard />
|
||||
<AccountTemplateSync />
|
||||
{children}
|
||||
<PluginDialogHost />
|
||||
<CommandPaletteHost />
|
||||
|
||||
@@ -83,8 +83,9 @@ import type { DirectoryEntry } from "@/stores/contact-store";
|
||||
import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||
import type { FileNode } from "@/lib/jmap/types";
|
||||
import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
|
||||
import { useTemplateStore, renderTemplateWithPlugins, notifyTemplateApplied } from "@/stores/template-store";
|
||||
import { renderTemplateWithPlugins, notifyTemplateApplied } from "@/stores/template-store";
|
||||
import { useTemplateSources } from "@/hooks/use-template-sources";
|
||||
import { personalTemplatesFor } from "@/lib/templates/personal-templates";
|
||||
import { notifyIdentitySelected } from "@/stores/identity-store";
|
||||
import { usePluginSignature } from "@/hooks/use-plugin-signature";
|
||||
import { useOrgSignature, useOrgSignatureIdentity } from "@/hooks/use-org-signature";
|
||||
@@ -1554,7 +1555,11 @@ export function EmailComposer({
|
||||
const addToTrustedSendersBook = useContactStore((s) => s.addToTrustedSendersBook);
|
||||
const addTrustedSender = useSettingsStore((s) => s.addTrustedSender);
|
||||
const trustedSendersAddressBook = useSettingsStore((s) => s.trustedSendersAddressBook);
|
||||
const addTemplate = useTemplateStore((s) => s.addTemplate);
|
||||
// "Save as template" keeps it with the account the message is written from.
|
||||
const addTemplate = useCallback(
|
||||
(data: Parameters<ReturnType<typeof personalTemplatesFor>['add']>[0]) => personalTemplatesFor(composingAccountId).add(data),
|
||||
[composingAccountId],
|
||||
);
|
||||
// The admin may leave users only organisation and team templates.
|
||||
const personalTemplatesHidden = useTemplateSources(composingAccountId).personalHidden;
|
||||
// Sign/encrypt is provided by crypto plugins (S/MIME, PGP) via the
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useTranslations } from 'next-intl';
|
||||
import { SettingsSection } from './settings-section';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { TemplateManagerModal } from '@/components/templates/template-manager-modal';
|
||||
import { useTemplateStore } from '@/stores/template-store';
|
||||
import { personalTemplatesFor } from '@/lib/templates/personal-templates';
|
||||
import { useTemplateSources } from '@/hooks/use-template-sources';
|
||||
import { toast } from '@/stores/toast-store';
|
||||
import { debug } from '@/lib/debug';
|
||||
@@ -21,15 +21,15 @@ export function TemplateSettings() {
|
||||
const t = useTranslations('settings.templates');
|
||||
const tNotif = useTranslations('notifications');
|
||||
|
||||
const { templates, exportAllTemplates, importTemplates: storeImport } = useTemplateStore();
|
||||
const { sources, personalHidden } = useTemplateSources();
|
||||
const personal = sources.find((source) => source.kind === 'personal')?.templates ?? [];
|
||||
const total = sources.reduce((n, source) => n + source.templates.length, 0);
|
||||
|
||||
const [showManager, setShowManager] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleExport = () => {
|
||||
const json = exportAllTemplates();
|
||||
const json = personalTemplatesFor().exportAll();
|
||||
const blob = new Blob([json], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
@@ -59,7 +59,7 @@ export function TemplateSettings() {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const content = e.target?.result as string;
|
||||
const result = storeImport(content);
|
||||
const result = personalTemplatesFor().importJson(content);
|
||||
|
||||
if (result.errors.length > 0) {
|
||||
toast.error(tNotif('templates_import_errors'));
|
||||
@@ -105,7 +105,7 @@ export function TemplateSettings() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleExport}
|
||||
disabled={templates.length === 0}
|
||||
disabled={personal.length === 0}
|
||||
>
|
||||
<Download className="w-4 h-4 me-1" />
|
||||
{t('export')}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { TemplateManagerModal } from '../template-manager-modal';
|
||||
import { DEFAULT_POLICY, type SettingsPolicy } from '@/lib/admin/types';
|
||||
import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
|
||||
@@ -8,6 +8,8 @@ import { resetAccountSubjects } from '@/lib/org/account-subjects';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { useTemplateStore } from '@/stores/template-store';
|
||||
import type { EmailTemplate } from '@/lib/template-types';
|
||||
import { emptyPartition, useAccountTemplatesStore } from '@/stores/account-templates-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
|
||||
// The template manager lists the organisation library read-only next to
|
||||
// the user's own templates; a template from it can be copied into the
|
||||
@@ -35,8 +37,12 @@ beforeEach(() => {
|
||||
resetAccountSubjects();
|
||||
setPolicy(RAW);
|
||||
useTemplateStore.setState({ templates: [own('mine', 'Mine')], recentTemplateIds: [], deletedTemplateIds: {} });
|
||||
useAccountTemplatesStore.setState({ personal: {}, teams: {}, active: null });
|
||||
});
|
||||
afterEach(() => {
|
||||
resetAccountSubjects();
|
||||
useAuthStore.setState({ activeAccountId: null } as never);
|
||||
});
|
||||
afterEach(() => resetAccountSubjects());
|
||||
|
||||
describe('template manager sources', () => {
|
||||
it('shows the organisation library read-only, with a preview and a copy into the user\'s own', () => {
|
||||
@@ -69,4 +75,24 @@ describe('template manager sources', () => {
|
||||
expect(within(row).getByText('Welcome')).toBeInTheDocument();
|
||||
expect(within(row).queryByRole('button', { name: 'copy_to_personal' })).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the account's templates in the account, says so, and names the ones that stay in this browser", () => {
|
||||
useAuthStore.setState({ activeAccountId: 'a1' } as never);
|
||||
useAccountTemplatesStore.setState({
|
||||
personal: { a1: { ...emptyPartition(), templates: [own('big', 'Big one')], localOnly: { big: 'too-large' } } },
|
||||
});
|
||||
render(<TemplateManagerModal isOpen onClose={() => {}} />);
|
||||
expect(screen.getByText('Big one')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Mine')).toBeNull();
|
||||
expect(screen.getByTestId('template-local-only')).toHaveTextContent('local_only_too_large');
|
||||
expect(screen.getByTestId('template-storage-note')).toHaveTextContent('personal_in_account');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'add' }));
|
||||
fireEvent.change(screen.getByPlaceholderText('name_placeholder'), { target: { value: 'New in the account' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'create' }));
|
||||
return waitFor(() => {
|
||||
expect(useAccountTemplatesStore.getState().personal.a1.templates.map((t) => t.name)).toEqual(['Big one', 'New in the account']);
|
||||
expect(useTemplateStore.getState().templates.map((t) => t.name)).toEqual(['Mine']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useTemplateStore } from '@/stores/template-store';
|
||||
import type { EmailTemplate } from '@/lib/template-types';
|
||||
import { emptyPartition, useAccountTemplatesStore } from '@/stores/account-templates-store';
|
||||
|
||||
// The picker shows the user's own templates, then the admin library under
|
||||
// "Organisation", from the policy of the account the message is written
|
||||
@@ -49,6 +50,7 @@ beforeEach(() => {
|
||||
} as never);
|
||||
setPolicy(RAW);
|
||||
useTemplateStore.setState({ templates: [own('mine', 'Mine')], recentTemplateIds: [], deletedTemplateIds: {} });
|
||||
useAccountTemplatesStore.setState({ personal: {}, teams: {}, active: null });
|
||||
});
|
||||
afterEach(() => {
|
||||
resetAccountSubjects();
|
||||
@@ -109,4 +111,16 @@ describe('template picker sources', () => {
|
||||
expect(within(screen.getByTestId('template-source-personal')).getByText('Welcome back')).toBeInTheDocument();
|
||||
expect(within(screen.getByTestId('template-source-organisation')).getByText('Welcome')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the own templates of the account the message is sent from once it keeps them', () => {
|
||||
useAccountTemplatesStore.setState({ personal: { a2: { ...emptyPartition(), templates: [own('dave-1', "Dave's own")] } } });
|
||||
const { unmount } = render(<TemplatePicker isOpen onClose={() => {}} onSelect={() => {}} accountId="a2" />);
|
||||
expect(screen.getByText("Dave's own")).toBeInTheDocument();
|
||||
expect(screen.queryByText('Mine')).toBeNull();
|
||||
unmount();
|
||||
// The active account has no copy of its own yet: the browser's list.
|
||||
render(<TemplatePicker isOpen onClose={() => {}} onSelect={() => {}} />);
|
||||
expect(screen.getByText('Mine')).toBeInTheDocument();
|
||||
expect(screen.queryByText("Dave's own")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useAccountTemplatesStore } from '@/stores/account-templates-store';
|
||||
import { refreshAccountTemplates } from '@/lib/templates/personal-templates';
|
||||
|
||||
/**
|
||||
* Syncs the active account's templates with the account (Stalwart 1.0
|
||||
* metadata) when it signs in or becomes active, and again when the tab
|
||||
* comes back into view. Renders nothing; does nothing on servers without
|
||||
* metadata.
|
||||
*/
|
||||
export function AccountTemplateSync() {
|
||||
const activeAccountId = useAuthStore((s) => s.activeAccountId);
|
||||
const client = useAuthStore((s) => s.client);
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||
|
||||
useEffect(() => {
|
||||
useAccountTemplatesStore.setState({ active: isAuthenticated ? activeAccountId : null });
|
||||
}, [activeAccountId, isAuthenticated]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated || !activeAccountId || !client) return;
|
||||
refreshAccountTemplates(activeAccountId, 0);
|
||||
const onVisible = () => {
|
||||
if (document.visibilityState === 'visible') refreshAccountTemplates(activeAccountId, 60_000);
|
||||
};
|
||||
document.addEventListener('visibilitychange', onVisible);
|
||||
return () => document.removeEventListener('visibilitychange', onVisible);
|
||||
}, [activeAccountId, client, isAuthenticated]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { X, FileText, Pencil, Trash2, Star, Copy, Search, Eye } from '@/components/icons';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -14,6 +14,7 @@ import { useTemplateSourceActions, type TemplateSaveResult } from '@/hooks/use-t
|
||||
import { OrgLockedNote } from '@/components/settings/org-locked-note';
|
||||
import { filterTemplates } from '@/lib/template-utils';
|
||||
import { PERSONAL_SOURCE, type TemplateSource } from '@/lib/templates/template-sources';
|
||||
import { refreshAccountTemplates } from '@/lib/templates/personal-templates';
|
||||
import type { EmailTemplate } from '@/lib/template-types';
|
||||
|
||||
interface TemplateManagerModalProps {
|
||||
@@ -51,6 +52,10 @@ export function TemplateManagerModal({ isOpen, onClose, accountId }: TemplateMan
|
||||
setError(null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) refreshAccountTemplates(accountId);
|
||||
}, [isOpen, accountId]);
|
||||
|
||||
const modalRef = useFocusTrap({
|
||||
isActive: isOpen,
|
||||
onEscape: () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslations, useLocale } from 'next-intl';
|
||||
import { X, Search, Star, FileText } from '@/components/icons';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
getAutoFilledPlaceholders,
|
||||
} from '@/lib/template-utils';
|
||||
import type { TemplateSource } from '@/lib/templates/template-sources';
|
||||
import { refreshAccountTemplates } from '@/lib/templates/personal-templates';
|
||||
import { PlaceholderFillModal } from './placeholder-fill-modal';
|
||||
import type { EmailTemplate } from '@/lib/template-types';
|
||||
|
||||
@@ -44,6 +45,11 @@ export function TemplatePicker({ isOpen, onClose, onSelect, accountId }: Templat
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<EmailTemplate | null>(null);
|
||||
const [showFillModal, setShowFillModal] = useState(false);
|
||||
|
||||
// What another device or a team member changed shows up when the picker opens.
|
||||
useEffect(() => {
|
||||
if (isOpen) refreshAccountTemplates(accountId);
|
||||
}, [isOpen, accountId]);
|
||||
|
||||
const modalRef = useFocusTrap({
|
||||
isActive: isOpen && !showFillModal,
|
||||
onEscape: onClose,
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { EmailTemplate } from '@/lib/template-types';
|
||||
import type { TemplateSource } from '@/lib/templates/template-sources';
|
||||
import { useTemplateStore } from '@/stores/template-store';
|
||||
import { personalTemplatesFor, resolveAccountId } from '@/lib/templates/personal-templates';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useAccountTemplatesStore, type LocalOnlyReason } from '@/stores/account-templates-store';
|
||||
|
||||
/** What a change to a template came to; anything but 'ok' keeps the form open. */
|
||||
export type TemplateSaveResult = 'ok' | 'too-large' | 'no-room' | 'forbidden' | 'failed';
|
||||
|
||||
/** Why a personal template could not go to the server and stays in this browser. */
|
||||
export type LocalOnlyReason = 'too-large' | 'no-room';
|
||||
export type { LocalOnlyReason };
|
||||
|
||||
type TemplateData = Omit<EmailTemplate, 'id' | 'createdAt' | 'updatedAt'>;
|
||||
|
||||
@@ -25,36 +26,41 @@ export interface TemplateSourceActions {
|
||||
storedInAccount: boolean;
|
||||
}
|
||||
|
||||
const readOnly = async (): Promise<TemplateSaveResult> => 'forbidden';
|
||||
const NO_LOCAL_ONLY: Record<string, LocalOnlyReason> = {};
|
||||
|
||||
/**
|
||||
* Changes to the templates of each source the manager shows, for the
|
||||
* signed-in account `accountId`. Read-only sources refuse every change.
|
||||
* signed-in account `accountId` (the active one when left out). Personal
|
||||
* templates change in this browser at once and go to the account in the
|
||||
* background; read-only sources refuse every change.
|
||||
*/
|
||||
export function useTemplateSourceActions(accountId?: string | null): TemplateSourceActions {
|
||||
void accountId;
|
||||
const resolvedId = useAuthStore((s) => accountId || s.activeAccountId);
|
||||
const partition = useAccountTemplatesStore((s) => (resolvedId ? s.personal[resolvedId] : undefined));
|
||||
const storedInAccount = !!partition;
|
||||
const localOnly = partition?.localOnly ?? NO_LOCAL_ONLY;
|
||||
return useMemo(() => {
|
||||
const store = () => useTemplateStore.getState();
|
||||
const ops = () => personalTemplatesFor(resolveAccountId(resolvedId));
|
||||
const personalOnly = <A extends unknown[]>(fn: (...args: A) => void) =>
|
||||
async (source: TemplateSource, ...args: A): Promise<TemplateSaveResult> => {
|
||||
if (source.kind !== 'personal' || !source.editable) return readOnly();
|
||||
if (source.kind !== 'personal' || !source.editable) return 'forbidden';
|
||||
fn(...args);
|
||||
return 'ok';
|
||||
};
|
||||
return {
|
||||
add: personalOnly((data: TemplateData) => { store().addTemplate(data); }),
|
||||
update: personalOnly((id: string, data: TemplateData) => store().updateTemplate(id, data)),
|
||||
remove: personalOnly((id: string) => store().deleteTemplate(id)),
|
||||
duplicate: personalOnly((id: string, suffix: string) => { store().duplicateTemplate(id, suffix); }),
|
||||
toggleFavorite: personalOnly((id: string) => store().toggleFavorite(id)),
|
||||
add: personalOnly((data: TemplateData) => { ops().add(data); }),
|
||||
update: personalOnly((id: string, data: TemplateData) => ops().update(id, data)),
|
||||
remove: personalOnly((id: string) => ops().remove(id)),
|
||||
duplicate: personalOnly((id: string, suffix: string) => { ops().duplicate(id, suffix); }),
|
||||
toggleFavorite: personalOnly((id: string) => ops().toggleFavorite(id)),
|
||||
copyToPersonal: async (template) => {
|
||||
store().addTemplate(copyOf(template));
|
||||
ops().add(copyOf(template));
|
||||
return 'ok';
|
||||
},
|
||||
localOnly: {},
|
||||
storedInAccount: false,
|
||||
localOnly,
|
||||
storedInAccount,
|
||||
};
|
||||
}, []);
|
||||
}, [resolvedId, localOnly, storedInAccount]);
|
||||
}
|
||||
|
||||
/** The fields a copy of a shared template takes into the user's own list. */
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { JMAPClient } from '../jmap/client';
|
||||
import { syncPersonalTemplates, PERSONAL_MARKER_KEY } from '../templates/account-template-sync';
|
||||
import { planPersonalSync } from '../templates/metadata-templates';
|
||||
import { useAccountTemplatesStore } from '@/stores/account-templates-store';
|
||||
import type { EmailTemplate } from '../template-types';
|
||||
import recorded from './fixtures/stalwart-1.0.0-template-metadata.json';
|
||||
import { syncAccountTemplates } from '../templates/personal-templates';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useTemplateStore } from '@/stores/template-store';
|
||||
|
||||
// Personal templates kept in the user's own Inbox metadata on Stalwart
|
||||
// v1.0.0, one namespace per template. The server below answers with the
|
||||
// envelopes recorded from stw-rel (fixtures/…template-metadata.json) and
|
||||
// keeps the metadata in memory, refusing like the real one once 64 KB are
|
||||
// used.
|
||||
|
||||
const METADATA_CAP = {
|
||||
'urn:ietf:params:jmap:metadata': {
|
||||
dataTypes: { Mailbox: { namespaces: [], supportsVendorNamespaces: true, supportsPrivate: true, maxDepth: 8 } },
|
||||
},
|
||||
};
|
||||
|
||||
function session(withMetadata = true) {
|
||||
return {
|
||||
capabilities: { 'urn:ietf:params:jmap:core': {}, 'urn:ietf:params:jmap:mail': {}, ...(withMetadata ? { 'urn:ietf:params:jmap:metadata': {} } : {}) },
|
||||
accounts: {
|
||||
bl: { name: 'carol@example.org', isPersonal: true, isReadOnly: false, accountCapabilities: { 'urn:ietf:params:jmap:mail': {}, ...(withMetadata ? METADATA_CAP : {}) } },
|
||||
},
|
||||
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'bl' },
|
||||
apiUrl: 'https://mail.example.com/jmap/api',
|
||||
downloadUrl: 'https://mail.example.com/jmap/download/{accountId}/{blobId}/{name}',
|
||||
uploadUrl: 'https://mail.example.com/jmap/upload/{accountId}/',
|
||||
eventSourceUrl: '',
|
||||
};
|
||||
}
|
||||
|
||||
const json = (body: unknown) => new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
const clone = <T,>(v: T): T => JSON.parse(JSON.stringify(v));
|
||||
|
||||
type Call = [string, Record<string, unknown>, string];
|
||||
let server: Record<string, unknown>;
|
||||
let sets: Array<{ key: string; value: unknown }>;
|
||||
let drop: boolean;
|
||||
const SHARED_MAX = 65536;
|
||||
|
||||
function answer(body: { methodCalls: Call[] }): Response {
|
||||
if (drop) throw new TypeError('Failed to fetch');
|
||||
const [method, args] = body.methodCalls[body.methodCalls.length - 1];
|
||||
if (method === 'Mailbox/get') {
|
||||
const out = clone(recorded.getEmpty);
|
||||
(out.methodResponses[1][1] as { list: Array<{ metadata: unknown }> }).list[0].metadata = clone(server);
|
||||
return json(out);
|
||||
}
|
||||
if (method === 'Mailbox/set') {
|
||||
const patch = (args.update as Record<string, Record<string, unknown>>).a;
|
||||
const [path, value] = Object.entries(patch)[0];
|
||||
const namespace = path.replace(/^metadata\//, '');
|
||||
const next = { ...server, [namespace]: value };
|
||||
if (value === null) delete next[namespace];
|
||||
if (new TextEncoder().encode(JSON.stringify(next)).length > SHARED_MAX) return json(clone(recorded.setNoRoom));
|
||||
server = next;
|
||||
sets.push({ key: namespace, value });
|
||||
return json(clone(value === null ? recorded.deleteOne : recorded.setOne));
|
||||
}
|
||||
throw new Error(`unexpected ${method}`);
|
||||
}
|
||||
|
||||
const tpl = (id: string, name: string, updatedAt = '2026-10-01T00:00:00.000Z', extra: Partial<EmailTemplate> = {}): EmailTemplate => ({
|
||||
id, name, subject: '', body: `${name} body`, isHTML: false, category: '', isFavorite: false,
|
||||
createdAt: '2026-10-01T00:00:00.000Z', updatedAt, ...extra,
|
||||
});
|
||||
const ns = (id: string) => `org.bulwarkmail.template.${id}`;
|
||||
|
||||
let client: JMAPClient;
|
||||
let connection: ReturnType<typeof vi.fn<(connected: boolean) => void>>;
|
||||
let legacy: EmailTemplate[];
|
||||
const sync = () => syncPersonalTemplates('carol@mail', client, () => legacy);
|
||||
const partition = () => useAccountTemplatesStore.getState().personal['carol@mail'];
|
||||
|
||||
async function connect(withMetadata = true) {
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||
fetchSpy.mockResolvedValueOnce(json(session(withMetadata)));
|
||||
client = new JMAPClient('https://mail.example.com', 'carol@example.org', 'pw');
|
||||
await client.connect();
|
||||
fetchSpy.mockReset();
|
||||
fetchSpy.mockImplementation(async (_url: RequestInfo | URL, init?: RequestInit) => answer(JSON.parse(String(init?.body ?? '{}'))));
|
||||
connection = vi.fn<(connected: boolean) => void>();
|
||||
client.onConnectionChange(connection);
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.useRealTimers();
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
server = {};
|
||||
sets = [];
|
||||
drop = false;
|
||||
legacy = [];
|
||||
useAccountTemplatesStore.setState({ personal: {}, teams: {}, active: null });
|
||||
await connect();
|
||||
});
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe('the first load with metadata', () => {
|
||||
it('uploads the browser\'s templates once into an empty account and marks it', async () => {
|
||||
legacy = [tpl('aaaa-1', 'Hello'), tpl('aaaa-2', 'Thanks')];
|
||||
expect(await sync()).toBe('synced');
|
||||
expect(sets.map((s) => s.key)).toEqual([ns('aaaa-1'), ns('aaaa-2'), `org.bulwarkmail.${PERSONAL_MARKER_KEY}`]);
|
||||
expect((server[ns('aaaa-1')] as EmailTemplate).name).toBe('Hello');
|
||||
expect(partition().templates.map((t) => t.name).sort()).toEqual(['Hello', 'Thanks']);
|
||||
|
||||
// Once: the next round has nothing to write.
|
||||
sets = [];
|
||||
expect(await sync()).toBe('synced');
|
||||
expect(sets).toEqual([]);
|
||||
});
|
||||
|
||||
it('doesn\'t upload the browser\'s list to an account set up before, even an empty one', async () => {
|
||||
server = { [`org.bulwarkmail.${PERSONAL_MARKER_KEY}`]: { v: 1 } };
|
||||
legacy = [tpl('aaaa-1', 'Deleted elsewhere long ago')];
|
||||
await sync();
|
||||
expect(sets).toEqual([]);
|
||||
expect(partition().templates).toEqual([]);
|
||||
});
|
||||
|
||||
it('takes the server copy in a second browser instead of the local list', async () => {
|
||||
server = { [ns('bbbb-1')]: tpl('bbbb-1', 'From the other browser'), [`org.bulwarkmail.${PERSONAL_MARKER_KEY}`]: { v: 1 } };
|
||||
legacy = [tpl('aaaa-1', 'Only here')];
|
||||
await sync();
|
||||
expect(partition().templates.map((t) => t.name)).toEqual(['From the other browser']);
|
||||
expect(sets).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps a template over 8 KB and one the server has no room for in this browser, with why', async () => {
|
||||
// 64 KB minus a bit is in use by something else already.
|
||||
server = { 'org.bulwarkmail.other': { fill: 'y'.repeat(SHARED_MAX - 4000) } };
|
||||
legacy = [tpl('aaaa-big', 'Big', undefined, { body: 'x'.repeat(9000) }), tpl('aaaa-mid', 'Mid', undefined, { body: 'z'.repeat(5000) }), tpl('aaaa-ok', 'Small')];
|
||||
await sync();
|
||||
const p = partition();
|
||||
expect(p.localOnly).toEqual({ 'aaaa-big': 'too-large', 'aaaa-mid': 'no-room' });
|
||||
expect(p.templates.map((t) => t.name).sort()).toEqual(['Big', 'Mid', 'Small']);
|
||||
expect(server[ns('aaaa-ok')]).toBeTruthy();
|
||||
expect(server[ns('aaaa-mid')]).toBeUndefined();
|
||||
// The 9 KB one never went out.
|
||||
expect(sets.some((s) => s.key === ns('aaaa-big'))).toBe(false);
|
||||
});
|
||||
|
||||
it('gives an id that doesn\'t fit a metadata key a new one', async () => {
|
||||
legacy = [tpl('Demo_Template 1', 'Old id')];
|
||||
await sync();
|
||||
const [template] = partition().templates;
|
||||
expect(template.id).not.toBe('Demo_Template 1');
|
||||
expect(server[ns(template.id)]).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('whose list is uploaded', () => {
|
||||
const original = useAuthStore.getState();
|
||||
afterEach(() => {
|
||||
localStorage.removeItem('settings-sync-owner');
|
||||
useAuthStore.setState({ activeAccountId: original.activeAccountId, getClientForAccount: original.getClientForAccount } as never);
|
||||
});
|
||||
|
||||
it("waits while the browser's list still holds another account's synced templates", async () => {
|
||||
useTemplateStore.setState({ templates: [tpl('aaaa-9', "Alice's")] });
|
||||
useAuthStore.setState({ activeAccountId: 'carol@mail', getClientForAccount: (id: string) => (id === 'carol@mail' ? client : undefined) } as never);
|
||||
localStorage.setItem('settings-sync-owner', 'alice@mail');
|
||||
expect(await syncAccountTemplates('carol@mail')).toBe('unsupported');
|
||||
expect(sets).toEqual([]);
|
||||
expect(partition()).toBeUndefined();
|
||||
|
||||
// Carol's settings have loaded: now the list is hers.
|
||||
useTemplateStore.setState({ templates: [tpl('cccc-1', "Carol's")] });
|
||||
localStorage.setItem('settings-sync-owner', 'carol@mail');
|
||||
expect(await syncAccountTemplates('carol@mail')).toBe('synced');
|
||||
expect(partition().templates.map((t) => t.name)).toEqual(["Carol's"]);
|
||||
});
|
||||
|
||||
it("never uploads the browser's list into an account that isn't the active one", async () => {
|
||||
useTemplateStore.setState({ templates: [tpl('aaaa-9', "Active account's")] });
|
||||
useAuthStore.setState({ activeAccountId: 'alice@mail', getClientForAccount: (id: string) => (id === 'carol@mail' ? client : undefined) } as never);
|
||||
expect(await syncAccountTemplates('carol@mail')).toBe('unsupported');
|
||||
expect(sets).toEqual([]);
|
||||
// Once set up elsewhere, its own copy is read from the server.
|
||||
server = { [ns('cccc-2')]: tpl('cccc-2', 'Set up elsewhere'), [`org.bulwarkmail.${PERSONAL_MARKER_KEY}`]: { v: 1 } };
|
||||
expect(await syncAccountTemplates('carol@mail')).toBe('synced');
|
||||
expect(partition().templates.map((t) => t.name)).toEqual(['Set up elsewhere']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('later rounds merge by id and updatedAt', () => {
|
||||
beforeEach(async () => {
|
||||
legacy = [tpl('aaaa-1', 'One'), tpl('aaaa-2', 'Two')];
|
||||
await sync();
|
||||
sets = [];
|
||||
});
|
||||
|
||||
it('takes a newer server version and uploads a newer local one', async () => {
|
||||
server[ns('aaaa-1')] = tpl('aaaa-1', 'One, edited elsewhere', '2026-10-05T00:00:00.000Z');
|
||||
useAccountTemplatesStore.getState().updatePersonal('carol@mail', (p) => ({
|
||||
...p, templates: p.templates.map((t) => (t.id === 'aaaa-2' ? { ...t, name: 'Two, edited here', updatedAt: '2026-10-06T00:00:00.000Z' } : t)),
|
||||
}));
|
||||
await sync();
|
||||
expect(partition().templates.find((t) => t.id === 'aaaa-1')?.name).toBe('One, edited elsewhere');
|
||||
expect(sets.map((s) => s.key)).toEqual([ns('aaaa-2')]);
|
||||
expect((server[ns('aaaa-2')] as EmailTemplate).name).toBe('Two, edited here');
|
||||
});
|
||||
|
||||
it('drops a template deleted elsewhere, and removes one deleted here from the server', async () => {
|
||||
delete server[ns('aaaa-1')];
|
||||
useAccountTemplatesStore.getState().updatePersonal('carol@mail', (p) => ({
|
||||
...p,
|
||||
templates: p.templates.filter((t) => t.id !== 'aaaa-2'),
|
||||
deletedTemplateIds: { 'aaaa-2': '2026-10-07T00:00:00.000Z' },
|
||||
}));
|
||||
await sync();
|
||||
expect(partition().templates).toEqual([]);
|
||||
expect(sets).toEqual([{ key: ns('aaaa-2'), value: null }]);
|
||||
expect(partition().deletedTemplateIds).toEqual({});
|
||||
});
|
||||
|
||||
it('keeps a template edited here after another browser deleted it', async () => {
|
||||
delete server[ns('aaaa-1')];
|
||||
useAccountTemplatesStore.getState().updatePersonal('carol@mail', (p) => ({
|
||||
...p, templates: p.templates.map((t) => (t.id === 'aaaa-1' ? { ...t, updatedAt: '2026-10-09T00:00:00.000Z' } : t)),
|
||||
}));
|
||||
await sync();
|
||||
expect(partition().templates.map((t) => t.id).sort()).toEqual(['aaaa-1', 'aaaa-2']);
|
||||
expect(sets.map((s) => s.key)).toEqual([ns('aaaa-1')]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('servers without metadata', () => {
|
||||
it('leaves an account without the capability alone', async () => {
|
||||
vi.restoreAllMocks();
|
||||
await connect(false);
|
||||
legacy = [tpl('aaaa-1', 'Local')];
|
||||
const fetchSpy = vi.mocked(globalThis.fetch);
|
||||
fetchSpy.mockClear();
|
||||
expect(await sync()).toBe('unsupported');
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(partition()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps the app online when the server drops the connection on metadata (the 1.0 test build)', async () => {
|
||||
drop = true;
|
||||
legacy = [tpl('aaaa-1', 'Local')];
|
||||
expect(await sync()).toBe('unsupported');
|
||||
expect(partition()).toBeUndefined();
|
||||
expect(connection).not.toHaveBeenCalledWith(false);
|
||||
// And it isn't asked again for a while.
|
||||
const fetchSpy = vi.mocked(globalThis.fetch);
|
||||
fetchSpy.mockClear();
|
||||
expect(await sync()).toBe('unsupported');
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('planPersonalSync', () => {
|
||||
it('keeps a deletion here over an older server copy, and an edit there over an older deletion', () => {
|
||||
const plan = planPersonalSync(
|
||||
{ templates: [], deletedTemplateIds: { a: '2026-10-05T00:00:00.000Z', b: '2026-10-05T00:00:00.000Z' }, serverVersions: { a: '2026-10-01T00:00:00.000Z', b: '2026-10-01T00:00:00.000Z' } },
|
||||
{ a: tpl('a', 'A', '2026-10-01T00:00:00.000Z'), b: tpl('b', 'B', '2026-10-06T00:00:00.000Z') },
|
||||
);
|
||||
expect(plan.deletes).toEqual(['a']);
|
||||
expect(plan.templates.map((t) => t.id)).toEqual(['b']);
|
||||
expect(plan.uploads).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
{
|
||||
"_comment": "Recorded from Stalwart v1.0.0 (stw-rel, http://10.10.10.62:8120) on 2026-10-28, org-carol@example.org own Inbox: Bulwark template metadata. Removed again after recording.",
|
||||
"getEmpty": {
|
||||
"methodResponses": [
|
||||
[
|
||||
"Mailbox/query",
|
||||
{
|
||||
"accountId": "bl",
|
||||
"queryState": "saq",
|
||||
"canCalculateChanges": true,
|
||||
"position": 0,
|
||||
"ids": [
|
||||
"a"
|
||||
]
|
||||
},
|
||||
"q"
|
||||
],
|
||||
[
|
||||
"Mailbox/get",
|
||||
{
|
||||
"accountId": "bl",
|
||||
"state": "saq",
|
||||
"list": [
|
||||
{
|
||||
"id": "a",
|
||||
"metadata": {}
|
||||
}
|
||||
],
|
||||
"notFound": []
|
||||
},
|
||||
"g"
|
||||
]
|
||||
],
|
||||
"sessionState": "c499329"
|
||||
},
|
||||
"setOne": {
|
||||
"methodResponses": [
|
||||
[
|
||||
"Mailbox/set",
|
||||
{
|
||||
"accountId": "bl",
|
||||
"oldState": "saq",
|
||||
"newState": "sau",
|
||||
"updated": {
|
||||
"a": null
|
||||
}
|
||||
},
|
||||
"0"
|
||||
]
|
||||
],
|
||||
"sessionState": "c499329"
|
||||
},
|
||||
"setMarker": {
|
||||
"methodResponses": [
|
||||
[
|
||||
"Mailbox/set",
|
||||
{
|
||||
"accountId": "bl",
|
||||
"oldState": "sau",
|
||||
"newState": "say",
|
||||
"updated": {
|
||||
"a": null
|
||||
}
|
||||
},
|
||||
"0"
|
||||
]
|
||||
],
|
||||
"sessionState": "c499329"
|
||||
},
|
||||
"getOne": {
|
||||
"methodResponses": [
|
||||
[
|
||||
"Mailbox/query",
|
||||
{
|
||||
"accountId": "bl",
|
||||
"queryState": "say",
|
||||
"canCalculateChanges": true,
|
||||
"position": 0,
|
||||
"ids": [
|
||||
"a"
|
||||
]
|
||||
},
|
||||
"q"
|
||||
],
|
||||
[
|
||||
"Mailbox/get",
|
||||
{
|
||||
"accountId": "bl",
|
||||
"state": "say",
|
||||
"list": [
|
||||
{
|
||||
"id": "a",
|
||||
"metadata": {
|
||||
"org.bulwarkmail.template.rec-1": {
|
||||
"id": "rec-1",
|
||||
"name": "Recorded",
|
||||
"subject": "Hi",
|
||||
"body": "Body",
|
||||
"isHTML": false,
|
||||
"category": "",
|
||||
"isFavorite": false,
|
||||
"createdAt": "2026-10-28T10:00:00.000Z",
|
||||
"updatedAt": "2026-10-28T10:00:00.000Z"
|
||||
},
|
||||
"org.bulwarkmail.templates": {
|
||||
"v": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"notFound": []
|
||||
},
|
||||
"g"
|
||||
]
|
||||
],
|
||||
"sessionState": "c499329"
|
||||
},
|
||||
"setNoRoom": {
|
||||
"methodResponses": [
|
||||
[
|
||||
"Mailbox/set",
|
||||
{
|
||||
"accountId": "bl",
|
||||
"oldState": "sb2",
|
||||
"newState": "sb2",
|
||||
"notUpdated": {
|
||||
"a": {
|
||||
"type": "invalidProperties",
|
||||
"description": "Metadata size 72076 exceeds the maximum of 65536 bytes.",
|
||||
"properties": [
|
||||
"metadata"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"0"
|
||||
]
|
||||
],
|
||||
"sessionState": "c499329"
|
||||
},
|
||||
"deleteOne": {
|
||||
"methodResponses": [
|
||||
[
|
||||
"Mailbox/set",
|
||||
{
|
||||
"accountId": "bl",
|
||||
"oldState": "sb2",
|
||||
"newState": "sca",
|
||||
"updated": {
|
||||
"a": null
|
||||
}
|
||||
},
|
||||
"0"
|
||||
]
|
||||
],
|
||||
"sessionState": "c499329"
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
// Host side of api.templates, api.identities and api.vacation: the message
|
||||
// templates (kept in the browser and synced with the settings), the sender
|
||||
// templates (the active account's own: in the account on Stalwart 1.0, in the
|
||||
// browser and synced with the settings elsewhere), the sender
|
||||
// identities and the out-of-office reply. Changes go through the same stores
|
||||
// and the same HTML sanitising as Settings, so what a plugin saves is held to
|
||||
// the rules a user's own edit is.
|
||||
@@ -8,7 +9,7 @@ import type {
|
||||
IdentityView, TemplateInput, VacationInput, VacationView, TemplateView,
|
||||
} from '../plugin-types';
|
||||
import type { EmailAddress, Identity } from '../jmap/types';
|
||||
import { useTemplateStore } from '@/stores/template-store';
|
||||
import { personalTemplatesFor } from '@/lib/templates/personal-templates';
|
||||
import { updateIdentityWithHooks, useIdentityStore } from '@/stores/identity-store';
|
||||
import { useVacationStore } from '@/stores/vacation-store';
|
||||
import { identityToView, templateToView, vacationToView } from '../plugin-projection';
|
||||
@@ -29,13 +30,17 @@ const MAX_TEMPLATE_SUBJECT = 998;
|
||||
const MAX_TEMPLATE_BODY = 100_000;
|
||||
const TEMPLATE_FIELDS = ['name', 'subject', 'body', 'isHTML', 'category'] as const;
|
||||
|
||||
// The active account's own templates: kept in the account on Stalwart 1.0,
|
||||
// in the browser's list otherwise (lib/templates/personal-templates.ts).
|
||||
const personal = () => personalTemplatesFor();
|
||||
|
||||
export function doTemplatesList(): TemplateView[] {
|
||||
return useTemplateStore.getState().templates.map(templateToView);
|
||||
return personal().templates().map(templateToView);
|
||||
}
|
||||
|
||||
export function doTemplatesGet(id: unknown): TemplateView | null {
|
||||
const templateId = readId(id, 'templates.get');
|
||||
const template = useTemplateStore.getState().templates.find((t) => t.id === templateId);
|
||||
const template = personal().templates().find((t) => t.id === templateId);
|
||||
return template ? templateToView(template) : null;
|
||||
}
|
||||
|
||||
@@ -59,13 +64,13 @@ export function doTemplatesCreate(input: unknown): TemplateView {
|
||||
const method = 'templates.create';
|
||||
const data = readObject(input, method, 'template', TEMPLATE_FIELDS) as TemplateInput & Record<string, unknown>;
|
||||
if (data.name === undefined) throw new Error(`${method}: name is required`);
|
||||
const store = useTemplateStore.getState();
|
||||
if (store.templates.length >= MAX_PLUGIN_TEMPLATES) {
|
||||
const store = personal();
|
||||
if (store.templates().length >= MAX_PLUGIN_TEMPLATES) {
|
||||
throw new Error(`${method}: there are already ${MAX_PLUGIN_TEMPLATES} templates`);
|
||||
}
|
||||
const isHTML = data.isHTML === undefined ? false : readBoolean(data.isHTML, method, 'isHTML');
|
||||
const fields = templateFields(method, data, isHTML);
|
||||
const created = store.addTemplate({
|
||||
const created = store.add({
|
||||
name: fields.name!,
|
||||
subject: fields.subject ?? '',
|
||||
body: fields.body ?? '',
|
||||
@@ -81,23 +86,25 @@ export function doTemplatesUpdate(id: unknown, patch: unknown): TemplateView {
|
||||
const templateId = readId(id, method);
|
||||
const data = readObject(patch, method, 'patch', TEMPLATE_FIELDS);
|
||||
if (Object.keys(data).length === 0) throw new Error(`${method}: patch is empty`);
|
||||
const existing = useTemplateStore.getState().templates.find((t) => t.id === templateId);
|
||||
const store = personal();
|
||||
const existing = store.templates().find((t) => t.id === templateId);
|
||||
if (!existing) throw new Error(`${method}: no template with id "${templateId}"`);
|
||||
const isHTML = data.isHTML === undefined ? !!existing.isHTML : readBoolean(data.isHTML, method, 'isHTML');
|
||||
const fields = templateFields(method, data, isHTML);
|
||||
// Turning an existing body into HTML must not let unsanitised text through.
|
||||
if (isHTML && !existing.isHTML && fields.body === undefined) fields.body = sanitizePluginBodyHtml(existing.body || '');
|
||||
useTemplateStore.getState().updateTemplate(templateId, fields);
|
||||
return templateToView(useTemplateStore.getState().templates.find((t) => t.id === templateId)!);
|
||||
store.update(templateId, fields);
|
||||
return templateToView(store.templates().find((t) => t.id === templateId)!);
|
||||
}
|
||||
|
||||
export function doTemplatesDelete(id: unknown): void {
|
||||
const method = 'templates.delete';
|
||||
const templateId = readId(id, method);
|
||||
if (!useTemplateStore.getState().templates.some((t) => t.id === templateId)) {
|
||||
const store = personal();
|
||||
if (!store.templates().some((t) => t.id === templateId)) {
|
||||
throw new Error(`${method}: no template with id "${templateId}"`);
|
||||
}
|
||||
useTemplateStore.getState().deleteTemplate(templateId);
|
||||
store.remove(templateId);
|
||||
}
|
||||
|
||||
// ─── Identities ───────────────────────────────────────────────
|
||||
|
||||
@@ -98,6 +98,16 @@ export function mergeStoredTemplates(
|
||||
stored: Record<string, unknown> | null,
|
||||
incoming: Record<string, unknown>
|
||||
): Record<string, unknown> {
|
||||
// The account keeps its templates in its own server storage (JMAP
|
||||
// metadata, Stalwart 1.0): the blob drops its copy. A push from a build
|
||||
// that doesn't know this brings them back, and the next push from one
|
||||
// that does drops them again.
|
||||
if (incoming.templatesInMetadata === true) {
|
||||
const rest = { ...incoming };
|
||||
delete rest.templates;
|
||||
delete rest.deletedTemplateIds;
|
||||
return rest;
|
||||
}
|
||||
const storedTemplates = stored ? rawTemplates(stored.templates) : null;
|
||||
if (!storedTemplates) return incoming;
|
||||
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* Keeps each signed-in account's personal templates in its own Inbox
|
||||
* metadata, and reads the team templates of its group mailboxes
|
||||
* (Stalwart v1.0.0 and later; lib/templates/metadata-templates.ts).
|
||||
*
|
||||
* Personal templates: the first time an account's metadata answers in this
|
||||
* browser and the server holds no templates for it yet, the browser's own
|
||||
* list (template-store, which settings sync also fills) is uploaded once,
|
||||
* and a marker entry says the account has been set up, so a later empty
|
||||
* server copy means "all deleted", not "never uploaded". After that, this
|
||||
* browser's copy (account-templates-store) and the server copy are merged
|
||||
* on every sync. A template the server refuses for its size stays here.
|
||||
*
|
||||
* Servers without metadata, and the pre-release 1.0 build that drops the
|
||||
* connection on metadata requests, never answer: their accounts keep using
|
||||
* the browser's list, and none of this touches the connection state
|
||||
* (client.getBulwarkMetadata catches every failure, and pauses the account
|
||||
* for ten minutes).
|
||||
*/
|
||||
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
import { MetadataTooLargeError } from '@/lib/jmap/bulwark-metadata';
|
||||
import type { EmailTemplate } from '@/lib/template-types';
|
||||
import { debug } from '@/lib/debug';
|
||||
import {
|
||||
isTooLargeForMetadata,
|
||||
planPersonalSync,
|
||||
templateKey,
|
||||
templateValue,
|
||||
templatesFromMetadata,
|
||||
withStorableId,
|
||||
} from './metadata-templates';
|
||||
import {
|
||||
emptyPartition,
|
||||
useAccountTemplatesStore,
|
||||
type LocalOnlyReason,
|
||||
type PersonalPartition,
|
||||
} from '@/stores/account-templates-store';
|
||||
|
||||
/** The entry that says an account's personal templates were set up on the server. */
|
||||
export const PERSONAL_MARKER_KEY = 'templates';
|
||||
|
||||
export type MetadataClient = Pick<IJMAPClient, 'getAccountId'> & Partial<Pick<IJMAPClient,
|
||||
'supportsBulwarkMetadata' | 'getBulwarkMetadata' | 'setBulwarkMetadata'>>;
|
||||
|
||||
export type SyncOutcome = 'synced' | 'unsupported' | 'busy';
|
||||
|
||||
/**
|
||||
* The browser's own list, for the one-time upload; null while it isn't
|
||||
* this account's (another account's settings are still loaded), and then
|
||||
* a server that was never set up waits for a later round.
|
||||
*/
|
||||
type LegacySource = () => EmailTemplate[] | null;
|
||||
|
||||
const running = new Map<string, Promise<SyncOutcome>>();
|
||||
const again = new Set<string>();
|
||||
|
||||
export function hasMetadata(client: MetadataClient | null | undefined, accountId: string): client is MetadataClient & Required<Pick<MetadataClient, 'getBulwarkMetadata' | 'setBulwarkMetadata'>> {
|
||||
return !!client
|
||||
&& typeof client.getBulwarkMetadata === 'function'
|
||||
&& typeof client.setBulwarkMetadata === 'function'
|
||||
&& client.supportsBulwarkMetadata?.(accountId) === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* One round for the signed-in account `localAccountId`: read its Inbox
|
||||
* metadata, merge, write what changed. Rounds for the same account don't
|
||||
* overlap; asking during one runs another afterwards.
|
||||
*/
|
||||
export function syncPersonalTemplates(localAccountId: string, client: MetadataClient, legacy: LegacySource): Promise<SyncOutcome> {
|
||||
const current = running.get(localAccountId);
|
||||
if (current) {
|
||||
again.add(localAccountId);
|
||||
return current;
|
||||
}
|
||||
const round = (async (): Promise<SyncOutcome> => {
|
||||
try {
|
||||
let outcome = await syncRound(localAccountId, client, legacy);
|
||||
while (again.delete(localAccountId) && outcome === 'synced') {
|
||||
outcome = await syncRound(localAccountId, client, legacy);
|
||||
}
|
||||
return outcome;
|
||||
} finally {
|
||||
running.delete(localAccountId);
|
||||
again.delete(localAccountId);
|
||||
}
|
||||
})();
|
||||
running.set(localAccountId, round);
|
||||
return round;
|
||||
}
|
||||
|
||||
async function syncRound(localAccountId: string, client: MetadataClient, legacy: LegacySource): Promise<SyncOutcome> {
|
||||
const accountId = client.getAccountId();
|
||||
if (!accountId || !hasMetadata(client, accountId)) return 'unsupported';
|
||||
const entries = await client.getBulwarkMetadata(accountId);
|
||||
if (!entries) return 'unsupported';
|
||||
|
||||
const store = useAccountTemplatesStore.getState();
|
||||
const server = templatesFromMetadata(entries);
|
||||
const setUp = PERSONAL_MARKER_KEY in entries || Object.keys(server).length > 0;
|
||||
let partition = store.personal[localAccountId];
|
||||
if (!partition) {
|
||||
// First answer in this browser. A server that has never held this
|
||||
// account's templates gets the browser's own list once.
|
||||
partition = emptyPartition();
|
||||
if (!setUp) {
|
||||
const own = legacy();
|
||||
if (!own) return 'unsupported';
|
||||
partition.templates = own.map(withStorableId);
|
||||
}
|
||||
store.setPersonal(localAccountId, partition);
|
||||
}
|
||||
|
||||
// Every id has to fit a metadata key; give the odd old one a new id.
|
||||
if (partition.templates.some((t) => withStorableId(t) !== t)) {
|
||||
partition = { ...partition, templates: partition.templates.map(withStorableId) };
|
||||
store.setPersonal(localAccountId, partition);
|
||||
}
|
||||
|
||||
const plan = planPersonalSync(partition, server);
|
||||
const serverVersions: Record<string, string> = {};
|
||||
for (const [id, template] of Object.entries(server)) serverVersions[id] = template.updatedAt;
|
||||
const localOnly: Record<string, LocalOnlyReason> = {};
|
||||
const failedDeletes = new Set<string>();
|
||||
let reachable = true;
|
||||
|
||||
for (const template of plan.uploads) {
|
||||
if (isTooLargeForMetadata(template)) {
|
||||
localOnly[template.id] = 'too-large';
|
||||
continue;
|
||||
}
|
||||
if (!reachable) continue;
|
||||
try {
|
||||
await client.setBulwarkMetadata(accountId, templateKey(template.id), templateValue(template));
|
||||
serverVersions[template.id] = template.updatedAt;
|
||||
} catch (error) {
|
||||
if (error instanceof MetadataTooLargeError) {
|
||||
localOnly[template.id] = 'no-room';
|
||||
} else {
|
||||
// Offline or refused: what is left goes up next time.
|
||||
debug.warn('templates', 'Could not save a template in the account:', error);
|
||||
reachable = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const id of plan.deletes) {
|
||||
if (!reachable) {
|
||||
failedDeletes.add(id);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await client.setBulwarkMetadata(accountId, templateKey(id), null);
|
||||
delete serverVersions[id];
|
||||
} catch (error) {
|
||||
debug.warn('templates', 'Could not remove a template from the account:', error);
|
||||
failedDeletes.add(id);
|
||||
reachable = false;
|
||||
}
|
||||
}
|
||||
if (!setUp && reachable) {
|
||||
try {
|
||||
await client.setBulwarkMetadata(accountId, PERSONAL_MARKER_KEY, { v: 1 });
|
||||
} catch (error) {
|
||||
debug.warn('templates', 'Could not mark the account as set up:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Changes made here while the round ran are newer than the plan: keep
|
||||
// them and go round again.
|
||||
const latest = useAccountTemplatesStore.getState().personal[localAccountId] ?? partition;
|
||||
const changedMeanwhile = latest !== partition;
|
||||
const deletedTemplateIds: Record<string, string> = {};
|
||||
for (const [id, at] of Object.entries(plan.deletedTemplateIds)) {
|
||||
if (failedDeletes.has(id)) deletedTemplateIds[id] = at;
|
||||
}
|
||||
const next: PersonalPartition = changedMeanwhile
|
||||
? mergeLater(latest, plan.templates, serverVersions, localOnly)
|
||||
: { templates: plan.templates, deletedTemplateIds, serverVersions, localOnly };
|
||||
useAccountTemplatesStore.getState().setPersonal(localAccountId, next);
|
||||
if (changedMeanwhile) again.add(localAccountId);
|
||||
return 'synced';
|
||||
}
|
||||
|
||||
/** The round's result under edits made while it ran: those win. */
|
||||
function mergeLater(
|
||||
latest: PersonalPartition,
|
||||
synced: EmailTemplate[],
|
||||
serverVersions: Record<string, string>,
|
||||
localOnly: Record<string, LocalOnlyReason>,
|
||||
): PersonalPartition {
|
||||
const byId = new Map(synced.map((t) => [t.id, t]));
|
||||
for (const template of latest.templates) {
|
||||
const known = byId.get(template.id);
|
||||
if (!known || Date.parse(template.updatedAt) >= Date.parse(known.updatedAt)) byId.set(template.id, template);
|
||||
}
|
||||
for (const id of Object.keys(latest.deletedTemplateIds)) byId.delete(id);
|
||||
return {
|
||||
templates: [...byId.values()],
|
||||
deletedTemplateIds: latest.deletedTemplateIds,
|
||||
serverVersions,
|
||||
localOnly: { ...latest.localOnly, ...localOnly },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Templates kept in an account's Inbox metadata (Stalwart v1.0.0 and
|
||||
* later): one namespace per template, `org.bulwarkmail.template.<id>`,
|
||||
* because Stalwart takes at most 8 KB per namespace and 64 KB of shared
|
||||
* metadata per mailbox (local-data/org-features-facts.md §2). Personal
|
||||
* templates go in the shared metadata of the user's own Inbox (the private
|
||||
* part holds only 16 KB), team templates in the group account's Inbox.
|
||||
*
|
||||
* Pure: reading the entries, and planning how the copy in this browser and
|
||||
* the server copy come together. lib/templates/account-template-sync.ts
|
||||
* does the requests.
|
||||
*/
|
||||
|
||||
import type { EmailTemplate } from '@/lib/template-types';
|
||||
import { mergeSyncedTemplates, parseSyncedTemplates } from '@/lib/template-utils';
|
||||
import { MAX_METADATA_ENTRY_BYTES, isValidMetadataKey, metadataValueBytes } from '@/lib/jmap/bulwark-metadata';
|
||||
import { generateUUID } from '@/lib/utils';
|
||||
|
||||
const KEY_PREFIX = 'template.';
|
||||
|
||||
/** The metadata key of a template (`template.<id>`). */
|
||||
export function templateKey(id: string): string {
|
||||
return `${KEY_PREFIX}${id}`;
|
||||
}
|
||||
|
||||
/** Ids Bulwark generates (UUIDs) fit a metadata key; older or imported ones may not. */
|
||||
export function isStorableTemplateId(id: string): boolean {
|
||||
return isValidMetadataKey(templateKey(id));
|
||||
}
|
||||
|
||||
/** The same template with an id that fits a metadata key. */
|
||||
export function withStorableId(template: EmailTemplate): EmailTemplate {
|
||||
return isStorableTemplateId(template.id) ? template : { ...template, id: generateUUID() };
|
||||
}
|
||||
|
||||
/** The value stored for a template: the template itself, which carries its id and `updatedAt`. */
|
||||
export function templateValue(template: EmailTemplate): EmailTemplate {
|
||||
return { ...template };
|
||||
}
|
||||
|
||||
/** Whether a template is larger than one metadata entry may be. */
|
||||
export function isTooLargeForMetadata(template: EmailTemplate): boolean {
|
||||
return metadataValueBytes(templateValue(template)) > MAX_METADATA_ENTRY_BYTES;
|
||||
}
|
||||
|
||||
/**
|
||||
* The templates among an Inbox's Bulwark metadata entries (`template.<id>`),
|
||||
* by id, validated and sanitised like a synced template. The key decides
|
||||
* the id.
|
||||
*/
|
||||
export function templatesFromMetadata(entries: Record<string, unknown> | null | undefined): Record<string, EmailTemplate> {
|
||||
const out: Record<string, EmailTemplate> = {};
|
||||
if (!entries) return out;
|
||||
for (const [key, value] of Object.entries(entries)) {
|
||||
if (!key.startsWith(KEY_PREFIX) || !value || typeof value !== 'object' || Array.isArray(value)) continue;
|
||||
const id = key.slice(KEY_PREFIX.length);
|
||||
const [template] = parseSyncedTemplates([{ ...(value as Record<string, unknown>), id }]) ?? [];
|
||||
if (template) out[id] = template;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** This browser's copy of an account's personal templates. */
|
||||
export interface PersonalCopy {
|
||||
templates: EmailTemplate[];
|
||||
/** Deleted here, waiting to be removed from the server: id -> ISO time. */
|
||||
deletedTemplateIds: Record<string, string>;
|
||||
/** `updatedAt` of each template as the server held it at the last sync. */
|
||||
serverVersions: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface PersonalSyncPlan {
|
||||
/** What this browser keeps after the merge. */
|
||||
templates: EmailTemplate[];
|
||||
deletedTemplateIds: Record<string, string>;
|
||||
/** Templates the server lacks or holds an older version of. */
|
||||
uploads: EmailTemplate[];
|
||||
/** Ids to remove from the server. */
|
||||
deletes: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Brings this browser's copy and the server copy together, per id with
|
||||
* the newer `updatedAt` winning (mergeSyncedTemplates):
|
||||
* - a template the server had at the last sync and no longer has was
|
||||
* deleted elsewhere and goes here too, unless it was changed here since;
|
||||
* - a template deleted here goes from the server, unless it was changed
|
||||
* there since;
|
||||
* - everything newer here than on the server is uploaded, which includes
|
||||
* templates the server never had (new, or kept here for lack of room).
|
||||
*/
|
||||
export function planPersonalSync(
|
||||
local: PersonalCopy,
|
||||
server: Record<string, EmailTemplate>,
|
||||
now: Date = new Date(),
|
||||
): PersonalSyncPlan {
|
||||
const kept = local.templates.filter((template) => {
|
||||
const seen = local.serverVersions[template.id];
|
||||
if (seen === undefined || server[template.id]) return true;
|
||||
return Date.parse(template.updatedAt) > Date.parse(seen);
|
||||
});
|
||||
const merged = mergeSyncedTemplates(
|
||||
{ templates: kept, deletedTemplateIds: local.deletedTemplateIds },
|
||||
{ templates: Object.values(server), deletedTemplateIds: {} },
|
||||
now,
|
||||
);
|
||||
const keptIds = new Set(merged.templates.map((t) => t.id));
|
||||
const uploads = merged.templates.filter((template) => {
|
||||
const there = server[template.id];
|
||||
return !there || Date.parse(template.updatedAt) > Date.parse(there.updatedAt);
|
||||
});
|
||||
const deletes = Object.keys(server).filter((id) => !keptIds.has(id));
|
||||
return { templates: merged.templates, deletedTemplateIds: merged.deletedTemplateIds, uploads, deletes };
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* A signed-in account's own templates, wherever they live: in the account
|
||||
* (account-templates-store, kept in the Inbox metadata on Stalwart v1.0.0
|
||||
* and later), or, for accounts without, the browser's list in
|
||||
* template-store as before. Everything that changes personal templates
|
||||
* (the template manager, the composer's "Save as template", plugins) goes
|
||||
* through here, so an account's templates stay that account's.
|
||||
*/
|
||||
|
||||
import type { EmailTemplate } from '@/lib/template-types';
|
||||
import { exportTemplates, filterTemplates, importTemplates as parseImport } from '@/lib/template-utils';
|
||||
import { templateHooks } from '@/lib/plugin-hooks';
|
||||
import { templateToView } from '@/lib/plugin-projection';
|
||||
import { generateUUID } from '@/lib/utils';
|
||||
import { onFullSignOut } from '@/lib/sign-out-cleanup';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useTemplateStore } from '@/stores/template-store';
|
||||
import { readSettingsOwner } from '@/stores/settings-store';
|
||||
import {
|
||||
notifyPersonalChange,
|
||||
onPersonalChange,
|
||||
useAccountTemplatesStore,
|
||||
type LocalOnlyReason,
|
||||
type PersonalPartition,
|
||||
} from '@/stores/account-templates-store';
|
||||
import { syncPersonalTemplates, type MetadataClient, type SyncOutcome } from './account-template-sync';
|
||||
|
||||
type TemplateData = Omit<EmailTemplate, 'id' | 'createdAt' | 'updatedAt'>;
|
||||
type TemplateUpdate = Partial<Omit<EmailTemplate, 'id' | 'createdAt'>>;
|
||||
|
||||
export interface PersonalTemplateOps {
|
||||
/** The account keeps its templates (in the server's metadata), not just this browser. */
|
||||
storedInAccount: boolean;
|
||||
templates: () => EmailTemplate[];
|
||||
localOnly: () => Record<string, LocalOnlyReason>;
|
||||
add: (data: TemplateData) => EmailTemplate;
|
||||
update: (id: string, updates: TemplateUpdate) => void;
|
||||
remove: (id: string) => void;
|
||||
duplicate: (id: string, nameSuffix?: string) => EmailTemplate | null;
|
||||
toggleFavorite: (id: string) => void;
|
||||
search: (query: string) => EmailTemplate[];
|
||||
exportAll: () => string;
|
||||
importJson: (json: string) => { count: number; errors: string[] };
|
||||
}
|
||||
|
||||
/** The account a call is about: the given one, or the active one. */
|
||||
export function resolveAccountId(accountId?: string | null): string | null {
|
||||
return accountId || useAuthStore.getState().activeAccountId || null;
|
||||
}
|
||||
|
||||
/** Whether the account's templates live in the account. */
|
||||
export function isStoredInAccount(accountId?: string | null): boolean {
|
||||
const id = resolveAccountId(accountId);
|
||||
return !!id && !!useAccountTemplatesStore.getState().personal[id];
|
||||
}
|
||||
|
||||
function legacyOps(): PersonalTemplateOps {
|
||||
const store = () => useTemplateStore.getState();
|
||||
return {
|
||||
storedInAccount: false,
|
||||
templates: () => store().templates,
|
||||
localOnly: () => ({}),
|
||||
add: (data) => store().addTemplate(data),
|
||||
update: (id, updates) => store().updateTemplate(id, updates),
|
||||
remove: (id) => store().deleteTemplate(id),
|
||||
duplicate: (id, suffix) => store().duplicateTemplate(id, suffix),
|
||||
toggleFavorite: (id) => store().toggleFavorite(id),
|
||||
search: (query) => store().searchTemplates(query),
|
||||
exportAll: () => store().exportAllTemplates(),
|
||||
importJson: (json) => store().importTemplates(json),
|
||||
};
|
||||
}
|
||||
|
||||
function accountOps(accountId: string): PersonalTemplateOps {
|
||||
const partition = (): PersonalPartition | undefined => useAccountTemplatesStore.getState().personal[accountId];
|
||||
const templates = () => partition()?.templates ?? [];
|
||||
const change = (fn: (p: PersonalPartition) => PersonalPartition) => {
|
||||
useAccountTemplatesStore.getState().updatePersonal(accountId, fn);
|
||||
notifyPersonalChange(accountId);
|
||||
};
|
||||
const find = (id: string) => templates().find((t) => t.id === id);
|
||||
const emitUpdate = (id: string) => {
|
||||
const template = find(id);
|
||||
if (template) void templateHooks.onTemplateUpdate.emit(templateToView(template));
|
||||
};
|
||||
const add = (data: TemplateData): EmailTemplate => {
|
||||
const now = new Date().toISOString();
|
||||
const template: EmailTemplate = { ...data, id: generateUUID(), createdAt: now, updatedAt: now };
|
||||
change((p) => ({ ...p, templates: [...p.templates, template] }));
|
||||
void templateHooks.onTemplateCreate.emit(templateToView(template));
|
||||
return template;
|
||||
};
|
||||
return {
|
||||
storedInAccount: true,
|
||||
templates,
|
||||
localOnly: () => partition()?.localOnly ?? {},
|
||||
add,
|
||||
update: (id, updates) => {
|
||||
if (!find(id)) return;
|
||||
const now = new Date().toISOString();
|
||||
change((p) => ({ ...p, templates: p.templates.map((t) => (t.id === id ? { ...t, ...updates, updatedAt: now } : t)) }));
|
||||
emitUpdate(id);
|
||||
},
|
||||
remove: (id) => {
|
||||
if (!find(id)) return;
|
||||
const now = new Date().toISOString();
|
||||
change((p) => {
|
||||
const localOnly = { ...p.localOnly };
|
||||
delete localOnly[id];
|
||||
return { ...p, templates: p.templates.filter((t) => t.id !== id), deletedTemplateIds: { ...p.deletedTemplateIds, [id]: now }, localOnly };
|
||||
});
|
||||
void templateHooks.onTemplateDelete.emit({ templateId: id });
|
||||
},
|
||||
duplicate: (id, suffix) => {
|
||||
const original = find(id);
|
||||
if (!original) return null;
|
||||
return add({ ...original, name: `${original.name} ${suffix || '(copy)'}`, isFavorite: false });
|
||||
},
|
||||
toggleFavorite: (id) => {
|
||||
const template = find(id);
|
||||
if (!template) return;
|
||||
const now = new Date().toISOString();
|
||||
change((p) => ({ ...p, templates: p.templates.map((t) => (t.id === id ? { ...t, isFavorite: !t.isFavorite, updatedAt: now } : t)) }));
|
||||
emitUpdate(id);
|
||||
},
|
||||
search: (query) => filterTemplates(templates(), query),
|
||||
exportAll: () => exportTemplates(templates()),
|
||||
importJson: (json) => {
|
||||
const result = parseImport(json);
|
||||
if (result.templates.length > 0) {
|
||||
change((p) => ({ ...p, templates: [...p.templates, ...result.templates] }));
|
||||
void templateHooks.onTemplatesImport.emit({ count: result.templates.length });
|
||||
}
|
||||
return { count: result.templates.length, errors: result.errors };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The personal templates of the signed-in account `accountId` (the active one when left out). */
|
||||
export function personalTemplatesFor(accountId?: string | null): PersonalTemplateOps {
|
||||
const id = resolveAccountId(accountId);
|
||||
return id && useAccountTemplatesStore.getState().personal[id] ? accountOps(id) : legacyOps();
|
||||
}
|
||||
|
||||
// ─── Syncing ──────────────────────────────────────────────────
|
||||
|
||||
function clientFor(accountId: string): MetadataClient | null {
|
||||
const auth = useAuthStore.getState();
|
||||
const client = auth.getClientForAccount?.(accountId) ?? (accountId === auth.activeAccountId ? auth.client : null);
|
||||
return (client as unknown as MetadataClient | null) ?? null;
|
||||
}
|
||||
|
||||
/** Sync the account's personal templates now. */
|
||||
export function syncAccountTemplates(accountId: string): Promise<SyncOutcome> {
|
||||
const client = clientFor(accountId);
|
||||
if (!client || typeof client.getAccountId !== 'function') return Promise.resolve('unsupported');
|
||||
return syncPersonalTemplates(accountId, client, () => browserListOf(accountId));
|
||||
}
|
||||
|
||||
/**
|
||||
* The browser's template list, if it is this account's: the active
|
||||
* account's once its synced settings are loaded, or a list no account's
|
||||
* settings sync owns (sync off, Lite). Never another account's, which
|
||||
* would copy one account's templates into another's mailbox.
|
||||
*/
|
||||
function browserListOf(accountId: string): EmailTemplate[] | null {
|
||||
if (accountId !== useAuthStore.getState().activeAccountId) return null;
|
||||
const owner = readSettingsOwner();
|
||||
if (owner !== null && owner !== accountId) return null;
|
||||
return useTemplateStore.getState().templates;
|
||||
}
|
||||
|
||||
// The browser's list changing for an account that doesn't keep its own
|
||||
// copy yet (its synced settings just loaded) is the moment to set it up.
|
||||
useTemplateStore.subscribe(() => {
|
||||
const active = useAuthStore.getState().activeAccountId;
|
||||
if (active && !useAccountTemplatesStore.getState().personal[active]) scheduleAccountSync(active);
|
||||
});
|
||||
|
||||
const SYNC_DELAY_MS = 800;
|
||||
const timers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
|
||||
/** Sync shortly after a change, once for a burst of changes. */
|
||||
export function scheduleAccountSync(accountId: string): void {
|
||||
const pending = timers.get(accountId);
|
||||
if (pending) clearTimeout(pending);
|
||||
timers.set(accountId, setTimeout(() => {
|
||||
timers.delete(accountId);
|
||||
void syncAccountTemplates(accountId);
|
||||
}, SYNC_DELAY_MS));
|
||||
}
|
||||
|
||||
onPersonalChange(scheduleAccountSync);
|
||||
|
||||
const lastSync = new Map<string, number>();
|
||||
|
||||
/** Sync when the last round is older than `maxAgeMs` (opening the picker or the manager, coming back to the tab). */
|
||||
export function refreshAccountTemplates(accountId: string | null | undefined, maxAgeMs = 15_000): void {
|
||||
const id = resolveAccountId(accountId);
|
||||
if (!id) return;
|
||||
const last = lastSync.get(id) ?? 0;
|
||||
if (Date.now() - last < maxAgeMs) return;
|
||||
lastSync.set(id, Date.now());
|
||||
void syncAccountTemplates(id);
|
||||
}
|
||||
|
||||
// At a full sign-out only what exists nowhere else stays in this browser.
|
||||
onFullSignOut(() => {
|
||||
lastSync.clear();
|
||||
useAccountTemplatesStore.getState().forgetServerCopies();
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { useSettingsStore } from '../settings-store';
|
||||
import { useTemplateStore } from '../template-store';
|
||||
import { emptyPartition, useAccountTemplatesStore } from '../account-templates-store';
|
||||
import { mergeStoredTemplates } from '@/lib/template-merge';
|
||||
import type { EmailTemplate } from '@/lib/template-types';
|
||||
|
||||
// An account that keeps its templates in its own Inbox metadata (Stalwart
|
||||
// 1.0) takes them out of the settings-sync blob; accounts without keep
|
||||
// syncing the browser's list as before.
|
||||
|
||||
const tpl = (id: string, name = id, updatedAt = '2026-10-01T00:00:00.000Z'): EmailTemplate => ({
|
||||
id, name, subject: '', body: '', isHTML: false, category: '', isFavorite: false,
|
||||
createdAt: '2026-10-01T00:00:00.000Z', updatedAt,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
useTemplateStore.setState({ templates: [tpl('browser-1', 'Browser')], recentTemplateIds: [], deletedTemplateIds: {} });
|
||||
useAccountTemplatesStore.setState({ personal: {}, teams: {}, active: null });
|
||||
});
|
||||
|
||||
describe('the settings blob', () => {
|
||||
it('carries the browser\'s templates for an account without its own copy, as before', () => {
|
||||
useAccountTemplatesStore.setState({ active: 'old@server' });
|
||||
const blob = JSON.parse(useSettingsStore.getState().exportSettings({ forSync: true }));
|
||||
expect(blob.templates.map((t: EmailTemplate) => t.name)).toEqual(['Browser']);
|
||||
expect(blob.templatesInMetadata).toBeUndefined();
|
||||
});
|
||||
|
||||
it('leaves the templates out of a push for an account that keeps them, and the stored blob drops its copy', () => {
|
||||
useAccountTemplatesStore.setState({ active: 'new@server', personal: { 'new@server': { ...emptyPartition(), templates: [tpl('acc-1', 'In the account')] } } });
|
||||
const blob = JSON.parse(useSettingsStore.getState().exportSettings({ forSync: true }));
|
||||
expect(blob.templates).toBeUndefined();
|
||||
expect(blob.templatesInMetadata).toBe(true);
|
||||
|
||||
const stored = { theme: 'dark', templates: [tpl('old', 'Old copy')], deletedTemplateIds: {} };
|
||||
const saved = mergeStoredTemplates(stored, blob);
|
||||
expect(saved.templates).toBeUndefined();
|
||||
expect(saved.deletedTemplateIds).toBeUndefined();
|
||||
expect(saved.templatesInMetadata).toBe(true);
|
||||
});
|
||||
|
||||
it('exports the account\'s own templates to a settings file', () => {
|
||||
useAccountTemplatesStore.setState({ active: 'new@server', personal: { 'new@server': { ...emptyPartition(), templates: [tpl('acc-1', 'In the account')] } } });
|
||||
const file = JSON.parse(useSettingsStore.getState().exportSettings());
|
||||
expect(file.templates.map((t: EmailTemplate) => t.name)).toEqual(['In the account']);
|
||||
});
|
||||
|
||||
it('doesn\'t touch the browser\'s list when a blob loads for an account that keeps its own', () => {
|
||||
useAccountTemplatesStore.setState({ personal: { 'new@server': { ...emptyPartition(), templates: [tpl('acc-1')] } } });
|
||||
useSettingsStore.getState().importSettings(JSON.stringify({ templates: [tpl('blob-1', 'From the blob')], deletedTemplateIds: { 'browser-1': '2026-10-09T00:00:00.000Z' } }), { serverAccountId: 'new@server' });
|
||||
expect(useTemplateStore.getState().templates.map((t) => t.name)).toEqual(['Browser']);
|
||||
expect(useAccountTemplatesStore.getState().personal['new@server'].templates.map((t) => t.id)).toEqual(['acc-1']);
|
||||
});
|
||||
|
||||
it('merges a blob into the browser\'s list for an account without, as before', () => {
|
||||
useSettingsStore.getState().importSettings(JSON.stringify({ templates: [tpl('blob-1', 'From the blob')], deletedTemplateIds: {} }), { serverAccountId: 'old@server' });
|
||||
expect(useTemplateStore.getState().templates.map((t) => t.name).sort()).toEqual(['Browser', 'From the blob']);
|
||||
});
|
||||
|
||||
it('adds the templates of an imported settings file to the active account\'s copy, never replacing it', () => {
|
||||
useAccountTemplatesStore.setState({ active: 'new@server', personal: { 'new@server': { ...emptyPartition(), templates: [tpl('acc-1', 'Kept')] } } });
|
||||
useSettingsStore.getState().importSettings(JSON.stringify({ templates: [tpl('file-1', 'From the file')], deletedTemplateIds: {} }));
|
||||
const names = useAccountTemplatesStore.getState().personal['new@server'].templates.map((t) => t.name).sort();
|
||||
expect(names).toEqual(['From the file', 'Kept']);
|
||||
expect(useTemplateStore.getState().templates.map((t) => t.name)).toEqual(['Browser']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Templates kept in a mail account rather than only in this browser
|
||||
* (Stalwart v1.0.0 and later, JMAP metadata; lib/templates/metadata-templates.ts):
|
||||
*
|
||||
* - `personal`: this browser's copy of each signed-in account's own
|
||||
* templates, by local account id. An account gets one the first time
|
||||
* its Inbox metadata answers (lib/templates/account-template-sync.ts);
|
||||
* until then, and on servers without metadata, its templates are the
|
||||
* browser's own list in template-store, as they always were.
|
||||
* - `teams`: the templates of the group mailboxes each signed-in account
|
||||
* is a member of, as last read. Not kept across reloads: the server
|
||||
* copy is the only one.
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import type { EmailTemplate } from '@/lib/template-types';
|
||||
import type { PersonalCopy } from '@/lib/templates/metadata-templates';
|
||||
|
||||
/** Why a personal template could not go to the server and stays in this browser. */
|
||||
export type LocalOnlyReason = 'too-large' | 'no-room';
|
||||
|
||||
export interface PersonalPartition extends PersonalCopy {
|
||||
/** Templates the server refused, with why. */
|
||||
localOnly: Record<string, LocalOnlyReason>;
|
||||
}
|
||||
|
||||
export interface TeamTemplates {
|
||||
/** The group's JMAP account id in the session of the signed-in account. */
|
||||
accountId: string;
|
||||
/** The group's address, as Stalwart names the account. */
|
||||
name: string;
|
||||
templates: EmailTemplate[];
|
||||
/** The member may change the group Inbox's metadata (myRights.maySetMetadata). */
|
||||
mayWrite: boolean;
|
||||
}
|
||||
|
||||
interface AccountTemplatesState {
|
||||
personal: Record<string, PersonalPartition>;
|
||||
/** The active signed-in account, kept here by the sync driver for the settings bridge. */
|
||||
active: string | null;
|
||||
teams: Record<string, TeamTemplates[]>;
|
||||
setPersonal: (accountId: string, partition: PersonalPartition) => void;
|
||||
updatePersonal: (accountId: string, change: (partition: PersonalPartition) => PersonalPartition) => void;
|
||||
setTeams: (accountId: string, teams: TeamTemplates[]) => void;
|
||||
/** At a full sign-out: keep only what exists nowhere else (templates the server never took). */
|
||||
forgetServerCopies: () => void;
|
||||
}
|
||||
|
||||
export const emptyPartition = (): PersonalPartition => ({
|
||||
templates: [],
|
||||
deletedTemplateIds: {},
|
||||
serverVersions: {},
|
||||
localOnly: {},
|
||||
});
|
||||
|
||||
export const useAccountTemplatesStore = create<AccountTemplatesState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
personal: {},
|
||||
active: null,
|
||||
teams: {},
|
||||
setPersonal: (accountId, partition) => set((state) => ({ personal: { ...state.personal, [accountId]: partition } })),
|
||||
updatePersonal: (accountId, change) => set((state) => {
|
||||
const current = state.personal[accountId];
|
||||
if (!current) return state;
|
||||
return { personal: { ...state.personal, [accountId]: change(current) } };
|
||||
}),
|
||||
setTeams: (accountId, teams) => set((state) => ({ teams: { ...state.teams, [accountId]: teams } })),
|
||||
forgetServerCopies: () => set((state) => {
|
||||
const personal: Record<string, PersonalPartition> = {};
|
||||
for (const [accountId, partition] of Object.entries(state.personal)) {
|
||||
const unsent = partition.templates.filter((t) => partition.serverVersions[t.id] !== t.updatedAt);
|
||||
if (unsent.length === 0) continue;
|
||||
personal[accountId] = { ...emptyPartition(), templates: unsent, localOnly: partition.localOnly };
|
||||
}
|
||||
return { personal, teams: {} };
|
||||
}),
|
||||
}),
|
||||
{
|
||||
name: 'account-templates',
|
||||
version: 1,
|
||||
partialize: (state) => ({ personal: state.personal }),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// A change made here (not one a sync wrote) asks for a sync. The sync
|
||||
// module registers the listener, so this store doesn't import it.
|
||||
const changeListeners = new Set<(accountId: string) => void>();
|
||||
|
||||
export function onPersonalChange(listener: (accountId: string) => void): () => void {
|
||||
changeListeners.add(listener);
|
||||
return () => changeListeners.delete(listener);
|
||||
}
|
||||
|
||||
export function notifyPersonalChange(accountId: string): void {
|
||||
for (const listener of changeListeners) listener(accountId);
|
||||
}
|
||||
|
||||
// Every tab writes the whole state back on a change; pick up another tab's.
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('storage', (event) => {
|
||||
if (event.key !== 'account-templates' || event.storageArea !== window.localStorage) return;
|
||||
void useAccountTemplatesStore.persist.rehydrate();
|
||||
});
|
||||
}
|
||||
+32
-16
@@ -56,7 +56,8 @@ let blockedSyncAccountId: string | null = null;
|
||||
// device settings and the first sign-in adopts them.
|
||||
const SETTINGS_OWNER_KEY = 'settings-sync-owner';
|
||||
|
||||
function readSettingsOwner(): string | null {
|
||||
/** The account whose server copy the local settings (and template list) mirror; null if none. */
|
||||
export function readSettingsOwner(): string | null {
|
||||
try {
|
||||
return typeof window === 'undefined' ? null : window.localStorage.getItem(SETTINGS_OWNER_KEY);
|
||||
} catch {
|
||||
@@ -89,14 +90,21 @@ interface SettingsSyncJob {
|
||||
// bindings uninitialized depending on which module loads first. Instead,
|
||||
// template-store registers itself here during its own module init.
|
||||
interface TemplateSyncBridge {
|
||||
getSyncedState: () => {
|
||||
/**
|
||||
* The templates of the account `accountId` (the one being synced, or the
|
||||
* active one). `storedInAccount`: they live in the account's own server
|
||||
* storage (JMAP metadata, Stalwart 1.0), so a sync push leaves them out
|
||||
* and the stored blob drops them (`templatesInMetadata`).
|
||||
*/
|
||||
getSyncedState: (opts?: { accountId?: string }) => {
|
||||
templates: EmailTemplate[];
|
||||
deletedTemplateIds: Record<string, string>;
|
||||
storedInAccount?: boolean;
|
||||
};
|
||||
applySyncedState: (
|
||||
templates: unknown,
|
||||
deletedTemplateIds: unknown,
|
||||
opts: { merge: boolean }
|
||||
opts: { merge: boolean; accountId?: string }
|
||||
) => void;
|
||||
}
|
||||
|
||||
@@ -129,9 +137,11 @@ function templateStateKey(templates: unknown, deletedTemplateIds: unknown): stri
|
||||
* Whether the local template state differs from what a server blob carries,
|
||||
* so the blob needs a push. False when there is nothing local to push.
|
||||
*/
|
||||
function localTemplatesAhead(serverSettings: Record<string, unknown> | null): boolean {
|
||||
const local = templateSyncBridge?.getSyncedState();
|
||||
function localTemplatesAhead(serverSettings: Record<string, unknown> | null, accountId?: string): boolean {
|
||||
const local = templateSyncBridge?.getSyncedState({ accountId });
|
||||
if (!local) return false;
|
||||
// Templates kept in the account leave the blob: push once to drop them.
|
||||
if (local.storedInAccount) return !!serverSettings && Array.isArray(serverSettings.templates);
|
||||
if (local.templates.length === 0 && Object.keys(local.deletedTemplateIds).length === 0) return false;
|
||||
if (!serverSettings) return true;
|
||||
return templateStateKey(local.templates, local.deletedTemplateIds) !==
|
||||
@@ -1204,9 +1214,11 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
}
|
||||
};
|
||||
|
||||
const buildExport = (opts?: { subscriptionOwner?: string }): string => {
|
||||
const buildExport = (opts?: { forSync?: boolean; subscriptionOwner?: string }): string => {
|
||||
const state = get();
|
||||
const templateSync = templateSyncBridge?.getSyncedState();
|
||||
const templateSync = templateSyncBridge?.getSyncedState({
|
||||
accountId: opts?.forSync && syncUsername && syncServerUrl ? generateAccountId(syncUsername, syncServerUrl) : undefined,
|
||||
});
|
||||
const subscriptionSync = calendarSubscriptionSyncBridge?.getSyncedState();
|
||||
const settings = {
|
||||
fontSize: state.fontSize,
|
||||
@@ -1338,12 +1350,16 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
locale: useLocaleStore.getState().locale,
|
||||
// Omitted entirely (rather than emitted empty) if the bridge has
|
||||
// not registered, so an importing device leaves its templates alone.
|
||||
...(templateSync
|
||||
? {
|
||||
templates: templateSync.templates,
|
||||
deletedTemplateIds: templateSync.deletedTemplateIds,
|
||||
}
|
||||
: {}),
|
||||
// Templates the account keeps itself are left out of a sync push;
|
||||
// the flag has the stored blob drop its old copy (mergeStoredTemplates).
|
||||
...(templateSync && opts?.forSync && templateSync.storedInAccount
|
||||
? { templatesInMetadata: true }
|
||||
: templateSync
|
||||
? {
|
||||
templates: templateSync.templates,
|
||||
deletedTemplateIds: templateSync.deletedTemplateIds,
|
||||
}
|
||||
: {}),
|
||||
// Omitted entirely if the bridge has not registered, so an importing
|
||||
// device leaves its calendar subscriptions alone.
|
||||
...(subscriptionSync
|
||||
@@ -1545,7 +1561,7 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
templateSyncBridge?.applySyncedState(
|
||||
settings.templates,
|
||||
settings.deletedTemplateIds,
|
||||
{ merge: Boolean(opts?.serverAccountId) }
|
||||
{ merge: Boolean(opts?.serverAccountId), accountId: opts?.serverAccountId }
|
||||
);
|
||||
calendarSubscriptionSyncBridge?.applySyncedState(
|
||||
settings.icalSubscriptions,
|
||||
@@ -1760,7 +1776,7 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
// previous account left in this browser (#1185).
|
||||
if (heldByOther) resetSyncedState({ keepCrossAccountViews });
|
||||
writeSettingsOwner(accountId);
|
||||
pushTemplatesAfterLoad = localTemplatesAhead(null);
|
||||
pushTemplatesAfterLoad = localTemplatesAhead(null, accountId);
|
||||
return false;
|
||||
}
|
||||
if (settings && typeof settings === 'object') {
|
||||
@@ -1778,7 +1794,7 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
});
|
||||
isLoadingFromServer = false;
|
||||
writeSettingsOwner(accountId);
|
||||
pushTemplatesAfterLoad = localTemplatesAhead(settings);
|
||||
pushTemplatesAfterLoad = localTemplatesAhead(settings, accountId);
|
||||
syncLog('Settings loaded from server successfully');
|
||||
void settingsHooks.onSettingsSync.emit({ direction: 'pulled' });
|
||||
// The per-account preferred sender identity (#507) is re-applied by
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user