Files
webmail/stores/template-store.ts
Linus Rath 88f20e1ab0 fix: never lose personal templates to a deletion, a clock or a settings load elsewhere
The first version took a template missing from the account's Inbox
metadata as deleted, skipped the browser's own list once the account
held anything, wrote the marker before the settings blob had loaded,
and let a round overwrite edits made while it ran. Each of these could
drop templates, or bring deleted ones back.

Now the marker entry `org.bulwarkmail.templates` holds tombstones
(90 days, at most 8 KB, oldest dropped first) and only a tombstone
deletes; a missing entry is uploaded again. Every round merges the
browser's list in when it is this account's, each version once, minus
what a newer tombstone deleted. While settings sync hasn't loaded the
account's settings, the list isn't used and no marker is written; a
blob that loads later is merged into the account's copy. The settings
blob drops its templates only once the marker is on the server. Without
settings sync the list goes into the first account that answers only.
A round that saw changes while it ran plans again against the server as
it left it. An answer without the metadata property counts as
unsupported. Changes move `updatedAt` past the previous value, dateless
synced templates get a fixed early date, and ids that don't fit a
metadata key get a new id derived from the old one. Queued changes go
out before an account signs out; a full sign-out keeps unsent templates
and deletions.

Team templates are written only if the entry is still the version the
edit started from ("Changed by someone else"), never carry Bcc, and a
refusal is told apart by Stalwart's error type. The template manager's
lists are a real tab list with arrow keys and 44 px row buttons on
phones.
2026-10-28 07:34:25 +01:00

292 lines
11 KiB
TypeScript

