Files
webmail/hooks/use-template-source-actions.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

117 lines
5.2 KiB
TypeScript

import { useMemo } from 'react';
import type { EmailTemplate } from '@/lib/template-types';
import type { TemplateSaveResult, TemplateSource } from '@/lib/templates/template-sources';
import type { TemplateData } from '@/lib/templates/template-reducer';
import { personalTemplatesFor } from '@/lib/templates/personal-templates';
import { deleteTeamTemplate, saveTeamTemplate } from '@/lib/templates/team-templates';
import { generateUUID } from '@/lib/utils';
import { useAuthStore } from '@/stores/auth-store';
import { useAccountTemplatesStore, type LocalOnlyReason } from '@/stores/account-templates-store';
export type { LocalOnlyReason, TemplateSaveResult };
export interface TemplateSourceActions {
add: (source: TemplateSource, data: TemplateData) => Promise<TemplateSaveResult>;
update: (source: TemplateSource, id: string, data: TemplateData) => Promise<TemplateSaveResult>;
remove: (source: TemplateSource, id: string) => Promise<TemplateSaveResult>;
duplicate: (source: TemplateSource, id: string, nameSuffix: string) => Promise<TemplateSaveResult>;
toggleFavorite: (source: TemplateSource, id: string) => Promise<TemplateSaveResult>;
/** A read-only (organisation or team) template copied into the user's own. */
copyToPersonal: (template: EmailTemplate) => Promise<TemplateSaveResult>;
/** Personal templates that stay in this browser, by id. */
localOnly: Record<string, LocalOnlyReason>;
/** Personal templates live in the account (server metadata), not only in this browser. */
storedInAccount: boolean;
}
const NO_LOCAL_ONLY: Record<string, LocalOnlyReason> = {};
/**
* Changes to the templates of each source the manager shows, for the
* 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; team templates are written to the group's Inbox at once;
* read-only sources refuse every change.
*/
export function useTemplateSourceActions(accountId?: string | null): TemplateSourceActions {
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 ops = () => personalTemplatesFor(resolvedId);
const group = (source: TemplateSource) => source.key.slice('team:'.length);
const find = (source: TemplateSource, id: string) => source.templates.find((t) => t.id === id);
const team = (source: TemplateSource, template: EmailTemplate, base: string | null): Promise<TemplateSaveResult> =>
resolvedId ? saveTeamTemplate(resolvedId, group(source), template, base) : Promise.resolve('failed');
const fresh = (data: TemplateData): EmailTemplate => {
const at = new Date().toISOString();
return { ...data, id: generateUUID(), createdAt: at, updatedAt: at };
};
return {
add: async (source, data) => {
if (!source.editable) return 'forbidden';
if (source.kind === 'personal') {
ops().add(data);
return 'ok';
}
return source.kind === 'team' ? team(source, fresh(data), null) : 'forbidden';
},
update: async (source, id, data) => {
if (!source.editable) return 'forbidden';
if (source.kind === 'personal') {
ops().update(id, data);
return 'ok';
}
const existing = find(source, id);
if (source.kind !== 'team' || !existing) return 'forbidden';
return team(source, { ...existing, ...data, id, updatedAt: new Date().toISOString() }, existing.updatedAt);
},
remove: async (source, id) => {
if (!source.editable) return 'forbidden';
if (source.kind === 'personal') {
ops().remove(id);
return 'ok';
}
const existing = find(source, id);
if (source.kind !== 'team' || !existing || !resolvedId) return 'forbidden';
return deleteTeamTemplate(resolvedId, group(source), id, existing.updatedAt);
},
duplicate: async (source, id, suffix) => {
if (!source.editable) return 'forbidden';
if (source.kind === 'personal') {
ops().duplicate(id, suffix);
return 'ok';
}
const existing = find(source, id);
if (source.kind !== 'team' || !existing) return 'forbidden';
return team(source, fresh({ ...copyOf(existing), name: `${existing.name} ${suffix}` }), null);
},
toggleFavorite: async (source, id) => {
if (source.kind !== 'personal' || !source.editable) return 'forbidden';
ops().toggleFavorite(id);
return 'ok';
},
copyToPersonal: async (template) => {
ops().add(copyOf(template));
return 'ok';
},
localOnly,
storedInAccount,
};
}, [resolvedId, localOnly, storedInAccount]);
}
/** The fields a copy of a shared template takes into the user's own list. */
export function copyOf(template: EmailTemplate): TemplateData {
return {
name: template.name,
subject: template.subject,
body: template.body,
isHTML: template.isHTML,
category: template.category,
defaultRecipients: template.defaultRecipients,
isFavorite: false,
};
}