From d321173a887c1ed8d70bd9531600824c276779ec Mon Sep 17 00:00:00 2001 From: Graham Pechenik <196518812+gpechenik@users.noreply.github.com> Date: Tue, 7 Apr 2026 16:13:42 -0700 Subject: [PATCH 01/17] Add labels extension: Gmail label management via extension system Adds a new bundled extension (mail-ext-labels) that provides: - Gmail API integration for listing, modifying message/thread labels - IPC handlers for labels operations - LabelsPanel renderer component - Preload API bridge for label operations Co-Authored-By: Claude Opus 4.6 (1M context) --- src/extensions/mail-ext-labels/package.json | 23 ++ src/extensions/mail-ext-labels/src/index.ts | 21 ++ .../mail-ext-labels/src/labels-provider.ts | 109 +++++++ src/main/index.ts | 6 + src/main/ipc/labels.ipc.ts | 103 ++++++ src/main/services/gmail-client.ts | 63 ++++ src/preload/index.ts | 29 ++ .../extensions/bundled/LabelsPanel.tsx | 306 ++++++++++++++++++ src/renderer/extensions/bundled/index.ts | 4 + 9 files changed, 664 insertions(+) create mode 100644 src/extensions/mail-ext-labels/package.json create mode 100644 src/extensions/mail-ext-labels/src/index.ts create mode 100644 src/extensions/mail-ext-labels/src/labels-provider.ts create mode 100644 src/main/ipc/labels.ipc.ts create mode 100644 src/renderer/extensions/bundled/LabelsPanel.tsx diff --git a/src/extensions/mail-ext-labels/package.json b/src/extensions/mail-ext-labels/package.json new file mode 100644 index 00000000..532943e6 --- /dev/null +++ b/src/extensions/mail-ext-labels/package.json @@ -0,0 +1,23 @@ +{ + "name": "mail-ext-labels", + "version": "1.0.0", + "description": "View Gmail labels on emails", + "main": "./src/index.ts", + "mailExtension": { + "id": "labels", + "displayName": "Labels", + "description": "View Gmail labels on emails", + "builtIn": true, + "activationEvents": ["onEmail"], + "contributes": { + "sidebarPanels": [ + { + "id": "email-labels", + "title": "Labels", + "priority": 80, + "scope": "sender" + } + ] + } + } +} diff --git a/src/extensions/mail-ext-labels/src/index.ts b/src/extensions/mail-ext-labels/src/index.ts new file mode 100644 index 00000000..49f50756 --- /dev/null +++ b/src/extensions/mail-ext-labels/src/index.ts @@ -0,0 +1,21 @@ +import type { + ExtensionContext, + ExtensionAPI, + ExtensionModule, +} from "../../../shared/extension-types"; +import { createLabelsProvider } from "./labels-provider"; + +const extension: ExtensionModule = { + async activate(context: ExtensionContext, api: ExtensionAPI): Promise { + context.logger.info("Activating labels extension"); + const provider = createLabelsProvider(context); + api.registerEnrichmentProvider(provider); + context.logger.info("Labels extension activated"); + }, + + async deactivate(): Promise { + console.log("[Ext:labels] Deactivated"); + }, +}; + +export const { activate, deactivate } = extension; diff --git a/src/extensions/mail-ext-labels/src/labels-provider.ts b/src/extensions/mail-ext-labels/src/labels-provider.ts new file mode 100644 index 00000000..de0b4968 --- /dev/null +++ b/src/extensions/mail-ext-labels/src/labels-provider.ts @@ -0,0 +1,109 @@ +import type { + ExtensionContext, + EnrichmentProvider, + EnrichmentData, +} from "../../../shared/extension-types"; +import type { DashboardEmail } from "../../../shared/types"; +import { getClient } from "../../../main/ipc/gmail.ipc"; + +export interface LabelInfo { + id: string; + name: string; + type: string; + color?: { textColor: string; backgroundColor: string }; +} + +// In-memory cache: accountId -> (labelId -> LabelInfo) +const labelCache = new Map>(); + +// System labels that the UI already shows via other means (inbox badge, unread styling, etc.) +const HIDDEN_SYSTEM_LABELS = new Set([ + "INBOX", + "UNREAD", + "SENT", + "DRAFT", + "SPAM", + "TRASH", + "IMPORTANT", + "STARRED", + "CATEGORY_PERSONAL", + "CATEGORY_SOCIAL", + "CATEGORY_UPDATES", + "CATEGORY_FORUMS", + "CATEGORY_PROMOTIONS", +]); + +async function fetchAndCacheLabels( + accountId: string, + logger: ExtensionContext["logger"], +): Promise> { + try { + const client = await getClient(accountId); + const labels = await client.listLabels(); + const map = new Map(); + for (const label of labels) { + map.set(label.id, label); + } + labelCache.set(accountId, map); + logger.info(`Cached ${labels.length} labels for account ${accountId}`); + return map; + } catch (error) { + logger.error("Failed to fetch labels:", error); + return new Map(); + } +} + +export function createLabelsProvider(context: ExtensionContext): EnrichmentProvider { + return { + id: "labels-provider", + panelId: "email-labels", + priority: 90, + + canEnrich(email: DashboardEmail): boolean { + return !!email.labelIds?.length; + }, + + async enrich(email: DashboardEmail): Promise { + const labelIds = email.labelIds; + if (!labelIds?.length) return null; + + const accountId = email.accountId || "default"; + + // Get or fetch label metadata + let labelMap = labelCache.get(accountId); + if (!labelMap) { + labelMap = await fetchAndCacheLabels(accountId, context.logger); + } + + // Resolve label IDs to full label info, filtering out system labels the UI already shows + const resolvedLabels: LabelInfo[] = []; + for (const id of labelIds) { + if (HIDDEN_SYSTEM_LABELS.has(id)) continue; + const info = labelMap.get(id); + if (info) { + resolvedLabels.push(info); + } else { + // Label not in cache — show the raw ID as a fallback + resolvedLabels.push({ id, name: id, type: "unknown" }); + } + } + + // Sort: user labels first (alphabetically), then system labels + resolvedLabels.sort((a, b) => { + if (a.type === "user" && b.type !== "user") return -1; + if (a.type !== "user" && b.type === "user") return 1; + return a.name.localeCompare(b.name); + }); + + return { + extensionId: "labels", + panelId: "email-labels", + data: { + labels: resolvedLabels, + allLabelIds: labelIds, + } as unknown as Record, + expiresAt: Date.now() + 5 * 60 * 1000, // 5 min TTL + }; + }, + }; +} diff --git a/src/main/index.ts b/src/main/index.ts index 6cba35b5..28f9c544 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -19,6 +19,7 @@ ipcMain.on("debug:log", (_, msg: string) => { import { ExtensionManifestSchema } from "../shared/extension-types"; import webSearchPackageJson from "../extensions/mail-ext-web-search/package.json"; import calendarPackageJson from "../extensions/mail-ext-calendar/package.json"; +import labelsPackageJson from "../extensions/mail-ext-labels/package.json"; import { createWindow, getIconPath } from "./window"; import { registerGmailIpc } from "./ipc/gmail.ipc"; import { registerAnalysisIpc } from "./ipc/analysis.ipc"; @@ -32,6 +33,7 @@ import { registerSearchIpc } from "./ipc/search.ipc"; import { registerOutboxIpc, registerNetworkIpc } from "./ipc/outbox.ipc"; import { registerMemoryIpc } from "./ipc/memory.ipc"; import { registerSplitsIpc } from "./ipc/splits.ipc"; +import { registerLabelsIpc } from "./ipc/labels.ipc"; import { registerSnippetsIpc } from "./ipc/snippets.ipc"; import { registerArchiveReadyIpc } from "./ipc/archive-ready.ipc"; import { registerSnoozeIpc } from "./ipc/snooze.ipc"; @@ -55,6 +57,7 @@ import { calendarSyncService } from "./services/calendar-sync"; import { emailSyncService } from "./services/email-sync"; import * as webSearchExtension from "../extensions/mail-ext-web-search/src/index"; import * as calendarExtension from "../extensions/mail-ext-calendar/src/index"; +import * as labelsExtension from "../extensions/mail-ext-labels/src/index"; // Skip Keychain for Chromium's internal cookie/localStorage encryption. // Without this, macOS prompts "wants to access data from other apps" on first launch @@ -517,6 +520,7 @@ app.whenReady().then(async () => { registerOutboxIpc(); registerMemoryIpc(); registerSplitsIpc(); + registerLabelsIpc(); registerSnippetsIpc(); registerArchiveReadyIpc(); registerSnoozeIpc(); @@ -548,10 +552,12 @@ app.whenReady().then(async () => { const webSearchManifest = ExtensionManifestSchema.parse(webSearchPackageJson.mailExtension); const calendarManifest = ExtensionManifestSchema.parse(calendarPackageJson.mailExtension); + const labelsManifest = ExtensionManifestSchema.parse(labelsPackageJson.mailExtension); Promise.all([ extensionHost.registerBundledExtensionFull(webSearchManifest, webSearchExtension), extensionHost.registerBundledExtensionFull(calendarManifest, calendarExtension), + extensionHost.registerBundledExtensionFull(labelsManifest, labelsExtension), ]) .then(() => { log.info("[Extensions] Bundled extensions activated"); diff --git a/src/main/ipc/labels.ipc.ts b/src/main/ipc/labels.ipc.ts new file mode 100644 index 00000000..118af828 --- /dev/null +++ b/src/main/ipc/labels.ipc.ts @@ -0,0 +1,103 @@ +import { ipcMain } from "electron"; +import { getClient } from "./gmail.ipc"; +import { updateEmailLabelIds, getEmailsByThread } from "../db"; +import type { IpcResponse } from "../../shared/types"; + +interface LabelInfo { + id: string; + name: string; + type: string; + color?: { textColor: string; backgroundColor: string }; +} + +export function registerLabelsIpc(): void { + // List all labels for an account + ipcMain.handle( + "labels:list", + async (_, { accountId }: { accountId: string }): Promise> => { + try { + const client = await getClient(accountId); + const labels = await client.listLabels(); + return { success: true, data: labels }; + } catch (error) { + console.error("[Labels] Failed to list labels:", error); + return { success: false, error: String(error) }; + } + }, + ); + + // Modify labels on a single message + ipcMain.handle( + "labels:modify-message", + async ( + _, + { + accountId, + emailId, + addLabelIds, + removeLabelIds, + }: { + accountId: string; + emailId: string; + addLabelIds: string[]; + removeLabelIds: string[]; + }, + ): Promise> => { + try { + const client = await getClient(accountId); + await client.modifyMessageLabels(emailId, addLabelIds, removeLabelIds); + + // Read back the updated message to get authoritative labelIds + const msg = await client.readEmail(emailId); + const newLabelIds = msg?.labelIds ?? []; + + // Update local DB + updateEmailLabelIds(emailId, newLabelIds); + + return { success: true, data: { labelIds: newLabelIds } }; + } catch (error) { + console.error("[Labels] Failed to modify message labels:", error); + return { success: false, error: String(error) }; + } + }, + ); + + // Modify labels on all messages in a thread + ipcMain.handle( + "labels:modify-thread", + async ( + _, + { + accountId, + threadId, + addLabelIds, + removeLabelIds, + }: { + accountId: string; + threadId: string; + addLabelIds: string[]; + removeLabelIds: string[]; + }, + ): Promise> => { + try { + const client = await getClient(accountId); + await client.modifyThreadLabels(threadId, addLabelIds, removeLabelIds); + + // Update local DB for all emails in the thread + const threadEmails = getEmailsByThread(threadId, accountId); + for (const email of threadEmails) { + const currentLabels = email.labelIds ?? []; + const updated = currentLabels + .filter((id) => !removeLabelIds.includes(id)) + .concat(addLabelIds.filter((id) => !currentLabels.includes(id))); + updateEmailLabelIds(email.id, updated); + } + + return { success: true, data: undefined }; + } catch (error) { + console.error("[Labels] Failed to modify thread labels:", error); + return { success: false, error: String(error) }; + } + }, + ); +} diff --git a/src/main/services/gmail-client.ts b/src/main/services/gmail-client.ts index 2b5bc280..7cd6a291 100644 --- a/src/main/services/gmail-client.ts +++ b/src/main/services/gmail-client.ts @@ -525,6 +525,69 @@ export class GmailClient { return allMessages; } + /** + * List all labels for the authenticated account. + * Returns id, name, type, and optional color for each label. + */ + async listLabels(): Promise< + Array<{ + id: string; + name: string; + type: string; + color?: { textColor: string; backgroundColor: string }; + }> + > { + const gmail = this.gmail!; + const response = await gmail.users.labels.list({ userId: "me" }); + const rawLabels = response.data.labels || []; + return rawLabels.map((l) => ({ + id: l.id!, + name: l.name!, + type: l.type || "user", + ...(l.color + ? { color: { textColor: l.color.textColor!, backgroundColor: l.color.backgroundColor! } } + : {}), + })); + } + + /** + * Modify labels on a message (add and/or remove arbitrary labels) + */ + async modifyMessageLabels( + messageId: string, + addLabelIds: string[], + removeLabelIds: string[], + ): Promise { + const gmail = this.gmail!; + await gmail.users.messages.modify({ + userId: "me", + id: messageId, + requestBody: { + addLabelIds: addLabelIds.length > 0 ? addLabelIds : undefined, + removeLabelIds: removeLabelIds.length > 0 ? removeLabelIds : undefined, + }, + }); + } + + /** + * Modify labels on all messages in a thread + */ + async modifyThreadLabels( + threadId: string, + addLabelIds: string[], + removeLabelIds: string[], + ): Promise { + const gmail = this.gmail!; + await gmail.users.threads.modify({ + userId: "me", + id: threadId, + requestBody: { + addLabelIds: addLabelIds.length > 0 ? addLabelIds : undefined, + removeLabelIds: removeLabelIds.length > 0 ? removeLabelIds : undefined, + }, + }); + } + /** * Get the total number of messages with a given label. * Uses the labels.get endpoint which returns exact counts. diff --git a/src/preload/index.ts b/src/preload/index.ts index 9507fecf..d305e751 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -187,6 +187,35 @@ const api = { ipcRenderer.invoke("emails:search-remote", { query, accountId, maxResults, pageToken }), }, + // Label operations + labels: { + list: (accountId: string): Promise => ipcRenderer.invoke("labels:list", { accountId }), + modifyMessage: ( + accountId: string, + emailId: string, + addLabelIds: string[], + removeLabelIds: string[], + ): Promise => + ipcRenderer.invoke("labels:modify-message", { + accountId, + emailId, + addLabelIds, + removeLabelIds, + }), + modifyThread: ( + accountId: string, + threadId: string, + addLabelIds: string[], + removeLabelIds: string[], + ): Promise => + ipcRenderer.invoke("labels:modify-thread", { + accountId, + threadId, + addLabelIds, + removeLabelIds, + }), + }, + // Style operations style: { getContext: (toAddress: string): Promise => diff --git a/src/renderer/extensions/bundled/LabelsPanel.tsx b/src/renderer/extensions/bundled/LabelsPanel.tsx new file mode 100644 index 00000000..7e748d07 --- /dev/null +++ b/src/renderer/extensions/bundled/LabelsPanel.tsx @@ -0,0 +1,306 @@ +import React, { useState, useCallback, useRef, useEffect, useMemo } from "react"; +import type { DashboardEmail } from "../../../shared/types"; +import type { ExtensionEnrichmentResult } from "../../../shared/extension-types"; + +interface LabelInfo { + id: string; + name: string; + type: string; + color?: { textColor: string; backgroundColor: string }; +} + +interface LabelsEnrichmentData { + labels: LabelInfo[]; + allLabelIds: string[]; +} + +interface LabelsPanelProps { + email: DashboardEmail; + threadEmails: DashboardEmail[]; + enrichment: ExtensionEnrichmentResult | null; + isLoading: boolean; +} + +// Type for the labels API on window.api +type LabelsAPI = { + list: (accountId: string) => Promise<{ success: boolean; data?: LabelInfo[] }>; + modifyMessage: ( + accountId: string, + emailId: string, + addLabelIds: string[], + removeLabelIds: string[], + ) => Promise<{ success: boolean; data?: { labelIds: string[] }; error?: string }>; + modifyThread: ( + accountId: string, + threadId: string, + addLabelIds: string[], + removeLabelIds: string[], + ) => Promise<{ success: boolean; error?: string }>; +}; + +declare global { + interface Window { + api: { + labels: LabelsAPI; + [key: string]: unknown; + }; + } +} + +export function LabelsPanel({ + email, + enrichment, + isLoading, +}: LabelsPanelProps): React.ReactElement { + const data = enrichment?.data as LabelsEnrichmentData | undefined; + const [currentLabels, setCurrentLabels] = useState([]); + const [allLabels, setAllLabels] = useState([]); + const [showPicker, setShowPicker] = useState(false); + const [search, setSearch] = useState(""); + const [busy, setBusy] = useState(false); + const [highlightIndex, setHighlightIndex] = useState(0); + const inputRef = useRef(null); + const pickerRef = useRef(null); + const listRef = useRef(null); + + // Sync enrichment data into local state + useEffect(() => { + if (data?.labels) { + setCurrentLabels(data.labels); + } + }, [data]); + + // Fetch all labels for the picker + useEffect(() => { + const accountId = email.accountId || "default"; + window.api.labels.list(accountId).then((result) => { + if (result.success && result.data) { + setAllLabels(result.data); + } + }); + }, [email.accountId]); + + // Focus input when picker opens + useEffect(() => { + if (showPicker && inputRef.current) { + inputRef.current.focus(); + } + }, [showPicker]); + + // Close picker on outside click + useEffect(() => { + if (!showPicker) return; + const handleClick = (e: MouseEvent) => { + if (pickerRef.current && !pickerRef.current.contains(e.target as Node)) { + setShowPicker(false); + setSearch(""); + } + }; + document.addEventListener("mousedown", handleClick); + return () => document.removeEventListener("mousedown", handleClick); + }, [showPicker]); + + const removeLabel = useCallback( + async (labelId: string) => { + const accountId = email.accountId || "default"; + const threadId = email.threadId; + setBusy(true); + // Gmail labels are thread-level — remove from entire thread + const result = await window.api.labels.modifyThread(accountId, threadId, [], [labelId]); + if (result.success) { + setCurrentLabels((prev) => prev.filter((l) => l.id !== labelId)); + } + setBusy(false); + }, + [email.threadId, email.accountId], + ); + + const addLabel = useCallback( + async (label: LabelInfo) => { + const accountId = email.accountId || "default"; + const threadId = email.threadId; + setBusy(true); + // Gmail labels are thread-level — add to entire thread + const result = await window.api.labels.modifyThread(accountId, threadId, [label.id], []); + if (result.success) { + setCurrentLabels((prev) => { + if (prev.some((l) => l.id === label.id)) return prev; + return [...prev, label].sort((a, b) => a.name.localeCompare(b.name)); + }); + } + setShowPicker(false); + setSearch(""); + setBusy(false); + }, + [email.threadId, email.accountId], + ); + + // Labels available to add (not already applied, not system, matches search) + const HIDDEN_SYSTEM = new Set([ + "INBOX", + "UNREAD", + "SENT", + "DRAFT", + "SPAM", + "TRASH", + "IMPORTANT", + "STARRED", + "CATEGORY_PERSONAL", + "CATEGORY_SOCIAL", + "CATEGORY_UPDATES", + "CATEGORY_FORUMS", + "CATEGORY_PROMOTIONS", + ]); + + // Reset highlight when search changes + useEffect(() => { + setHighlightIndex(0); + }, [search]); + + const availableLabels = useMemo(() => { + const currentIds = new Set(currentLabels.map((l) => l.id)); + return allLabels + .filter((l) => !HIDDEN_SYSTEM.has(l.id) && !currentIds.has(l.id)) + .filter((l) => !search || l.name.toLowerCase().includes(search.toLowerCase())) + .sort((a, b) => a.name.localeCompare(b.name)); + }, [allLabels, currentLabels, search]); + + return ( +
+ {isLoading && ( +
+ + + + + Loading labels... +
+ )} + + {!isLoading && ( + <> + {/* Current labels as removable chips */} +
+ {currentLabels.map((label) => ( + + {label.name} + + + ))} + + {currentLabels.length === 0 && ( + No labels + )} +
+ + {/* Add label button / picker */} +
+ {!showPicker ? ( + + ) : ( +
+ setSearch(e.target.value)} + placeholder="Search labels..." + className="w-full px-3 py-2 text-sm bg-transparent border-b border-gray-200 dark:border-gray-600 outline-none text-gray-900 dark:text-gray-100 placeholder-gray-400" + onKeyDown={(e) => { + if (e.key === "Escape") { + setShowPicker(false); + setSearch(""); + } else if (e.key === "ArrowDown") { + e.preventDefault(); + setHighlightIndex((i) => + Math.min(i + 1, Math.min(availableLabels.length - 1, 49)), + ); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setHighlightIndex((i) => Math.max(i - 1, 0)); + } else if (e.key === "Enter" && availableLabels.length > 0) { + addLabel( + availableLabels[Math.min(highlightIndex, availableLabels.length - 1)], + ); + } + }} + /> +
+ {availableLabels.length === 0 && ( +
No matching labels
+ )} + {availableLabels.slice(0, 50).map((label, index) => ( + + ))} +
+
+ )} +
+ + )} +
+ ); +} diff --git a/src/renderer/extensions/bundled/index.ts b/src/renderer/extensions/bundled/index.ts index fe91d349..dfecf0f8 100644 --- a/src/renderer/extensions/bundled/index.ts +++ b/src/renderer/extensions/bundled/index.ts @@ -7,6 +7,7 @@ import { registerPanelComponent } from "../ExtensionPanelSlot"; import { SenderProfilePanel } from "./SenderProfilePanel"; import { CalendarPanel } from "../../../extensions/mail-ext-calendar/src/renderer/CalendarPanel"; +import { LabelsPanel } from "./LabelsPanel"; import { registerPrivateExtensions } from "../private-extensions"; import { loadInstalledExtensionPanels } from "../installed-extensions"; @@ -21,6 +22,9 @@ export function registerBundledExtensions(): void { // Register calendar extension's day-view panel registerPanelComponent("calendar", "day-view", CalendarPanel); + // Register labels extension's panel + registerPanelComponent("labels", "email-labels", LabelsPanel); + // Register private extension panels (loaded from extensions-private/) registerPrivateExtensions(); From 294871d6dc95e928389f9ac1ece1fedfcc7fea3e Mon Sep 17 00:00:00 2001 From: Graham Pechenik <196518812+gpechenik@users.noreply.github.com> Date: Tue, 7 Apr 2026 16:16:48 -0700 Subject: [PATCH 02/17] =?UTF-8?q?fix(qa):=20ISSUE-001=20=E2=80=94=20add=20?= =?UTF-8?q?explicit=20type=20to=20labels=20list=20callback=20to=20fix=20TS?= =?UTF-8?q?=20error?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- src/renderer/extensions/bundled/LabelsPanel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/extensions/bundled/LabelsPanel.tsx b/src/renderer/extensions/bundled/LabelsPanel.tsx index 7e748d07..59cb1c1f 100644 --- a/src/renderer/extensions/bundled/LabelsPanel.tsx +++ b/src/renderer/extensions/bundled/LabelsPanel.tsx @@ -73,7 +73,7 @@ export function LabelsPanel({ // Fetch all labels for the picker useEffect(() => { const accountId = email.accountId || "default"; - window.api.labels.list(accountId).then((result) => { + window.api.labels.list(accountId).then((result: { success: boolean; data?: LabelInfo[] }) => { if (result.success && result.data) { setAllLabels(result.data); } From 046a691cad875643595178e86de9e3f4682a63bb Mon Sep 17 00:00:00 2001 From: Graham Pechenik <196518812+gpechenik@users.noreply.github.com> Date: Tue, 7 Apr 2026 16:17:08 -0700 Subject: [PATCH 03/17] =?UTF-8?q?fix(qa):=20ISSUE-002=20=E2=80=94=20use=20?= =?UTF-8?q?createLogger=20instead=20of=20console.error=20in=20labels=20IPC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Matches the established pattern in all other IPC handlers (sync.ipc.ts, archive-ready.ipc.ts, etc.) for structured JSON logging with redaction. Uses pino's { err: error } merge object convention. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/main/ipc/labels.ipc.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/main/ipc/labels.ipc.ts b/src/main/ipc/labels.ipc.ts index 118af828..00620675 100644 --- a/src/main/ipc/labels.ipc.ts +++ b/src/main/ipc/labels.ipc.ts @@ -2,6 +2,9 @@ import { ipcMain } from "electron"; import { getClient } from "./gmail.ipc"; import { updateEmailLabelIds, getEmailsByThread } from "../db"; import type { IpcResponse } from "../../shared/types"; +import { createLogger } from "../services/logger"; + +const log = createLogger("labels-ipc"); interface LabelInfo { id: string; @@ -20,7 +23,7 @@ export function registerLabelsIpc(): void { const labels = await client.listLabels(); return { success: true, data: labels }; } catch (error) { - console.error("[Labels] Failed to list labels:", error); + log.error({ err: error }, "Failed to list labels"); return { success: false, error: String(error) }; } }, @@ -56,7 +59,7 @@ export function registerLabelsIpc(): void { return { success: true, data: { labelIds: newLabelIds } }; } catch (error) { - console.error("[Labels] Failed to modify message labels:", error); + log.error({ err: error }, "Failed to modify message labels"); return { success: false, error: String(error) }; } }, @@ -95,7 +98,7 @@ export function registerLabelsIpc(): void { return { success: true, data: undefined }; } catch (error) { - console.error("[Labels] Failed to modify thread labels:", error); + log.error({ err: error }, "Failed to modify thread labels"); return { success: false, error: String(error) }; } }, From 5e273db1c14f3f508a58c7c6943acdcb67bec687 Mon Sep 17 00:00:00 2001 From: Graham Pechenik <196518812+gpechenik@users.noreply.github.com> Date: Tue, 7 Apr 2026 16:19:56 -0700 Subject: [PATCH 04/17] =?UTF-8?q?fix(qa):=20ISSUE-003=20=E2=80=94=20read?= =?UTF-8?q?=20back=20authoritative=20labels=20from=20Gmail=20after=20threa?= =?UTF-8?q?d=20modification?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The modifyThread handler was computing labels locally with filter+concat, which could diverge from Gmail's actual state. Now reads back each message's labelIds from Gmail after modification, matching the pattern used by modifyMessage. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/main/ipc/labels.ipc.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/main/ipc/labels.ipc.ts b/src/main/ipc/labels.ipc.ts index 00620675..85c51667 100644 --- a/src/main/ipc/labels.ipc.ts +++ b/src/main/ipc/labels.ipc.ts @@ -86,14 +86,12 @@ export function registerLabelsIpc(): void { const client = await getClient(accountId); await client.modifyThreadLabels(threadId, addLabelIds, removeLabelIds); - // Update local DB for all emails in the thread + // Read back authoritative labelIds from Gmail for each thread message const threadEmails = getEmailsByThread(threadId, accountId); for (const email of threadEmails) { - const currentLabels = email.labelIds ?? []; - const updated = currentLabels - .filter((id) => !removeLabelIds.includes(id)) - .concat(addLabelIds.filter((id) => !currentLabels.includes(id))); - updateEmailLabelIds(email.id, updated); + const msg = await client.readEmail(email.id); + const newLabelIds = msg?.labelIds ?? []; + updateEmailLabelIds(email.id, newLabelIds); } return { success: true, data: undefined }; From 9cad0b699d81c840c8f350f9f2c82c5443d38aa1 Mon Sep 17 00:00:00 2001 From: Graham Pechenik <196518812+gpechenik@users.noreply.github.com> Date: Tue, 7 Apr 2026 16:20:06 -0700 Subject: [PATCH 05/17] =?UTF-8?q?fix(qa):=20ISSUE-004=20=E2=80=94=20handle?= =?UTF-8?q?=20rejected=20promise=20in=20labels=20list=20fetch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without a .catch(), an IPC failure would produce an unhandled rejection warning. The picker gracefully degrades to empty when labels can't load. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/renderer/extensions/bundled/LabelsPanel.tsx | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/renderer/extensions/bundled/LabelsPanel.tsx b/src/renderer/extensions/bundled/LabelsPanel.tsx index 59cb1c1f..82b485ae 100644 --- a/src/renderer/extensions/bundled/LabelsPanel.tsx +++ b/src/renderer/extensions/bundled/LabelsPanel.tsx @@ -73,11 +73,16 @@ export function LabelsPanel({ // Fetch all labels for the picker useEffect(() => { const accountId = email.accountId || "default"; - window.api.labels.list(accountId).then((result: { success: boolean; data?: LabelInfo[] }) => { - if (result.success && result.data) { - setAllLabels(result.data); - } - }); + window.api.labels + .list(accountId) + .then((result: { success: boolean; data?: LabelInfo[] }) => { + if (result.success && result.data) { + setAllLabels(result.data); + } + }) + .catch(() => { + // Labels list failed — picker will show empty, which is safe + }); }, [email.accountId]); // Focus input when picker opens From fcfd9bdd6e1007ccf1f0ed6a7f443b2fdaaef8cc Mon Sep 17 00:00:00 2001 From: Graham Pechenik <196518812+gpechenik@users.noreply.github.com> Date: Tue, 7 Apr 2026 16:53:24 -0700 Subject: [PATCH 06/17] fix(review): auto-fix 7 issues found during pre-landing review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix scope: "sender" → "email" in labels manifest (data correctness bug: sender-scoped enrichment cache serves wrong labels across emails) - Fix canEnrich to always return true (unlabeled emails need "Add label" button) - Add try/finally in removeLabel and addLabel (prevents stuck busy state on error) - Remove unused listRef and threadEmails destructuring - Move HIDDEN_SYSTEM set to module scope (was recreated every render) - Add early return for no-op label modifications (empty arrays) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/extensions/mail-ext-labels/package.json | 2 +- .../mail-ext-labels/src/labels-provider.ts | 5 +- src/main/services/gmail-client.ts | 2 + .../extensions/bundled/LabelsPanel.tsx | 73 ++++++++++--------- 4 files changed, 45 insertions(+), 37 deletions(-) diff --git a/src/extensions/mail-ext-labels/package.json b/src/extensions/mail-ext-labels/package.json index 532943e6..49835b58 100644 --- a/src/extensions/mail-ext-labels/package.json +++ b/src/extensions/mail-ext-labels/package.json @@ -15,7 +15,7 @@ "id": "email-labels", "title": "Labels", "priority": 80, - "scope": "sender" + "scope": "email" } ] } diff --git a/src/extensions/mail-ext-labels/src/labels-provider.ts b/src/extensions/mail-ext-labels/src/labels-provider.ts index de0b4968..89a9b116 100644 --- a/src/extensions/mail-ext-labels/src/labels-provider.ts +++ b/src/extensions/mail-ext-labels/src/labels-provider.ts @@ -59,8 +59,9 @@ export function createLabelsProvider(context: ExtensionContext): EnrichmentProvi panelId: "email-labels", priority: 90, - canEnrich(email: DashboardEmail): boolean { - return !!email.labelIds?.length; + canEnrich(_email: DashboardEmail): boolean { + // Always show the labels panel — even unlabeled emails need the "Add label" button + return true; }, async enrich(email: DashboardEmail): Promise { diff --git a/src/main/services/gmail-client.ts b/src/main/services/gmail-client.ts index 7cd6a291..23067d66 100644 --- a/src/main/services/gmail-client.ts +++ b/src/main/services/gmail-client.ts @@ -558,6 +558,7 @@ export class GmailClient { addLabelIds: string[], removeLabelIds: string[], ): Promise { + if (addLabelIds.length === 0 && removeLabelIds.length === 0) return; const gmail = this.gmail!; await gmail.users.messages.modify({ userId: "me", @@ -577,6 +578,7 @@ export class GmailClient { addLabelIds: string[], removeLabelIds: string[], ): Promise { + if (addLabelIds.length === 0 && removeLabelIds.length === 0) return; const gmail = this.gmail!; await gmail.users.threads.modify({ userId: "me", diff --git a/src/renderer/extensions/bundled/LabelsPanel.tsx b/src/renderer/extensions/bundled/LabelsPanel.tsx index 82b485ae..79659157 100644 --- a/src/renderer/extensions/bundled/LabelsPanel.tsx +++ b/src/renderer/extensions/bundled/LabelsPanel.tsx @@ -47,6 +47,23 @@ declare global { } } +// System labels the UI already shows via other means — filter from picker and display +const HIDDEN_SYSTEM = new Set([ + "INBOX", + "UNREAD", + "SENT", + "DRAFT", + "SPAM", + "TRASH", + "IMPORTANT", + "STARRED", + "CATEGORY_PERSONAL", + "CATEGORY_SOCIAL", + "CATEGORY_UPDATES", + "CATEGORY_FORUMS", + "CATEGORY_PROMOTIONS", +]); + export function LabelsPanel({ email, enrichment, @@ -61,7 +78,6 @@ export function LabelsPanel({ const [highlightIndex, setHighlightIndex] = useState(0); const inputRef = useRef(null); const pickerRef = useRef(null); - const listRef = useRef(null); // Sync enrichment data into local state useEffect(() => { @@ -110,12 +126,15 @@ export function LabelsPanel({ const accountId = email.accountId || "default"; const threadId = email.threadId; setBusy(true); - // Gmail labels are thread-level — remove from entire thread - const result = await window.api.labels.modifyThread(accountId, threadId, [], [labelId]); - if (result.success) { - setCurrentLabels((prev) => prev.filter((l) => l.id !== labelId)); + try { + // Gmail labels are thread-level — remove from entire thread + const result = await window.api.labels.modifyThread(accountId, threadId, [], [labelId]); + if (result.success) { + setCurrentLabels((prev) => prev.filter((l) => l.id !== labelId)); + } + } finally { + setBusy(false); } - setBusy(false); }, [email.threadId, email.accountId], ); @@ -125,38 +144,24 @@ export function LabelsPanel({ const accountId = email.accountId || "default"; const threadId = email.threadId; setBusy(true); - // Gmail labels are thread-level — add to entire thread - const result = await window.api.labels.modifyThread(accountId, threadId, [label.id], []); - if (result.success) { - setCurrentLabels((prev) => { - if (prev.some((l) => l.id === label.id)) return prev; - return [...prev, label].sort((a, b) => a.name.localeCompare(b.name)); - }); + try { + // Gmail labels are thread-level — add to entire thread + const result = await window.api.labels.modifyThread(accountId, threadId, [label.id], []); + if (result.success) { + setCurrentLabels((prev) => { + if (prev.some((l) => l.id === label.id)) return prev; + return [...prev, label].sort((a, b) => a.name.localeCompare(b.name)); + }); + } + setShowPicker(false); + setSearch(""); + } finally { + setBusy(false); } - setShowPicker(false); - setSearch(""); - setBusy(false); }, [email.threadId, email.accountId], ); - // Labels available to add (not already applied, not system, matches search) - const HIDDEN_SYSTEM = new Set([ - "INBOX", - "UNREAD", - "SENT", - "DRAFT", - "SPAM", - "TRASH", - "IMPORTANT", - "STARRED", - "CATEGORY_PERSONAL", - "CATEGORY_SOCIAL", - "CATEGORY_UPDATES", - "CATEGORY_FORUMS", - "CATEGORY_PROMOTIONS", - ]); - // Reset highlight when search changes useEffect(() => { setHighlightIndex(0); @@ -271,7 +276,7 @@ export function LabelsPanel({ } }} /> -
+
{availableLabels.length === 0 && (
No matching labels
)} From e8b9f29b9cdd1578ca4c63ab0b5014fdcf90eec1 Mon Sep 17 00:00:00 2001 From: Graham Pechenik <196518812+gpechenik@users.noreply.github.com> Date: Tue, 7 Apr 2026 17:02:09 -0700 Subject: [PATCH 07/17] fix(review): parallelize thread readback + extract shared LabelInfo type - Parallelize readEmail calls in modifyThread with Promise.all instead of sequential for-loop (50-message thread was 50 sequential full fetches) - Extract LabelInfo interface to shared/types.ts, removing duplicates from labels-provider.ts, labels.ipc.ts, LabelsPanel.tsx, and gmail-client.ts Co-Authored-By: Claude Opus 4.6 (1M context) --- .../mail-ext-labels/src/labels-provider.ts | 9 +------- src/main/ipc/labels.ipc.ts | 22 +++++++------------ src/main/services/gmail-client.ts | 10 ++------- .../extensions/bundled/LabelsPanel.tsx | 9 +------- src/shared/types.ts | 8 +++++++ 5 files changed, 20 insertions(+), 38 deletions(-) diff --git a/src/extensions/mail-ext-labels/src/labels-provider.ts b/src/extensions/mail-ext-labels/src/labels-provider.ts index 89a9b116..89715f9f 100644 --- a/src/extensions/mail-ext-labels/src/labels-provider.ts +++ b/src/extensions/mail-ext-labels/src/labels-provider.ts @@ -3,16 +3,9 @@ import type { EnrichmentProvider, EnrichmentData, } from "../../../shared/extension-types"; -import type { DashboardEmail } from "../../../shared/types"; +import type { DashboardEmail, LabelInfo } from "../../../shared/types"; import { getClient } from "../../../main/ipc/gmail.ipc"; -export interface LabelInfo { - id: string; - name: string; - type: string; - color?: { textColor: string; backgroundColor: string }; -} - // In-memory cache: accountId -> (labelId -> LabelInfo) const labelCache = new Map>(); diff --git a/src/main/ipc/labels.ipc.ts b/src/main/ipc/labels.ipc.ts index 85c51667..17cb91aa 100644 --- a/src/main/ipc/labels.ipc.ts +++ b/src/main/ipc/labels.ipc.ts @@ -1,18 +1,11 @@ import { ipcMain } from "electron"; import { getClient } from "./gmail.ipc"; import { updateEmailLabelIds, getEmailsByThread } from "../db"; -import type { IpcResponse } from "../../shared/types"; +import type { IpcResponse, LabelInfo } from "../../shared/types"; import { createLogger } from "../services/logger"; const log = createLogger("labels-ipc"); -interface LabelInfo { - id: string; - name: string; - type: string; - color?: { textColor: string; backgroundColor: string }; -} - export function registerLabelsIpc(): void { // List all labels for an account ipcMain.handle( @@ -86,13 +79,14 @@ export function registerLabelsIpc(): void { const client = await getClient(accountId); await client.modifyThreadLabels(threadId, addLabelIds, removeLabelIds); - // Read back authoritative labelIds from Gmail for each thread message + // Read back authoritative labelIds from Gmail for each thread message (parallel) const threadEmails = getEmailsByThread(threadId, accountId); - for (const email of threadEmails) { - const msg = await client.readEmail(email.id); - const newLabelIds = msg?.labelIds ?? []; - updateEmailLabelIds(email.id, newLabelIds); - } + await Promise.all( + threadEmails.map(async (email) => { + const msg = await client.readEmail(email.id); + updateEmailLabelIds(email.id, msg?.labelIds ?? []); + }), + ); return { success: true, data: undefined }; } catch (error) { diff --git a/src/main/services/gmail-client.ts b/src/main/services/gmail-client.ts index 23067d66..fe0e8c94 100644 --- a/src/main/services/gmail-client.ts +++ b/src/main/services/gmail-client.ts @@ -17,6 +17,7 @@ import type { ComposeMessageOptions, AttachmentMeta, SendAsAlias, + LabelInfo, } from "../../shared/types"; import { getAccounts } from "../db"; import { getDataDir } from "../data-dir"; @@ -529,14 +530,7 @@ export class GmailClient { * List all labels for the authenticated account. * Returns id, name, type, and optional color for each label. */ - async listLabels(): Promise< - Array<{ - id: string; - name: string; - type: string; - color?: { textColor: string; backgroundColor: string }; - }> - > { + async listLabels(): Promise { const gmail = this.gmail!; const response = await gmail.users.labels.list({ userId: "me" }); const rawLabels = response.data.labels || []; diff --git a/src/renderer/extensions/bundled/LabelsPanel.tsx b/src/renderer/extensions/bundled/LabelsPanel.tsx index 79659157..bca658a6 100644 --- a/src/renderer/extensions/bundled/LabelsPanel.tsx +++ b/src/renderer/extensions/bundled/LabelsPanel.tsx @@ -1,14 +1,7 @@ import React, { useState, useCallback, useRef, useEffect, useMemo } from "react"; -import type { DashboardEmail } from "../../../shared/types"; +import type { DashboardEmail, LabelInfo } from "../../../shared/types"; import type { ExtensionEnrichmentResult } from "../../../shared/extension-types"; -interface LabelInfo { - id: string; - name: string; - type: string; - color?: { textColor: string; backgroundColor: string }; -} - interface LabelsEnrichmentData { labels: LabelInfo[]; allLabelIds: string[]; diff --git a/src/shared/types.ts b/src/shared/types.ts index b3a8ae36..dfd991a6 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -11,6 +11,14 @@ export const AttachmentMetaSchema = z.object({ export type AttachmentMeta = z.infer; +// Gmail label metadata +export interface LabelInfo { + id: string; + name: string; + type: string; + color?: { textColor: string; backgroundColor: string }; +} + // Email from Gmail API export const EmailSchema = z.object({ id: z.string(), From 4dd9a78390549cdc8f281729b955ba9c173395d4 Mon Sep 17 00:00:00 2001 From: Graham Pechenik <196518812+gpechenik@users.noreply.github.com> Date: Tue, 7 Apr 2026 17:13:27 -0700 Subject: [PATCH 08/17] fix: bypass sender-scoped enrichment cache for labels The extension host caches enrichment data by sender email when scope is "sender". This means all emails from the same person return the same cached labels, which is wrong (different emails have different labels). Fix: keep "sender" scope for Sender tab placement, but make the enrichment provider a no-op (returns null). LabelsPanel now resolves labels directly from email.labelIds via window.api.labels.list(), bypassing the enrichment cache entirely. Labels update correctly when navigating between emails from the same sender. --- src/extensions/mail-ext-labels/package.json | 2 +- .../mail-ext-labels/src/labels-provider.ts | 101 +++--------------- .../extensions/bundled/LabelsPanel.tsx | 41 +++---- 3 files changed, 36 insertions(+), 108 deletions(-) diff --git a/src/extensions/mail-ext-labels/package.json b/src/extensions/mail-ext-labels/package.json index 49835b58..532943e6 100644 --- a/src/extensions/mail-ext-labels/package.json +++ b/src/extensions/mail-ext-labels/package.json @@ -15,7 +15,7 @@ "id": "email-labels", "title": "Labels", "priority": 80, - "scope": "email" + "scope": "sender" } ] } diff --git a/src/extensions/mail-ext-labels/src/labels-provider.ts b/src/extensions/mail-ext-labels/src/labels-provider.ts index 89715f9f..414e5051 100644 --- a/src/extensions/mail-ext-labels/src/labels-provider.ts +++ b/src/extensions/mail-ext-labels/src/labels-provider.ts @@ -3,101 +3,28 @@ import type { EnrichmentProvider, EnrichmentData, } from "../../../shared/extension-types"; -import type { DashboardEmail, LabelInfo } from "../../../shared/types"; -import { getClient } from "../../../main/ipc/gmail.ipc"; - -// In-memory cache: accountId -> (labelId -> LabelInfo) -const labelCache = new Map>(); - -// System labels that the UI already shows via other means (inbox badge, unread styling, etc.) -const HIDDEN_SYSTEM_LABELS = new Set([ - "INBOX", - "UNREAD", - "SENT", - "DRAFT", - "SPAM", - "TRASH", - "IMPORTANT", - "STARRED", - "CATEGORY_PERSONAL", - "CATEGORY_SOCIAL", - "CATEGORY_UPDATES", - "CATEGORY_FORUMS", - "CATEGORY_PROMOTIONS", -]); - -async function fetchAndCacheLabels( - accountId: string, - logger: ExtensionContext["logger"], -): Promise> { - try { - const client = await getClient(accountId); - const labels = await client.listLabels(); - const map = new Map(); - for (const label of labels) { - map.set(label.id, label); - } - labelCache.set(accountId, map); - logger.info(`Cached ${labels.length} labels for account ${accountId}`); - return map; - } catch (error) { - logger.error("Failed to fetch labels:", error); - return new Map(); - } -} - -export function createLabelsProvider(context: ExtensionContext): EnrichmentProvider { +import type { DashboardEmail } from "../../../shared/types"; + +/** + * Labels enrichment provider — registered to keep the sidebar panel active, + * but returns null for enrichment data. The LabelsPanel component resolves + * labels directly via window.api.labels to avoid the sender-scoped enrichment + * cache (which would serve stale labels across different emails from the same sender). + */ +export function createLabelsProvider(_context: ExtensionContext): EnrichmentProvider { return { id: "labels-provider", panelId: "email-labels", priority: 90, - canEnrich(_email: DashboardEmail): boolean { - // Always show the labels panel — even unlabeled emails need the "Add label" button + canEnrich(): boolean { return true; }, - async enrich(email: DashboardEmail): Promise { - const labelIds = email.labelIds; - if (!labelIds?.length) return null; - - const accountId = email.accountId || "default"; - - // Get or fetch label metadata - let labelMap = labelCache.get(accountId); - if (!labelMap) { - labelMap = await fetchAndCacheLabels(accountId, context.logger); - } - - // Resolve label IDs to full label info, filtering out system labels the UI already shows - const resolvedLabels: LabelInfo[] = []; - for (const id of labelIds) { - if (HIDDEN_SYSTEM_LABELS.has(id)) continue; - const info = labelMap.get(id); - if (info) { - resolvedLabels.push(info); - } else { - // Label not in cache — show the raw ID as a fallback - resolvedLabels.push({ id, name: id, type: "unknown" }); - } - } - - // Sort: user labels first (alphabetically), then system labels - resolvedLabels.sort((a, b) => { - if (a.type === "user" && b.type !== "user") return -1; - if (a.type !== "user" && b.type === "user") return 1; - return a.name.localeCompare(b.name); - }); - - return { - extensionId: "labels", - panelId: "email-labels", - data: { - labels: resolvedLabels, - allLabelIds: labelIds, - } as unknown as Record, - expiresAt: Date.now() + 5 * 60 * 1000, // 5 min TTL - }; + async enrich(_email: DashboardEmail): Promise { + // Labels are resolved directly in LabelsPanel via window.api.labels + // to avoid sender-scoped enrichment cache returning wrong labels. + return null; }, }; } diff --git a/src/renderer/extensions/bundled/LabelsPanel.tsx b/src/renderer/extensions/bundled/LabelsPanel.tsx index bca658a6..d8db8db5 100644 --- a/src/renderer/extensions/bundled/LabelsPanel.tsx +++ b/src/renderer/extensions/bundled/LabelsPanel.tsx @@ -2,11 +2,6 @@ import React, { useState, useCallback, useRef, useEffect, useMemo } from "react" import type { DashboardEmail, LabelInfo } from "../../../shared/types"; import type { ExtensionEnrichmentResult } from "../../../shared/extension-types"; -interface LabelsEnrichmentData { - labels: LabelInfo[]; - allLabelIds: string[]; -} - interface LabelsPanelProps { email: DashboardEmail; threadEmails: DashboardEmail[]; @@ -59,12 +54,10 @@ const HIDDEN_SYSTEM = new Set([ export function LabelsPanel({ email, - enrichment, - isLoading, }: LabelsPanelProps): React.ReactElement { - const data = enrichment?.data as LabelsEnrichmentData | undefined; const [currentLabels, setCurrentLabels] = useState([]); const [allLabels, setAllLabels] = useState([]); + const [labelsLoaded, setLabelsLoaded] = useState(false); const [showPicker, setShowPicker] = useState(false); const [search, setSearch] = useState(""); const [busy, setBusy] = useState(false); @@ -72,27 +65,35 @@ export function LabelsPanel({ const inputRef = useRef(null); const pickerRef = useRef(null); - // Sync enrichment data into local state - useEffect(() => { - if (data?.labels) { - setCurrentLabels(data.labels); - } - }, [data]); - - // Fetch all labels for the picker + // Fetch all labels and resolve current email's labels directly + // (bypasses sender-scoped enrichment cache which would serve wrong labels) useEffect(() => { const accountId = email.accountId || "default"; + setLabelsLoaded(false); window.api.labels .list(accountId) .then((result: { success: boolean; data?: LabelInfo[] }) => { if (result.success && result.data) { setAllLabels(result.data); + + // Resolve this email's labelIds to full label info + const labelIds = email.labelIds ?? []; + const labelMap = new Map(result.data.map((l) => [l.id, l])); + const resolved: LabelInfo[] = []; + for (const id of labelIds) { + if (HIDDEN_SYSTEM.has(id)) continue; + const info = labelMap.get(id); + if (info) resolved.push(info); + } + resolved.sort((a, b) => a.name.localeCompare(b.name)); + setCurrentLabels(resolved); } + setLabelsLoaded(true); }) .catch(() => { - // Labels list failed — picker will show empty, which is safe + setLabelsLoaded(true); }); - }, [email.accountId]); + }, [email.id, email.accountId, email.labelIds]); // Focus input when picker opens useEffect(() => { @@ -170,7 +171,7 @@ export function LabelsPanel({ return (
- {isLoading && ( + {!labelsLoaded && (
)} - {!isLoading && ( + {labelsLoaded && ( <> {/* Current labels as removable chips */}
From 7080fea9e85550c5bff7a27bfb8b6ec29bc2f287 Mon Sep 17 00:00:00 2001 From: Graham Pechenik <196518812+gpechenik@users.noreply.github.com> Date: Tue, 7 Apr 2026 17:21:38 -0700 Subject: [PATCH 09/17] feat: add 'l' keyboard shortcut to open label picker Pressing 'l' when an email is selected switches to the Sender tab and opens the label picker in the Labels panel. Matches Gmail's native label shortcut. Closes the picker state immediately after triggering so the panel manages its own open/close lifecycle. Addresses the keyboard shortcut requested in #76. --- src/renderer/extensions/bundled/LabelsPanel.tsx | 15 ++++++++++++--- src/renderer/hooks/useKeyboardShortcuts.ts | 9 +++++++++ src/renderer/store/index.ts | 14 ++++++++++++++ 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/src/renderer/extensions/bundled/LabelsPanel.tsx b/src/renderer/extensions/bundled/LabelsPanel.tsx index d8db8db5..c2e69488 100644 --- a/src/renderer/extensions/bundled/LabelsPanel.tsx +++ b/src/renderer/extensions/bundled/LabelsPanel.tsx @@ -1,6 +1,7 @@ import React, { useState, useCallback, useRef, useEffect, useMemo } from "react"; import type { DashboardEmail, LabelInfo } from "../../../shared/types"; import type { ExtensionEnrichmentResult } from "../../../shared/extension-types"; +import { useAppStore } from "../../store"; interface LabelsPanelProps { email: DashboardEmail; @@ -52,9 +53,7 @@ const HIDDEN_SYSTEM = new Set([ "CATEGORY_PROMOTIONS", ]); -export function LabelsPanel({ - email, -}: LabelsPanelProps): React.ReactElement { +export function LabelsPanel({ email }: LabelsPanelProps): React.ReactElement { const [currentLabels, setCurrentLabels] = useState([]); const [allLabels, setAllLabels] = useState([]); const [labelsLoaded, setLabelsLoaded] = useState(false); @@ -64,6 +63,16 @@ export function LabelsPanel({ const [highlightIndex, setHighlightIndex] = useState(0); const inputRef = useRef(null); const pickerRef = useRef(null); + const isLabelPickerOpen = useAppStore((s) => s.isLabelPickerOpen); + const closeLabelPicker = useAppStore((s) => s.closeLabelPicker); + + // Open picker when triggered by 'l' hotkey + useEffect(() => { + if (isLabelPickerOpen && labelsLoaded) { + setShowPicker(true); + closeLabelPicker(); + } + }, [isLabelPickerOpen, labelsLoaded, closeLabelPicker]); // Fetch all labels and resolve current email's labels directly // (bypasses sender-scoped enrichment cache which would serve wrong labels) diff --git a/src/renderer/hooks/useKeyboardShortcuts.ts b/src/renderer/hooks/useKeyboardShortcuts.ts index 913f6bec..a8b9df0f 100644 --- a/src/renderer/hooks/useKeyboardShortcuts.ts +++ b/src/renderer/hooks/useKeyboardShortcuts.ts @@ -1034,6 +1034,15 @@ export function useKeyboardShortcuts(options: UseKeyboardShortcutsOptions = {}) } break; + // Labels — open label picker (Gmail's native shortcut) + case "l": + if (selectedEmailId) { + e.preventDefault(); + state.setSidebarTab("sender"); + state.openLabelPicker(); + } + break; + // Search (exclude Shift+/ which is "?" for help) case "/": if (!e.shiftKey) { diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index 7096cec1..26cd6fac 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -218,6 +218,9 @@ interface AppState { // Command palette state isCommandPaletteOpen: boolean; + // Label picker state (toggled by 'l' hotkey) + isLabelPickerOpen: boolean; + // Search state isSearchOpen: boolean; activeSearchQuery: string | null; @@ -372,6 +375,10 @@ interface AppState { openCommandPalette: () => void; closeCommandPalette: () => void; + // Label picker actions + openLabelPicker: () => void; + closeLabelPicker: () => void; + // Find-in-page isFindBarOpen: boolean; openFindBar: () => void; @@ -572,6 +579,9 @@ export const useAppStore = create((set, get) => ({ // Command palette state isCommandPaletteOpen: false, + // Label picker state + isLabelPickerOpen: false, + // Find-in-page state isFindBarOpen: false, @@ -902,6 +912,10 @@ export const useAppStore = create((set, get) => ({ openCommandPalette: () => set({ isCommandPaletteOpen: true }), closeCommandPalette: () => set({ isCommandPaletteOpen: false }), + // Label picker actions + openLabelPicker: () => set({ isLabelPickerOpen: true }), + closeLabelPicker: () => set({ isLabelPickerOpen: false }), + // Find-in-page actions openFindBar: () => set({ isFindBarOpen: true }), closeFindBar: () => set({ isFindBarOpen: false }), From cd7a021bd843ffa5468d3b4371ab90e43384d605 Mon Sep 17 00:00:00 2001 From: Graham Pechenik <196518812+gpechenik@users.noreply.github.com> Date: Tue, 7 Apr 2026 17:37:04 -0700 Subject: [PATCH 10/17] fix: aggregate labels across all thread emails Gmail labels are thread-level, but the panel was only showing labels from the currently displayed email. A label applied to an earlier message in the thread (e.g., manually applied -Urgent) would not appear. Now aggregates labelIds from all emails in the thread using the threadEmails prop. --- src/renderer/extensions/bundled/LabelsPanel.tsx | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/renderer/extensions/bundled/LabelsPanel.tsx b/src/renderer/extensions/bundled/LabelsPanel.tsx index c2e69488..642d162e 100644 --- a/src/renderer/extensions/bundled/LabelsPanel.tsx +++ b/src/renderer/extensions/bundled/LabelsPanel.tsx @@ -53,7 +53,7 @@ const HIDDEN_SYSTEM = new Set([ "CATEGORY_PROMOTIONS", ]); -export function LabelsPanel({ email }: LabelsPanelProps): React.ReactElement { +export function LabelsPanel({ email, threadEmails }: LabelsPanelProps): React.ReactElement { const [currentLabels, setCurrentLabels] = useState([]); const [allLabels, setAllLabels] = useState([]); const [labelsLoaded, setLabelsLoaded] = useState(false); @@ -85,8 +85,14 @@ export function LabelsPanel({ email }: LabelsPanelProps): React.ReactElement { if (result.success && result.data) { setAllLabels(result.data); - // Resolve this email's labelIds to full label info - const labelIds = email.labelIds ?? []; + // Aggregate labelIds across all emails in the thread (Gmail labels are thread-level) + const allThreadLabelIds = new Set(); + for (const e of [email, ...threadEmails]) { + for (const id of e.labelIds ?? []) { + allThreadLabelIds.add(id); + } + } + const labelIds = [...allThreadLabelIds]; const labelMap = new Map(result.data.map((l) => [l.id, l])); const resolved: LabelInfo[] = []; for (const id of labelIds) { @@ -102,7 +108,7 @@ export function LabelsPanel({ email }: LabelsPanelProps): React.ReactElement { .catch(() => { setLabelsLoaded(true); }); - }, [email.id, email.accountId, email.labelIds]); + }, [email.id, email.accountId, email.labelIds, threadEmails]); // Focus input when picker opens useEffect(() => { From 3c1a31bf1c967d79d3316febbea2785c3f83afb4 Mon Sep 17 00:00:00 2001 From: Graham Pechenik <196518812+gpechenik@users.noreply.github.com> Date: Tue, 7 Apr 2026 17:41:19 -0700 Subject: [PATCH 11/17] fix: filter Gmail star variants from label display --- src/renderer/extensions/bundled/LabelsPanel.tsx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/renderer/extensions/bundled/LabelsPanel.tsx b/src/renderer/extensions/bundled/LabelsPanel.tsx index 642d162e..6fe3851f 100644 --- a/src/renderer/extensions/bundled/LabelsPanel.tsx +++ b/src/renderer/extensions/bundled/LabelsPanel.tsx @@ -51,6 +51,19 @@ const HIDDEN_SYSTEM = new Set([ "CATEGORY_UPDATES", "CATEGORY_FORUMS", "CATEGORY_PROMOTIONS", + // Gmail star variants — redundant with the star icon in the UI + "YELLOW_STAR", + "RED_STAR", + "ORANGE_STAR", + "GREEN_STAR", + "BLUE_STAR", + "PURPLE_STAR", + "RED_BANG", + "ORANGE_GUILLEMET", + "YELLOW_BANG", + "GREEN_CHECK", + "BLUE_INFO", + "PURPLE_QUESTION", ]); export function LabelsPanel({ email, threadEmails }: LabelsPanelProps): React.ReactElement { From a65b42aa2c50789e6fb1b5c803e667991c8f32ca Mon Sep 17 00:00:00 2001 From: Graham Pechenik <196518812+gpechenik@users.noreply.github.com> Date: Tue, 7 Apr 2026 17:44:10 -0700 Subject: [PATCH 12/17] fix(review): clear label picker signal on email change + sync store after mutation 1. Clear isLabelPickerOpen when selectedEmailId changes, preventing the picker from auto-opening on the wrong email after navigation. 2. After addLabel/removeLabel succeeds, update email.labelIds in the Zustand store for all thread emails so navigating away and back shows correct labels without waiting for background sync. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/renderer/extensions/bundled/LabelsPanel.tsx | 17 +++++++++++++++-- src/renderer/store/index.ts | 2 +- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/renderer/extensions/bundled/LabelsPanel.tsx b/src/renderer/extensions/bundled/LabelsPanel.tsx index 6fe3851f..0ad6c06c 100644 --- a/src/renderer/extensions/bundled/LabelsPanel.tsx +++ b/src/renderer/extensions/bundled/LabelsPanel.tsx @@ -78,6 +78,7 @@ export function LabelsPanel({ email, threadEmails }: LabelsPanelProps): React.Re const pickerRef = useRef(null); const isLabelPickerOpen = useAppStore((s) => s.isLabelPickerOpen); const closeLabelPicker = useAppStore((s) => s.closeLabelPicker); + const updateEmail = useAppStore((s) => s.updateEmail); // Open picker when triggered by 'l' hotkey useEffect(() => { @@ -153,12 +154,17 @@ export function LabelsPanel({ email, threadEmails }: LabelsPanelProps): React.Re const result = await window.api.labels.modifyThread(accountId, threadId, [], [labelId]); if (result.success) { setCurrentLabels((prev) => prev.filter((l) => l.id !== labelId)); + // Sync store so navigating away and back shows correct labels + for (const te of threadEmails) { + const updated = (te.labelIds ?? []).filter((id) => id !== labelId); + updateEmail(te.id, { labelIds: updated }); + } } } finally { setBusy(false); } }, - [email.threadId, email.accountId], + [email.threadId, email.accountId, threadEmails, updateEmail], ); const addLabel = useCallback( @@ -174,6 +180,13 @@ export function LabelsPanel({ email, threadEmails }: LabelsPanelProps): React.Re if (prev.some((l) => l.id === label.id)) return prev; return [...prev, label].sort((a, b) => a.name.localeCompare(b.name)); }); + // Sync store so navigating away and back shows correct labels + for (const te of threadEmails) { + const current = te.labelIds ?? []; + if (!current.includes(label.id)) { + updateEmail(te.id, { labelIds: [...current, label.id] }); + } + } } setShowPicker(false); setSearch(""); @@ -181,7 +194,7 @@ export function LabelsPanel({ email, threadEmails }: LabelsPanelProps): React.Re setBusy(false); } }, - [email.threadId, email.accountId], + [email.threadId, email.accountId, threadEmails, updateEmail], ); // Reset highlight when search changes diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index 26cd6fac..fd9d6553 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -791,7 +791,7 @@ export const useAppStore = create((set, get) => ({ selectedEmailId: nextEmailId, }; }), - setSelectedEmailId: (id) => set({ selectedEmailId: id }), + setSelectedEmailId: (id) => set({ selectedEmailId: id, isLabelPickerOpen: false }), setSelectedThreadId: (id) => set({ selectedThreadId: id }), setFocusedThreadEmailId: (id) => set({ focusedThreadEmailId: id }), toggleThreadExpanded: (threadId) => From 027942ce3f04203300368dd798cc0216c5f0ed1a Mon Sep 17 00:00:00 2001 From: Graham Pechenik <196518812+gpechenik@users.noreply.github.com> Date: Tue, 7 Apr 2026 18:01:24 -0700 Subject: [PATCH 13/17] feat: create new labels from the picker When the type-ahead search has no matches, show a "Create [name]" option. Creates the label in Gmail, adds it to the local label list, and immediately applies it to the thread. Also works via Enter key. --- src/main/ipc/labels.ipc.ts | 18 +++++ src/main/services/gmail-client.ts | 17 +++++ src/preload/index.ts | 2 + .../extensions/bundled/LabelsPanel.tsx | 66 +++++++++++++++++-- 4 files changed, 98 insertions(+), 5 deletions(-) diff --git a/src/main/ipc/labels.ipc.ts b/src/main/ipc/labels.ipc.ts index 17cb91aa..4cf7f9ed 100644 --- a/src/main/ipc/labels.ipc.ts +++ b/src/main/ipc/labels.ipc.ts @@ -22,6 +22,24 @@ export function registerLabelsIpc(): void { }, ); + // Create a new Gmail label + ipcMain.handle( + "labels:create", + async ( + _, + { accountId, name }: { accountId: string; name: string }, + ): Promise> => { + try { + const client = await getClient(accountId); + const label = await client.createLabel(name); + return { success: true, data: label }; + } catch (error) { + log.error({ err: error }, "[Labels] Failed to create label"); + return { success: false, error: String(error) }; + } + }, + ); + // Modify labels on a single message ipcMain.handle( "labels:modify-message", diff --git a/src/main/services/gmail-client.ts b/src/main/services/gmail-client.ts index fe0e8c94..8e79e83e 100644 --- a/src/main/services/gmail-client.ts +++ b/src/main/services/gmail-client.ts @@ -544,6 +544,23 @@ export class GmailClient { })); } + /** + * Create a new Gmail label. Returns the created label's id and name. + */ + async createLabel(name: string): Promise { + const gmail = this.gmail!; + const response = await gmail.users.labels.create({ + userId: "me", + requestBody: { + name, + labelListVisibility: "labelShow", + messageListVisibility: "show", + }, + }); + const l = response.data; + return { id: l.id!, name: l.name!, type: "user" }; + } + /** * Modify labels on a message (add and/or remove arbitrary labels) */ diff --git a/src/preload/index.ts b/src/preload/index.ts index d305e751..bc680081 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -190,6 +190,8 @@ const api = { // Label operations labels: { list: (accountId: string): Promise => ipcRenderer.invoke("labels:list", { accountId }), + create: (accountId: string, name: string): Promise => + ipcRenderer.invoke("labels:create", { accountId, name }), modifyMessage: ( accountId: string, emailId: string, diff --git a/src/renderer/extensions/bundled/LabelsPanel.tsx b/src/renderer/extensions/bundled/LabelsPanel.tsx index 0ad6c06c..9bc0cbf2 100644 --- a/src/renderer/extensions/bundled/LabelsPanel.tsx +++ b/src/renderer/extensions/bundled/LabelsPanel.tsx @@ -13,6 +13,10 @@ interface LabelsPanelProps { // Type for the labels API on window.api type LabelsAPI = { list: (accountId: string) => Promise<{ success: boolean; data?: LabelInfo[] }>; + create: ( + accountId: string, + name: string, + ) => Promise<{ success: boolean; data?: LabelInfo; error?: string }>; modifyMessage: ( accountId: string, emailId: string, @@ -197,6 +201,45 @@ export function LabelsPanel({ email, threadEmails }: LabelsPanelProps): React.Re [email.threadId, email.accountId, threadEmails, updateEmail], ); + const createAndAddLabel = useCallback( + async (name: string) => { + const accountId = email.accountId || "default"; + const threadId = email.threadId; + setBusy(true); + try { + const createResult = await window.api.labels.create(accountId, name); + if (createResult.success && createResult.data) { + const newLabel = createResult.data; + // Add to allLabels so it appears in future searches + setAllLabels((prev) => [...prev, newLabel]); + // Apply to thread + const result = await window.api.labels.modifyThread( + accountId, + threadId, + [newLabel.id], + [], + ); + if (result.success) { + setCurrentLabels((prev) => + [...prev, newLabel].sort((a, b) => a.name.localeCompare(b.name)), + ); + for (const te of threadEmails) { + const current = te.labelIds ?? []; + if (!current.includes(newLabel.id)) { + updateEmail(te.id, { labelIds: [...current, newLabel.id] }); + } + } + } + } + setShowPicker(false); + setSearch(""); + } finally { + setBusy(false); + } + }, + [email.threadId, email.accountId, threadEmails, updateEmail], + ); + // Reset highlight when search changes useEffect(() => { setHighlightIndex(0); @@ -304,17 +347,30 @@ export function LabelsPanel({ email, threadEmails }: LabelsPanelProps): React.Re } else if (e.key === "ArrowUp") { e.preventDefault(); setHighlightIndex((i) => Math.max(i - 1, 0)); - } else if (e.key === "Enter" && availableLabels.length > 0) { - addLabel( - availableLabels[Math.min(highlightIndex, availableLabels.length - 1)], - ); + } else if (e.key === "Enter") { + if (availableLabels.length > 0) { + addLabel( + availableLabels[Math.min(highlightIndex, availableLabels.length - 1)], + ); + } else if (search.trim()) { + createAndAddLabel(search.trim()); + } } }} />
- {availableLabels.length === 0 && ( + {availableLabels.length === 0 && !search.trim() && (
No matching labels
)} + {availableLabels.length === 0 && search.trim() && ( + + )} {availableLabels.slice(0, 50).map((label, index) => ( + <> + {createError && ( +
+ {createError} +
+ )} + + )} {availableLabels.slice(0, 50).map((label, index) => (