// Undo tokens for area c (email.batch, filters.create, calendar.deleteEvent, // app.setSettings). What an undo needs stays in the host: the plugin only // gets a random token, bound to its id and the signed-in account, good for // 30 minutes. So a plugin can take back what it did, but can never hand the // host a copy of something to re-create. import { generateUUID } from '../../utils'; import type { IJMAPClient } from '../../jmap/client-interface'; import { useAuthStore } from '@/stores/auth-store'; /** How long an undo token can be used. */ export const UNDO_TOKEN_TTL_MS = 30 * 60_000; /** Most tokens kept per plugin; the oldest go first. */ const MAX_TOKENS_PER_PLUGIN = 50; interface Entry { pluginId: string; accountId: string; kind: string; expires: number; data: unknown; /** Its undo is running: a second call waits for nothing and is refused. */ busy?: boolean; } const tokens = new Map(); function prune(now: number): void { for (const [token, entry] of tokens) if (entry.expires <= now) tokens.delete(token); } /** Keeps `data` for an undo of `kind` and returns its token. */ export function issueUndoToken(pluginId: string, accountId: string, kind: string, data: unknown): string { const now = Date.now(); prune(now); const mine = [...tokens].filter(([, e]) => e.pluginId === pluginId); for (const [token] of mine.slice(0, Math.max(0, mine.length - MAX_TOKENS_PER_PLUGIN + 1))) tokens.delete(token); const token = generateUUID(); tokens.set(token, { pluginId, accountId, kind, expires: now + UNDO_TOKEN_TTL_MS, data }); return token; } /** * Runs the undo a token stands for: only for the plugin that got it, in the * same account, for the same kind, before it ran out. The token is spent * when the undo went through; when it failed the token stays, so the plugin * can try again. One undo at a time per token. */ export async function withUndoToken( method: string, pluginId: string, accountId: string, kind: string, token: unknown, run: (data: T) => Promise, ): Promise { if (typeof token !== 'string' || !token) throw new Error(`${method}: undoToken must be a string`); // Expired tokens are dropped when new ones are made; the time is checked here. const entry = tokens.get(token); if (!entry || entry.busy || entry.expires <= Date.now() || entry.pluginId !== pluginId || entry.accountId !== accountId || entry.kind !== kind) { throw new Error(`${method}: this can't be undone any more`); } entry.busy = true; try { const result = await run(entry.data as T); tokens.delete(token); return result; } finally { entry.busy = false; } } /** The account an undo token is bound to: the client's JMAP account, else the signed-in login. */ export function undoAccount(client: IJMAPClient): string { return client.getAccountId?.() || useAuthStore.getState().activeAccountId || ''; } /** For tests. */ export function clearUndoTokens(): void { tokens.clear(); }