Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
d321173
Add labels extension: Gmail label management via extension system
gpechenik Apr 7, 2026
294871d
fix(qa): ISSUE-001 — add explicit type to labels list callback to fix…
gpechenik Apr 7, 2026
046a691
fix(qa): ISSUE-002 — use createLogger instead of console.error in lab…
gpechenik Apr 7, 2026
5e273db
fix(qa): ISSUE-003 — read back authoritative labels from Gmail after …
gpechenik Apr 7, 2026
9cad0b6
fix(qa): ISSUE-004 — handle rejected promise in labels list fetch
gpechenik Apr 7, 2026
fcfd9bd
fix(review): auto-fix 7 issues found during pre-landing review
gpechenik Apr 7, 2026
e8b9f29
fix(review): parallelize thread readback + extract shared LabelInfo type
gpechenik Apr 8, 2026
4dd9a78
fix: bypass sender-scoped enrichment cache for labels
gpechenik Apr 8, 2026
7080fea
feat: add 'l' keyboard shortcut to open label picker
gpechenik Apr 8, 2026
cd7a021
fix: aggregate labels across all thread emails
gpechenik Apr 8, 2026
3c1a31b
fix: filter Gmail star variants from label display
gpechenik Apr 8, 2026
a65b42a
fix(review): clear label picker signal on email change + sync store a…
gpechenik Apr 8, 2026
027942c
feat: create new labels from the picker
gpechenik Apr 8, 2026
97eb359
fix: stabilize label rendering to prevent flickering
gpechenik Apr 8, 2026
41ee6e0
fix(review): guard Enter key against busy state + show create label e…
gpechenik Apr 8, 2026
1b1acc7
fix: remove console.log in deactivate + guard ArrowDown on empty list
gpechenik Apr 8, 2026
f9f6569
fix: include email prop in store updates after label mutation
gpechenik Apr 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions src/extensions/mail-ext-labels/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
}
}
21 changes: 21 additions & 0 deletions src/extensions/mail-ext-labels/src/index.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
context.logger.info("Activating labels extension");
const provider = createLabelsProvider(context);
api.registerEnrichmentProvider(provider);
context.logger.info("Labels extension activated");
},

async deactivate(): Promise<void> {
// No cleanup needed — label cache is in-memory and clears on exit
},
};

export const { activate, deactivate } = extension;
30 changes: 30 additions & 0 deletions src/extensions/mail-ext-labels/src/labels-provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type {
ExtensionContext,
EnrichmentProvider,
EnrichmentData,
} from "../../../shared/extension-types";
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(): boolean {
return true;
},

