Skip to content

Fix: split label conditions match by name and check all thread emails#31

Open
gpechenik wants to merge 7 commits into
ankitvgupta:mainfrom
gpechenik:fix/split-label-matching
Open

Fix: split label conditions match by name and check all thread emails#31
gpechenik wants to merge 7 commits into
ankitvgupta:mainfrom
gpechenik:fix/split-label-matching

Conversation

@gpechenik
Copy link
Copy Markdown
Contributor

@gpechenik gpechenik commented Mar 31, 2026

Problem

Split conditions with type label don't work when users enter label names. Two issues:

  1. Name vs ID mismatch: The split condition stores the user-entered label name (e.g., Respond), but email.labelIds contains Gmail API IDs (e.g., Label_1496681257177018521). The current includes() check in split-conditions.ts compares these directly, so they never match.

  2. Only checks latest email: threadMatchesSplit() only evaluates the latest email in a thread. Gmail labels are thread-level, but locally stored labelIds may 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

  • Add listLabels() to GmailClient — calls gmail.users.labels.list() to get the name→ID mapping
  • Add a labels:list IPC handler + preload exposure so the renderer can fetch labels
  • Populate a label name→ID map on app mount (setLabelMap in split-conditions.ts)
  • Update the label case in evaluateCondition() to resolve names to IDs before matching
  • Update threadMatchesSplit() in both SplitTabs.tsx and store/index.ts to check all thread emails when the split has a label condition

Testing

  1. Create a split with a label condition using a label name (e.g., Respond)
  2. Verify the split tab shows the correct count and matches threads
  3. Verify threads where an older message (not the latest) carries the label still appear in the split

The labels:list endpoint is read-only and uses the existing gmail.modify OAuth scope. No new scopes or permissions needed.


Open with Devin

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
devin-ai-integration[bot]

This comment was marked as resolved.

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented Mar 31, 2026

Greptile Summary

This 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 labels:list IPC endpoint, building a name→ID map on app mount, and scanning all thread emails for label conditions — is conceptually sound, but a critical wiring mistake makes the entire name-resolution feature inert at runtime.

Key changes and issues:

  • src/main/services/gmail-client.ts / labels.ipc.ts / preload/index.ts: New listLabels() method and IPC plumbing are clean and correct.
  • src/renderer/App.tsx (P1): labels.list(\"default\") is called with the literal string \"default\". Real account IDs are never \"default\" — they are email addresses or UUIDs set during initializeSync. getClient(\"default\") will fail to find stored OAuth tokens, the IPC handler returns { success: false }, and the silent .catch(() => {}) ensures setLabelMap is never called. The labelNameToIds map stays empty for every user, so label name matching never works.
  • src/renderer/components/SplitTabs.tsx / store/index.ts (P2): The "check all emails" path evaluates all split conditions against each individual email. For AND-logic splits with a label condition plus other conditions, the thread only matches if a single email satisfies all conditions simultaneously — an incomplete fix for mixed AND-condition splits.
  • src/renderer/utils/split-conditions.ts (P2): The labelNameToIds singleton has no account context; in multi-account setups, same-named labels from different accounts clobber each other in the map.

Confidence Score: 4/5

The 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

Filename Overview
src/renderer/App.tsx Adds a useEffect to populate the label map on mount, but hardcodes "default" as the account ID, which is never a real account ID — causing the IPC call to fail silently and the label map to remain empty.
src/renderer/utils/split-conditions.ts Adds a module-level label name→ID map and resolves label names before matching; logic is correct when the map is populated, but the singleton has no account context (P2) and is never populated due to the "default" account ID bug in App.tsx.
src/renderer/components/SplitTabs.tsx threadMatchesSplit now checks all emails for label conditions; correctly fixes label-only and OR splits, but AND splits mixing label with other conditions still fail to match when the label and the other condition are on different emails in the thread.
src/renderer/store/index.ts Duplicates the same threadMatchesSplit fix as SplitTabs.tsx — carries the same incomplete AND-condition behavior.
src/main/ipc/labels.ipc.ts New IPC handler for labels:list; correctly wraps the Gmail client call and returns a typed IpcResponse.
src/main/services/gmail-client.ts Adds listLabels() method wrapping gmail.users.labels.list; implementation is clean and maps the response correctly.
src/preload/index.ts Exposes labels.list to the renderer via contextBridge; returns Promise which requires callers to cast, but functionally correct.
src/main/index.ts Registers the new registerLabelsIpc() handler alongside other IPC registrations — clean, no issues.

Sequence Diagram

sequenceDiagram
    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
Loading

Reviews (1): Last reviewed commit: "Fix split label conditions: match by nam..." | Re-trigger Greptile

Comment thread src/renderer/App.tsx Outdated
Comment on lines +586 to +590
window.api.labels.list("default").then((result: { success: boolean; data?: Array<{ id: string; name: string }> }) => {
if (result.success && result.data) {
setLabelMap(result.data);
}
}).catch(() => {});
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 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.

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.

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.

Comment thread src/renderer/components/SplitTabs.tsx Outdated
Comment on lines 9 to 13
const hasLabelCondition = split.conditions.some((c) => c.type === "label");
if (hasLabelCondition) {
return thread.emails.some((email) => emailMatchesSplit(email, split));
}
return emailMatchesSplit(thread.latestEmail, split);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 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.

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.

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.

Comment thread src/renderer/utils/split-conditions.ts Outdated
Comment on lines +5 to +12
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);
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 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.

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.

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
devin-ai-integration[bot]

This comment was marked as resolved.

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.
devin-ai-integration[bot]

This comment was marked as resolved.

@ankitvgupta
Copy link
Copy Markdown
Owner

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

@gpechenik
Copy link
Copy Markdown
Contributor Author

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>
devin-ai-integration[bot]

This comment was marked as resolved.

@gpechenik
Copy link
Copy Markdown
Contributor Author

QA Report (gstack /qa)

Branch: fix/split-label-matching | Date: 2026-03-31

Test Results

Suite Passed Skipped Failed
Unit 1,049 148 0
Integration 16 0 1 (pre-existing)
Targeted (split-conditions, gmail-client, store) 137 0 0

Key Findings

  • Zero regressions. All 1,049 unit tests pass. All tests covering changed code pass.
  • The one integration failure (app launches and shows main window) is a pre-existing environment issue (running Electron process conflicts with test launch). Fails identically on main.
  • All evaluateCondition tests (from/to/subject/label matching, wildcards, negate) pass.
  • All threadMatchesSplit tests (AND/OR logic with multiple conditions) pass.
  • All GmailClient tests (69 tests including API methods) pass.

Coverage Note

The new functions (setLabelMap, resolveLabelNameToId, listLabels, labels:list IPC handler) don't have dedicated unit tests yet. The existing test suite validates the integration points but edge cases (case-insensitive collisions, multi-account fallback) are untested. Tests would be a good follow-up PR.

Verdict

PASS. 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>
devin-ai-integration[bot]

This comment was marked as resolved.

…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants