Skip to content
Open
11 changes: 10 additions & 1 deletion src/main/ipc/analysis.ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);

Expand Down
9 changes: 8 additions & 1 deletion src/main/services/draft-pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
9 changes: 7 additions & 2 deletions src/main/services/email-analyzer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AnalysisResult> {
async analyze(
email: Email,
userEmail?: string,
accountId?: string,
labelNames?: string[],
): Promise<AnalysisResult> {
const emailContent = this.formatEmailForAnalysis(email);

// Always append JSON format suffix to ensure structured output,
Expand Down Expand Up @@ -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}`,
},
],
},
Expand Down
12 changes: 12 additions & 0 deletions src/main/services/gmail-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
80 changes: 79 additions & 1 deletion src/main/services/prefetch-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { map: Map<string, string>; ts: number }>();
// In-flight fetch promises, keyed by accountId, to deduplicate concurrent calls
const labelFetchInFlight = new Map<string, Promise<Map<string, string>>>();

// 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<Map<string, string>> {
const { getClient } = await import("../ipc/gmail.ipc");
const client = await getClient(accountId);
const labels = await client.listLabels();
const map = new Map<string, string>();
for (const label of labels) {
map.set(label.id, label.name);
}
return map;
}

export async function resolveLabelNames(
labelIds: string[] | undefined,
accountId: string | undefined,
): Promise<string[]> {
if (!labelIds?.length || !accountId) return [];

// Check cache freshness
const cached = labelNameCache.get(accountId);
Comment on lines +64 to +65

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Silent undefined await when in-flight promise completes before lookup

labelFetchInFlight.get(accountId) can return undefined if the promise resolved and the .finally() handler ran before this line executes. await undefined resolves to undefined (no error), so execution falls through to labelNameCache.get(accountId) — which works correctly because the cache was already populated.

This is functionally safe but fragile: a reader unfamiliar with the intent may not realise the cache-read on line 72 is the actual source of truth after this await. Consider capturing the promise reference before the conditional block so the await is always on a valid, held reference:

const inFlight = labelFetchInFlight.get(accountId);
if (inFlight) {
  try { await inFlight; } catch (err) { ... return []; }
}

This makes the intent explicit and avoids dependence on the timing of the .finally() cleanup.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. Fixed in 76b38b0 — the promise reference is now captured in a local variable before the conditional, so the await is always on a valid held reference regardless of .finally() timing.

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> {
Expand Down Expand Up @@ -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++;
Expand Down
42 changes: 42 additions & 0 deletions tests/unit/email-analyzer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"}',
Expand Down
104 changes: 104 additions & 0 deletions tests/unit/prefetch-service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, string>,
): string[] {
return labelIds
.filter((id) => !HIDDEN_LABELS.has(id))
.map((id) => nameMap.get(id) ?? id)
.filter((name) => !name.startsWith("Label_"));
Comment on lines +779 to +786

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Tests re-implement logic instead of testing the real function

The filtering tests define a local filterLabelNames that mirrors resolveLabelNames's internal logic rather than calling the exported function. If HIDDEN_LABELS, the fallback ?? id, or the startsWith("Label_") filter change in prefetch-service.ts, these tests will still pass because they validate their own private copy of the logic.

Similarly, the TTL tests only check that now - ts > TTL is a valid boolean expression — they don't exercise resolveLabelNames's cache-expiry path at all.

A better approach: extract the pure filtering step into an exported helper (e.g. filterResolvedLabelNames(ids, map)), unit-test that directly, and use test doubles / mocking for the Gmail-API-dependent paths to test the full resolveLabelNames flow.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged. The tests re-implement the filtering logic locally because resolveLabelNames has transitive Electron imports (getClient from gmail.ipc) that make direct import impossible in the test environment. Extracting a pure filterResolvedLabelNames helper is a good idea for a follow-up — it would make the filtering testable without mocking the full Gmail client chain.

}

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);
});
});
Loading