async enrich(_email: DashboardEmail): Promise<EnrichmentData | null> {
// Labels are resolved directly in LabelsPanel via window.api.labels
// to avoid sender-scoped enrichment cache returning wrong labels.
return null;
},
};
}
6 changes: 6 additions & 0 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand All @@ -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
Expand Down Expand Up @@ -517,6 +520,7 @@ app.whenReady().then(async () => {
registerOutboxIpc();
registerMemoryIpc();
registerSplitsIpc();
registerLabelsIpc();
registerSnippetsIpc();
registerArchiveReadyIpc();
registerSnoozeIpc();
Expand Down Expand Up @@ -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");
Expand Down
116 changes: 116 additions & 0 deletions src/main/ipc/labels.ipc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { ipcMain } from "electron";
import { getClient } from "./gmail.ipc";
import { updateEmailLabelIds, getEmailsByThread } from "../db";
import type { IpcResponse, LabelInfo } from "../../shared/types";
import { createLogger } from "../services/logger";

const log = createLogger("labels-ipc");

export function registerLabelsIpc(): void {
// List all labels for an account
ipcMain.handle(
"labels:list",
async (_, { accountId }: { accountId: string }): Promise<IpcResponse<LabelInfo[]>> => {
try {
const client = await getClient(accountId);
const labels = await client.listLabels();
return { success: true, data: labels };
} catch (error) {
log.error({ err: error }, "Failed to list labels");
return { success: false, error: String(error) };
}
},
);

// Create a new Gmail label
ipcMain.handle(
"labels:create",
async (
_,
{ accountId, name }: { accountId: string; name: string },
): Promise<IpcResponse<LabelInfo>> => {
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",
async (
_,
{
accountId,
emailId,
addLabelIds,
removeLabelIds,
}: {
accountId: string;
emailId: string;
addLabelIds: string[];
removeLabelIds: string[];
},
): Promise<IpcResponse<{ labelIds: string[] }>> => {
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 updateEmailLabelIds not scoped by accountId, inconsistent with other DB operations

The labels:modify-message and labels:modify-thread IPC handlers both have accountId available but call updateEmailLabelIds(emailId, newLabelIds) which only filters by WHERE id = ? (src/main/db/index.ts:610). In contrast, deleteEmail at src/main/db/index.ts:617 correctly filters by WHERE id = ? AND account_id = ?. In a multi-account setup, if two Gmail accounts happen to share a message ID, this UPDATE would modify emails belonging to the wrong account. The fix should thread accountId through to updateEmailLabelIds and add AND account_id = ? to the WHERE clause.

Prompt for agents
The updateEmailLabelIds function in src/main/db/index.ts:608-611 only filters by email ID without account_id scoping. This is inconsistent with deleteEmail (line 613-617) which uses both id and account_id. The fix requires two changes: (1) In src/main/db/index.ts, change updateEmailLabelIds to accept an accountId parameter and add AND account_id = ? to the WHERE clause: `db.prepare("UPDATE emails SET label_ids = ? WHERE id = ? AND account_id = ?").run(JSON.stringify(labelIds), emailId, accountId)`. (2) In src/main/ipc/labels.ipc.ts, pass accountId in both call sites: line 69 `updateEmailLabelIds(emailId, newLabelIds, accountId)` and line 105 `updateEmailLabelIds(email.id, msg?.labelIds ?? [], accountId)`.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

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.

Valid observation, but this is a pre-existing issue in updateEmailLabelIds (defined at src/main/db/index.ts:610). The function signature is (emailId, labelIds) with no accountId parameter, and the WHERE clause is WHERE id = ?. Fixing it would mean changing the shared DB function signature, which is out of scope for this PR. Noting for a follow-up.


return { success: true, data: { labelIds: newLabelIds } };
} catch (error) {
log.error({ err: error }, "Failed to modify message labels");
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<IpcResponse<void>> => {
try {
const client = await getClient(accountId);
await client.modifyThreadLabels(threadId, addLabelIds, removeLabelIds);

// Read back authoritative labelIds from Gmail for each thread message (parallel)
const threadEmails = getEmailsByThread(threadId, accountId);
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) {
log.error({ err: error }, "Failed to modify thread labels");
return { success: false, error: String(error) };
}
},
);
}
76 changes: 76 additions & 0 deletions src/main/services/gmail-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type {
ComposeMessageOptions,
AttachmentMeta,
SendAsAlias,
LabelInfo,
} from "../../shared/types";
import { getAccounts } from "../db";
import { getDataDir } from "../data-dir";
Expand Down Expand Up @@ -525,6 +526,81 @@ 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<LabelInfo[]> {
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! } }
: {}),
}));
}

/**
* Create a new Gmail label. Returns the created label's id and name.
*/
async createLabel(name: string): Promise<LabelInfo> {
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)
*/
async modifyMessageLabels(
messageId: string,
addLabelIds: string[],
removeLabelIds: string[],
): Promise<void> {
if (addLabelIds.length === 0 && removeLabelIds.length === 0) return;
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<void> {
if (addLabelIds.length === 0 && removeLabelIds.length === 0) return;
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.
Expand Down
31 changes: 31 additions & 0 deletions src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,37 @@ const api = {
ipcRenderer.invoke("emails:search-remote", { query, accountId, maxResults, pageToken }),
},

// Label operations
labels: {
list: (accountId: string): Promise<unknown> => ipcRenderer.invoke("labels:list", { accountId }),
create: (accountId: string, name: string): Promise<unknown> =>
ipcRenderer.invoke("labels:create", { accountId, name }),
modifyMessage: (
accountId: string,
emailId: string,
addLabelIds: string[],
removeLabelIds: string[],
): Promise<unknown> =>
ipcRenderer.invoke("labels:modify-message", {
accountId,
emailId,
addLabelIds,
removeLabelIds,
}),
modifyThread: (
accountId: string,
threadId: string,
addLabelIds: string[],
removeLabelIds: string[],
): Promise<unknown> =>
ipcRenderer.invoke("labels:modify-thread", {
accountId,
threadId,
addLabelIds,
removeLabelIds,
}),
},

// Style operations
style: {
getContext: (toAddress: string): Promise<unknown> =>
Expand Down
Loading
Loading