Fix: split label conditions match by name and check all thread emails#31
Fix: split label conditions match by name and check all thread emails#31gpechenik wants to merge 7 commits into
Conversation
Split conditions with type "label" previously compared the user-entered label name (e.g., "-Respond") against email.labelIds, which contains Gmail API IDs (e.g., "Label_1496681257177018521"). These never matched. This adds a label name→ID lookup populated on app mount via a new labels:list IPC endpoint, so split conditions resolve label names to their Gmail IDs before matching. Additionally, label-based splits now check all emails in a thread rather than only the latest. Gmail labels are thread-level, but only the message that was present during the initial sync may carry the label in the local DB. Checking all thread emails ensures label splits match correctly. Changes: - Add listLabels() to GmailClient (gmail.users.labels.list) - Add labels:list IPC handler and preload exposure - Populate label name→ID map on app mount in App.tsx - Update split-conditions.ts to resolve label names to IDs - Update threadMatchesSplit in SplitTabs.tsx and store/index.ts to check all thread emails for label conditions
Greptile SummaryThis PR fixes two real bugs in the label split-condition logic: label names entered by users were being compared directly against Gmail API label IDs (which never match), and only the latest email in a thread was checked for the label even though Gmail labels are thread-level and may be stored only on older messages. The approach — adding a Key changes and issues:
Confidence Score: 4/5The PR should not merge until the hardcoded "default" account ID in App.tsx is fixed — as-is the label name resolution feature is completely non-functional for all users. One P1 bug (hardcoded "default" account ID) renders the core feature of this PR — label name-to-ID resolution — inert at runtime. The IPC plumbing on the main/preload side is correct; the problem is solely in how App.tsx invokes it. Two P2 issues (AND-condition split logic, account-unaware singleton) are worth addressing but do not block merge on their own. src/renderer/App.tsx — hardcoded "default" account ID silently prevents the label map from ever being populated. Important Files Changed
Sequence DiagramsequenceDiagram
participant App as App.tsx (mount)
participant Preload as preload/index.ts
participant IPC as labels.ipc.ts
participant Gmail as GmailClient.listLabels()
participant Map as split-conditions.ts (labelNameToIds)
participant Split as threadMatchesSplit()
App->>Preload: labels.list("default") wrong ID
Preload->>IPC: ipcRenderer.invoke("labels:list", {accountId:"default"})
IPC->>Gmail: getClient("default") connect() fails
Gmail-->>IPC: throws Error (no tokens for "default")
IPC-->>Preload: {success: false}
Preload-->>App: {success: false}
Note over App,Map: setLabelMap() never called - labelNameToIds stays empty
Note over Split: Fix path if account ID were correct
App->>Preload: labels.list(realAccountId)
Preload->>IPC: ipcRenderer.invoke("labels:list", {accountId:realId})
IPC->>Gmail: getClient(realId) success
Gmail-->>IPC: [{id:"Label_149...", name:"Respond"}, ...]
IPC-->>App: {success:true, data:[...]}
App->>Map: setLabelMap(data)
Split->>Map: labelNameToIds.get("respond") returns "Label_149..."
Map-->>Split: "Label_149..."
Split->>Split: email.labelIds.includes("Label_149...") matches
Reviews (1): Last reviewed commit: "Fix split label conditions: match by nam..." | Re-trigger Greptile |
| window.api.labels.list("default").then((result: { success: boolean; data?: Array<{ id: string; name: string }> }) => { | ||
| if (result.success && result.data) { | ||
| setLabelMap(result.data); | ||
| } | ||
| }).catch(() => {}); |
There was a problem hiding this comment.
Hardcoded
"default" makes the label map never populate
"default" is not a real account ID — it is only the last-resort fallback returned by resolveTargetAccountId when getAccounts() returns an empty array. In a normal installation, account IDs are the user's email address or a UUID (set in initializeSync a few lines below). getClient("default") will attempt to create a GmailClient with that literal string, fail to find stored OAuth tokens for it, throw during connect(), and the IPC handler will return { success: false }. The .catch(() => {}) silently discards the error, so setLabelMap is never called and labelNameToIds stays empty. The entire label-name-to-ID lookup introduced in this PR will never work.
The fix should use a real account ID. The simplest approach is to defer this call until after initializeSync resolves and use the actual primary account ID — or, for multi-account support, call labels.list once per connected account and merge the results into the map.
There was a problem hiding this comment.
Already fixed in 47c6863.
What was done: The hardcoded "default" was replaced with currentAccountId from the Zustand store. The useEffect now correctly re-fetches labels whenever the active account changes. This was addressed in commit 2 along with multi-account label map support.
| const hasLabelCondition = split.conditions.some((c) => c.type === "label"); | ||
| if (hasLabelCondition) { | ||
| return thread.emails.some((email) => emailMatchesSplit(email, split)); | ||
| } | ||
| return emailMatchesSplit(thread.latestEmail, split); |
There was a problem hiding this comment.
AND-logic splits with mixed label + other conditions still produce incorrect results
The new "check all emails" path calls emailMatchesSplit(email, split) for each email, which evaluates all conditions (including non-label ones like from / subject) against that single email using the split's AND/OR logic.
For an AND split like [from="boss@company.com", label="Important"], if email[0] (older) has the Important label but is from coworker@, and email[1] (latest) is from boss@ but lacks the label, then emailMatchesSplit returns false for both — the same wrong result as before this PR. The fix works correctly for label-only splits and OR-logic splits, but AND splits with mixed conditions remain incomplete. The same logic applies to the identical function in src/renderer/store/index.ts at line 1798.
There was a problem hiding this comment.
Already fixed in 47c6863.
What was done: threadMatchesSplit now separates label conditions from non-label conditions. Label conditions are evaluated against all emails in the thread (thread.emails.some), while non-label conditions (from, subject, etc.) are evaluated against only the latest email. Results are then combined with AND/OR logic. This correctly handles the mixed-condition case.
| const labelNameToIds = new Map<string, string>(); | ||
|
|
||
| export function setLabelMap(labels: Array<{ id: string; name: string }>): void { | ||
| labelNameToIds.clear(); | ||
| for (const label of labels) { | ||
| labelNameToIds.set(label.name.toLowerCase(), label.id); | ||
| } | ||
| } |
There was a problem hiding this comment.
Module-level singleton has no account context
labelNameToIds is a global map shared by all accounts. In a multi-account setup, two accounts could have labels with the same name but different IDs (e.g., both accounts have a "Work" label with different Gmail IDs). Whichever account's labels are loaded last wins, silently producing wrong matches for the other account's splits. Consider keying the map by accountId and looking up by account when evaluating conditions.
There was a problem hiding this comment.
Already fixed in a20eec5 + 47c6863.
What was done: The label map is now keyed by account ID (Map<string, Map<string, string>>). resolveLabelNameToId prefers the specific account's labels, with a fallback to other accounts. setLabelMap is called per-account when the active account changes.
- Use currentAccountId from store instead of hardcoded "default" for label list fetch; re-fetch when active account changes - Separate label and non-label conditions in threadMatchesSplit: label conditions check all thread emails, non-label conditions check only the latest email, then combine with the split's AND/OR logic - Key the label name→ID map by accountId so multi-account setups with same-named labels don't clobber each other
resolveLabelNameToId now accepts an optional accountId and checks that account's label map first. Falls back to searching all accounts only if the specific account has no match. Prevents wrong label IDs in multi-account setups where different accounts have same-named labels.
The label name→ID map is module-level state outside React's dependency tracking. When labels load asynchronously after initial render, split useMemo hooks would not recompute until an unrelated state change triggered a re-render. Add a labelMapVersion counter to the Zustand store, incremented when the label map is populated. Both SplitTabs and useSplitFilteredThreads include it in their useMemo dependencies, ensuring splits recompute when the label map becomes available.
|
Nice! To speed up review please install gstack and run /review and /qa on the PR. You can also use the /reviewloop skill on this repo to satisfy the review bots. Will get this in asap |
|
Thanks! Happy to run the review tools. Really enjoying the app — I spend most of my day in email and Exo has been a huge upgrade. Glad to contribute back. |
…errors
Ran gstack /review against the PR. All three Greptile P1/P2 comments
were already addressed in commits 2-4. Two minor auto-fixes:
- Remove unused `incrementLabelMapVersion` destructure from SearchResultsView
- Replace silent `.catch(() => {})` with console.warn for label fetch errors
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
QA Report (gstack /qa)Branch: Test Results
Key Findings
Coverage NoteThe new functions ( VerdictPASS. Safe to merge. |
For "NOT label X" conditions, .some() returns true if any email in the thread lacks the label (nearly always true). The correct semantic is .every() — all emails must lack the label for the thread to match. Also adds labelMapVersion to isNonExclusive deps so archiveReadyCount recomputes when the label map is populated. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ructuring The variable was used in App's useEffect (line 591) but only destructured in SearchResultsView (removed in the review cleanup commit as unused there). The .catch() silently swallowed the resulting ReferenceError, making the label map version counter never increment — breaking reactivity for label-based split conditions. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Problem
Split conditions with type
labeldon't work when users enter label names. Two issues:Name vs ID mismatch: The split condition stores the user-entered label name (e.g.,
Respond), butemail.labelIdscontains Gmail API IDs (e.g.,Label_1496681257177018521). The currentincludes()check insplit-conditions.tscompares these directly, so they never match.Only checks latest email:
threadMatchesSplit()only evaluates the latest email in a thread. Gmail labels are thread-level, but locally storedlabelIdsmay only be present on the message that was synced when the label was applied. Threads where an older message carries the label are missed.Fix
listLabels()toGmailClient— callsgmail.users.labels.list()to get the name→ID mappinglabels:listIPC handler + preload exposure so the renderer can fetch labelssetLabelMapinsplit-conditions.ts)labelcase inevaluateCondition()to resolve names to IDs before matchingthreadMatchesSplit()in bothSplitTabs.tsxandstore/index.tsto check all thread emails when the split has a label conditionTesting
Respond)The
labels:listendpoint is read-only and uses the existinggmail.modifyOAuth scope. No new scopes or permissions needed.