mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-06 00:15:32 +00:00
250 lines
10 KiB
TypeScript
250 lines
10 KiB
TypeScript
/**
|
|
* 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 { onBeforeSignOut, onFullSignOut } from '@/lib/sign-out-cleanup';
|
|
import { IS_LITE } from '@/lib/lite';
|
|
import { getCachedConfig } from '@/hooks/use-config';
|
|
import { useAuthStore } from '@/stores/auth-store';
|
|
import { useTemplateStore } from '@/stores/template-store';
|
|
import { isSettingsLoadSettled, readSettingsOwner, useSettingsStore } from '@/stores/settings-store';
|
|
import {
|
|
notifyPersonalChange,
|
|
onPersonalChange,
|
|
useAccountTemplatesStore,
|
|
type LocalOnlyReason,
|
|
type PersonalPartition,
|
|
} from '@/stores/account-templates-store';
|
|
import * as reducer from './template-reducer';
|
|
import { clientFor } from './account-clients';
|
|
import { pendingRound, syncPersonalTemplates, type BrowserList, type SyncOutcome } from './account-template-sync';
|
|
|
|
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: reducer.TemplateData) => EmailTemplate;
|
|
update: (id: string, updates: reducer.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: (list: reducer.TemplateListState) => reducer.TemplateListState) => {
|
|
useAccountTemplatesStore.getState().updatePersonal(accountId, (p) => ({ ...p, ...fn({ templates: p.templates, deletedTemplateIds: p.deletedTemplateIds }) }));
|
|
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: reducer.TemplateData): EmailTemplate => {
|
|
let created: EmailTemplate | null = null;
|
|
change((list) => {
|
|
const result = reducer.addTemplate(list, data);
|
|
created = result.template;
|
|
return result.state;
|
|
});
|
|
void templateHooks.onTemplateCreate.emit(templateToView(created!));
|
|
return created!;
|
|
};
|
|
return {
|
|
storedInAccount: true,
|
|
templates,
|
|
localOnly: () => partition()?.localOnly ?? {},
|
|
add,
|
|
update: (id, updates) => {
|
|
if (!find(id)) return;
|
|
change((list) => reducer.updateTemplate(list, id, updates));
|
|
emitUpdate(id);
|
|
},
|
|
remove: (id) => {
|
|
if (!find(id)) return;
|
|
change((list) => reducer.deleteTemplate(list, id));
|
|
void templateHooks.onTemplateDelete.emit({ templateId: id });
|
|
},
|
|
duplicate: (id, suffix) => {
|
|
let created: EmailTemplate | null = null;
|
|
change((list) => {
|
|
const result = reducer.duplicateTemplate(list, id, suffix);
|
|
if (!result) return list;
|
|
created = result.template;
|
|
return result.state;
|
|
});
|
|
if (created) void templateHooks.onTemplateCreate.emit(templateToView(created));
|
|
return created;
|
|
},
|
|
toggleFavorite: (id) => {
|
|
if (!find(id)) return;
|
|
change((list) => reducer.toggleFavorite(list, id));
|
|
emitUpdate(id);
|
|
},
|
|
search: (query) => filterTemplates(templates(), query),
|
|
exportAll: () => exportTemplates(templates()),
|
|
importJson: (json) => {
|
|
const result = parseImport(json);
|
|
if (result.templates.length > 0) {
|
|
change((list) => reducer.appendTemplates(list, 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();
|
|
}
|
|
|
|
// ─── Whose is the browser's list ──────────────────────────────
|
|
|
|
/**
|
|
* The browser's template list as far as `accountId` is concerned.
|
|
*
|
|
* With settings sync, the list mirrors the settings of the account that
|
|
* loaded last (settings-sync-owner): it is this account's once its settings
|
|
* have loaded, and unknown before that; a load that ended without making
|
|
* any account the owner (the server refused sync) leaves the list to no
|
|
* one, as without sync.
|
|
*
|
|
* Without settings sync (Lite, sync off), the list belongs to no account:
|
|
* it goes into the first account whose metadata answers, and only that
|
|
* one, so one person's templates don't turn up in every mailbox signed in
|
|
* in this browser.
|
|
*/
|
|
export function browserListFor(accountId: string): BrowserList {
|
|
const templates = useTemplateStore.getState().templates;
|
|
const config = getCachedConfig();
|
|
if (!IS_LITE && !config) return { status: 'unknown' };
|
|
const syncOn = !IS_LITE && config?.settingsSyncEnabled === true && !useSettingsStore.getState().settingsSyncDisabled;
|
|
if (syncOn) {
|
|
const owner = readSettingsOwner();
|
|
const active = useAuthStore.getState().activeAccountId;
|
|
if (owner !== null) {
|
|
if (owner === accountId && accountId === active) return { status: 'list', templates };
|
|
return accountId === active ? { status: 'unknown' } : { status: 'none' };
|
|
}
|
|
if (!isSettingsLoadSettled(accountId)) return accountId === active ? { status: 'unknown' } : { status: 'none' };
|
|
}
|
|
const given = useAccountTemplatesStore.getState().browserListOwner;
|
|
if (given !== null && given !== accountId) return { status: 'none' };
|
|
return {
|
|
status: 'list',
|
|
templates,
|
|
claim: () => {
|
|
if (useAccountTemplatesStore.getState().browserListOwner === null) useAccountTemplatesStore.setState({ browserListOwner: accountId });
|
|
},
|
|
};
|
|
}
|
|
|
|
// ─── Syncing ──────────────────────────────────────────────────
|
|
|
|
/** Sync the account's personal templates now. */
|
|
export function syncAccountTemplates(accountId: string): Promise<SyncOutcome> {
|
|
const client = clientFor(accountId);
|
|
if (!client) return Promise.resolve('unsupported');
|
|
return syncPersonalTemplates(accountId, client, () => browserListFor(accountId));
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* Before an account signs out: send what is queued for it (above all
|
|
* deletions, which otherwise come back from the server next time). The
|
|
* auth store bounds the wait.
|
|
*/
|
|
export async function flushAccountTemplates(accountId: string): Promise<void> {
|
|
const ids = accountId === 'all' ? Object.keys(useAccountTemplatesStore.getState().personal) : [accountId];
|
|
await Promise.all(ids.map(async (id) => {
|
|
if (!useAccountTemplatesStore.getState().personal[id]) return;
|
|
const timer = timers.get(id);
|
|
if (timer) {
|
|
clearTimeout(timer);
|
|
timers.delete(id);
|
|
}
|
|
await pendingRound(id);
|
|
await syncAccountTemplates(id);
|
|
}));
|
|
}
|
|
|
|
onBeforeSignOut(flushAccountTemplates);
|
|
|
|
// At a full sign-out only what exists nowhere else stays in this browser:
|
|
// templates and deletions the server hasn't taken yet.
|
|
onFullSignOut(() => {
|
|
lastSync.clear();
|
|
useAccountTemplatesStore.getState().forgetServerCopies();
|
|
});
|