Files
webmail/lib/templates/template-reducer.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

75 lines
3.2 KiB
TypeScript

/**
* The changes a user makes to a list of their own templates, as pure
* functions over `{ templates, deletedTemplateIds }`. The browser's list
* (template-store) and an account's copy (account-templates-store) both use
* them, so both behave the same.
*
* Every change moves `updatedAt` forward from the template's previous value
* even when this device's clock is behind the one that wrote it last;
* otherwise the merge by `updatedAt` would throw the change away.
*/
import type { EmailTemplate } from '@/lib/template-types';
import { generateUUID } from '@/lib/utils';
export interface TemplateListState {
templates: EmailTemplate[];
deletedTemplateIds: Record<string, string>;
}
export type TemplateData = Omit<EmailTemplate, 'id' | 'createdAt' | 'updatedAt'>;
export type TemplateUpdate = Partial<Omit<EmailTemplate, 'id' | 'createdAt'>>;
/** `updatedAt` for a change: now, or just after the previous value if that is later. */
export function nextUpdatedAt(previous?: string, now: number = Date.now()): string {
const before = previous ? Date.parse(previous) : NaN;
return new Date(Number.isNaN(before) ? now : Math.max(now, before + 1)).toISOString();
}
export function addTemplate(state: TemplateListState, data: TemplateData, now = Date.now()): { state: TemplateListState; template: EmailTemplate } {
const at = new Date(now).toISOString();
const template: EmailTemplate = { ...data, id: generateUUID(), createdAt: at, updatedAt: at };
return { state: { ...state, templates: [...state.templates, template] }, template };
}
export function updateTemplate(state: TemplateListState, id: string, updates: TemplateUpdate, now = Date.now()): TemplateListState {
return {
...state,
templates: state.templates.map((t) => (t.id === id ? { ...t, ...updates, updatedAt: nextUpdatedAt(t.updatedAt, now) } : t)),
};
}
export function toggleFavorite(state: TemplateListState, id: string, now = Date.now()): TemplateListState {
return {
...state,
templates: state.templates.map((t) => (t.id === id ? { ...t, isFavorite: !t.isFavorite, updatedAt: nextUpdatedAt(t.updatedAt, now) } : t)),
};
}
/** Removes a template and leaves a tombstone newer than the template, so the deletion wins the merge. */
export function deleteTemplate(state: TemplateListState, id: string, now = Date.now()): TemplateListState {
const template = state.templates.find((t) => t.id === id);
if (!template) return state;
return {
templates: state.templates.filter((t) => t.id !== id),
deletedTemplateIds: { ...state.deletedTemplateIds, [id]: nextUpdatedAt(template.updatedAt, now) },
};
}
export function duplicateTemplate(
state: TemplateListState,
id: string,
nameSuffix?: string,
now = Date.now(),
): { state: TemplateListState; template: EmailTemplate } | null {
const original = state.templates.find((t) => t.id === id);
if (!original) return null;
const { id: _id, createdAt: _c, updatedAt: _u, ...data } = original;
void _id; void _c; void _u;
return addTemplate(state, { ...data, name: `${original.name} ${nameSuffix || '(copy)'}`, isFavorite: false }, now);
}
export function appendTemplates(state: TemplateListState, templates: EmailTemplate[]): TemplateListState {
return { ...state, templates: [...state.templates, ...templates] };
}