Introduced 1 production defect in 180 days, median 8 days to fix.
import type { ThreadDestination } from '@/modules/threads/thread/utils/thread-actions';
import { moveThreadsTo } from '@/modules/threads/thread/utils/thread-actions';
import { usePredictivePrefetch } from '@/hooks/use-predictive-prefetch';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTRPC } from '@/providers/query-provider';
import { htmlToPlainText } from '@/lib/html-utils';
import { useCedarStore } from '@/modules/store';
import type { ParsedMessage, UndoAction } from '@/modules/threads/threadList/store/threadSlice';
import { hasPendingSend, performUndo } from '@/modules/drafting/hooks/use-undo-send';
import { isClientOnlyThreadId } from '@/modules/drafting/store/draftSlice';
import {
guardArchivedThreads,
releaseArchivedThreads,
} from '@/modules/threads/rendering/archived-thread-guard';
import { useCallback } from 'react';
import posthog from 'posthog-js';
import { toast } from 'sonner';
function canRedoAction(action: UndoAction): boolean {
switch (action.type) {
case 'markAsRead':
case 'markAsUnread':
case 'toggleStar':
case 'toggleImportant':
case 'toggleLabel':
case 'move':
case 'delete':
case 'markDone':
return true;
case 'setRemind':
case 'undoSend':
return false;
}
}
/** Extract the current mail folder from the URL (e.g. /mail/inbox → "inbox") */
function getCurrentFolder(): string {
const match = window.location.pathname.match(/\/mail\/([^/?#]+)/);
return match?.[1] ?? 'inbox';
}
export function useOptimisticActions() {
const trpc = useTRPC();
const queryClient = useQueryClient();
const { afterArchiveOrDelete } = usePredictivePrefetch();
// Get Zustand state mutation functions
const zustandToggleStar = useCedarStore((state) => state.toggleStar);
const zustandMarkAsRead = useCedarStore((state) => state.markAsRead);
const zustandToggleImportant = useCedarStore((state) => state.toggleImportant);
const zustandToggleLabel = useCedarStore((state) => state.toggleLabel);
const zustandRemoveFromList = useCedarStore((state) => state.removeFromList);
const removeDraftFromThread = useCedarStore((state) => state.removeDraftFromThread);
const addDraftTombstones = useCedarStore((state) => state.addDraftTombstones);
const clearDraftTombstones = useCedarStore((state) => state.clearDraftTombstones);
const setThreadData = useCedarStore((state) => state.setThreadData);
const pushUndo = useCedarStore((state) => state.pushUndo);
const pushCommand = useCedarStore((state) => state.pushCommand);
const selectThreadId = useCedarStore((state) => state.selectThreadId);
const navigateToNextThread = useCedarStore((state) => state.navigateToNextThread);
// Get server mutation functions
const { mutateAsync: markAsReadServer } = useMutation(trpc.mail.markAsRead.mutationOptions());
const { mutateAsync: markAsUnreadServer } = useMutation(trpc.mail.markAsUnread.mutationOptions());
const { mutateAsync: toggleStarServer } = useMutation(trpc.mail.toggleStar.mutationOptions());
const { mutateAsync: toggleImportantServer } = useMutation(
trpc.mail.toggleImportant.mutationOptions(),
);
const { mutateAsync: bulkDeleteThread } = useMutation(trpc.mail.bulkDelete.mutationOptions());
const { mutateAsync: markDoneServer } = useMutation(trpc.mail.markDone.mutationOptions());
const { mutateAsync: markActiveServer } = useMutation(trpc.mail.markActive.mutationOptions());
const { mutateAsync: setRemindServer } = useMutation(trpc.mail.setRemind.mutationOptions());
const { mutateAsync: cancelRemindServer } = useMutation(
trpc.mail.cancelRemind.mutationOptions(),
);
const { mutateAsync: modifyLabelsServer } = useMutation(trpc.mail.modifyLabels.mutationOptions());
const { mutateAsync: deleteDraft } = useMutation(trpc.drafts.delete.mutationOptions());
// Cancel any in-flight listThreads fetches before mutating, so stale responses
// can't land and overwrite optimistic state.
const cancelListThreadsQueries = useCallback(
() => queryClient.cancelQueries({ queryKey=[redacted] }),
[queryClient, trpc.mail.listThreads],
);
const optimisticMarkAsRead = useCallback(
async (threadIds: string[], silent = false) => {
if (!threadIds.length) return;
zustandMarkAsRead(threadIds, true);
if (!silent) {
pushUndo({ type: 'markAsRead', threadIds });
}
try {
await cancelListThreadsQueries();
await markAsReadServer({ ids: threadIds });
threadIds.forEach((threadId) => {
queryClient.invalidateQueries({
queryKey=[redacted] id: threadId }),
});
});
posthog.capture('email_marked_read');
} catch (error) {
zustandMarkAsRead(threadIds, false);
console.error('Error marking as read:', error);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[zustandMarkAsRead, cancelListThreadsQueries, queryClient],
);
const optimisticMarkAsUnread = useCallback(
async (threadIds: string[]) => {
if (!threadIds.length) return;
zustandMarkAsRead(threadIds, false);
pushUndo({ type: 'markAsUnread', threadIds });
try {
await cancelListThreadsQueries();
await markAsUnreadServer({ ids: threadIds });
threadIds.forEach((threadId) => {
queryClient.invalidateQueries({
queryKey=[redacted] id: threadId }),
});
});
posthog.capture('email_marked_unread');
} catch (error) {
zustandMarkAsRead(threadIds, true);
console.error('Error marking as unread:', error);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[zustandMarkAsRead, cancelListThreadsQueries, queryClient],
);
const optimisticToggleStar = useCallback(
async (threadIds: string[], starred: boolean) => {
if (!threadIds.length) return;
zustandToggleStar(threadIds, starred);
pushUndo({ type: 'toggleStar', threadIds, starred });
try {
await cancelListThreadsQueries();
await toggleStarServer({ ids: threadIds });
threadIds.forEach((threadId) => {
queryClient.invalidateQueries({
queryKey=[redacted] id: threadId }),
});
});
posthog.capture(starred ? 'email_starred' : 'email_unstarred');
} catch (error) {
zustandToggleStar(threadIds, !starred);
console.error('Error toggling star:', error);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[zustandToggleStar, cancelListThreadsQueries, queryClient],
);
// Helper to optimistically remove threads from TanStack Query cache
// IMPORTANT: Must use infiniteQueryKey() for infinite queries, not queryKey()
const removeThreadsFromQueryCache = useCallback(
(threadIds: string[]) => {
const threadIdSet = new Set(threadIds);
queryClient.setQueriesData(
{ queryKey=[redacted] },
(
oldData: { pages: { threads: { id: string }[] }[]; pageParams: unknown[] } | undefined,
) => {
if (!oldData) return oldData;
return {
...oldData,
pages: oldData.pages.map((page) => ({
...page,
threads: page.threads.filter((thread) => !threadIdSet.has(thread.id)),
})),
};
},
);
},
[queryClient, trpc.mail.listThreads],
);
// Helper to optimistically restore threads into TanStack Query cache.
// Reconstructs the ThreadSummary from Zustand threadData and inserts it into
// the correct sorted position (newest-first by latestReceivedOn) in each page.
const restoreThreadsToQueryCache = useCallback(
async (threadIds: string[]) => {
const allThreadData = useCedarStore.getState().threadData;
// Build ThreadSummary objects from Zustand data
const summaries = threadIds.map((threadId) => {
const data = allThreadData[threadId];
if (!data) return { id: threadId, historyId: null };
const latest = data.latest ?? data.messages[data.messages.length - 1];
return {
id: threadId,
historyId: null,
$raw: {
subject: latest?.subject,
sender: latest?.sender,
snippet: latest?.snippet,
labels: data.labels,
latestReceivedOn: latest?.receivedOn,
messageCount: data.messages.length,
hasDraft: data.messages.some((m) => m.isDraft),
},
};
});
// Cancel any in-flight listThreads fetches so they don't land and overwrite the restore
await queryClient.cancelQueries({ queryKey=[redacted] });
queryClient.setQueriesData(
{ queryKey=[redacted] },
(oldData: { pages: { threads: { id: string; $raw?: { latestReceivedOn?: string } }[] }[]; pageParams: unknown[] } | undefined) => {
if (!oldData) return oldData;
// Insert into the first page at the correct sorted position.
// The list is newest-first; scan until we find an entry older than this thread.
const newPages = oldData.pages.map((page, pageIndex) => {
if (pageIndex !== 0) return page;
const updatedThreads = [...page.threads];
for (const summary of summaries) {
if (updatedThreads.some((t) => t.id === summary.id)) continue;
const threadTime = summary.$raw?.latestReceivedOn ?? '';
let insertAt = updatedThreads.length;
for (let i = 0; i < updatedThreads.length; i++) {
const entryTime = updatedThreads[i].$raw?.latestReceivedOn ?? '';
if (threadTime > entryTime) {
insertAt = i;
break;
}
}
updatedThreads.splice(insertAt, 0, summary);
}
return { ...page, threads: updatedThreads };
});
return { ...oldData, pages: newPages };
},
);
},
[queryClient, trpc.mail.listThreads],
);
const zustandRestoreToList = useCedarStore((state) => state.restoreToList);
// Optimistically adjust `getSplitCounts` badges for a markDone-style action:
// each affected inbox's count drops by however many of `threadIds` were in
// its cached list, and `done.count` rises by the unique thread count. The
// server-side `getSplitCounts` refetch (triggered by invalidate after the
// mutation succeeds) reconciles with Gmail's `resultSizeEstimate` after.
// Returns a rollback that restores every mutated cache entry on failure.
const decrementInboxCountsForMarkDone = useCallback(
(threadIds: string[]): { rollback: () => void } => {
const threadIdSet = new Set(threadIds);
// (queryHash | compiledQuery) → how many of threadIds are in that
// listThreads cache entry. A single thread can live in multiple inbox
// tabs simultaneously (e.g. default + important), so each tab's count
// decrements independently.
const matchedByKey = new Map<string, number>();
const listEntries = queryClient.getQueriesData<{
pages: { threads: { id: string }[] }[];
}>({ queryKey=[redacted] });
for (const [queryKey, data] of listEntries) {
if (!data) continue;
const input = (queryKey as unknown as [unknown, { input?: { queryHash?: string; compiledQuery?: string } }])[1]?.input;
const key=[redacted] ?? input?.compiledQuery;
if (!key) continue;
const matched = new Set<string>();
for (const page of data.pages) {
for (const t of page.threads) if (threadIdSet.has(t.id)) matched.add(t.id);
}
if (matched.size === 0) continue;
// Multiple cache entries can exist for the same queryHash with
// different page sizes / cursors — take the max (any one entry is a
// snapshot of the same underlying inbox).
matchedByKey.set(key, Math.max(matchedByKey.get(key) ?? 0, matched.size));
}
type SplitCountsData = {
byId: Record<string, { count: number; isExact: boolean }>;
done: { count: number; isExact: boolean };
};
const snapshots: Array<{ queryKey=[redacted] unknown[]; prev: SplitCountsData }> = [];
// setQueriesData's updater doesn't expose the queryKey in v5, so
// enumerate matched entries first and update each by key.
const countEntries = queryClient.getQueriesData<SplitCountsData>({
queryKey=[redacted],
});
for (const [queryKey, oldData] of countEntries) {
if (!oldData) continue;
const input = (queryKey as unknown as [unknown, { input?: { splits?: { inboxId: string; queryHash?: string; compiledQuery?: string }[] } }])[1]?.input;
const splits = input?.splits ?? [];
const byId = { ...oldData.byId };
let mutated = false;
for (const split of splits) {
const key=[redacted] ?? split.compiledQuery;
if (!key) continue;
const matched = matchedByKey.get(key);
if (!matched) continue;
const current = byId[split.inboxId];
if (!current) continue;
byId[split.inboxId] = {
...current,
count: Math.max(0, current.count - matched),
};
mutated = true;
}
// Done bumps by the number of unique threads marked done, regardless
// of which (or how many) inbox tabs they sat in.
const done = {
...oldData.done,
count: oldData.done.count + threadIds.length,
};
if (!mutated && threadIds.length === 0) continue;
snapshots.push({ queryKey, prev: oldData });
queryClient.setQueryData<SplitCountsData>(queryKey, { ...oldData, byId, done });
}
return {
rollback: () => {
for (const { queryKey, prev } of snapshots) {
queryClient.setQueryData(queryKey, prev);
}
},
};
},
[queryClient, trpc.mail.listThreads, trpc.mail.getSplitCounts],
);
const optimisticMoveThreadsTo = useCallback(
async (threadIds: string[], currentFolder: string, destination: ThreadDestination) => {
if (!threadIds.length || !destination) return;
const currentThreadId = useCedarStore.getState().selectedThreadId;
if (currentThreadId && threadIds.includes(currentThreadId)) {
navigateToNextThread();
// If still on the deleted thread (e.g. it was the last in the list), clear selection
if (useCedarStore.getState().selectedThreadId === currentThreadId) {
selectThreadId(null);
}
}
zustandRemoveFromList(threadIds);
removeThreadsFromQueryCache(threadIds);
pushUndo({ type: 'move', threadIds, fromFolder: currentFolder, toFolder: destination });
try {
await cancelListThreadsQueries();
await moveThreadsTo({
threadIds,
currentFolder,
destination,
});
removeThreadsFromQueryCache(threadIds);
posthog.capture('email_moved');
// Predictively prefetch inbox during idle time
afterArchiveOrDelete();
} catch (error) {
await queryClient.invalidateQueries({
queryKey=[redacted],
});
console.error('Error moving thread:', error);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[
zustandRemoveFromList,
removeThreadsFromQueryCache,
cancelListThreadsQueries,
navigateToNextThread,
selectThreadId,
queryClient,
afterArchiveOrDelete,
],
);
const optimisticDeleteThreads = useCallback(
async (threadIds: string[]) => {
if (!threadIds.length) return;
const fromFolder = getCurrentFolder();
const currentThreadId = useCedarStore.getState().selectedThreadId;
if (currentThreadId && threadIds.includes(currentThreadId)) {
navigateToNextThread();
// If still on the deleted thread (e.g. it was the last in the list), clear selection
if (useCedarStore.getState().selectedThreadId === currentThreadId) {
selectThreadId(null);
}
}
zustandRemoveFromList(threadIds);
removeThreadsFromQueryCache(threadIds);
pushUndo({ type: 'delete', threadIds, fromFolder });
try {
await cancelListThreadsQueries();
await bulkDeleteThread({ ids: threadIds });
removeThreadsFromQueryCache(threadIds);
posthog.capture('email_moved');
// Predictively prefetch inbox during idle time
afterArchiveOrDelete();
} catch (error) {
await queryClient.invalidateQueries({
queryKey=[redacted],
});
console.error('Error deleting thread:', error);
}
},
[
zustandRemoveFromList,
removeThreadsFromQueryCache,
cancelListThreadsQueries,
navigateToNextThread,
selectThreadId,
queryClient,
trpc.mail.listThreads,
bulkDeleteThread,
afterArchiveOrDelete,
],
);
const optimisticMarkDone = useCallback(
async (threadIds: string[]) => {
if (!threadIds.length) return;
const currentThreadId = useCedarStore.getState().selectedThreadId;
if (currentThreadId && threadIds.includes(currentThreadId)) {
navigateToNextThread();
if (useCedarStore.getState().selectedThreadId === currentThreadId) {
selectThreadId(null);
}
}
// Must run BEFORE removeThreadsFromQueryCache: this helper reads the
// listThreads cache to figure out which inbox(es) each thread sits in.
const countsRollback = decrementInboxCountsForMarkDone(threadIds);
zustandRemoveFromList(threadIds);
removeThreadsFromQueryCache(threadIds);
// Hold the removal as an invariant: any later write that puts these ids
// back — a fetch response landing after this mutation settles, an
// IndexedDB restore, a prefetch — is repaired. See archived-thread-guard.
guardArchivedThreads(threadIds);
pushUndo({ type: 'markDone', threadIds });
try {
await cancelListThreadsQueries();
await Promise.all(threadIds.map((threadId) => markDoneServer({ threadId })));
// Re-assert the removal now that the server has committed the archive.
// `cancelListThreadsQueries` only kills fetches in flight at that
// instant; anything that STARTS during the ~230-380ms round-trip (the
// 5-minute interval, window focus, a side-inbox prefetch, fetchNextPage)
// gets a page that legitimately still contains these threads, because
// the archive hadn't reached `crm_thread_labels` yet. When that response
// lands it replaces the cache and the threads are back in the inbox
// until the next page-1 refresh. `optimisticMoveThreadsTo` and
// `optimisticDeleteThreads` already do this second pass; mark-done was
// the one that didn't. See apps/server/src/docs/bug-mark-done-threads-reappear.md.
removeThreadsFromQueryCache(threadIds);
await queryClient.invalidateQueries({
queryKey=[redacted],
});
posthog.capture('email_marked_done');
} catch (error) {
countsRollback.rollback();
// Release before restoring, or the guard fights the rollback.
releaseArchivedThreads(threadIds);
await restoreThreadsToQueryCache(threadIds);
zustandRestoreToList(threadIds);
toast.error('Failed to mark as done');
console.error('Error marking as done:', error);
}
},
[
zustandRemoveFromList,
zustandRestoreToList,
removeThreadsFromQueryCache,
restoreThreadsToQueryCache,
cancelListThreadsQueries,
decrementInboxCountsForMarkDone,
navigateToNextThread,
selectThreadId,
pushUndo,
markDoneServer,
queryClient,
trpc.mail.listThreads,
trpc.mail.getSplitCounts,
],
);
const optimisticToggleImportant = useCallback(
async (threadIds: string[], isImportant: boolean) => {
if (!threadIds.length) return;
zustandToggleImportant(threadIds, isImportant);
pushUndo({ type: 'toggleImportant', threadIds, important: isImportant });
try {
await cancelListThreadsQueries();
await toggleImportantServer({ ids: threadIds });
threadIds.forEach((threadId) => {
queryClient.invalidateQueries({
queryKey=[redacted] id: threadId }),
});
});
posthog.capture(isImportant ? 'email_marked_important' : 'email_unmarked_important');
} catch (error) {
zustandToggleImportant(threadIds, !isImportant);
console.error('Error toggling important:', error);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[zustandToggleImportant, cancelListThreadsQueries, queryClient],
);
const optimisticToggleLabel = useCallback(
async (threadIds: string[], labelId: string, labelName: string, add: boolean) => {
if (!threadIds.length || !labelId) return;
zustandToggleLabel(threadIds, labelId, labelName, add);
pushUndo({ type: 'toggleLabel', threadIds, labelId, labelName, added: add });
try {
await cancelListThreadsQueries();
await modifyLabelsServer({
threadId: threadIds,
addLabels: add ? [labelId] : [],
removeLabels: add ? [] : [labelId],
});
threadIds.forEach((threadId) => {
queryClient.invalidateQueries({
queryKey=[redacted] id: threadId }),
});
});
posthog.capture(add ? 'email_label_added' : 'email_label_removed');
} catch (error) {
zustandToggleLabel(threadIds, labelId, labelName, !add);
console.error('Error toggling label:', error);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[zustandToggleLabel, cancelListThreadsQueries, queryClient],
);
const optimisticSetRemind = useCallback(
async (threadIds: string[], remindAt: Date) => {
if (!threadIds.length) return;
const currentThreadId = useCedarStore.getState().selectedThreadId;
if (currentThreadId && threadIds.includes(currentThreadId)) {
navigateToNextThread();
if (useCedarStore.getState().selectedThreadId === currentThreadId) {
selectThreadId(null);
}
}
zustandRemoveFromList(threadIds);
removeThreadsFromQueryCache(threadIds);
pushUndo({ type: 'setRemind', threadIds });
try {
await cancelListThreadsQueries();
const results = await Promise.all(
threadIds.map((threadId) =>
setRemindServer({ threadId, remindAt: remindAt.toISOString() }),
),
);
if (results.every((r) => r.success)) {
await queryClient.invalidateQueries({
queryKey=[redacted],
});
posthog.capture('email_remind_set');
return;
}
throw new Error('One or more reminders failed to set');
} catch (error) {
await restoreThreadsToQueryCache(threadIds);
zustandRestoreToList(threadIds);
toast.error('Failed to set reminder');
console.error('Error setting reminder:', error);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[
navigateToNextThread,
selectThreadId,
zustandRemoveFromList,
removeThreadsFromQueryCache,
cancelListThreadsQueries,
setRemindServer,
queryClient,
trpc.mail.getSplitCounts,
restoreThreadsToQueryCache,
zustandRestoreToList,
],
);
const optimisticCancelRemind = useCallback(
async (threadIds: string[]) => {
if (!threadIds.length) return;
try {
await Promise.all(threadIds.map((threadId) => cancelRemindServer({ threadId })));
posthog.capture('email_remind_cancelled');
} catch (error) {
console.error('Error cancelling reminder:', error);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[cancelRemindServer],
);
const optimisticDeleteDraft = useCallback(
async (rowId: string) => {
if (!rowId) return;
// `rowId` arrives from the thread-list draft row. It may be:
// (a) a thread id — the common case for Gmail drafts shown as draft
// threads in the list (Draft component passes `message.id` which is
// the threadId of the draft row);
// (b) the draft message id / draftId / draftSessionId — for synthetic
// client-side drafts whose row id equals the draft's own identifier.
// We need both lookups: (a) first because it's the dominant case and the
// rowId never matches a draft message's id/draftId/draftSessionId.
const allThreadData = useCedarStore.getState().threadData;
let parentThreadId: string | undefined;
let draftMessage: ParsedMessage | undefined;
const threadFromRowId = allThreadData[rowId];
if (threadFromRowId) {
const draft = threadFromRowId.messages.findLast((msg) => msg.isDraft);
if (draft) {
parentThreadId = rowId;
draftMessage = draft;
}
}
if (!draftMessage) {
for (const [threadId, threadData] of Object.entries(allThreadData)) {
const match = threadData.messages.find(
(msg) =>
msg.isDraft &&
(msg.id === rowId || msg.draftId === rowId || msg.draftSessionId === rowId),
);
if (match) {
parentThreadId = threadId;
draftMessage = match;
break;
}
}
}
// Neither lookup finds anything when the row has only list-preview data:
// `batchPopulateThreadMetadata` fills `messages` with placeholders carrying
// `id: ''` and `isDraft: false`, so there is no draft to match on and no id
// to match against. Left unhandled, `parentThreadId` stays undefined, the
// server guard below (`draftIdToSend || emailHeaderMessageId ||
// parentThreadId`) is all-falsy, and `drafts.delete` is never called at all —
// the row vanishes locally and returns on the next listThreads refetch.
// Fall back to the row's own thread id: `deleteDraft` on the server resolves
// the draft from the provider, then from the stored snapshot, when handed
// nothing but a threadId. A client-only compose session has no provider
// thread behind it, so it stays local-only as before.
if (!parentThreadId && !isClientOnlyThreadId(rowId)) {
parentThreadId = rowId;
}
const identityCandidates = [
draftMessage?.draftId,
draftMessage?.draftSessionId,
draftMessage?.id,
rowId,
].filter((v): v is string => Boolean(v));
// Snapshots so we can fully roll back if the server call fails.
const preDeleteThread = parentThreadId
? useCedarStore.getState().getThreadData(parentThreadId)
: undefined;
const preDeleteCacheSnapshot = parentThreadId
? queryClient.getQueryData(trpc.mail.get.queryKey({ id: parentThreadId }))
: undefined;
zustandRemoveFromList([rowId]);
// A thread whose only message IS the draft — every agent follow-up draft is
// one — stops being a row the moment the draft goes. `zustandRemoveFromList`
// drops it from the Zustand list, but the inbox renders `mail.listThreads`,
// so leaving that cache alone keeps the row: it opens onto a thread whose one
// message is now tombstoned, i.e. a blank body under a subject line. Patch the
// list the same way every other row-removing action here does.
const becomesEmpty = Boolean(
parentThreadId && preDeleteThread && !preDeleteThread.messages.some((msg) => !msg.isDraft),
);
if (becomesEmpty && parentThreadId) {
removeThreadsFromQueryCache([parentThreadId]);
}
if (parentThreadId && identityCandidates.length > 0) {
removeDraftFromThread({ threadId: parentThreadId, identities: identityCandidates });
// Tombstone the identities so any in-flight or near-future mail.get refetch
// that still echoes the draft (Gmail not yet synced, server-side pendingDraft
// not yet cleared) is suppressed by applyDraftTombstones on the next merge.
addDraftTombstones({ threadId: parentThreadId, identities: identityCandidates });
const idSet = new Set(identityCandidates);
queryClient.setQueryData(trpc.mail.get.queryKey({ id: parentThreadId }), (old) => {
if (!old) return old;
const oldMessages = (old as { messages: ParsedMessage[] }).messages;
const nextMessages = oldMessages.filter((msg) => {
if (!msg.isDraft) return true;
if (msg.draftId && idSet.has(msg.draftId)) return false;
if (msg.draftSessionId && idSet.has(msg.draftSessionId)) return false;
if (idSet.has(msg.id)) return false;
return true;
});
const latestNonDraft = nextMessages.findLast((msg) => !msg.isDraft);
return {
...old,
messages: nextMessages,
latest: latestNonDraft || (old as { latest: ParsedMessage }).latest,
totalReplies: Math.max(
0,
((old as { totalReplies: number }).totalReplies || 0) -
(oldMessages.length - nextMessages.length),
),
};
});
}
try {
// Only hit the server when we have at least one identifier that could map
// to a real provider draft. Pure synthetic drafts (no draftId, no
// emailHeaderMessageId) live only in client/canvas state and don't need a
// Gmail delete; the tombstone above is enough to keep them away.
const draftIdToSend = draftMessage?.draftId ?? null;
const emailHeaderMessageId = draftMessage?.emailHeaderMessageId ?? null;
if (draftIdToSend || emailHeaderMessageId || parentThreadId) {
await deleteDraft({
draftId: draftIdToSend,
emailHeaderMessageId,
threadId: parentThreadId,
});
}
const plainTextBody = htmlToPlainText(draftMessage?.processedHtml);
posthog.capture('draft_deleted', {
draft_id: draftIdToSend ?? rowId,
has_subject: !!draftMessage?.subject,
subject_length: draftMessage?.subject?.length || 0,
has_body: !!plainTextBody,
body_length: plainTextBody.length,
recipient_count:
(draftMessage?.to?.length || 0) +
(draftMessage?.cc?.length || 0) +
(draftMessage?.bcc?.length || 0),
to_count: draftMessage?.to?.length || 0,
has_cc: (draftMessage?.cc?.length || 0) > 0,
has_bcc: (draftMessage?.bcc?.length || 0) > 0,
});
} catch (error) {
console.error('Error deleting draft:', error);
// Roll back: drop tombstones first so the restored draft isn't filtered
// out by applyDraftTombstones on the next setThreadData, then restore
// threadData and the React Query cache.
if (parentThreadId && identityCandidates.length > 0) {
clearDraftTombstones({ threadId: parentThreadId, identities: identityCandidates });
}
if (parentThreadId && preDeleteThread) {
setThreadData(parentThreadId, preDeleteThread);
}
if (parentThreadId && preDeleteCacheSnapshot !== undefined) {
queryClient.setQueryData(
trpc.mail.get.queryKey({ id: parentThreadId }),
preDeleteCacheSnapshot,
);
}
// Put the row back. Runs after `setThreadData` above, because the restore
// rebuilds the list entry out of Zustand's thread data.
if (becomesEmpty && parentThreadId) {
await restoreThreadsToQueryCache([parentThreadId]);
}
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[
zustandRemoveFromList,
removeDraftFromThread,
addDraftTombstones,
clearDraftTombstones,
setThreadData,
removeThreadsFromQueryCache,
restoreThreadsToQueryCache,
queryClient,
],
);
/** Replay the inverse of a recorded undo action — no invalidateQueries; optimistic state stands */
const executeUndo = useCallback(
async (action: UndoAction) => {
switch (action.type) {
case 'markAsRead':
zustandMarkAsRead(action.threadIds, false);
await markAsUnreadServer({ ids: action.threadIds });
break;
case 'markAsUnread':
zustandMarkAsRead(action.threadIds, true);
await markAsReadServer({ ids: action.threadIds });
break;
case 'toggleStar':
zustandToggleStar(action.threadIds, !action.starred);
await toggleStarServer({ ids: action.threadIds });
break;
case 'toggleImportant':
zustandToggleImportant(action.threadIds, !action.important);
await toggleImportantServer({ ids: action.threadIds });
break;
case 'toggleLabel':
zustandToggleLabel(action.threadIds, action.labelId, action.labelName, !action.added);
await modifyLabelsServer({
threadId: action.threadIds,
addLabels: !action.added ? [action.labelId] : [],
removeLabels: !action.added ? [] : [action.labelId],
});
break;
case 'move':
await restoreThreadsToQueryCache(action.threadIds);
zustandRestoreToList(action.threadIds);
await moveThreadsTo({
threadIds: action.threadIds,
currentFolder: action.toFolder,
destination: action.fromFolder as ThreadDestination,
});
break;
case 'delete':
await restoreThreadsToQueryCache(action.threadIds);
zustandRestoreToList(action.threadIds);
await moveThreadsTo({
threadIds: action.threadIds,
currentFolder: 'bin',
destination: 'inbox',
});
break;
case 'markDone':
// Undo puts them back in the inbox on purpose — stop watching first,
// or the detector reports the restore as a resurrection.
releaseArchivedThreads(action.threadIds);
await restoreThreadsToQueryCache(action.threadIds);
zustandRestoreToList(action.threadIds);
await Promise.all(action.threadIds.map((threadId) => markActiveServer({ threadId })));
await queryClient.invalidateQueries({
queryKey=[redacted],
});
break;
case 'setRemind':
await restoreThreadsToQueryCache(action.threadIds);
zustandRestoreToList(action.threadIds);
await Promise.all(
action.threadIds.map((threadId) => cancelRemindServer({ threadId })),
);
await queryClient.invalidateQueries({
queryKey=[redacted],
});
break;
case 'undoSend':
if (hasPendingSend()) {
await performUndo();
}
break;
}
},
[
zustandMarkAsRead,
zustandToggleStar,
zustandToggleImportant,
zustandToggleLabel,
zustandRestoreToList,
restoreThreadsToQueryCache,
markAsReadServer,
markAsUnreadServer,
toggleStarServer,
toggleImportantServer,
modifyLabelsServer,
cancelRemindServer,
markActiveServer,
queryClient,
trpc.mail.listThreads,
trpc.mail.getSplitCounts,
],
);
/** Replay the original recorded action for redo */
const executeRedo = useCallback(
async (action: UndoAction) => {
switch (action.type) {
case 'markAsRead':
zustandMarkAsRead(action.threadIds, true);
await markAsReadServer({ ids: action.threadIds });
break;
case 'markAsUnread':
zustandMarkAsRead(action.threadIds, false);
await markAsUnreadServer({ ids: action.threadIds });
break;
case 'toggleStar':
zustandToggleStar(action.threadIds, action.starred);
await toggleStarServer({ ids: action.threadIds });
break;
case 'toggleImportant':
zustandToggleImportant(action.threadIds, action.important);
await toggleImportantServer({ ids: action.threadIds });
break;
case 'toggleLabel':
zustandToggleLabel(action.threadIds, action.labelId, action.labelName, action.added);
await modifyLabelsServer({
threadId: action.threadIds,
addLabels: action.added ? [action.labelId] : [],
removeLabels: action.added ? [] : [action.labelId],
});
break;
case 'move':
zustandRemoveFromList(action.threadIds);
removeThreadsFromQueryCache(action.threadIds);
await moveThreadsTo({
threadIds: action.threadIds,
currentFolder: action.fromFolder,
destination: action.toFolder as ThreadDestination,
});
break;
case 'delete':
zustandRemoveFromList(action.threadIds);
removeThreadsFromQueryCache(action.threadIds);
await bulkDeleteThread({ ids: action.threadIds });
break;
case 'markDone':
zustandRemoveFromList(action.threadIds);
removeThreadsFromQueryCache(action.threadIds);
guardArchivedThreads(action.threadIds);
await Promise.all(action.threadIds.map((threadId) => markDoneServer({ threadId })));
// Same re-assert as optimisticMarkDone — a response computed during
// the round-trip still lists these threads.
removeThreadsFromQueryCache(action.threadIds);
await queryClient.invalidateQueries({
queryKey=[redacted],
});
break;
case 'setRemind':
case 'undoSend':
throw new Error(`Redo is not supported for action type "${action.type}"`);
}
},
[
zustandMarkAsRead,
zustandToggleStar,
zustandToggleImportant,
zustandToggleLabel,
zustandRemoveFromList,
removeThreadsFromQueryCache,
markAsReadServer,
markAsUnreadServer,
toggleStarServer,
toggleImportantServer,
modifyLabelsServer,
bulkDeleteThread,
markDoneServer,
queryClient,
trpc.mail.listThreads,
trpc.mail.getSplitCounts,
],
);
const undoLastAction = useCallback(async () => {
const store = useCedarStore.getState();
const entry = store.popUndo();
if (!entry) return;
try {
await executeUndo(entry.action);
if (canRedoAction(entry.action)) {
useCedarStore.getState().pushRedo(entry.action);
}
if (entry.restoreThreadId) {
useCedarStore.getState().selectThreadId(entry.restoreThreadId);
useCedarStore.getState().setIsThreadOpen(true);
}
posthog.capture('undo_action', { type: entry.action.type });
} catch (error) {
console.error('Error undoing action:', error);
pushCommand({
kind: 'toast',
id: `undo-error-${Date.now()}`,
variant: 'error',
text: 'Failed to undo — refreshing',
autoDismissMs: 4000,
});
await queryClient.invalidateQueries({
queryKey=[redacted],
});
}
}, [executeUndo, pushCommand, queryClient, trpc.mail.listThreads]);
const redoLastAction = useCallback(async () => {
const store = useCedarStore.getState();
const entry = store.popRedo();
if (!entry) return;
try {
await executeRedo(entry.action);
useCedarStore.getState().pushUndo(entry.action, { clearRedo: false });
posthog.capture('redo_action', { type: entry.action.type });
} catch (error) {
console.error('Error redoing action:', error);
pushCommand({
kind: 'toast',
id: `redo-error-${Date.now()}`,
variant: 'error',
text: 'Failed to redo — refreshing',
autoDismissMs: 4000,
});
await queryClient.invalidateQueries({
queryKey=[redacted],
});
}
}, [executeRedo, pushCommand, queryClient, trpc.mail.listThreads]);
return {
optimisticMarkAsRead,
optimisticMarkAsUnread,
optimisticToggleStar,
optimisticMoveThreadsTo,
optimisticDeleteThreads,
optimisticMarkDone,
optimisticToggleImportant,
optimisticToggleLabel,
optimisticDeleteDraft,
optimisticSetRemind,
optimisticCancelRemind,
undoLastAction,
redoLastAction,
};
}