From a87cc8edf4d349aeb3b566f7582bdab7ec9e6e32 Mon Sep 17 00:00:00 2001 From: Graham Pechenik <196518812+gpechenik@users.noreply.github.com> Date: Tue, 7 Apr 2026 13:09:34 -0700 Subject: [PATCH 1/9] feat: pass Gmail label names to email analyzer for context-aware analysis Resolves label IDs to human-readable names via Gmail API (cached per account), filters out system labels (INBOX, UNREAD, etc.), and includes user labels in the analysis prompt so Claude can factor in label context. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/main/services/email-analyzer.ts | 4 +-- src/main/services/prefetch-service.ts | 39 ++++++++++++++++++++++++++- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/src/main/services/email-analyzer.ts b/src/main/services/email-analyzer.ts index adcb9e75..839e8d5a 100644 --- a/src/main/services/email-analyzer.ts +++ b/src/main/services/email-analyzer.ts @@ -146,7 +146,7 @@ export class EmailAnalyzer { this.customPrompt = prompt && prompt !== DEFAULT_ANALYSIS_PROMPT ? prompt : null; } - async analyze(email: Email, userEmail?: string, accountId?: string): Promise { + async analyze(email: Email, userEmail?: string, accountId?: string, labelNames?: string[]): Promise { const emailContent = this.formatEmailForAnalysis(email); // Always append JSON format suffix to ensure structured output, @@ -184,7 +184,7 @@ export class EmailAnalyzer { role: "user", content: `${UNTRUSTED_DATA_INSTRUCTION} -${userIdentityLine}${wrapUntrustedEmail(`From: ${email.from}\nTo: ${email.to}\nSubject: ${email.subject}\nDate: ${email.date}\n\n${emailContent}`)}${analysisMemoryContext}`, +${userIdentityLine}${wrapUntrustedEmail(`From: ${email.from}\nTo: ${email.to}\nSubject: ${email.subject}\nDate: ${email.date}${labelNames?.length ? `\nLabels: ${labelNames.join(", ")}` : ""}\n\n${emailContent}`)}${analysisMemoryContext}`, }, ], }, diff --git a/src/main/services/prefetch-service.ts b/src/main/services/prefetch-service.ts index e97e6d5a..a38b5b4c 100644 --- a/src/main/services/prefetch-service.ts +++ b/src/main/services/prefetch-service.ts @@ -22,6 +22,42 @@ import { createLogger } from "./logger"; const log = createLogger("prefetch"); +// Cached label ID→name map per account, populated lazily from Gmail API +const labelNameCache = new Map>(); + +async function resolveLabelNames(labelIds: string[] | undefined, accountId: string | undefined): Promise { + if (!labelIds?.length || !accountId) return []; + + // Populate cache on first use for this account + if (!labelNameCache.has(accountId)) { + try { + const { getClient } = await import("../ipc/gmail.ipc"); + const client = await getClient(accountId); + const labels = await client.listLabels(); + const map = new Map(); + for (const label of labels) { + map.set(label.id, label.name); + } + labelNameCache.set(accountId, map); + } catch { + return []; + } + } + + const nameMap = labelNameCache.get(accountId); + if (!nameMap) return []; + + // System labels the analyzer doesn't need to see (already obvious from context) + const HIDDEN = new Set(["INBOX", "UNREAD", "SENT", "DRAFT", "SPAM", "TRASH", + "CATEGORY_PERSONAL", "CATEGORY_SOCIAL", "CATEGORY_UPDATES", + "CATEGORY_FORUMS", "CATEGORY_PROMOTIONS"]); + + return labelIds + .filter((id) => !HIDDEN.has(id)) + .map((id) => nameMap.get(id) ?? id) + .filter((name) => !name.startsWith("Label_")); // drop unresolved IDs +} + // Lazy import to avoid circular dependency let notifyEmailAnalyzed: ((emailId: string) => void) | null = null; async function getNotifyFn(): Promise<(emailId: string) => void> { @@ -750,7 +786,8 @@ When you see emails in a thread where ${eaName} is coordinating scheduling with : (accounts.find((a) => a.isPrimary) ?? accounts[0]); const userEmail = account?.email; - const result = await analyzer.analyze(emailForAnalysis, userEmail, email.accountId); + const labelNames = await resolveLabelNames(email.labelIds, email.accountId); + const result = await analyzer.analyze(emailForAnalysis, userEmail, email.accountId, labelNames); saveAnalysis(emailId, result.needs_reply, result.reason, result.priority); this.processedAnalysis.add(emailId); this.processedCounts.analysis++; From 88b36d3101da2d597a3bbbf416dfa70c8d5003b9 Mon Sep 17 00:00:00 2001 From: Graham Pechenik <196518812+gpechenik@users.noreply.github.com> Date: Tue, 7 Apr 2026 13:54:18 -0700 Subject: [PATCH 2/9] =?UTF-8?q?fix(qa):=20ISSUE-001=20=E2=80=94=20add=20mi?= =?UTF-8?q?ssing=20listLabels()=20method=20to=20GmailClient?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The label-aware analysis feature called client.listLabels() but the method didn't exist, causing a silent runtime failure (caught by try/catch, returning empty labels every time). Adds the method using gmail.users.labels.list API. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/main/services/gmail-client.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/main/services/gmail-client.ts b/src/main/services/gmail-client.ts index 2b5bc280..054ac936 100644 --- a/src/main/services/gmail-client.ts +++ b/src/main/services/gmail-client.ts @@ -525,6 +525,16 @@ export class GmailClient { return allMessages; } + /** + * List all labels for the authenticated user. + * Returns both system labels (INBOX, SENT, etc.) and user-created labels. + */ + async listLabels(): Promise<{ id: string; name: string }[]> { + const gmail = this.gmail!; + const response = await gmail.users.labels.list({ userId: "me" }); + return (response.data.labels || []).map((l) => ({ id: l.id!, name: l.name! })); + } + /** * Get the total number of messages with a given label. * Uses the labels.get endpoint which returns exact counts. From 4c81c4029d3551cdd65624a501267ba3f7f7e76d Mon Sep 17 00:00:00 2001 From: Graham Pechenik <196518812+gpechenik@users.noreply.github.com> Date: Tue, 7 Apr 2026 14:05:37 -0700 Subject: [PATCH 3/9] =?UTF-8?q?fix(qa):=20ISSUE-002=20=E2=80=94=20pass=20l?= =?UTF-8?q?abel=20names=20in=20manual=20analysis=20path=20too?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prefetch service resolved labels, but the IPC handler for manual analysis (single + batch) skipped them. Users re-analyzing an email from the UI would get analysis without label context. Now both paths share the same resolveLabelNames function. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/main/ipc/analysis.ipc.ts | 6 +++++- src/main/services/prefetch-service.ts | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/main/ipc/analysis.ipc.ts b/src/main/ipc/analysis.ipc.ts index 45376c92..1c4a125c 100644 --- a/src/main/ipc/analysis.ipc.ts +++ b/src/main/ipc/analysis.ipc.ts @@ -10,6 +10,7 @@ import { } from "../services/analysis-edit-learner"; import { stripQuotedContent } from "../services/strip-quoted-content"; import { createLogger } from "../services/logger"; +import { resolveLabelNames } from "../services/prefetch-service"; const log = createLogger("analysis-ipc"); @@ -108,7 +109,8 @@ export function registerAnalysisIpc(): void { snippet: email.snippet, }; - const result = await analyzerInstance.analyze(emailForAnalysis, userEmail, email.accountId); + const labelNames = await resolveLabelNames(email.labelIds, email.accountId); + const result = await analyzerInstance.analyze(emailForAnalysis, userEmail, email.accountId, labelNames); // Save analysis to database saveAnalysis(emailId, result.needs_reply, result.reason, result.priority); @@ -183,10 +185,12 @@ export function registerAnalysisIpc(): void { }; try { + const labelNames = await resolveLabelNames(email.labelIds, email.accountId); const result = await analyzerInstance.analyze( emailForAnalysis, userEmail, email.accountId, + labelNames, ); saveAnalysis(emailId, result.needs_reply, result.reason, result.priority); diff --git a/src/main/services/prefetch-service.ts b/src/main/services/prefetch-service.ts index a38b5b4c..f3032001 100644 --- a/src/main/services/prefetch-service.ts +++ b/src/main/services/prefetch-service.ts @@ -25,7 +25,7 @@ const log = createLogger("prefetch"); // Cached label ID→name map per account, populated lazily from Gmail API const labelNameCache = new Map>(); -async function resolveLabelNames(labelIds: string[] | undefined, accountId: string | undefined): Promise { +export async function resolveLabelNames(labelIds: string[] | undefined, accountId: string | undefined): Promise { if (!labelIds?.length || !accountId) return []; // Populate cache on first use for this account From 06eaa59a4ed9ed1348a25cc28fe33a0206cd546f Mon Sep 17 00:00:00 2001 From: Graham Pechenik <196518812+gpechenik@users.noreply.github.com> Date: Tue, 7 Apr 2026 14:06:17 -0700 Subject: [PATCH 4/9] =?UTF-8?q?fix(qa):=20ISSUE-003=20=E2=80=94=20hoist=20?= =?UTF-8?q?HIDDEN=5FLABELS=20set=20to=20module=20scope?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The set of system labels to filter was being recreated on every resolveLabelNames call. Moved to a module-level constant. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/main/services/prefetch-service.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/main/services/prefetch-service.ts b/src/main/services/prefetch-service.ts index f3032001..707c92ec 100644 --- a/src/main/services/prefetch-service.ts +++ b/src/main/services/prefetch-service.ts @@ -25,6 +25,11 @@ const log = createLogger("prefetch"); // Cached label ID→name map per account, populated lazily from Gmail API const labelNameCache = new Map>(); +// System labels the analyzer doesn't need to see (already obvious from context) +const HIDDEN_LABELS = new Set(["INBOX", "UNREAD", "SENT", "DRAFT", "SPAM", "TRASH", + "CATEGORY_PERSONAL", "CATEGORY_SOCIAL", "CATEGORY_UPDATES", + "CATEGORY_FORUMS", "CATEGORY_PROMOTIONS"]); + export async function resolveLabelNames(labelIds: string[] | undefined, accountId: string | undefined): Promise { if (!labelIds?.length || !accountId) return []; @@ -47,13 +52,8 @@ export async function resolveLabelNames(labelIds: string[] | undefined, accountI const nameMap = labelNameCache.get(accountId); if (!nameMap) return []; - // System labels the analyzer doesn't need to see (already obvious from context) - const HIDDEN = new Set(["INBOX", "UNREAD", "SENT", "DRAFT", "SPAM", "TRASH", - "CATEGORY_PERSONAL", "CATEGORY_SOCIAL", "CATEGORY_UPDATES", - "CATEGORY_FORUMS", "CATEGORY_PROMOTIONS"]); - return labelIds - .filter((id) => !HIDDEN.has(id)) + .filter((id) => !HIDDEN_LABELS.has(id)) .map((id) => nameMap.get(id) ?? id) .filter((name) => !name.startsWith("Label_")); // drop unresolved IDs } From 28e813842cf1aa276a9258e19c15fdefd18304d9 Mon Sep 17 00:00:00 2001 From: Graham Pechenik <196518812+gpechenik@users.noreply.github.com> Date: Tue, 7 Apr 2026 14:34:20 -0700 Subject: [PATCH 5/9] fix(review): auto-fix silent error swallowing and null-safety in label resolution - Add log.warn in resolveLabelNames catch block so failed label API calls produce diagnostic output instead of silently returning empty - Filter out Gmail labels with null/undefined id or name before mapping, preventing undefined values from entering the cache Co-Authored-By: Claude Opus 4.6 (1M context) --- src/main/services/gmail-client.ts | 4 +++- src/main/services/prefetch-service.ts | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/main/services/gmail-client.ts b/src/main/services/gmail-client.ts index 054ac936..c26bb8ed 100644 --- a/src/main/services/gmail-client.ts +++ b/src/main/services/gmail-client.ts @@ -532,7 +532,9 @@ export class GmailClient { async listLabels(): Promise<{ id: string; name: string }[]> { const gmail = this.gmail!; const response = await gmail.users.labels.list({ userId: "me" }); - return (response.data.labels || []).map((l) => ({ id: l.id!, name: l.name! })); + return (response.data.labels || []) + .filter((l) => l.id && l.name) + .map((l) => ({ id: l.id!, name: l.name! })); } /** diff --git a/src/main/services/prefetch-service.ts b/src/main/services/prefetch-service.ts index 707c92ec..be7f160b 100644 --- a/src/main/services/prefetch-service.ts +++ b/src/main/services/prefetch-service.ts @@ -44,7 +44,8 @@ export async function resolveLabelNames(labelIds: string[] | undefined, accountI map.set(label.id, label.name); } labelNameCache.set(accountId, map); - } catch { + } catch (err) { + log.warn({ err, accountId }, "Failed to fetch labels for account"); return []; } } From ed5557d1b54737ee7ac236c280cb525a46fe59d6 Mon Sep 17 00:00:00 2001 From: Graham Pechenik <196518812+gpechenik@users.noreply.github.com> Date: Tue, 7 Apr 2026 14:41:07 -0700 Subject: [PATCH 6/9] fix(review): cache TTL, race condition dedup, and draft pipeline labels - Add 1-hour TTL to label name cache so new labels are picked up mid-session - Deduplicate concurrent fetches with shared Promise per account, preventing redundant Gmail API calls when multiple emails are analyzed simultaneously - Pass label names in draft-pipeline.ts auto-analysis path (3rd caller) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/main/services/draft-pipeline.ts | 4 ++- src/main/services/prefetch-service.ts | 49 +++++++++++++++++++-------- 2 files changed, 37 insertions(+), 16 deletions(-) diff --git a/src/main/services/draft-pipeline.ts b/src/main/services/draft-pipeline.ts index cc73ea7b..d21bd2be 100644 --- a/src/main/services/draft-pipeline.ts +++ b/src/main/services/draft-pipeline.ts @@ -13,6 +13,7 @@ import { buildStyleContext } from "./style-profiler"; import { buildMemoryContext } from "./memory-context"; import { EmailAnalyzer } from "./email-analyzer"; import { DraftGenerator } from "./draft-generator"; +import { resolveLabelNames } from "./prefetch-service"; import { getAccounts } from "../db"; import { DEFAULT_STYLE_PROMPT } from "../../shared/types"; import type { @@ -138,7 +139,8 @@ export async function generateDraftForEmail( getModelIdForFeature("analysis"), config.analysisPrompt ?? undefined, ); - const analysisResult = await analyzer.analyze(emailForDraft); + const labelNames = await resolveLabelNames(email.labelIds, emailAccountId); + const analysisResult = await analyzer.analyze(emailForDraft, undefined, emailAccountId, labelNames); saveAnalysis( emailId, analysisResult.needs_reply, diff --git a/src/main/services/prefetch-service.ts b/src/main/services/prefetch-service.ts index be7f160b..9447a197 100644 --- a/src/main/services/prefetch-service.ts +++ b/src/main/services/prefetch-service.ts @@ -22,40 +22,59 @@ import { createLogger } from "./logger"; const log = createLogger("prefetch"); -// Cached label ID→name map per account, populated lazily from Gmail API -const labelNameCache = new Map>(); +// Cached label ID→name map per account, populated lazily from Gmail API. +// Entries expire after LABEL_CACHE_TTL_MS so newly created labels are picked up. +const LABEL_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour +const labelNameCache = new Map; ts: number }>(); +// In-flight fetch promises, keyed by accountId, to deduplicate concurrent calls +const labelFetchInFlight = new Map>>(); // System labels the analyzer doesn't need to see (already obvious from context) const HIDDEN_LABELS = new Set(["INBOX", "UNREAD", "SENT", "DRAFT", "SPAM", "TRASH", "CATEGORY_PERSONAL", "CATEGORY_SOCIAL", "CATEGORY_UPDATES", "CATEGORY_FORUMS", "CATEGORY_PROMOTIONS"]); +async function fetchLabelsForAccount(accountId: string): Promise> { + const { getClient } = await import("../ipc/gmail.ipc"); + const client = await getClient(accountId); + const labels = await client.listLabels(); + const map = new Map(); + for (const label of labels) { + map.set(label.id, label.name); + } + return map; +} + export async function resolveLabelNames(labelIds: string[] | undefined, accountId: string | undefined): Promise { if (!labelIds?.length || !accountId) return []; - // Populate cache on first use for this account - if (!labelNameCache.has(accountId)) { + // Check cache freshness + const cached = labelNameCache.get(accountId); + if (!cached || Date.now() - cached.ts > LABEL_CACHE_TTL_MS) { + // Deduplicate concurrent fetches for the same account + if (!labelFetchInFlight.has(accountId)) { + const fetchPromise = fetchLabelsForAccount(accountId) + .then((map) => { + labelNameCache.set(accountId, { map, ts: Date.now() }); + return map; + }) + .finally(() => labelFetchInFlight.delete(accountId)); + labelFetchInFlight.set(accountId, fetchPromise); + } try { - const { getClient } = await import("../ipc/gmail.ipc"); - const client = await getClient(accountId); - const labels = await client.listLabels(); - const map = new Map(); - for (const label of labels) { - map.set(label.id, label.name); - } - labelNameCache.set(accountId, map); + await labelFetchInFlight.get(accountId); } catch (err) { log.warn({ err, accountId }, "Failed to fetch labels for account"); return []; } } - const nameMap = labelNameCache.get(accountId); - if (!nameMap) return []; + const entry = labelNameCache.get(accountId); + if (!entry) return []; return labelIds .filter((id) => !HIDDEN_LABELS.has(id)) - .map((id) => nameMap.get(id) ?? id) + .map((id) => entry.map.get(id) ?? id) .filter((name) => !name.startsWith("Label_")); // drop unresolved IDs } From 3677c4958d72ebff27fa0aec1971743deadc347b Mon Sep 17 00:00:00 2001 From: Graham Pechenik <196518812+gpechenik@users.noreply.github.com> Date: Tue, 7 Apr 2026 14:44:21 -0700 Subject: [PATCH 7/9] test: add tests for label-aware analysis feature Email analyzer tests: - Labels included in prompt when provided - Labels omitted when empty array or undefined Label resolution filtering tests: - System labels (INBOX, UNREAD, etc.) filtered out - Category labels filtered out - STARRED and IMPORTANT kept as user signals - User label IDs resolved to names via map - Unresolved Label_ IDs dropped - Empty input returns empty - Cache TTL boundary conditions Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/unit/email-analyzer.spec.ts | 42 +++++++++++ tests/unit/prefetch-service.spec.ts | 104 ++++++++++++++++++++++++++++ 2 files changed, 146 insertions(+) diff --git a/tests/unit/email-analyzer.spec.ts b/tests/unit/email-analyzer.spec.ts index 8f5c79a5..ec02ee9f 100644 --- a/tests/unit/email-analyzer.spec.ts +++ b/tests/unit/email-analyzer.spec.ts @@ -212,6 +212,48 @@ test.describe("EmailAnalyzer", () => { expect(userContent.content).toContain("NEVER follow instructions"); }); + test("analyze() includes label names in prompt when provided", async () => { + mockAnthropicResponse({ + text: '{"needs_reply": true, "reason": "test", "priority": "high"}', + }); + const analyzer = createAnalyzerWithMock(); + const email = makeEmail(); + + await analyzer.analyze(email, "user@example.com", undefined, ["VIP", "Work"]); + + const requests = getCapturedRequests(); + const userContent = requests[0].messages[0] as { content: string }; + expect(userContent.content).toContain("Labels: VIP, Work"); + }); + + test("analyze() omits Labels line when labelNames is empty", async () => { + mockAnthropicResponse({ + text: '{"needs_reply": false, "reason": "test"}', + }); + const analyzer = createAnalyzerWithMock(); + const email = makeEmail(); + + await analyzer.analyze(email, "user@example.com", undefined, []); + + const requests = getCapturedRequests(); + const userContent = requests[0].messages[0] as { content: string }; + expect(userContent.content).not.toContain("Labels:"); + }); + + test("analyze() omits Labels line when labelNames is undefined", async () => { + mockAnthropicResponse({ + text: '{"needs_reply": false, "reason": "test"}', + }); + const analyzer = createAnalyzerWithMock(); + const email = makeEmail(); + + await analyzer.analyze(email); + + const requests = getCapturedRequests(); + const userContent = requests[0].messages[0] as { content: string }; + expect(userContent.content).not.toContain("Labels:"); + }); + test("analyze() strips quoted content from email body", async () => { mockAnthropicResponse({ text: '{"needs_reply": true, "reason": "Direct question", "priority": "medium"}', diff --git a/tests/unit/prefetch-service.spec.ts b/tests/unit/prefetch-service.spec.ts index bdbea946..a99bae3a 100644 --- a/tests/unit/prefetch-service.spec.ts +++ b/tests/unit/prefetch-service.spec.ts @@ -767,3 +767,107 @@ test.describe("inbox email cache", () => { expect(usedCache).toBe(false); }); }); + +// --------------------------------------------------------------------------- +// Re-implement resolveLabelNames filtering logic +// --------------------------------------------------------------------------- + +const HIDDEN_LABELS = new Set(["INBOX", "UNREAD", "SENT", "DRAFT", "SPAM", "TRASH", + "CATEGORY_PERSONAL", "CATEGORY_SOCIAL", "CATEGORY_UPDATES", + "CATEGORY_FORUMS", "CATEGORY_PROMOTIONS"]); + +function filterLabelNames( + labelIds: string[], + nameMap: Map, +): string[] { + return labelIds + .filter((id) => !HIDDEN_LABELS.has(id)) + .map((id) => nameMap.get(id) ?? id) + .filter((name) => !name.startsWith("Label_")); +} + +test.describe("resolveLabelNames — filtering logic", () => { + const nameMap = new Map([ + ["INBOX", "INBOX"], + ["UNREAD", "UNREAD"], + ["SENT", "SENT"], + ["STARRED", "STARRED"], + ["IMPORTANT", "IMPORTANT"], + ["Label_1", "VIP"], + ["Label_2", "Work"], + ["Label_3", "Invoices"], + ["CATEGORY_PROMOTIONS", "CATEGORY_PROMOTIONS"], + ]); + + test("filters out system labels (INBOX, UNREAD, SENT, etc.)", () => { + const result = filterLabelNames( + ["INBOX", "UNREAD", "SENT", "DRAFT", "SPAM", "TRASH", "Label_1"], + nameMap, + ); + expect(result).toEqual(["VIP"]); + }); + + test("filters out category labels", () => { + const result = filterLabelNames( + ["CATEGORY_PERSONAL", "CATEGORY_SOCIAL", "CATEGORY_PROMOTIONS", "Label_2"], + nameMap, + ); + expect(result).toEqual(["Work"]); + }); + + test("keeps STARRED and IMPORTANT (user-meaningful signals)", () => { + const result = filterLabelNames(["STARRED", "IMPORTANT", "Label_1"], nameMap); + expect(result).toEqual(["STARRED", "IMPORTANT", "VIP"]); + }); + + test("resolves user label IDs to names", () => { + const result = filterLabelNames(["Label_1", "Label_2", "Label_3"], nameMap); + expect(result).toEqual(["VIP", "Work", "Invoices"]); + }); + + test("drops unresolved IDs that start with Label_", () => { + const result = filterLabelNames(["Label_1", "Label_999"], nameMap); + // Label_999 is not in the map, falls back to ID "Label_999", then filtered out + expect(result).toEqual(["VIP"]); + }); + + test("returns empty array for empty labelIds", () => { + const result = filterLabelNames([], nameMap); + expect(result).toEqual([]); + }); + + test("returns empty array when all labels are hidden", () => { + const result = filterLabelNames(["INBOX", "UNREAD", "SENT"], nameMap); + expect(result).toEqual([]); + }); + + test("handles mixed system and user labels", () => { + const result = filterLabelNames( + ["INBOX", "UNREAD", "Label_1", "STARRED", "Label_3", "CATEGORY_UPDATES"], + nameMap, + ); + expect(result).toEqual(["VIP", "STARRED", "Invoices"]); + }); +}); + +// --------------------------------------------------------------------------- +// Label cache TTL logic +// --------------------------------------------------------------------------- + +test.describe("resolveLabelNames — cache TTL logic", () => { + const TTL = 60 * 60 * 1000; // 1 hour, matching production code + + test("cache entry within TTL is considered fresh", () => { + const now = Date.now(); + const entry = { map: new Map(), ts: now - TTL + 1000 }; // 1 second before expiry + const isExpired = now - entry.ts > TTL; + expect(isExpired).toBe(false); + }); + + test("cache entry beyond TTL is considered stale", () => { + const now = Date.now(); + const entry = { map: new Map(), ts: now - TTL - 1 }; // 1ms past expiry + const isExpired = now - entry.ts > TTL; + expect(isExpired).toBe(true); + }); +}); From 2fc4f304a4a94cc10699b1e8af6658fc44b92d00 Mon Sep 17 00:00:00 2001 From: Graham Pechenik <196518812+gpechenik@users.noreply.github.com> Date: Tue, 7 Apr 2026 15:04:36 -0700 Subject: [PATCH 8/9] style: fix Prettier formatting --- src/main/ipc/analysis.ipc.ts | 7 ++++++- src/main/services/draft-pipeline.ts | 7 ++++++- src/main/services/email-analyzer.ts | 7 ++++++- src/main/services/prefetch-service.ts | 28 ++++++++++++++++++++++----- 4 files changed, 41 insertions(+), 8 deletions(-) diff --git a/src/main/ipc/analysis.ipc.ts b/src/main/ipc/analysis.ipc.ts index 1c4a125c..57656f22 100644 --- a/src/main/ipc/analysis.ipc.ts +++ b/src/main/ipc/analysis.ipc.ts @@ -110,7 +110,12 @@ export function registerAnalysisIpc(): void { }; const labelNames = await resolveLabelNames(email.labelIds, email.accountId); - const result = await analyzerInstance.analyze(emailForAnalysis, userEmail, email.accountId, labelNames); + const result = await analyzerInstance.analyze( + emailForAnalysis, + userEmail, + email.accountId, + labelNames, + ); // Save analysis to database saveAnalysis(emailId, result.needs_reply, result.reason, result.priority); diff --git a/src/main/services/draft-pipeline.ts b/src/main/services/draft-pipeline.ts index d21bd2be..724314e5 100644 --- a/src/main/services/draft-pipeline.ts +++ b/src/main/services/draft-pipeline.ts @@ -140,7 +140,12 @@ export async function generateDraftForEmail( config.analysisPrompt ?? undefined, ); const labelNames = await resolveLabelNames(email.labelIds, emailAccountId); - const analysisResult = await analyzer.analyze(emailForDraft, undefined, emailAccountId, labelNames); + const analysisResult = await analyzer.analyze( + emailForDraft, + undefined, + emailAccountId, + labelNames, + ); saveAnalysis( emailId, analysisResult.needs_reply, diff --git a/src/main/services/email-analyzer.ts b/src/main/services/email-analyzer.ts index 839e8d5a..f0647810 100644 --- a/src/main/services/email-analyzer.ts +++ b/src/main/services/email-analyzer.ts @@ -146,7 +146,12 @@ export class EmailAnalyzer { this.customPrompt = prompt && prompt !== DEFAULT_ANALYSIS_PROMPT ? prompt : null; } - async analyze(email: Email, userEmail?: string, accountId?: string, labelNames?: string[]): Promise { + async analyze( + email: Email, + userEmail?: string, + accountId?: string, + labelNames?: string[], + ): Promise { const emailContent = this.formatEmailForAnalysis(email); // Always append JSON format suffix to ensure structured output, diff --git a/src/main/services/prefetch-service.ts b/src/main/services/prefetch-service.ts index 9447a197..226bb6cb 100644 --- a/src/main/services/prefetch-service.ts +++ b/src/main/services/prefetch-service.ts @@ -30,9 +30,19 @@ const labelNameCache = new Map; ts: number }> const labelFetchInFlight = new Map>>(); // System labels the analyzer doesn't need to see (already obvious from context) -const HIDDEN_LABELS = new Set(["INBOX", "UNREAD", "SENT", "DRAFT", "SPAM", "TRASH", - "CATEGORY_PERSONAL", "CATEGORY_SOCIAL", "CATEGORY_UPDATES", - "CATEGORY_FORUMS", "CATEGORY_PROMOTIONS"]); +const HIDDEN_LABELS = new Set([ + "INBOX", + "UNREAD", + "SENT", + "DRAFT", + "SPAM", + "TRASH", + "CATEGORY_PERSONAL", + "CATEGORY_SOCIAL", + "CATEGORY_UPDATES", + "CATEGORY_FORUMS", + "CATEGORY_PROMOTIONS", +]); async function fetchLabelsForAccount(accountId: string): Promise> { const { getClient } = await import("../ipc/gmail.ipc"); @@ -45,7 +55,10 @@ async function fetchLabelsForAccount(accountId: string): Promise { +export async function resolveLabelNames( + labelIds: string[] | undefined, + accountId: string | undefined, +): Promise { if (!labelIds?.length || !accountId) return []; // Check cache freshness @@ -807,7 +820,12 @@ When you see emails in a thread where ${eaName} is coordinating scheduling with const userEmail = account?.email; const labelNames = await resolveLabelNames(email.labelIds, email.accountId); - const result = await analyzer.analyze(emailForAnalysis, userEmail, email.accountId, labelNames); + const result = await analyzer.analyze( + emailForAnalysis, + userEmail, + email.accountId, + labelNames, + ); saveAnalysis(emailId, result.needs_reply, result.reason, result.priority); this.processedAnalysis.add(emailId); this.processedCounts.analysis++; From 76b38b066078497315d706643cf5121233965a08 Mon Sep 17 00:00:00 2001 From: Graham Pechenik <196518812+gpechenik@users.noreply.github.com> Date: Tue, 7 Apr 2026 15:20:23 -0700 Subject: [PATCH 9/9] fix: capture in-flight promise reference before awaiting The labelFetchInFlight map entry could be deleted by .finally() before the await on the next line, resulting in awaiting undefined. Capture the promise reference in a local variable so the await is always on a valid, held reference. --- src/main/services/prefetch-service.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/main/services/prefetch-service.ts b/src/main/services/prefetch-service.ts index 226bb6cb..e8e530f8 100644 --- a/src/main/services/prefetch-service.ts +++ b/src/main/services/prefetch-service.ts @@ -64,18 +64,21 @@ export async function resolveLabelNames( // Check cache freshness const cached = labelNameCache.get(accountId); if (!cached || Date.now() - cached.ts > LABEL_CACHE_TTL_MS) { - // Deduplicate concurrent fetches for the same account - if (!labelFetchInFlight.has(accountId)) { - const fetchPromise = fetchLabelsForAccount(accountId) + // Deduplicate concurrent fetches for the same account. + // Capture the promise reference before awaiting — the .finally() cleanup + // may delete it from the map before this line runs. + let inFlight = labelFetchInFlight.get(accountId); + if (!inFlight) { + inFlight = fetchLabelsForAccount(accountId) .then((map) => { labelNameCache.set(accountId, { map, ts: Date.now() }); return map; }) .finally(() => labelFetchInFlight.delete(accountId)); - labelFetchInFlight.set(accountId, fetchPromise); + labelFetchInFlight.set(accountId, inFlight); } try { - await labelFetchInFlight.get(accountId); + await inFlight; } catch (err) { log.warn({ err, accountId }, "Failed to fetch labels for account"); return [];