Skip to content
2 changes: 2 additions & 0 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,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 { registerArchiveReadyIpc } from "./ipc/archive-ready.ipc";
import { registerSnoozeIpc } from "./ipc/snooze.ipc";
import { registerScheduledSendIpc } from "./ipc/scheduled-send.ipc";
Expand Down Expand Up @@ -391,6 +392,7 @@ app.whenReady().then(async () => {
registerOutboxIpc();
registerMemoryIpc();
registerSplitsIpc();
registerLabelsIpc();
registerArchiveReadyIpc();
registerSnoozeIpc();
registerScheduledSendIpc();
Expand Down
27 changes: 27 additions & 0 deletions src/main/ipc/labels.ipc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { ipcMain } from "electron";
import { getClient } from "./gmail.ipc";
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<IpcResponse<LabelInfo[]>> => {
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) };
}
}
);
}
16 changes: 16 additions & 0 deletions src/main/services/gmail-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -504,6 +504,22 @@ 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! } } : {}),
}));
}

/**
* Get the total number of messages with a given label.
* Uses the labels.get endpoint which returns exact counts.
Expand Down
6 changes: 6 additions & 0 deletions src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,12 @@ const api = {
ipcRenderer.invoke("emails:search-remote", { query, accountId, maxResults, pageToken }),
},

// Label operations
labels: {
list: (accountId: string): Promise<unknown> =>
ipcRenderer.invoke("labels:list", { accountId }),
},

// Style operations
style: {
getContext: (toAddress: string): Promise<unknown> =>
Expand Down
14 changes: 14 additions & 0 deletions src/renderer/App.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useEffect, useState, useCallback, useRef, useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { useAppStore, useThreadedEmails, type Account, type SyncStatus, type PrefetchProgress, type BackgroundSyncProgress } from "./store";
import { setLabelMap } from "./utils/split-conditions";
import { EmailList } from "./components/EmailList";
import { EmailDetail } from "./components/EmailDetail";
import { EmailPreviewSidebar } from "./components/EmailPreviewSidebar";
Expand Down Expand Up @@ -493,6 +494,7 @@ export default function App() {
addSentEmails,
setSplits,
syncProgress,
incrementLabelMapVersion,
} = useAppStore();

// Initialize keyboard shortcuts
Expand Down Expand Up @@ -580,6 +582,18 @@ export default function App() {
});
}, [setSplits]);

// Populate label name→ID map so split conditions can match labels by name.
// Re-fetches when the active account changes (different accounts may have different labels).
useEffect(() => {
if (!currentAccountId) return;
window.api.labels.list(currentAccountId).then((result: { success: boolean; data?: Array<{ id: string; name: string }> }) => {
if (result.success && result.data) {
setLabelMap(currentAccountId, result.data);
incrementLabelMapVersion();
}
}).catch((err) => console.warn("[Labels] Failed to fetch labels:", err));
}, [currentAccountId, incrementLabelMapVersion]);
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.

// Initialize sync and accounts
const initializeSync = useCallback(async () => {
try {
Expand Down
33 changes: 29 additions & 4 deletions src/renderer/components/SplitTabs.tsx
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -1,10 +1,34 @@
import { useMemo } from "react";
import { useAppStore, useThreadedEmails, type EmailThread } from "../store";
import type { InboxSplit } from "../../shared/types";
import { emailMatchesSplit } from "../utils/split-conditions";
import { emailMatchesSplit, evaluateCondition } from "../utils/split-conditions";

function threadMatchesSplit(thread: EmailThread, split: InboxSplit): boolean {
return emailMatchesSplit(thread.latestEmail, split);
const labelConditions = split.conditions.filter((c) => c.type === "label");

// No label conditions — use the original latest-email-only path
if (labelConditions.length === 0) {
return emailMatchesSplit(thread.latestEmail, split);
}

// Evaluate label conditions against the thread (Gmail labels are thread-level).
// Negated conditions use .every() — "NOT label X" means no email has it.
const labelResults = labelConditions.map((c) =>
c.negate
? thread.emails.every((email) => evaluateCondition(email, c))
: thread.emails.some((email) => evaluateCondition(email, c))
);
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.

// Evaluate non-label conditions against only the latest email
const otherConditions = split.conditions.filter((c) => c.type !== "label");
const otherResults = otherConditions.map((c) =>
evaluateCondition(thread.latestEmail, c)
);

const allResults = [...labelResults, ...otherResults];
return split.conditionLogic === "and"
? allResults.every(Boolean)
: allResults.some(Boolean);
}

interface TabProps {
Expand Down Expand Up @@ -45,6 +69,7 @@ export function SplitTabs() {
const archiveReadyThreadIds = useAppStore((state) => state.archiveReadyThreadIds);
const recentlyUnsnoozedThreadIds = useAppStore((state) => state.recentlyUnsnoozedThreadIds);
const localDrafts = useAppStore((state) => state.localDrafts);
const labelMapVersion = useAppStore((state) => state.labelMapVersion);
const { threads, needsReply, done, snoozedCount } = useThreadedEmails();

// Filter splits for current account
Expand All @@ -59,7 +84,7 @@ export function SplitTabs() {
return (t: EmailThread) =>
recentlyUnsnoozedThreadIds.has(t.threadId) ||
!exclusiveSplits.some((s) => threadMatchesSplit(t, s));
}, [splits, recentlyUnsnoozedThreadIds]);
}, [splits, recentlyUnsnoozedThreadIds, labelMapVersion]);

const archiveReadyCount = useMemo(
() => threads.filter(
Expand Down Expand Up @@ -94,7 +119,7 @@ export function SplitTabs() {
}

return map;
}, [threads, needsReply, done, splits, isNonExclusive]);
}, [threads, needsReply, done, splits, isNonExclusive, labelMapVersion]);

// Sort splits by order
const sortedSplits = useMemo(
Expand Down
35 changes: 32 additions & 3 deletions src/renderer/store/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { create } from "zustand";
import { clearPendingLabelUpdates } from "../hooks-bridge";
import { applyOptimisticReads, addOptimisticReads } from "../optimistic-reads";
import type { DashboardEmail, ComposeMode, OutboxStats, InboxSplit, ThemePreference, InboxDensity, SnoozedEmail, ScheduledMessageStats, SendMessageOptions, LocalDraft } from "../../shared/types";
import { emailMatchesSplit } from "../utils/split-conditions";
import { emailMatchesSplit, evaluateCondition } from "../utils/split-conditions";
import type { AgentProviderConfig, AgentTaskInfo, AgentProviderRun, AgentTaskHistoryEntry, ScopedAgentEvent, PendingConfirmation, AgentContext } from "../../shared/agent-types";

export type SettingsTab = "general" | "accounts" | "calendar" | "splits" | "signatures" | "prompts" | "style" | "assistant" | "memories" | "queue" | "agents" | "analytics" | "extensions";
Expand Down Expand Up @@ -212,6 +212,7 @@ interface AppState {
// Inbox splits state
splits: InboxSplit[];
currentSplitId: string | null;
labelMapVersion: number; // incremented when label name→ID map is populated

// Theme state
themePreference: ThemePreference;
Expand Down Expand Up @@ -376,6 +377,7 @@ interface AppState {
// Inbox splits actions
setSplits: (splits: InboxSplit[]) => void;
setCurrentSplitId: (id: string | null) => void;
incrementLabelMapVersion: () => void;

// Theme actions
setThemePreference: (preference: ThemePreference) => void;
Expand Down Expand Up @@ -538,6 +540,7 @@ export const useAppStore = create<AppState>((set, get) => ({
// Inbox splits state
splits: [],
currentSplitId: "__priority__",
labelMapVersion: 0,

// Theme state
themePreference: "system",
Expand Down Expand Up @@ -954,6 +957,7 @@ export const useAppStore = create<AppState>((set, get) => ({
// Inbox splits actions
setSplits: (splits) => set({ splits }),
setCurrentSplitId: (id) => set({ currentSplitId: id }),
incrementLabelMapVersion: () => set((s) => ({ labelMapVersion: s.labelMapVersion + 1 })),

// Theme actions
setThemePreference: (preference) => set({ themePreference: preference }),
Expand Down Expand Up @@ -1796,7 +1800,31 @@ export function useThreadedEmails() {
}

function threadMatchesSplit(thread: EmailThread, split: InboxSplit): boolean {
return emailMatchesSplit(thread.latestEmail, split);
const labelConditions = split.conditions.filter((c) => c.type === "label");

// No label conditions — use the original latest-email-only path
if (labelConditions.length === 0) {
return emailMatchesSplit(thread.latestEmail, split);
}

// Evaluate label conditions against the thread (Gmail labels are thread-level).
// Negated conditions use .every() — "NOT label X" means no email has it.
const labelResults = labelConditions.map((c) =>
c.negate
? thread.emails.every((email) => evaluateCondition(email, c))
: thread.emails.some((email) => evaluateCondition(email, c))
);
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.

// Evaluate non-label conditions against only the latest email
const otherConditions = split.conditions.filter((c) => c.type !== "label");
const otherResults = otherConditions.map((c) =>
evaluateCondition(thread.latestEmail, c)
);

const allResults = [...labelResults, ...otherResults];
return split.conditionLogic === "and"
? allResults.every(Boolean)
: allResults.some(Boolean);
}

// Selector for split-filtered threaded emails
Expand All @@ -1811,6 +1839,7 @@ export function useSplitFilteredThreads() {
const recentlyUnsnoozedThreadIds = useAppStore((state) => state.recentlyUnsnoozedThreadIds);
const unsnoozedReturnTimes = useAppStore((state) => state.unsnoozedReturnTimes);
const sentEmails = useAppStore((state) => state.sentEmails);
const labelMapVersion = useAppStore((state) => state.labelMapVersion);

return useMemo(() => {
// Filter splits for current account
Expand Down Expand Up @@ -1988,7 +2017,7 @@ export function useSplitFilteredThreads() {
snoozed: baseResult.snoozed,
snoozedCount: baseResult.snoozedCount,
};
}, [baseResult, allSplits, currentAccountId, accounts, currentSplitId, archiveReadyThreadIds, recentlyUnsnoozedThreadIds, unsnoozedReturnTimes, sentEmails]);
}, [baseResult, allSplits, currentAccountId, accounts, currentSplitId, archiveReadyThreadIds, recentlyUnsnoozedThreadIds, unsnoozedReturnTimes, sentEmails, labelMapVersion]);
}

// Legacy selector for backwards compatibility
Expand Down
39 changes: 38 additions & 1 deletion src/renderer/utils/split-conditions.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,35 @@
import type { InboxSplit, DashboardEmail } from "../../shared/types";

// Label name → ID lookup for split condition matching, keyed by account.
// Populated asynchronously on app mount; falls back to direct ID comparison when empty.
const labelNameToIdsByAccount = new Map<string, Map<string, string>>();

export function setLabelMap(accountId: string, labels: Array<{ id: string; name: string }>): void {
const map = new Map<string, string>();
for (const label of labels) {
map.set(label.name.toLowerCase(), label.id);
}
labelNameToIdsByAccount.set(accountId, map);
}
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.

function resolveLabelNameToId(value: string, accountId?: string): string | undefined {
const lower = value.toLowerCase();
// Prefer the specific account's label map when available
if (accountId) {
const accountMap = labelNameToIdsByAccount.get(accountId);
if (accountMap) {
const id = accountMap.get(lower);
if (id) return id;
}
}
// Fall back to searching all accounts
for (const map of labelNameToIdsByAccount.values()) {
const id = map.get(lower);
if (id) return id;
}
return undefined;
}

// Convert a glob-like pattern to a regex
// Supports: * (matches anything), ? (matches single char)
function patternToRegex(pattern: string): RegExp {
Expand Down Expand Up @@ -45,7 +75,14 @@ export function evaluateCondition(email: DashboardEmail, condition: InboxSplit["
break;
}
case "label": {
matches = email.labelIds?.includes(condition.value) ?? false;
// Match by label ID directly, or resolve label name → ID
const labelIds = email.labelIds ?? [];
if (labelIds.includes(condition.value)) {
matches = true;
} else {
const resolvedId = resolveLabelNameToId(condition.value, email.accountId);
matches = resolvedId ? labelIds.includes(resolvedId) : false;
}
break;
}
case "has_attachment": {
Expand Down