From bab1f1a43310e4b34134007e544de6ea44692da5 Mon Sep 17 00:00:00 2001 From: Ankit Gupta Date: Mon, 6 Apr 2026 18:41:53 -0400 Subject: [PATCH 01/18] fix: delete local drafts when email is reclassified as skip When an email gets reclassified as "skip" (e.g., someone else replied or user replied from another device), any existing local draft, its agent trace, and the corresponding Gmail draft are now cleaned up automatically. Changes: - saveAnalysis() detects skip reclassification and deletes local draft + agent trace, returning cleanup info for async operations - Prefetch service cancels in-flight agents and deletes Gmail drafts when re-analysis yields skip - Analysis override IPC handler performs the same cleanup - Renderer clears draft from UI state on skip reclassification Co-Authored-By: Claude Opus 4.6 (1M context) --- src/main/db/index.ts | 52 ++++++++++++++++++++++++- src/main/ipc/analysis.ipc.ts | 26 ++++++++++++- src/main/services/prefetch-service.ts | 20 +++++++++- src/renderer/components/EmailDetail.tsx | 10 ++++- 4 files changed, 101 insertions(+), 7 deletions(-) diff --git a/src/main/db/index.ts b/src/main/db/index.ts index 85772919..c564515f 100644 --- a/src/main/db/index.ts +++ b/src/main/db/index.ts @@ -1468,18 +1468,66 @@ function rowToDashboardEmail(row: Record): DashboardEmail { } // Analysis operations +export interface DraftCleanupInfo { + gmailDraftId: string | null; + agentTaskId: string | null; + accountId: string | null; +} + +/** + * Save analysis for an email. When the priority is "skip" and the email had + * a draft, the local draft and its agent trace are deleted automatically. + * Returns cleanup info so the caller can cancel in-flight agents and delete + * the Gmail draft (which requires async network calls outside the DB layer). + */ export function saveAnalysis( emailId: string, needsReply: boolean, reason: string, priority?: string, -): void { +): DraftCleanupInfo | null { const db = getDatabase(); + const effectivePriority = priority || null; + + // When reclassifying as "skip", clean up any existing draft + let cleanupInfo: DraftCleanupInfo | null = null; + if (effectivePriority === "skip") { + const draftRow = db + .prepare( + `SELECT d.gmail_draft_id, d.agent_task_id, e.account_id + FROM drafts d JOIN emails e ON d.email_id = e.id + WHERE d.email_id = ?`, + ) + .get(emailId) as + | { gmail_draft_id: string | null; agent_task_id: string | null; account_id: string | null } + | undefined; + + if (draftRow) { + cleanupInfo = { + gmailDraftId: draftRow.gmail_draft_id, + agentTaskId: draftRow.agent_task_id, + accountId: draftRow.account_id, + }; + + // Delete agent trace first (references draft's agent_task_id) + if (draftRow.agent_task_id) { + db.prepare(`DELETE FROM agent_conversation_mirror WHERE local_task_id = ?`).run( + draftRow.agent_task_id, + ); + } + + // Delete the local draft + db.prepare("DELETE FROM drafts WHERE email_id = ?").run(emailId); + } + } + const stmt = db.prepare(` INSERT OR REPLACE INTO analyses (email_id, needs_reply, reason, priority, analyzed_at) VALUES (?, ?, ?, ?, ?) `); - stmt.run(emailId, needsReply ? 1 : 0, reason, priority || null, Date.now()); + stmt.run(emailId, needsReply ? 1 : 0, reason, effectivePriority, Date.now()); + + return cleanupInfo; } // Draft operations diff --git a/src/main/ipc/analysis.ipc.ts b/src/main/ipc/analysis.ipc.ts index 45376c92..76c57b24 100644 --- a/src/main/ipc/analysis.ipc.ts +++ b/src/main/ipc/analysis.ipc.ts @@ -9,6 +9,7 @@ import { learnFromPriorityOverrideInferred, } from "../services/analysis-edit-learner"; import { stripQuotedContent } from "../services/strip-quoted-content"; +import { deleteGmailDraftById } from "../services/gmail-draft-sync"; import { createLogger } from "../services/logger"; const log = createLogger("analysis-ipc"); @@ -239,8 +240,8 @@ export function registerAnalysisIpc(): void { const originalNeedsReply = originalAnalysis?.needsReply ?? false; const originalPriority = originalAnalysis?.priority ?? null; - // Update the analysis in DB - saveAnalysis( + // Update the analysis in DB (also deletes local draft + trace if reclassified as skip) + const draftCleanup = saveAnalysis( emailId, newNeedsReply, originalAnalysis?.reason ?? "User override", @@ -251,6 +252,27 @@ export function registerAnalysisIpc(): void { `[Analysis] Priority overridden for ${emailId}: ${originalPriority ?? "skip"} → ${newPriority ?? "skip"}`, ); + // If reclassified as skip and had a draft, clean up Gmail draft and cancel agents + if (draftCleanup) { + log.info(`[Analysis] Cleaning up draft for ${emailId} after skip reclassification`); + if (draftCleanup.gmailDraftId && draftCleanup.accountId) { + deleteGmailDraftById(draftCleanup.accountId, draftCleanup.gmailDraftId).catch( + () => {}, + ); + } + if (draftCleanup.agentTaskId) { + const { agentCoordinator } = await import("../agents/agent-coordinator"); + agentCoordinator.cancel(draftCleanup.agentTaskId); + } + // Cancel any in-flight auto-draft agent + const { prefetchService } = await import("../services/prefetch-service"); + const activeTaskId = prefetchService.getActiveAgentTaskId(emailId); + if (activeTaskId) { + const { agentCoordinator } = await import("../agents/agent-coordinator"); + agentCoordinator.cancel(activeTaskId); + } + } + // Learn from the override in the background (don't block the UI) const accountId = email.accountId ?? "default"; const senderMatch = email.from.match(/<([^>]+)>/) ?? email.from.match(/([^\s<]+@[^\s>]+)/); diff --git a/src/main/services/prefetch-service.ts b/src/main/services/prefetch-service.ts index b59dcd66..e5424e75 100644 --- a/src/main/services/prefetch-service.ts +++ b/src/main/services/prefetch-service.ts @@ -18,6 +18,7 @@ import { agentCoordinator } from "../agents/agent-coordinator"; import type { AgentContext } from "../agents/types"; import { DEFAULT_AGENT_DRAFTER_PROMPT } from "../../shared/types"; import type { Email, DashboardEmail } from "../../shared/types"; +import { deleteGmailDraftById } from "./gmail-draft-sync"; import { createLogger } from "./logger"; const log = createLogger("prefetch"); @@ -748,10 +749,27 @@ When you see emails in a thread where ${eaName} is coordinating scheduling with const userEmail = account?.email; const result = await analyzer.analyze(emailForAnalysis, userEmail, email.accountId); - saveAnalysis(emailId, result.needs_reply, result.reason, result.priority); + const draftCleanup = saveAnalysis(emailId, result.needs_reply, result.reason, result.priority); this.processedAnalysis.add(emailId); this.processedCounts.analysis++; + // If reclassified as "skip" and had a draft, clean up Gmail draft and cancel agents + if (draftCleanup) { + log.info( + `[Prefetch] Email ${emailId} reclassified as skip — deleted local draft` + + (draftCleanup.gmailDraftId ? ` and queuing Gmail draft deletion` : ``), + ); + if (draftCleanup.gmailDraftId && draftCleanup.accountId) { + deleteGmailDraftById(draftCleanup.accountId, draftCleanup.gmailDraftId).catch(() => {}); + } + // Cancel any in-flight agent draft for this email + const activeTaskId = this.activeAgentTaskIds.get(emailId); + if (activeTaskId) { + agentCoordinator.cancel(activeTaskId); + } + this.processedDrafts.delete(emailId); + } + log.info( `[Prefetch] Analyzed ${emailId}: ${result.priority} priority, needs_reply=${result.needs_reply}`, ); diff --git a/src/renderer/components/EmailDetail.tsx b/src/renderer/components/EmailDetail.tsx index c9176bda..7474bf54 100644 --- a/src/renderer/components/EmailDetail.tsx +++ b/src/renderer/components/EmailDetail.tsx @@ -3568,13 +3568,19 @@ export function EmailDetail({ isFullView = false }: EmailDetailProps) { { - updateEmail(latestReceivedEmail.id, { + const updates: Partial = { analysis: { ...latestReceivedEmail.analysis!, needsReply: newNeedsReply, priority: (newPriority as "high" | "medium" | "low" | "skip" | null) ?? undefined, }, - }); + }; + // When reclassified as skip, clear the draft from UI state + // (main process deletes local + Gmail draft automatically) + if (newPriority === "skip" || newPriority === null) { + updates.draft = undefined; + } + updateEmail(latestReceivedEmail.id, updates); }} /> )} From 022c80024293b48d2954669d95a3ae21e4a42880 Mon Sep 17 00:00:00 2001 From: Ankit Gupta Date: Mon, 6 Apr 2026 18:46:52 -0400 Subject: [PATCH 02/18] fix: discard unsent drafts when archiving a thread When a thread is archived (manually, via archive-ready single, or archive-ready bulk), any unsent drafts on that thread are now cleaned up: local draft rows and agent traces are deleted, and Gmail drafts are removed via the API. Changes: - Added deleteThreadDrafts() DB helper that deletes all drafts + traces for a thread and returns cleanup info for Gmail draft deletion - emails:archive-thread handler cleans up drafts optimistically (before network call branching) so it works offline too - archive-ready:archive-thread and archive-ready:archive-all handlers clean up drafts after successful archive - All fake data paths also clean up drafts for consistency Co-Authored-By: Claude Opus 4.6 (1M context) --- src/main/db/index.ts | 40 +++++++++++++++++++++++++++++++ src/main/ipc/archive-ready.ipc.ts | 19 +++++++++++++++ src/main/ipc/sync.ipc.ts | 21 ++++++++-------- 3 files changed, 69 insertions(+), 11 deletions(-) diff --git a/src/main/db/index.ts b/src/main/db/index.ts index c564515f..5782623b 100644 --- a/src/main/db/index.ts +++ b/src/main/db/index.ts @@ -1619,6 +1619,46 @@ export function deleteDraft(emailId: string): void { db.prepare("DELETE FROM drafts WHERE email_id = ?").run(emailId); } +/** + * Delete all drafts for a thread. Removes local draft rows and agent traces. + * Returns cleanup info for each deleted draft so callers can handle Gmail + * draft deletion and agent cancellation (async operations outside the DB layer). + */ +export function deleteThreadDrafts(threadId: string, accountId: string): DraftCleanupInfo[] { + const db = getDatabase(); + const rows = db + .prepare( + `SELECT d.email_id, d.gmail_draft_id, d.agent_task_id + FROM drafts d JOIN emails e ON d.email_id = e.id + WHERE e.thread_id = ? AND e.account_id = ?`, + ) + .all(threadId, accountId) as Array<{ + email_id: string; + gmail_draft_id: string | null; + agent_task_id: string | null; + }>; + + if (rows.length === 0) return []; + + const cleanupInfos: DraftCleanupInfo[] = []; + for (const row of rows) { + cleanupInfos.push({ + gmailDraftId: row.gmail_draft_id, + agentTaskId: row.agent_task_id, + accountId, + }); + + if (row.agent_task_id) { + db.prepare(`DELETE FROM agent_conversation_mirror WHERE local_task_id = ?`).run( + row.agent_task_id, + ); + } + db.prepare("DELETE FROM drafts WHERE email_id = ?").run(row.email_id); + } + + return cleanupInfos; +} + /** Get the RFC 5322 Message-ID header for an email (used for reply threading). */ export function getEmailMessageIdHeader(emailId: string): string | null { const db = getDatabase(); diff --git a/src/main/ipc/archive-ready.ipc.ts b/src/main/ipc/archive-ready.ipc.ts index 4af02355..d01c1180 100644 --- a/src/main/ipc/archive-ready.ipc.ts +++ b/src/main/ipc/archive-ready.ipc.ts @@ -9,7 +9,9 @@ import { getAnalyzedArchiveThreadIds, getAccounts, updateEmailLabelIds, + deleteThreadDrafts, } from "../db"; +import { deleteGmailDraftById } from "../services/gmail-draft-sync"; import { getConfig, getModelIdForFeature } from "./settings.ipc"; import { getEmailSyncService } from "./sync.ipc"; import type { IpcResponse, DashboardEmail } from "../../shared/types"; @@ -264,6 +266,7 @@ export function registerArchiveReadyIpc(): void { (email.labelIds || []).filter((l: string) => l !== "INBOX"), ); } + deleteThreadDrafts(threadId, accountId); dismissArchiveReady(threadId, accountId); const win = getMainWindow(); if (win && removedIds.length > 0) { @@ -302,6 +305,14 @@ export function registerArchiveReadyIpc(): void { if (archivedIds.length > 0) { dismissArchiveReady(threadId, accountId); + // Clean up drafts and agent traces for archived thread + const draftCleanups = deleteThreadDrafts(threadId, accountId); + for (const cleanup of draftCleanups) { + if (cleanup.gmailDraftId) { + deleteGmailDraftById(accountId, cleanup.gmailDraftId).catch(() => {}); + } + } + // Notify renderer: remove entire thread (including SENT emails) so no ghost thread remains const allThreadEmailIds = threadEmails.map((e) => e.id); const win = getMainWindow(); @@ -341,6 +352,7 @@ export function registerArchiveReadyIpc(): void { ); allRemovedIds.push(email.id); } + deleteThreadDrafts(row.threadId, accountId); dismissArchiveReady(row.threadId, accountId); } @@ -390,6 +402,13 @@ export function registerArchiveReadyIpc(): void { for (const email of threadEmails) { allRemovedIds.push(email.id); } + // Clean up drafts and agent traces for archived thread + const draftCleanups = deleteThreadDrafts(row.threadId, accountId); + for (const cleanup of draftCleanups) { + if (cleanup.gmailDraftId) { + deleteGmailDraftById(accountId, cleanup.gmailDraftId).catch(() => {}); + } + } dismissArchiveReady(row.threadId, accountId); archived++; } diff --git a/src/main/ipc/sync.ipc.ts b/src/main/ipc/sync.ipc.ts index 6d0da8f2..767adddc 100644 --- a/src/main/ipc/sync.ipc.ts +++ b/src/main/ipc/sync.ipc.ts @@ -7,6 +7,7 @@ import { networkMonitor } from "../services/network-monitor"; import { outboxService } from "../services/outbox-service"; import { pendingActionsQueue } from "../services/pending-actions"; import { isNetworkError } from "../services/network-errors"; +import { deleteGmailDraftById } from "../services/gmail-draft-sync"; import { getAccounts, saveAccount, @@ -30,6 +31,7 @@ import { saveCorrespondentProfile, updateAccountDisplayName, deleteAgentTrace, + deleteThreadDrafts, saveDraft, type AccountRecord, } from "../db"; @@ -1196,6 +1198,14 @@ export function registerSyncIpc(): void { ); } + // Clean up unsent drafts and agent traces for archived thread + const draftCleanups = deleteThreadDrafts(threadId, accountId); + for (const cleanup of draftCleanups) { + if (cleanup.gmailDraftId && cleanup.accountId) { + deleteGmailDraftById(cleanup.accountId, cleanup.gmailDraftId).catch(() => {}); + } + } + if (useFakeData) { return { success: true, data: undefined }; } @@ -1243,17 +1253,6 @@ export function registerSyncIpc(): void { } } - // Clean up agent traces for archived thread emails - for (const email of threadEmails) { - if (email.draft?.agentTaskId) { - try { - deleteAgentTrace(email.draft.agentTaskId); - } catch { - /* non-critical */ - } - } - } - // Notify renderer: remove entire thread (including SENT) so no ghost threads remain if (archivedIds.length > 0) { const allThreadEmailIds = threadEmails.map((e) => e.id); From 904952d8b43531aa9794332056d16a1d6cdeb4bf Mon Sep 17 00:00:00 2001 From: Ankit Gupta Date: Mon, 6 Apr 2026 19:07:49 -0400 Subject: [PATCH 03/18] fix: prevent send-then-archive race condition with draft cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a user sends a reply and immediately archives the thread, there was a race where deleteThreadDrafts (from the archive handler) could delete the draft before draft-edit learning had a chance to read it, or could try to delete a Gmail draft that was already irrelevant. Fix: the compose:send handler now cleans up thread drafts itself after a successful send: 1. Snapshots the draft body synchronously BEFORE sending (for learning) 2. Sends the message via Gmail API 3. Deletes local drafts + agent traces + Gmail drafts for the thread 4. Passes the snapshot to learnFromDraftEdit so learning works even though the draft row is gone This means if the user archives immediately after send, deleteThreadDrafts finds no drafts to clean up — no race condition. Also added optional draftSnapshot param to learnFromDraftEdit so callers can provide a pre-read draft body when the DB row may be gone. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/main/ipc/compose.ipc.ts | 31 +++++++++++++++++++++++-- src/main/services/draft-edit-learner.ts | 6 +++-- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/src/main/ipc/compose.ipc.ts b/src/main/ipc/compose.ipc.ts index 4a27a864..606c0b60 100644 --- a/src/main/ipc/compose.ipc.ts +++ b/src/main/ipc/compose.ipc.ts @@ -16,11 +16,14 @@ import { getSendAsAliases, getSendAsAliasFetchedAt, upsertSendAsAliases, + deleteThreadDrafts, + getThreadDraftBody, } from "../db"; import { networkMonitor } from "../services/network-monitor"; import { outboxService } from "../services/outbox-service"; import { prefetchService } from "../services/prefetch-service"; import { isNetworkError } from "../services/network-errors"; +import { deleteGmailDraftById } from "../services/gmail-draft-sync"; import { learnFromDraftEdit } from "../services/draft-edit-learner"; import type { IpcResponse, @@ -302,11 +305,14 @@ export function registerComposeIpc(): void { await new Promise((resolve) => setTimeout(resolve, 500)); // Still trigger draft-edit learning in demo mode so we can test it if (options.threadId && !options.isForward) { + const draftSnapshot = getThreadDraftBody(options.threadId, options.accountId); + deleteThreadDrafts(options.threadId, options.accountId); learnFromDraftEdit({ threadId: options.threadId, accountId: options.accountId, sentBodyHtml: options.bodyHtml || "", sentBodyText: options.bodyText, + draftSnapshot: draftSnapshot ?? undefined, }) .then((result) => { if (result && (result.promoted.length > 0 || result.draftMemoriesCreated > 0)) { @@ -356,20 +362,41 @@ export function registerComposeIpc(): void { return { success: true, data: result }; } + // Snapshot draft body BEFORE send so it's available for learning even if + // the draft is deleted (e.g. user archives the thread immediately after send). + const draftSnapshot = + options.threadId && !options.isForward + ? getThreadDraftBody(options.threadId, options.accountId) + : null; + const result = await client.sendMessage(options); - // After sending a reply, mark the thread as read and re-queue analysis - // Skip for forwards — forwarding doesn't mean the user addressed the original conversation + // After sending a reply, clean up thread drafts and re-queue analysis. + // Skip for forwards — forwarding doesn't mean the user addressed the original conversation. if (options.threadId && !options.isForward) { triggerThreadReanalysis(options.threadId, options.accountId); // Fire-and-forget: mark thread read so Gmail shows it as read markThreadAsReadAfterSend(client, options.threadId, options.accountId); + + // Clean up the AI draft now that the reply has been sent. + // Must happen after snapshotting the draft body (for learning) but before + // returning to the renderer — prevents a race where the user archives the + // thread immediately and deleteThreadDrafts tries to delete the Gmail draft + // that was already consumed or is no longer relevant. + const draftCleanups = deleteThreadDrafts(options.threadId, options.accountId); + for (const cleanup of draftCleanups) { + if (cleanup.gmailDraftId) { + deleteGmailDraftById(options.accountId, cleanup.gmailDraftId).catch(() => {}); + } + } + // Fire-and-forget: learn from draft edits (compare AI draft vs what was sent) learnFromDraftEdit({ threadId: options.threadId, accountId: options.accountId, sentBodyHtml: options.bodyHtml || "", sentBodyText: options.bodyText, + draftSnapshot: draftSnapshot ?? undefined, }) .then((result) => { if (result && (result.promoted.length > 0 || result.draftMemoriesCreated > 0)) { diff --git a/src/main/services/draft-edit-learner.ts b/src/main/services/draft-edit-learner.ts index 3b09ab1c..08bd9aa3 100644 --- a/src/main/services/draft-edit-learner.ts +++ b/src/main/services/draft-edit-learner.ts @@ -660,12 +660,14 @@ export async function learnFromDraftEdit(params: { accountId: string; sentBodyHtml: string; sentBodyText?: string; + /** Pre-read draft snapshot — use when the draft row may be deleted before this runs. */ + draftSnapshot?: { draftBody: string; fromAddress: string; subject: string } | null; }): Promise { const { threadId, accountId, sentBodyHtml } = params; log.info(`[DraftEditLearner] Called for thread ${threadId}`); - // 1. Find the original AI draft for this thread - const draftInfo = getThreadDraftBody(threadId, accountId); + // 1. Find the original AI draft for this thread (use snapshot if provided) + const draftInfo = params.draftSnapshot ?? getThreadDraftBody(threadId, accountId); if (!draftInfo) { log.info(`[DraftEditLearner] No AI draft found for thread ${threadId} — skipping`); return null; From 083b5bf36a21855daeafbd010302dfd752ea4777 Mon Sep 17 00:00:00 2001 From: Ankit Gupta Date: Mon, 6 Apr 2026 19:14:38 -0400 Subject: [PATCH 04/18] fix: wrap draft cleanup DB operations in transactions Review fixes: - saveAnalysis: draft deletion + analysis save are now atomic via db.transaction(), preventing orphaned state if the process crashes - deleteThreadDrafts: batch deletes wrapped in transaction for atomicity - analysis.ipc.ts: deduplicated agentCoordinator dynamic import Co-Authored-By: Claude Opus 4.6 (1M context) --- src/main/db/index.ts | 45 +++++++++++++++++++++--------------- src/main/ipc/analysis.ipc.ts | 3 +-- 2 files changed, 28 insertions(+), 20 deletions(-) diff --git a/src/main/db/index.ts b/src/main/db/index.ts index 5782623b..a7e05fde 100644 --- a/src/main/db/index.ts +++ b/src/main/db/index.ts @@ -1489,7 +1489,8 @@ export function saveAnalysis( const db = getDatabase(); const effectivePriority = priority || null; - // When reclassifying as "skip", clean up any existing draft + // When reclassifying as "skip", clean up any existing draft. + // Read draft info outside the transaction so we can return it for async cleanup. let cleanupInfo: DraftCleanupInfo | null = null; if (effectivePriority === "skip") { const draftRow = db @@ -1508,24 +1509,26 @@ export function saveAnalysis( agentTaskId: draftRow.agent_task_id, accountId: draftRow.account_id, }; + } + } - // Delete agent trace first (references draft's agent_task_id) - if (draftRow.agent_task_id) { + // Wrap all DB writes in a transaction so draft deletion + analysis save are atomic + const doWrites = db.transaction(() => { + if (cleanupInfo) { + if (cleanupInfo.agentTaskId) { db.prepare(`DELETE FROM agent_conversation_mirror WHERE local_task_id = ?`).run( - draftRow.agent_task_id, + cleanupInfo.agentTaskId, ); } - - // Delete the local draft db.prepare("DELETE FROM drafts WHERE email_id = ?").run(emailId); } - } - const stmt = db.prepare(` - INSERT OR REPLACE INTO analyses (email_id, needs_reply, reason, priority, analyzed_at) - VALUES (?, ?, ?, ?, ?) - `); - stmt.run(emailId, needsReply ? 1 : 0, reason, effectivePriority, Date.now()); + db.prepare( + `INSERT OR REPLACE INTO analyses (email_id, needs_reply, reason, priority, analyzed_at) + VALUES (?, ?, ?, ?, ?)`, + ).run(emailId, needsReply ? 1 : 0, reason, effectivePriority, Date.now()); + }); + doWrites(); return cleanupInfo; } @@ -1647,14 +1650,20 @@ export function deleteThreadDrafts(threadId: string, accountId: string): DraftCl agentTaskId: row.agent_task_id, accountId, }); + } - if (row.agent_task_id) { - db.prepare(`DELETE FROM agent_conversation_mirror WHERE local_task_id = ?`).run( - row.agent_task_id, - ); + // Batch all deletes in a transaction for atomicity + const doDeletes = db.transaction(() => { + for (const row of rows) { + if (row.agent_task_id) { + db.prepare(`DELETE FROM agent_conversation_mirror WHERE local_task_id = ?`).run( + row.agent_task_id, + ); + } + db.prepare("DELETE FROM drafts WHERE email_id = ?").run(row.email_id); } - db.prepare("DELETE FROM drafts WHERE email_id = ?").run(row.email_id); - } + }); + doDeletes(); return cleanupInfos; } diff --git a/src/main/ipc/analysis.ipc.ts b/src/main/ipc/analysis.ipc.ts index 76c57b24..12bd6d85 100644 --- a/src/main/ipc/analysis.ipc.ts +++ b/src/main/ipc/analysis.ipc.ts @@ -260,15 +260,14 @@ export function registerAnalysisIpc(): void { () => {}, ); } + const { agentCoordinator } = await import("../agents/agent-coordinator"); if (draftCleanup.agentTaskId) { - const { agentCoordinator } = await import("../agents/agent-coordinator"); agentCoordinator.cancel(draftCleanup.agentTaskId); } // Cancel any in-flight auto-draft agent const { prefetchService } = await import("../services/prefetch-service"); const activeTaskId = prefetchService.getActiveAgentTaskId(emailId); if (activeTaskId) { - const { agentCoordinator } = await import("../agents/agent-coordinator"); agentCoordinator.cancel(activeTaskId); } } From 89a9b4d9be34529d78a622ac60e2cc1b5bdb93f9 Mon Sep 17 00:00:00 2001 From: Ankit Gupta Date: Mon, 6 Apr 2026 19:23:30 -0400 Subject: [PATCH 05/18] feat: enable AI features in demo mode Demo mode should only fake the inbox data source. All AI features now run normally in demo mode: - Agent draft generation (prefetch service + drafts IPC) - Sender lookup via web search - Draft-edit learning (Claude API calls for observation filtering and memory scope consolidation) - Analysis-edit learning (priority override inference, memory matching, scope classification) - Archive-ready thread analysis - Draft save, refine, and rerun Test mode (EXO_TEST_MODE) still disables all of these to avoid API calls during automated tests. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/main/ipc/archive-ready.ipc.ts | 3 +-- src/main/ipc/drafts.ipc.ts | 3 +-- src/main/ipc/settings.ipc.ts | 9 ++++----- src/main/services/analysis-edit-learner.ts | 12 ++++++------ src/main/services/draft-edit-learner.ts | 8 ++++---- src/main/services/prefetch-service.ts | 15 +++++---------- 6 files changed, 21 insertions(+), 29 deletions(-) diff --git a/src/main/ipc/archive-ready.ipc.ts b/src/main/ipc/archive-ready.ipc.ts index d01c1180..77cced92 100644 --- a/src/main/ipc/archive-ready.ipc.ts +++ b/src/main/ipc/archive-ready.ipc.ts @@ -20,8 +20,7 @@ import { createLogger } from "../services/logger"; const log = createLogger("archive-ready-ipc"); const isTestMode = process.env.EXO_TEST_MODE === "true"; -const isDemoMode = process.env.EXO_DEMO_MODE === "true"; -const useFakeData = isTestMode || isDemoMode; +const useFakeData = isTestMode; let analyzer: ArchiveReadyAnalyzer | null = null; diff --git a/src/main/ipc/drafts.ipc.ts b/src/main/ipc/drafts.ipc.ts index 3713091a..5bb96499 100644 --- a/src/main/ipc/drafts.ipc.ts +++ b/src/main/ipc/drafts.ipc.ts @@ -25,8 +25,7 @@ import { createLogger } from "../services/logger"; const log = createLogger("drafts-ipc"); const isTestMode = process.env.EXO_TEST_MODE === "true"; -const isDemoMode = process.env.EXO_DEMO_MODE === "true"; -const useFakeData = isTestMode || isDemoMode; +const useFakeData = isTestMode; export function registerDraftsIpc(): void { // Save an edited draft diff --git a/src/main/ipc/settings.ipc.ts b/src/main/ipc/settings.ipc.ts index 8b5a691a..94ef9312 100644 --- a/src/main/ipc/settings.ipc.ts +++ b/src/main/ipc/settings.ipc.ts @@ -709,14 +709,13 @@ export function registerSettingsIpc(): void { { from, email: emailAddr }: { from: string; email: string }, ): Promise> => { const isTestMode = process.env.EXO_TEST_MODE === "true"; - const isDemoMode = process.env.EXO_DEMO_MODE === "true"; - if (isTestMode || isDemoMode) { - // Return mock data in demo mode + if (isTestMode) { + // Return mock data in test mode const mockProfile: SenderProfile = { email: emailAddr, - name: from.split("<")[0].trim() || "Demo Sender", - summary: "Demo sender profile - web search disabled in demo mode.", + name: from.split("<")[0].trim() || "Test Sender", + summary: "Test sender profile - web search disabled in test mode.", lookupAt: Date.now(), }; return { success: true, data: mockProfile }; diff --git a/src/main/services/analysis-edit-learner.ts b/src/main/services/analysis-edit-learner.ts index 70a71397..5faea2ae 100644 --- a/src/main/services/analysis-edit-learner.ts +++ b/src/main/services/analysis-edit-learner.ts @@ -285,8 +285,8 @@ export async function learnFromPriorityOverrideInferred( * Use Claude to extract generalizable observations from a priority override. */ async function analyzeOverride(override: AnalysisOverride): Promise { - // Skip API call in test/demo mode - if (process.env.EXO_TEST_MODE === "true" || process.env.EXO_DEMO_MODE === "true") { + // Skip API call in test mode + if (process.env.EXO_TEST_MODE === "true") { return null; } @@ -383,8 +383,8 @@ async function matchAnalysisDraftMemories( observations: AnalysisObservation[], draftMemories: DraftMemory[], ): Promise> { - // Skip API call in test/demo mode - if (process.env.EXO_TEST_MODE === "true" || process.env.EXO_DEMO_MODE === "true") { + // Skip API call in test mode + if (process.env.EXO_TEST_MODE === "true") { return observations.map((_, i) => ({ observationIndex: i, matchedDraftMemoryId: null })); } @@ -439,8 +439,8 @@ async function classifyScope( senderEmail: string, senderDomain: string, ): Promise<{ scope: MemoryScope; scopeValue: string | null }> { - // Skip API call in test/demo mode — default to person scope - if (process.env.EXO_TEST_MODE === "true" || process.env.EXO_DEMO_MODE === "true") { + // Skip API call in test mode — default to person scope + if (process.env.EXO_TEST_MODE === "true") { return { scope: "person", scopeValue: senderEmail.toLowerCase() }; } diff --git a/src/main/services/draft-edit-learner.ts b/src/main/services/draft-edit-learner.ts index 08bd9aa3..b7797c39 100644 --- a/src/main/services/draft-edit-learner.ts +++ b/src/main/services/draft-edit-learner.ts @@ -399,8 +399,8 @@ export async function filterAgainstPromotedMemories( return observations; } - // Skip API call in demo/test mode — return all observations unfiltered - if (process.env.EXO_TEST_MODE === "true" || process.env.EXO_DEMO_MODE === "true") { + // Skip API call in test mode — return all observations unfiltered + if (process.env.EXO_TEST_MODE === "true") { return observations; } @@ -497,8 +497,8 @@ export async function consolidateMemoryScopes( return { action: "save", deletedIds: [], createdGlobal: null, coveringMemoryId: null }; } - // Skip API call in demo/test mode — treat all candidates as new - if (process.env.EXO_TEST_MODE === "true" || process.env.EXO_DEMO_MODE === "true") { + // Skip API call in test mode — treat all candidates as new + if (process.env.EXO_TEST_MODE === "true") { return { action: "save", deletedIds: [], createdGlobal: null, coveringMemoryId: null }; } diff --git a/src/main/services/prefetch-service.ts b/src/main/services/prefetch-service.ts index e5424e75..ba5d4070 100644 --- a/src/main/services/prefetch-service.ts +++ b/src/main/services/prefetch-service.ts @@ -356,12 +356,11 @@ When you see emails in a thread where ${eaName} is coordinating scheduling with // draft reply per thread is all that's needed. const autoDraft = config.autoDraft; const isTestMode = process.env.EXO_TEST_MODE === "true"; - const isDemoMode = process.env.EXO_DEMO_MODE === "true"; - const skipAgentDrafts = autoDraft?.enabled === false || isTestMode || isDemoMode; + const skipAgentDrafts = autoDraft?.enabled === false || isTestMode; if (skipAgentDrafts) { if (autoDraft?.enabled === false) log.info("[Prefetch] Auto-drafting disabled in config — skipping agent drafts"); - if (isTestMode || isDemoMode) log.info("[Prefetch] Test/demo mode — skipping agent drafts"); + if (isTestMode) log.info("[Prefetch] Test mode — skipping agent drafts"); } const allowedPriorities = autoDraft?.priorities ?? ["high", "medium", "low"]; const candidateEmails = skipAgentDrafts @@ -835,11 +834,9 @@ When you see emails in a thread where ${eaName} is coordinating scheduling with const autoDraftAllowed = autoDraftConfig?.enabled !== false; const autoDraftPriorities = autoDraftConfig?.priorities ?? ["high", "medium", "low"]; const isTest = process.env.EXO_TEST_MODE === "true"; - const isDemo = process.env.EXO_DEMO_MODE === "true"; if ( autoDraftAllowed && !isTest && - !isDemo && result.priority !== "skip" && autoDraftPriorities.includes(result.priority || "low") && !this.processedDrafts.has(emailId) && @@ -1059,10 +1056,9 @@ When you see emails in a thread where ${eaName} is coordinating scheduling with private async processAgentDraft(emailId: string): Promise { if (this.processedDrafts.has(emailId)) return; - // Skip in test/demo mode — agent worker may not be available or we shouldn't make real API calls + // Skip in test mode — agent worker may not be available const isTestMode = process.env.EXO_TEST_MODE === "true"; - const isDemoMode = process.env.EXO_DEMO_MODE === "true"; - if (isTestMode || isDemoMode) { + if (isTestMode) { this.processedDrafts.add(emailId); this.markAgentDraftDone(emailId, "completed"); return; @@ -1420,8 +1416,7 @@ When you see emails in a thread where ${eaName} is coordinating scheduling with if (this.agentDraftBacklog.some((t) => t.emailId === emailId)) return; const isTest = process.env.EXO_TEST_MODE === "true"; - const isDemo = process.env.EXO_DEMO_MODE === "true"; - if (isTest || isDemo) return; + if (isTest) return; // Clear and re-set thread tracking only when we actually queue const email = getEmail(emailId); From 0444b46c3d0d24d461fbd782dc4dff79d8ec45c2 Mon Sep 17 00:00:00 2001 From: Ankit Gupta Date: Mon, 6 Apr 2026 20:26:18 -0400 Subject: [PATCH 06/18] style: fix prettier formatting Co-Authored-By: Claude Opus 4.6 (1M context) --- src/main/ipc/analysis.ipc.ts | 4 +--- src/main/services/prefetch-service.ts | 7 ++++++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/main/ipc/analysis.ipc.ts b/src/main/ipc/analysis.ipc.ts index 12bd6d85..9e5afcc7 100644 --- a/src/main/ipc/analysis.ipc.ts +++ b/src/main/ipc/analysis.ipc.ts @@ -256,9 +256,7 @@ export function registerAnalysisIpc(): void { if (draftCleanup) { log.info(`[Analysis] Cleaning up draft for ${emailId} after skip reclassification`); if (draftCleanup.gmailDraftId && draftCleanup.accountId) { - deleteGmailDraftById(draftCleanup.accountId, draftCleanup.gmailDraftId).catch( - () => {}, - ); + deleteGmailDraftById(draftCleanup.accountId, draftCleanup.gmailDraftId).catch(() => {}); } const { agentCoordinator } = await import("../agents/agent-coordinator"); if (draftCleanup.agentTaskId) { diff --git a/src/main/services/prefetch-service.ts b/src/main/services/prefetch-service.ts index ba5d4070..06242307 100644 --- a/src/main/services/prefetch-service.ts +++ b/src/main/services/prefetch-service.ts @@ -748,7 +748,12 @@ When you see emails in a thread where ${eaName} is coordinating scheduling with const userEmail = account?.email; const result = await analyzer.analyze(emailForAnalysis, userEmail, email.accountId); - const draftCleanup = saveAnalysis(emailId, result.needs_reply, result.reason, result.priority); + const draftCleanup = saveAnalysis( + emailId, + result.needs_reply, + result.reason, + result.priority, + ); this.processedAnalysis.add(emailId); this.processedCounts.analysis++; From d8a3f6fffff320b5b7b49e5999fbdaeed9633623 Mon Sep 17 00:00:00 2001 From: Ankit Gupta Date: Mon, 6 Apr 2026 20:33:28 -0400 Subject: [PATCH 07/18] fix: normalize null priority to "skip" for draft cleanup The UI represents "Skip" as priority=null + needsReply=false, but saveAnalysis checks for the literal string "skip" to trigger draft cleanup. Without normalization, user-initiated skip overrides would clear the draft from the UI but leave it in the DB and Gmail. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/main/ipc/analysis.ipc.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/main/ipc/analysis.ipc.ts b/src/main/ipc/analysis.ipc.ts index 9e5afcc7..2971dcd3 100644 --- a/src/main/ipc/analysis.ipc.ts +++ b/src/main/ipc/analysis.ipc.ts @@ -240,12 +240,17 @@ export function registerAnalysisIpc(): void { const originalNeedsReply = originalAnalysis?.needsReply ?? false; const originalPriority = originalAnalysis?.priority ?? null; + // Normalize: the UI represents "skip" as priority=null + needsReply=false, + // but saveAnalysis checks for the literal string "skip" to trigger draft cleanup. + const normalizedPriority = + newPriority === null && !newNeedsReply ? "skip" : (newPriority ?? undefined); + // Update the analysis in DB (also deletes local draft + trace if reclassified as skip) const draftCleanup = saveAnalysis( emailId, newNeedsReply, originalAnalysis?.reason ?? "User override", - newPriority ?? undefined, + normalizedPriority, ); log.info( From af350f8819e5c6330ec3b643566879c9244c59f6 Mon Sep 17 00:00:00 2001 From: Ankit Gupta Date: Mon, 6 Apr 2026 20:36:59 -0400 Subject: [PATCH 08/18] fix: cancel in-flight agent tasks when archiving threads All three archive handlers now cancel agentTaskId from cleanup info, preventing a race where an in-flight draft agent completes after the DB row is deleted and re-creates a ghost draft for an archived email. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/main/ipc/archive-ready.ipc.ts | 12 ++++++++++-- src/main/ipc/sync.ipc.ts | 6 +++++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/main/ipc/archive-ready.ipc.ts b/src/main/ipc/archive-ready.ipc.ts index 77cced92..44d974f0 100644 --- a/src/main/ipc/archive-ready.ipc.ts +++ b/src/main/ipc/archive-ready.ipc.ts @@ -304,12 +304,16 @@ export function registerArchiveReadyIpc(): void { if (archivedIds.length > 0) { dismissArchiveReady(threadId, accountId); - // Clean up drafts and agent traces for archived thread + // Clean up drafts, agent traces, and in-flight agents for archived thread const draftCleanups = deleteThreadDrafts(threadId, accountId); for (const cleanup of draftCleanups) { if (cleanup.gmailDraftId) { deleteGmailDraftById(accountId, cleanup.gmailDraftId).catch(() => {}); } + if (cleanup.agentTaskId) { + const { agentCoordinator } = await import("../agents/agent-coordinator"); + agentCoordinator.cancel(cleanup.agentTaskId); + } } // Notify renderer: remove entire thread (including SENT emails) so no ghost thread remains @@ -401,12 +405,16 @@ export function registerArchiveReadyIpc(): void { for (const email of threadEmails) { allRemovedIds.push(email.id); } - // Clean up drafts and agent traces for archived thread + // Clean up drafts, agent traces, and in-flight agents for archived thread const draftCleanups = deleteThreadDrafts(row.threadId, accountId); for (const cleanup of draftCleanups) { if (cleanup.gmailDraftId) { deleteGmailDraftById(accountId, cleanup.gmailDraftId).catch(() => {}); } + if (cleanup.agentTaskId) { + const { agentCoordinator } = await import("../agents/agent-coordinator"); + agentCoordinator.cancel(cleanup.agentTaskId); + } } dismissArchiveReady(row.threadId, accountId); archived++; diff --git a/src/main/ipc/sync.ipc.ts b/src/main/ipc/sync.ipc.ts index 767adddc..8cf4b09e 100644 --- a/src/main/ipc/sync.ipc.ts +++ b/src/main/ipc/sync.ipc.ts @@ -1198,12 +1198,16 @@ export function registerSyncIpc(): void { ); } - // Clean up unsent drafts and agent traces for archived thread + // Clean up unsent drafts, agent traces, and in-flight agents for archived thread const draftCleanups = deleteThreadDrafts(threadId, accountId); for (const cleanup of draftCleanups) { if (cleanup.gmailDraftId && cleanup.accountId) { deleteGmailDraftById(cleanup.accountId, cleanup.gmailDraftId).catch(() => {}); } + if (cleanup.agentTaskId) { + const { agentCoordinator } = await import("../agents/agent-coordinator"); + agentCoordinator.cancel(cleanup.agentTaskId); + } } if (useFakeData) { From 65ee4d565fb33fad931c9b89f254db33a0f195d3 Mon Sep 17 00:00:00 2001 From: Ankit Gupta Date: Mon, 6 Apr 2026 20:54:07 -0400 Subject: [PATCH 09/18] fix: skip reclassification cleans up all thread drafts, not just one email MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit saveAnalysis now looks up all drafts in the thread when an email is reclassified as skip. The UI shows priority at the thread level, but the draft might be on a different email (e.g., draft on email A, new email B arrives and gets classified as skip). Previously only the specific email's draft was checked. Also removed draft cleanup from archive handlers — archiving can race with undo-send (user sends then immediately archives within the undo window). If we delete drafts on archive, the user can't undo the send. Drafts for archived threads are harmless (invisible in inbox) and get cleaned up by compose:send, saveAnalysis, or the 30-day retention sweep. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/main/db/index.ts | 73 ++++++++++++++++++--------- src/main/ipc/analysis.ipc.ts | 18 ++++--- src/main/ipc/archive-ready.ipc.ts | 27 ---------- src/main/ipc/sync.ipc.ts | 20 +++----- src/main/services/prefetch-service.ts | 14 +++-- 5 files changed, 76 insertions(+), 76 deletions(-) diff --git a/src/main/db/index.ts b/src/main/db/index.ts index a7e05fde..838fac26 100644 --- a/src/main/db/index.ts +++ b/src/main/db/index.ts @@ -1489,38 +1489,63 @@ export function saveAnalysis( const db = getDatabase(); const effectivePriority = priority || null; - // When reclassifying as "skip", clean up any existing draft. - // Read draft info outside the transaction so we can return it for async cleanup. - let cleanupInfo: DraftCleanupInfo | null = null; + // When reclassifying as "skip", clean up drafts for the ENTIRE thread, not just + // this email. The UI shows priority at the thread level, and the draft might be + // on a different email than the one being reclassified (e.g., draft is on email A + // but email B arrives and gets classified as skip). + const cleanupInfos: DraftCleanupInfo[] = []; if (effectivePriority === "skip") { - const draftRow = db - .prepare( - `SELECT d.gmail_draft_id, d.agent_task_id, e.account_id - FROM drafts d JOIN emails e ON d.email_id = e.id - WHERE d.email_id = ?`, - ) - .get(emailId) as - | { gmail_draft_id: string | null; agent_task_id: string | null; account_id: string | null } - | undefined; - - if (draftRow) { - cleanupInfo = { - gmailDraftId: draftRow.gmail_draft_id, - agentTaskId: draftRow.agent_task_id, - accountId: draftRow.account_id, - }; + // Look up thread_id and account_id for this email + const emailRow = db + .prepare(`SELECT thread_id, account_id FROM emails WHERE id = ?`) + .get(emailId) as { thread_id: string; account_id: string } | undefined; + + if (emailRow) { + // Find all drafts in this thread + const draftRows = db + .prepare( + `SELECT d.email_id, d.gmail_draft_id, d.agent_task_id + FROM drafts d JOIN emails e ON d.email_id = e.id + WHERE e.thread_id = ? AND e.account_id = ?`, + ) + .all(emailRow.thread_id, emailRow.account_id) as Array<{ + email_id: string; + gmail_draft_id: string | null; + agent_task_id: string | null; + }>; + + for (const row of draftRows) { + cleanupInfos.push({ + gmailDraftId: row.gmail_draft_id, + agentTaskId: row.agent_task_id, + accountId: emailRow.account_id, + }); + } } } // Wrap all DB writes in a transaction so draft deletion + analysis save are atomic const doWrites = db.transaction(() => { - if (cleanupInfo) { - if (cleanupInfo.agentTaskId) { + for (const info of cleanupInfos) { + if (info.agentTaskId) { db.prepare(`DELETE FROM agent_conversation_mirror WHERE local_task_id = ?`).run( - cleanupInfo.agentTaskId, + info.agentTaskId, ); } - db.prepare("DELETE FROM drafts WHERE email_id = ?").run(emailId); + } + if (cleanupInfos.length > 0) { + // Look up the thread again inside the transaction for the DELETE + const emailRow = db + .prepare(`SELECT thread_id, account_id FROM emails WHERE id = ?`) + .get(emailId) as { thread_id: string; account_id: string } | undefined; + if (emailRow) { + db.prepare( + `DELETE FROM drafts WHERE email_id IN ( + SELECT d.email_id FROM drafts d JOIN emails e ON d.email_id = e.id + WHERE e.thread_id = ? AND e.account_id = ? + )`, + ).run(emailRow.thread_id, emailRow.account_id); + } } db.prepare( @@ -1530,7 +1555,7 @@ export function saveAnalysis( }); doWrites(); - return cleanupInfo; + return cleanupInfos.length > 0 ? cleanupInfos : null; } // Draft operations diff --git a/src/main/ipc/analysis.ipc.ts b/src/main/ipc/analysis.ipc.ts index 2971dcd3..3b7388e8 100644 --- a/src/main/ipc/analysis.ipc.ts +++ b/src/main/ipc/analysis.ipc.ts @@ -257,15 +257,19 @@ export function registerAnalysisIpc(): void { `[Analysis] Priority overridden for ${emailId}: ${originalPriority ?? "skip"} → ${newPriority ?? "skip"}`, ); - // If reclassified as skip and had a draft, clean up Gmail draft and cancel agents + // If reclassified as skip and thread had drafts, clean up Gmail drafts and cancel agents if (draftCleanup) { - log.info(`[Analysis] Cleaning up draft for ${emailId} after skip reclassification`); - if (draftCleanup.gmailDraftId && draftCleanup.accountId) { - deleteGmailDraftById(draftCleanup.accountId, draftCleanup.gmailDraftId).catch(() => {}); - } + log.info( + `[Analysis] Cleaning up ${draftCleanup.length} thread draft(s) for ${emailId} after skip reclassification`, + ); const { agentCoordinator } = await import("../agents/agent-coordinator"); - if (draftCleanup.agentTaskId) { - agentCoordinator.cancel(draftCleanup.agentTaskId); + for (const cleanup of draftCleanup) { + if (cleanup.gmailDraftId && cleanup.accountId) { + deleteGmailDraftById(cleanup.accountId, cleanup.gmailDraftId).catch(() => {}); + } + if (cleanup.agentTaskId) { + agentCoordinator.cancel(cleanup.agentTaskId); + } } // Cancel any in-flight auto-draft agent const { prefetchService } = await import("../services/prefetch-service"); diff --git a/src/main/ipc/archive-ready.ipc.ts b/src/main/ipc/archive-ready.ipc.ts index 44d974f0..b6c1a7fb 100644 --- a/src/main/ipc/archive-ready.ipc.ts +++ b/src/main/ipc/archive-ready.ipc.ts @@ -9,9 +9,7 @@ import { getAnalyzedArchiveThreadIds, getAccounts, updateEmailLabelIds, - deleteThreadDrafts, } from "../db"; -import { deleteGmailDraftById } from "../services/gmail-draft-sync"; import { getConfig, getModelIdForFeature } from "./settings.ipc"; import { getEmailSyncService } from "./sync.ipc"; import type { IpcResponse, DashboardEmail } from "../../shared/types"; @@ -265,7 +263,6 @@ export function registerArchiveReadyIpc(): void { (email.labelIds || []).filter((l: string) => l !== "INBOX"), ); } - deleteThreadDrafts(threadId, accountId); dismissArchiveReady(threadId, accountId); const win = getMainWindow(); if (win && removedIds.length > 0) { @@ -304,18 +301,6 @@ export function registerArchiveReadyIpc(): void { if (archivedIds.length > 0) { dismissArchiveReady(threadId, accountId); - // Clean up drafts, agent traces, and in-flight agents for archived thread - const draftCleanups = deleteThreadDrafts(threadId, accountId); - for (const cleanup of draftCleanups) { - if (cleanup.gmailDraftId) { - deleteGmailDraftById(accountId, cleanup.gmailDraftId).catch(() => {}); - } - if (cleanup.agentTaskId) { - const { agentCoordinator } = await import("../agents/agent-coordinator"); - agentCoordinator.cancel(cleanup.agentTaskId); - } - } - // Notify renderer: remove entire thread (including SENT emails) so no ghost thread remains const allThreadEmailIds = threadEmails.map((e) => e.id); const win = getMainWindow(); @@ -355,7 +340,6 @@ export function registerArchiveReadyIpc(): void { ); allRemovedIds.push(email.id); } - deleteThreadDrafts(row.threadId, accountId); dismissArchiveReady(row.threadId, accountId); } @@ -405,17 +389,6 @@ export function registerArchiveReadyIpc(): void { for (const email of threadEmails) { allRemovedIds.push(email.id); } - // Clean up drafts, agent traces, and in-flight agents for archived thread - const draftCleanups = deleteThreadDrafts(row.threadId, accountId); - for (const cleanup of draftCleanups) { - if (cleanup.gmailDraftId) { - deleteGmailDraftById(accountId, cleanup.gmailDraftId).catch(() => {}); - } - if (cleanup.agentTaskId) { - const { agentCoordinator } = await import("../agents/agent-coordinator"); - agentCoordinator.cancel(cleanup.agentTaskId); - } - } dismissArchiveReady(row.threadId, accountId); archived++; } diff --git a/src/main/ipc/sync.ipc.ts b/src/main/ipc/sync.ipc.ts index 8cf4b09e..7765deb5 100644 --- a/src/main/ipc/sync.ipc.ts +++ b/src/main/ipc/sync.ipc.ts @@ -7,7 +7,6 @@ import { networkMonitor } from "../services/network-monitor"; import { outboxService } from "../services/outbox-service"; import { pendingActionsQueue } from "../services/pending-actions"; import { isNetworkError } from "../services/network-errors"; -import { deleteGmailDraftById } from "../services/gmail-draft-sync"; import { getAccounts, saveAccount, @@ -31,7 +30,6 @@ import { saveCorrespondentProfile, updateAccountDisplayName, deleteAgentTrace, - deleteThreadDrafts, saveDraft, type AccountRecord, } from "../db"; @@ -1198,17 +1196,13 @@ export function registerSyncIpc(): void { ); } - // Clean up unsent drafts, agent traces, and in-flight agents for archived thread - const draftCleanups = deleteThreadDrafts(threadId, accountId); - for (const cleanup of draftCleanups) { - if (cleanup.gmailDraftId && cleanup.accountId) { - deleteGmailDraftById(cleanup.accountId, cleanup.gmailDraftId).catch(() => {}); - } - if (cleanup.agentTaskId) { - const { agentCoordinator } = await import("../agents/agent-coordinator"); - agentCoordinator.cancel(cleanup.agentTaskId); - } - } + // NOTE: Draft cleanup is intentionally NOT done here. Archiving can race + // with undo-send (the user sends, then immediately archives within the undo + // window). If we delete drafts now, the user can't undo the send. Instead: + // - compose:send cleans up drafts when the send actually fires + // - saveAnalysis cleans up drafts when emails are reclassified as "skip" + // - Orphaned drafts for archived threads are harmless (invisible in inbox) + // and get cleaned up by the 30-day data retention sweep. if (useFakeData) { return { success: true, data: undefined }; diff --git a/src/main/services/prefetch-service.ts b/src/main/services/prefetch-service.ts index 06242307..92615089 100644 --- a/src/main/services/prefetch-service.ts +++ b/src/main/services/prefetch-service.ts @@ -757,14 +757,18 @@ When you see emails in a thread where ${eaName} is coordinating scheduling with this.processedAnalysis.add(emailId); this.processedCounts.analysis++; - // If reclassified as "skip" and had a draft, clean up Gmail draft and cancel agents + // If reclassified as "skip" and thread had drafts, clean up Gmail drafts and cancel agents if (draftCleanup) { log.info( - `[Prefetch] Email ${emailId} reclassified as skip — deleted local draft` + - (draftCleanup.gmailDraftId ? ` and queuing Gmail draft deletion` : ``), + `[Prefetch] Email ${emailId} reclassified as skip — deleted ${draftCleanup.length} thread draft(s)`, ); - if (draftCleanup.gmailDraftId && draftCleanup.accountId) { - deleteGmailDraftById(draftCleanup.accountId, draftCleanup.gmailDraftId).catch(() => {}); + for (const cleanup of draftCleanup) { + if (cleanup.gmailDraftId && cleanup.accountId) { + deleteGmailDraftById(cleanup.accountId, cleanup.gmailDraftId).catch(() => {}); + } + if (cleanup.agentTaskId) { + agentCoordinator.cancel(cleanup.agentTaskId); + } } // Cancel any in-flight agent draft for this email const activeTaskId = this.activeAgentTaskIds.get(emailId); From eb6465d3be19a0a02d6a49251b655887784bc558 Mon Sep 17 00:00:00 2001 From: Ankit Gupta Date: Mon, 6 Apr 2026 21:01:38 -0400 Subject: [PATCH 10/18] fix: fix return type and cancel agents in demo compose path - Fixed saveAnalysis return type: DraftCleanupInfo[] | null (was DraftCleanupInfo | null, causing tsconfig.node.json type check failure) - Demo compose:send path now cancels in-flight agent tasks from deleteThreadDrafts cleanup info (agents run in demo mode now) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/main/db/index.ts | 2 +- src/main/ipc/compose.ipc.ts | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/main/db/index.ts b/src/main/db/index.ts index 838fac26..b01559f7 100644 --- a/src/main/db/index.ts +++ b/src/main/db/index.ts @@ -1485,7 +1485,7 @@ export function saveAnalysis( needsReply: boolean, reason: string, priority?: string, -): DraftCleanupInfo | null { +): DraftCleanupInfo[] | null { const db = getDatabase(); const effectivePriority = priority || null; diff --git a/src/main/ipc/compose.ipc.ts b/src/main/ipc/compose.ipc.ts index 606c0b60..48e1f583 100644 --- a/src/main/ipc/compose.ipc.ts +++ b/src/main/ipc/compose.ipc.ts @@ -306,7 +306,14 @@ export function registerComposeIpc(): void { // Still trigger draft-edit learning in demo mode so we can test it if (options.threadId && !options.isForward) { const draftSnapshot = getThreadDraftBody(options.threadId, options.accountId); - deleteThreadDrafts(options.threadId, options.accountId); + const demoCleanups = deleteThreadDrafts(options.threadId, options.accountId); + // Cancel in-flight agents (agent drafts now run in demo mode) + for (const cleanup of demoCleanups) { + if (cleanup.agentTaskId) { + const { agentCoordinator } = await import("../agents/agent-coordinator"); + agentCoordinator.cancel(cleanup.agentTaskId); + } + } learnFromDraftEdit({ threadId: options.threadId, accountId: options.accountId, From 815fcf6d5508b54f614a4443691aa9cda70c511d Mon Sep 17 00:00:00 2001 From: Ankit Gupta Date: Mon, 6 Apr 2026 21:11:51 -0400 Subject: [PATCH 11/18] revert: restore demo mode gates for AI features E2E tests use EXO_DEMO_MODE=true and rely on these gates to prevent real API calls. Reverting the demo mode changes to keep CI green. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/main/ipc/archive-ready.ipc.ts | 3 ++- src/main/ipc/drafts.ipc.ts | 3 ++- src/main/ipc/settings.ipc.ts | 9 +++++---- src/main/services/analysis-edit-learner.ts | 8 ++++---- src/main/services/draft-edit-learner.ts | 8 ++++---- src/main/services/prefetch-service.ts | 16 +++++++++++----- 6 files changed, 28 insertions(+), 19 deletions(-) diff --git a/src/main/ipc/archive-ready.ipc.ts b/src/main/ipc/archive-ready.ipc.ts index b6c1a7fb..4af02355 100644 --- a/src/main/ipc/archive-ready.ipc.ts +++ b/src/main/ipc/archive-ready.ipc.ts @@ -18,7 +18,8 @@ import { createLogger } from "../services/logger"; const log = createLogger("archive-ready-ipc"); const isTestMode = process.env.EXO_TEST_MODE === "true"; -const useFakeData = isTestMode; +const isDemoMode = process.env.EXO_DEMO_MODE === "true"; +const useFakeData = isTestMode || isDemoMode; let analyzer: ArchiveReadyAnalyzer | null = null; diff --git a/src/main/ipc/drafts.ipc.ts b/src/main/ipc/drafts.ipc.ts index 5bb96499..3713091a 100644 --- a/src/main/ipc/drafts.ipc.ts +++ b/src/main/ipc/drafts.ipc.ts @@ -25,7 +25,8 @@ import { createLogger } from "../services/logger"; const log = createLogger("drafts-ipc"); const isTestMode = process.env.EXO_TEST_MODE === "true"; -const useFakeData = isTestMode; +const isDemoMode = process.env.EXO_DEMO_MODE === "true"; +const useFakeData = isTestMode || isDemoMode; export function registerDraftsIpc(): void { // Save an edited draft diff --git a/src/main/ipc/settings.ipc.ts b/src/main/ipc/settings.ipc.ts index 94ef9312..915ee4cc 100644 --- a/src/main/ipc/settings.ipc.ts +++ b/src/main/ipc/settings.ipc.ts @@ -709,13 +709,14 @@ export function registerSettingsIpc(): void { { from, email: emailAddr }: { from: string; email: string }, ): Promise> => { const isTestMode = process.env.EXO_TEST_MODE === "true"; + const isDemoMode = process.env.EXO_DEMO_MODE === "true"; - if (isTestMode) { - // Return mock data in test mode + if (isTestMode || isDemoMode) { + // Return mock data in test/demo mode const mockProfile: SenderProfile = { email: emailAddr, - name: from.split("<")[0].trim() || "Test Sender", - summary: "Test sender profile - web search disabled in test mode.", + name: from.split("<")[0].trim() || "Demo Sender", + summary: "Demo sender profile - web search disabled in demo mode.", lookupAt: Date.now(), }; return { success: true, data: mockProfile }; diff --git a/src/main/services/analysis-edit-learner.ts b/src/main/services/analysis-edit-learner.ts index 5faea2ae..dfef8319 100644 --- a/src/main/services/analysis-edit-learner.ts +++ b/src/main/services/analysis-edit-learner.ts @@ -285,8 +285,8 @@ export async function learnFromPriorityOverrideInferred( * Use Claude to extract generalizable observations from a priority override. */ async function analyzeOverride(override: AnalysisOverride): Promise { - // Skip API call in test mode - if (process.env.EXO_TEST_MODE === "true") { + // Skip API call in test/demo mode + if (process.env.EXO_TEST_MODE === "true" || process.env.EXO_DEMO_MODE === "true") { return null; } @@ -383,8 +383,8 @@ async function matchAnalysisDraftMemories( observations: AnalysisObservation[], draftMemories: DraftMemory[], ): Promise> { - // Skip API call in test mode - if (process.env.EXO_TEST_MODE === "true") { + // Skip API call in test/demo mode + if (process.env.EXO_TEST_MODE === "true" || process.env.EXO_DEMO_MODE === "true") { return observations.map((_, i) => ({ observationIndex: i, matchedDraftMemoryId: null })); } diff --git a/src/main/services/draft-edit-learner.ts b/src/main/services/draft-edit-learner.ts index b7797c39..75f54dd7 100644 --- a/src/main/services/draft-edit-learner.ts +++ b/src/main/services/draft-edit-learner.ts @@ -399,8 +399,8 @@ export async function filterAgainstPromotedMemories( return observations; } - // Skip API call in test mode — return all observations unfiltered - if (process.env.EXO_TEST_MODE === "true") { + // Skip API call in test/demo mode — return all observations unfiltered + if (process.env.EXO_TEST_MODE === "true" || process.env.EXO_DEMO_MODE === "true") { return observations; } @@ -497,8 +497,8 @@ export async function consolidateMemoryScopes( return { action: "save", deletedIds: [], createdGlobal: null, coveringMemoryId: null }; } - // Skip API call in test mode — treat all candidates as new - if (process.env.EXO_TEST_MODE === "true") { + // Skip API call in test/demo mode — treat all candidates as new + if (process.env.EXO_TEST_MODE === "true" || process.env.EXO_DEMO_MODE === "true") { return { action: "save", deletedIds: [], createdGlobal: null, coveringMemoryId: null }; } diff --git a/src/main/services/prefetch-service.ts b/src/main/services/prefetch-service.ts index 92615089..70ad1f37 100644 --- a/src/main/services/prefetch-service.ts +++ b/src/main/services/prefetch-service.ts @@ -356,11 +356,13 @@ When you see emails in a thread where ${eaName} is coordinating scheduling with // draft reply per thread is all that's needed. const autoDraft = config.autoDraft; const isTestMode = process.env.EXO_TEST_MODE === "true"; - const skipAgentDrafts = autoDraft?.enabled === false || isTestMode; + const isDemoMode = process.env.EXO_DEMO_MODE === "true"; + const skipAgentDrafts = autoDraft?.enabled === false || isTestMode || isDemoMode; if (skipAgentDrafts) { if (autoDraft?.enabled === false) log.info("[Prefetch] Auto-drafting disabled in config — skipping agent drafts"); - if (isTestMode) log.info("[Prefetch] Test mode — skipping agent drafts"); + if (isTestMode || isDemoMode) + log.info("[Prefetch] Test/demo mode — skipping agent drafts"); } const allowedPriorities = autoDraft?.priorities ?? ["high", "medium", "low"]; const candidateEmails = skipAgentDrafts @@ -843,9 +845,11 @@ When you see emails in a thread where ${eaName} is coordinating scheduling with const autoDraftAllowed = autoDraftConfig?.enabled !== false; const autoDraftPriorities = autoDraftConfig?.priorities ?? ["high", "medium", "low"]; const isTest = process.env.EXO_TEST_MODE === "true"; + const isDemo = process.env.EXO_DEMO_MODE === "true"; if ( autoDraftAllowed && !isTest && + !isDemo && result.priority !== "skip" && autoDraftPriorities.includes(result.priority || "low") && !this.processedDrafts.has(emailId) && @@ -1065,9 +1069,10 @@ When you see emails in a thread where ${eaName} is coordinating scheduling with private async processAgentDraft(emailId: string): Promise { if (this.processedDrafts.has(emailId)) return; - // Skip in test mode — agent worker may not be available + // Skip in test/demo mode — agent worker may not be available or we shouldn't make real API calls const isTestMode = process.env.EXO_TEST_MODE === "true"; - if (isTestMode) { + const isDemoMode = process.env.EXO_DEMO_MODE === "true"; + if (isTestMode || isDemoMode) { this.processedDrafts.add(emailId); this.markAgentDraftDone(emailId, "completed"); return; @@ -1425,7 +1430,8 @@ When you see emails in a thread where ${eaName} is coordinating scheduling with if (this.agentDraftBacklog.some((t) => t.emailId === emailId)) return; const isTest = process.env.EXO_TEST_MODE === "true"; - if (isTest) return; + const isDemo = process.env.EXO_DEMO_MODE === "true"; + if (isTest || isDemo) return; // Clear and re-set thread tracking only when we actually queue const email = getEmail(emailId); From 2534f8cfecfc9bea347cfea348354aa825006f1e Mon Sep 17 00:00:00 2001 From: Ankit Gupta Date: Mon, 6 Apr 2026 21:14:34 -0400 Subject: [PATCH 12/18] style: fix prettier formatting Co-Authored-By: Claude Opus 4.6 (1M context) --- src/main/services/prefetch-service.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/services/prefetch-service.ts b/src/main/services/prefetch-service.ts index 70ad1f37..29ce60b9 100644 --- a/src/main/services/prefetch-service.ts +++ b/src/main/services/prefetch-service.ts @@ -361,8 +361,7 @@ When you see emails in a thread where ${eaName} is coordinating scheduling with if (skipAgentDrafts) { if (autoDraft?.enabled === false) log.info("[Prefetch] Auto-drafting disabled in config — skipping agent drafts"); - if (isTestMode || isDemoMode) - log.info("[Prefetch] Test/demo mode — skipping agent drafts"); + if (isTestMode || isDemoMode) log.info("[Prefetch] Test/demo mode — skipping agent drafts"); } const allowedPriorities = autoDraft?.priorities ?? ["high", "medium", "low"]; const candidateEmails = skipAgentDrafts From 5683ffabd03bd79b77fd101901140721d45774e3 Mon Sep 17 00:00:00 2001 From: Ankit Gupta Date: Mon, 6 Apr 2026 21:19:27 -0400 Subject: [PATCH 13/18] fix: handle saveAnalysis cleanup in all callers, cancel agents on send - analysis:analyze and analysis:analyze-batch now handle the DraftCleanupInfo[] return from saveAnalysis (delete Gmail drafts) - compose:send real path now cancels agentTaskId from cleanup info Co-Authored-By: Claude Opus 4.6 (1M context) --- src/main/ipc/analysis.ipc.ts | 20 +++++++++++++++++--- src/main/ipc/compose.ipc.ts | 4 ++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/main/ipc/analysis.ipc.ts b/src/main/ipc/analysis.ipc.ts index 3b7388e8..635e40bb 100644 --- a/src/main/ipc/analysis.ipc.ts +++ b/src/main/ipc/analysis.ipc.ts @@ -111,8 +111,15 @@ export function registerAnalysisIpc(): void { const result = await analyzerInstance.analyze(emailForAnalysis, userEmail, email.accountId); - // Save analysis to database - saveAnalysis(emailId, result.needs_reply, result.reason, result.priority); + // Save analysis to database (also cleans up thread drafts if reclassified as skip) + const draftCleanup = saveAnalysis(emailId, result.needs_reply, result.reason, result.priority); + if (draftCleanup) { + for (const cleanup of draftCleanup) { + if (cleanup.gmailDraftId && cleanup.accountId) { + deleteGmailDraftById(cleanup.accountId, cleanup.gmailDraftId).catch(() => {}); + } + } + } // Return updated email with analysis const updatedEmail = getEmail(emailId); @@ -189,7 +196,14 @@ export function registerAnalysisIpc(): void { userEmail, email.accountId, ); - saveAnalysis(emailId, result.needs_reply, result.reason, result.priority); + const batchCleanup = saveAnalysis(emailId, result.needs_reply, result.reason, result.priority); + if (batchCleanup) { + for (const cleanup of batchCleanup) { + if (cleanup.gmailDraftId && cleanup.accountId) { + deleteGmailDraftById(cleanup.accountId, cleanup.gmailDraftId).catch(() => {}); + } + } + } const updatedEmail = getEmail(emailId); if (updatedEmail) { diff --git a/src/main/ipc/compose.ipc.ts b/src/main/ipc/compose.ipc.ts index 48e1f583..49611b9c 100644 --- a/src/main/ipc/compose.ipc.ts +++ b/src/main/ipc/compose.ipc.ts @@ -395,6 +395,10 @@ export function registerComposeIpc(): void { if (cleanup.gmailDraftId) { deleteGmailDraftById(options.accountId, cleanup.gmailDraftId).catch(() => {}); } + if (cleanup.agentTaskId) { + const { agentCoordinator } = await import("../agents/agent-coordinator"); + agentCoordinator.cancel(cleanup.agentTaskId); + } } // Fire-and-forget: learn from draft edits (compare AI draft vs what was sent) From 6a3645f6a76bf2209dcda2a392a4cc1829c127c3 Mon Sep 17 00:00:00 2001 From: Ankit Gupta Date: Mon, 6 Apr 2026 21:23:06 -0400 Subject: [PATCH 14/18] style: fix prettier formatting Co-Authored-By: Claude Opus 4.6 (1M context) --- src/main/ipc/analysis.ipc.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/main/ipc/analysis.ipc.ts b/src/main/ipc/analysis.ipc.ts index 635e40bb..49f3d6f2 100644 --- a/src/main/ipc/analysis.ipc.ts +++ b/src/main/ipc/analysis.ipc.ts @@ -112,7 +112,12 @@ export function registerAnalysisIpc(): void { const result = await analyzerInstance.analyze(emailForAnalysis, userEmail, email.accountId); // Save analysis to database (also cleans up thread drafts if reclassified as skip) - const draftCleanup = saveAnalysis(emailId, result.needs_reply, result.reason, result.priority); + const draftCleanup = saveAnalysis( + emailId, + result.needs_reply, + result.reason, + result.priority, + ); if (draftCleanup) { for (const cleanup of draftCleanup) { if (cleanup.gmailDraftId && cleanup.accountId) { @@ -196,7 +201,12 @@ export function registerAnalysisIpc(): void { userEmail, email.accountId, ); - const batchCleanup = saveAnalysis(emailId, result.needs_reply, result.reason, result.priority); + const batchCleanup = saveAnalysis( + emailId, + result.needs_reply, + result.reason, + result.priority, + ); if (batchCleanup) { for (const cleanup of batchCleanup) { if (cleanup.gmailDraftId && cleanup.accountId) { From 5644e104af33b98adf4cd10b3af2684123610dfd Mon Sep 17 00:00:00 2001 From: Ankit Gupta Date: Mon, 6 Apr 2026 21:27:09 -0400 Subject: [PATCH 15/18] fix: restore missing demo mode gate in classifyScope Co-Authored-By: Claude Opus 4.6 (1M context) --- src/main/services/analysis-edit-learner.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/services/analysis-edit-learner.ts b/src/main/services/analysis-edit-learner.ts index dfef8319..70a71397 100644 --- a/src/main/services/analysis-edit-learner.ts +++ b/src/main/services/analysis-edit-learner.ts @@ -439,8 +439,8 @@ async function classifyScope( senderEmail: string, senderDomain: string, ): Promise<{ scope: MemoryScope; scopeValue: string | null }> { - // Skip API call in test mode — default to person scope - if (process.env.EXO_TEST_MODE === "true") { + // Skip API call in test/demo mode — default to person scope + if (process.env.EXO_TEST_MODE === "true" || process.env.EXO_DEMO_MODE === "true") { return { scope: "person", scopeValue: senderEmail.toLowerCase() }; } From 477af70847236d1fb9e582d0d5af892ab6c97f6f Mon Sep 17 00:00:00 2001 From: Ankit Gupta Date: Mon, 6 Apr 2026 21:34:33 -0400 Subject: [PATCH 16/18] fix: clear drafts from all thread emails in UI on skip reclassification The draft may be on a different email than latestReceivedEmail (which is the email whose analysis is being overridden). Now iterates all threadEmails and clears draft from any that have one. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/renderer/components/EmailDetail.tsx | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/renderer/components/EmailDetail.tsx b/src/renderer/components/EmailDetail.tsx index 7474bf54..d4ce623f 100644 --- a/src/renderer/components/EmailDetail.tsx +++ b/src/renderer/components/EmailDetail.tsx @@ -3575,12 +3575,17 @@ export function EmailDetail({ isFullView = false }: EmailDetailProps) { priority: (newPriority as "high" | "medium" | "low" | "skip" | null) ?? undefined, }, }; - // When reclassified as skip, clear the draft from UI state - // (main process deletes local + Gmail draft automatically) + updateEmail(latestReceivedEmail.id, updates); + // When reclassified as skip, clear drafts from ALL thread emails in UI state + // (main process deletes all thread drafts automatically via saveAnalysis). + // The draft may be on a different email than latestReceivedEmail. if (newPriority === "skip" || newPriority === null) { - updates.draft = undefined; + for (const email of threadEmails) { + if (email.draft) { + updateEmail(email.id, { draft: undefined }); + } + } } - updateEmail(latestReceivedEmail.id, updates); }} /> )} From a4f864b0d77f7ccd3ab431b620a28f53e4fd982a Mon Sep 17 00:00:00 2001 From: Ankit Gupta Date: Mon, 6 Apr 2026 22:17:02 -0400 Subject: [PATCH 17/18] fix: clear processedDraftThreads when thread drafts cleaned up on skip When saveAnalysis deletes all thread drafts due to skip reclassification, the thread-level dedup set (processedDraftThreads) must also be cleared. Otherwise new emails arriving in the thread that need a reply would be blocked from getting an auto-draft. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/main/services/prefetch-service.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/services/prefetch-service.ts b/src/main/services/prefetch-service.ts index 29ce60b9..913796e2 100644 --- a/src/main/services/prefetch-service.ts +++ b/src/main/services/prefetch-service.ts @@ -777,6 +777,10 @@ When you see emails in a thread where ${eaName} is coordinating scheduling with agentCoordinator.cancel(activeTaskId); } this.processedDrafts.delete(emailId); + // Also clear thread-level dedup so new emails in the thread can get drafts + if (email.threadId) { + this.processedDraftThreads.delete(email.threadId); + } } log.info( From bd6eb3037779edbede43985dec07bf260a3b6408 Mon Sep 17 00:00:00 2001 From: Ankit Gupta Date: Tue, 7 Apr 2026 00:04:50 -0400 Subject: [PATCH 18/18] fix: handle saveAnalysis cleanup in all callers, cancel agents on send agent-coordinator.ts and draft-pipeline.ts now handle the DraftCleanupInfo[] return from saveAnalysis, ensuring Gmail drafts are deleted when the analyzer classifies an email as skip. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/main/agents/agent-coordinator.ts | 16 ++++++++++++++-- src/main/services/draft-pipeline.ts | 11 +++++++++-- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/src/main/agents/agent-coordinator.ts b/src/main/agents/agent-coordinator.ts index 8679d6a2..d0b4c8a2 100644 --- a/src/main/agents/agent-coordinator.ts +++ b/src/main/agents/agent-coordinator.ts @@ -64,8 +64,20 @@ export class AgentCoordinator { getInboxEmails: (accountId?: string) => db.getInboxEmails(accountId), getAllEmails: (accountId?: string) => db.getAllEmails(accountId), searchEmails: (query: string, options?: db.SearchOptions) => db.searchEmails(query, options), - saveAnalysis: (emailId: string, needsReply: boolean, reason: string, priority?: string) => - db.saveAnalysis(emailId, needsReply, reason, priority), + saveAnalysis: (emailId: string, needsReply: boolean, reason: string, priority?: string) => { + const cleanup = db.saveAnalysis(emailId, needsReply, reason, priority); + // If reclassified as skip, fire-and-forget Gmail draft cleanup + if (cleanup) { + for (const c of cleanup) { + if (c.gmailDraftId && c.accountId) { + import("../services/gmail-draft-sync").then(({ deleteGmailDraftById }) => + deleteGmailDraftById(c.accountId!, c.gmailDraftId!).catch(() => {}), + ); + } + } + } + return cleanup; + }, saveDraft: ( emailId: string, draftBody: string, diff --git a/src/main/services/draft-pipeline.ts b/src/main/services/draft-pipeline.ts index cc73ea7b..df80f864 100644 --- a/src/main/services/draft-pipeline.ts +++ b/src/main/services/draft-pipeline.ts @@ -6,7 +6,7 @@ * assembly → DraftGenerator call → DB save. */ import { getEmail, saveAnalysis } from "../db"; -import { saveDraftAndSync } from "./gmail-draft-sync"; +import { saveDraftAndSync, deleteGmailDraftById } from "./gmail-draft-sync"; import { getConfig, getModelIdForFeature } from "../ipc/settings.ipc"; import { getEmailSyncService } from "../ipc/sync.ipc"; import { buildStyleContext } from "./style-profiler"; @@ -139,12 +139,19 @@ export async function generateDraftForEmail( config.analysisPrompt ?? undefined, ); const analysisResult = await analyzer.analyze(emailForDraft); - saveAnalysis( + const draftCleanup = saveAnalysis( emailId, analysisResult.needs_reply, analysisResult.reason, analysisResult.priority, ); + if (draftCleanup) { + for (const cleanup of draftCleanup) { + if (cleanup.gmailDraftId && cleanup.accountId) { + deleteGmailDraftById(cleanup.accountId, cleanup.gmailDraftId).catch(() => {}); + } + } + } email.analysis = { needsReply: analysisResult.needs_reply, reason: analysisResult.reason,