diff --git a/src/main/index.ts b/src/main/index.ts index 6a9373a3..4a676dbd 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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"; @@ -391,6 +392,7 @@ app.whenReady().then(async () => { registerOutboxIpc(); registerMemoryIpc(); registerSplitsIpc(); + registerLabelsIpc(); registerArchiveReadyIpc(); registerSnoozeIpc(); registerScheduledSendIpc(); diff --git a/src/main/ipc/labels.ipc.ts b/src/main/ipc/labels.ipc.ts new file mode 100644 index 00000000..f8e89afd --- /dev/null +++ b/src/main/ipc/labels.ipc.ts @@ -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> => { + 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) }; + } + } + ); +} diff --git a/src/main/services/gmail-client.ts b/src/main/services/gmail-client.ts index a20d63f9..ab89c1fb 100644 --- a/src/main/services/gmail-client.ts +++ b/src/main/services/gmail-client.ts @@ -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> { + 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. diff --git a/src/preload/index.ts b/src/preload/index.ts index a5073163..10e0efe6 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -144,6 +144,12 @@ const api = { ipcRenderer.invoke("emails:search-remote", { query, accountId, maxResults, pageToken }), }, + // Label operations + labels: { + list: (accountId: string): Promise => + ipcRenderer.invoke("labels:list", { accountId }), + }, + // Style operations style: { getContext: (toAddress: string): Promise => diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index fcc41ccc..6f31fe52 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -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"; @@ -493,6 +494,7 @@ export default function App() { addSentEmails, setSplits, syncProgress, + incrementLabelMapVersion, } = useAppStore(); // Initialize keyboard shortcuts @@ -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]); + // Initialize sync and accounts const initializeSync = useCallback(async () => { try { diff --git a/src/renderer/components/SplitTabs.tsx b/src/renderer/components/SplitTabs.tsx index 6f54563a..2fef4fc8 100644 --- a/src/renderer/components/SplitTabs.tsx +++ b/src/renderer/components/SplitTabs.tsx @@ -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)) + ); + + // 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 { @@ -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 @@ -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( @@ -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( diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index 988660e9..dbecbbe7 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -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"; @@ -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; @@ -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; @@ -538,6 +540,7 @@ export const useAppStore = create((set, get) => ({ // Inbox splits state splits: [], currentSplitId: "__priority__", + labelMapVersion: 0, // Theme state themePreference: "system", @@ -954,6 +957,7 @@ export const useAppStore = create((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 }), @@ -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)) + ); + + // 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 @@ -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 @@ -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 diff --git a/src/renderer/utils/split-conditions.ts b/src/renderer/utils/split-conditions.ts index 42ef2932..bdbaa5e8 100644 --- a/src/renderer/utils/split-conditions.ts +++ b/src/renderer/utils/split-conditions.ts @@ -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>(); + +export function setLabelMap(accountId: string, labels: Array<{ id: string; name: string }>): void { + const map = new Map(); + for (const label of labels) { + map.set(label.name.toLowerCase(), label.id); + } + labelNameToIdsByAccount.set(accountId, map); +} + +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 { @@ -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": {