import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { EmailTemplate } from '@/lib/template-types';
import {
exportTemplates as exportUtil,
importTemplates as importUtil,
filterTemplates,
mergeSyncedTemplates,
parseSyncedTemplates,
parseTemplateTombstones,
} from '@/lib/template-utils';
import * as reducer from '@/lib/templates/template-reducer';
import { registerTemplateSyncBridge } from './settings-store';
import { notifyPersonalChange, useAccountTemplatesStore } from './account-templates-store';
import { withStorableId } from '@/lib/templates/metadata-templates';
import { templateHooks } from '@/lib/plugin-hooks';
import { templateToView } from '@/lib/plugin-projection';
const MAX_RECENT = 5;
const listOf = (state: { templates: EmailTemplate[]; deletedTemplateIds: Record<string, string> }): reducer.TemplateListState =>
({ templates: state.templates, deletedTemplateIds: state.deletedTemplateIds });
interface TemplateStore {
templates: EmailTemplate[];
recentTemplateIds: string[];
/**
* Template id -> ISO time it was deleted. Synced alongside `templates` in
* the settings blob so deletions propagate across devices instead of being
* resurrected by the next merge (see applySyncedState).
*/
deletedTemplateIds: Record<string, string>;
addTemplate: (template: Omit<EmailTemplate, 'id' | 'createdAt' | 'updatedAt'>) => EmailTemplate;
updateTemplate: (id: string, updates: Partial<Omit<EmailTemplate, 'id' | 'createdAt'>>) => void;
deleteTemplate: (id: string) => void;
duplicateTemplate: (id: string, nameSuffix?: string) => EmailTemplate | null;
toggleFavorite: (id: string) => void;
recordUsage: (id: string) => void;
getTemplatesByCategory: () => Record<string, EmailTemplate[]>;
getFavorites: () => EmailTemplate[];
getRecent: () => EmailTemplate[];
searchTemplates: (query: string) => EmailTemplate[];
exportAllTemplates: () => string;
importTemplates: (json: string) => { count: number; errors: string[] };
/**
* Applies a `templates` / `deletedTemplateIds` pair from a synced settings
* blob or settings-file import. `merge: true` (per-account server loads)
* merges by id with the newer `updatedAt` winning; `merge: false` (file
* imports) replaces wholesale. A blob from a build that predates template
* sync (no `templates` array) leaves the local store untouched.
*/
applySyncedState: (
templates: unknown,
deletedTemplateIds: unknown,
opts: { merge: boolean }
) => void;
}
export const useTemplateStore = create<TemplateStore>()(
persist(
(set, get) => ({
templates: [],
recentTemplateIds: [],
deletedTemplateIds: {},
addTemplate: (data) => {
const { state, template } = reducer.addTemplate(listOf(get()), data);
set(state);
void templateHooks.onTemplateCreate.emit(templateToView(template));
return template;
},
updateTemplate: (id, updates) => {
set(reducer.updateTemplate(listOf(get()), id, updates));
emitTemplateUpdate(id);
},
deleteTemplate: (id) => {
if (!get().templates.some((t) => t.id === id)) return;
set((state) => ({
...reducer.deleteTemplate(listOf(state), id),
recentTemplateIds: state.recentTemplateIds.filter((rid) => rid !== id),
}));
void templateHooks.onTemplateDelete.emit({ templateId: id });
},
duplicateTemplate: (id, nameSuffix) => {
const result = reducer.duplicateTemplate(listOf(get()), id, nameSuffix);
if (!result) return null;
set(result.state);
void templateHooks.onTemplateCreate.emit(templateToView(result.template));
return result.template;
},
toggleFavorite: (id) => {
set(reducer.toggleFavorite(listOf(get()), id));
emitTemplateUpdate(id);
},
recordUsage: (id) => {
set((state) => {
const filtered = state.recentTemplateIds.filter((rid) => rid !== id);
return {
recentTemplateIds: [id, ...filtered].slice(0, MAX_RECENT),
};
});
},
getTemplatesByCategory: () => {
const { templates } = get();
const grouped: Record<string, EmailTemplate[]> = {};
for (const t of templates) {
const cat = t.category || '';
if (!grouped[cat]) grouped[cat] = [];
grouped[cat].push(t);
}
return grouped;
},
getFavorites: () => {
return get().templates.filter((t) => t.isFavorite);
},
getRecent: () => {
const { templates, recentTemplateIds } = get();
return recentTemplateIds
.map((id) => templates.find((t) => t.id === id))
.filter(Boolean) as EmailTemplate[];
},
searchTemplates: (query) => {
return filterTemplates(get().templates, query);
},
exportAllTemplates: () => {
return exportUtil(get().templates);
},
importTemplates: (json) => {
const result = importUtil(json);
if (result.templates.length > 0) {
set(reducer.appendTemplates(listOf(get()), result.templates));
void templateHooks.onTemplatesImport.emit({ count: result.templates.length });
}
return { count: result.templates.length, errors: result.errors };
},
applySyncedState: (templatesValue, tombstonesValue, opts) => {
const incoming = parseSyncedTemplates(templatesValue);
if (!incoming) return;
const incomingTombstones = parseTemplateTombstones(tombstonesValue);
set((state) => {
const next = opts.merge
? mergeSyncedTemplates(
{ templates: state.templates, deletedTemplateIds: state.deletedTemplateIds },
{ templates: incoming, deletedTemplateIds: incomingTombstones }
)
: { templates: incoming, deletedTemplateIds: incomingTombstones };
const ids = new Set(next.templates.map((t) => t.id));
return {
...next,
recentTemplateIds: state.recentTemplateIds.filter((id) => ids.has(id)),
};
});
},
}),
{
name: 'template-storage',
version: 1,
}
)
);
function emitTemplateUpdate(id: string): void {
const template = useTemplateStore.getState().templates.find((t) => t.id === id);
if (template) void templateHooks.onTemplateUpdate.emit(templateToView(template));
}
export interface RenderedTemplate {
subject: string;
html: string;
text: string;
}
/**
* Hands a template about to be inserted into the composer to the
* onTemplateRender plugins. Returns null when no plugin listens, so the
* composer keeps its own rendering; otherwise the plugins' version, with any
* field a plugin returned as a non-string kept from the input.
*/
export async function renderTemplateWithPlugins(
templateId: string,
rendered: RenderedTemplate,
): Promise<RenderedTemplate | null> {
if (templateHooks.onTemplateRender.size === 0) return null;
const result = await templateHooks.onTemplateRender.transform(rendered, { templateId }) as Partial<RenderedTemplate> | null;
return {
subject: typeof result?.subject === 'string' ? result.subject : rendered.subject,
html: typeof result?.html === 'string' ? result.html : rendered.html,
text: typeof result?.text === 'string' ? result.text : rendered.text,
};
}
/** Tells plugins a template was inserted into the composer. */
export function notifyTemplateApplied(template: EmailTemplate): void {
void templateHooks.onTemplateApply.emit(templateToView(template));
}
// Every tab keeps its own in-memory copy and writes the whole list back to
// localStorage on any change, so a second tab that never saw a template
// created in the first one would overwrite it (and push the shorter list).
// Pick up the other tab's write instead.
if (typeof window !== 'undefined') {
window.addEventListener('storage', (event) => {
if (event.key !== 'template-storage' || event.storageArea !== window.localStorage) return;
void useTemplateStore.persist.rehydrate();
});
}
// Registers templates with the cross-device settings sync (#825): they are
// included in the synced blob / settings export, applied back on server loads
// and file imports, and template changes schedule a sync push. Registration
// (rather than settings-store importing this module) avoids an import cycle -
// see registerTemplateSyncBridge.
//
// An account that keeps its templates in its own Inbox metadata (Stalwart
// 1.0, account-templates-store) leaves the blob once its marker entry is on
// the server: from then on its pushes carry no templates, and the flag has
// the stored blob drop its copy. Until then the blob syncs the browser's
// list as before. A blob loaded for such an account is merged into the
// browser's list as always and into the account's copy as well, so
// templates that only the blob has still reach the account.
const accountCopy = (accountId: string | undefined) => {
const accounts = useAccountTemplatesStore.getState();
const id = accountId ?? accounts.active ?? undefined;
return id ? accounts.personal[id] : undefined;
};
function mergeIntoAccount(accountId: string, templates: unknown, deletedTemplateIds: unknown): void {
const incoming = parseSyncedTemplates(templates);
if (!incoming || incoming.length === 0) return;
useAccountTemplatesStore.getState().updatePersonal(accountId, (copy) => ({
...copy,
...mergeSyncedTemplates(
{ templates: copy.templates, deletedTemplateIds: copy.deletedTemplateIds },
{ templates: incoming.map(withStorableId), deletedTemplateIds: parseTemplateTombstones(deletedTemplateIds) },
),
}));
notifyPersonalChange(accountId);
}
registerTemplateSyncBridge(
{
getSyncedState: (opts) => {
const copy = accountCopy(opts?.accountId);
if (copy?.markerOnServer) return { templates: copy.templates, deletedTemplateIds: copy.deletedTemplateIds, storedInAccount: true };
const { templates, deletedTemplateIds } = useTemplateStore.getState();
return { templates, deletedTemplateIds };
},
applySyncedState: (templates, deletedTemplateIds, opts) => {
if (opts.accountId) {
useTemplateStore.getState().applySyncedState(templates, deletedTemplateIds, opts);
if (accountCopy(opts.accountId)) mergeIntoAccount(opts.accountId, templates, deletedTemplateIds);
return;
}
// A settings file the user imports: into the active account's copy
// when it keeps one, added by id (never a wholesale replace, which
// would delete server templates).
const active = useAccountTemplatesStore.getState().active;
if (active && accountCopy(active)) {
mergeIntoAccount(active, templates, {});
return;
}
useTemplateStore.getState().applySyncedState(templates, deletedTemplateIds, opts);
},
},
(listener) => {
useTemplateStore.subscribe(listener);
// An account whose marker just reached the server pushes once, so the
// blob drops its copy.
useAccountTemplatesStore.subscribe((state, prev) => {
const newlyMarked = Object.entries(state.personal).some(([id, copy]) => copy.markerOnServer && !prev.personal[id]?.markerOnServer);
if (newlyMarked) listener();
});
}
);