diff --git a/src/main/ipc/analysis.ipc.ts b/src/main/ipc/analysis.ipc.ts index 45376c92..57656f22 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,13 @@ 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 +190,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/draft-pipeline.ts b/src/main/services/draft-pipeline.ts index cc73ea7b..724314e5 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,13 @@ 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/email-analyzer.ts b/src/main/services/email-analyzer.ts index adcb9e75..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): 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 +189,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/gmail-client.ts b/src/main/services/gmail-client.ts index 2b5bc280..c26bb8ed 100644 --- a/src/main/services/gmail-client.ts +++ b/src/main/services/gmail-client.ts @@ -525,6 +525,18 @@ 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 || []) + .filter((l) => l.id && l.name) + .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. diff --git a/src/main/services/prefetch-service.ts b/src/main/services/prefetch-service.ts index e97e6d5a..e8e530f8 100644 --- a/src/main/services/prefetch-service.ts +++ b/src/main/services/prefetch-service.ts @@ -22,6 +22,78 @@ import { createLogger } from "./logger"; const log = createLogger("prefetch"); +// 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 []; + + // 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. + // 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, inFlight); + } + try { + await inFlight; + } catch (err) { + log.warn({ err, accountId }, "Failed to fetch labels for account"); + return []; + } + } + + const entry = labelNameCache.get(accountId); + if (!entry) return []; + + return labelIds + .filter((id) => !HIDDEN_LABELS.has(id)) + .map((id) => entry.map.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 +822,13 @@ 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++; 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); + }); +});