Skip to content

feat(search): server-side email search#1186

Merged
andrinoff merged 10 commits intofloatpane:masterfrom
mvanhorn:feature/508-server-side-search
Apr 29, 2026
Merged

feat(search): server-side email search#1186
andrinoff merged 10 commits intofloatpane:masterfrom
mvanhorn:feature/508-server-side-search

Conversation

@mvanhorn
Copy link
Copy Markdown
Contributor

@mvanhorn mvanhorn commented Apr 28, 2026

What?

  • Add EmailSearcher interface (Search(ctx, folder, SearchQuery) ([]Email, error)) to backend.Provider
  • Add SearchQuery struct + ParseSearchQuery DSL parser supporting from:, to:, subject:, body:, since:, before:, larger:, plus quoted multi-word values and bare body terms
  • IMAP backend: fetcher.SearchMailbox uses go-imap/v2 UIDSearch, prefers ESEARCH RETURN (ALL) (RFC 4731) when imap.CapESearch or imap.CapIMAP4rev2 is advertised, falls back to UID SEARCH for older servers
  • JMAP backend: Email/query with FilterCondition (From, To, Subject, Body, After, Before, MinSize)
  • POP3 backend: returns backend.ErrNotSupported
  • TUI: new SearchOverlay triggered by / from inbox, results render below the email list, Enter applies them as a temporary "Search Results" view, Esc clears
  • Multi-account: / from "All Accounts" runs across every supported account; ErrNotSupported is skipped silently so a mixed IMAP+JMAP+POP3 setup still surfaces results from the supported backends
  • Default keybind inbox.search = / in default_keybinds.json
  • Demo helper at screenshots/cmd/search_view/main.go + screenshots/search_demo.tape so the demo regenerates without IMAP credentials
  • Tests: parser DSL (quoted values, body precedence, mixed prefixed+bare), JMAP filter assembly, fetcher criteria mapping, POP3 unsupported

Why?

Matches the spec from issue #508 directly:

Add a Search(ctx context.Context, query string, folder string) ([]Email, error) method to the backend.Provider interface ... Implement using IMAP SEARCH ... JMAP Email/query filters ... search TUI overlay (e.g. triggered by / key)

Sibling issue #1131 added the ESEARCH + DSL prefix detail (from:, to:, subject:, body:, since:, larger:, ESEARCH RETURN ALL with UID SEARCH fallback).

The launch thread on r/coolgithubprojects had filtering as the very first comment ("By chance are there filtering options?"), so server-side search closes both the maintainer's and a user-visible gap.

Closes #508. Partially addresses #1131. Out of scope: #1129 (full-text index across folders).

Adds an EmailSearcher interface to backend.Provider:
- backend.SearchQuery + ParseSearchQuery DSL (from:, to:, subject:, body:, since:, before:, larger:)
- IMAP backend: ESEARCH RETURN ALL when supported, falls back to UID SEARCH
- JMAP backend: Email/query with FilterCondition
- POP3 backend: returns ErrNotSupported
- TUI overlay triggered by '/' from inbox; results applied as a temporary view, Esc returns to normal inbox
- Default keybind 'inbox.search' = '/'
- Tests for parser, JMAP filter assembly, fetcher criteria mapping, POP3 unsupported

Closes floatpane#508. Partial: floatpane#1131.
- ParseSearchQuery: quote-aware tokenizer; preserve bare body terms when other fields are set; body: prefix wins over bare terms
- tui/search: do not set done=true on backend error; results pane no longer shows 'Press Enter to apply' when search failed
- main: partial results across accounts; skip ErrNotSupported instead of aborting; suppress parent ESC navigation when inbox search overlay or active state was just consumed
- tui/inbox: expose IsSearchActive() for parent state introspection
- backend tests: cover quoted values, mixed bare+prefixed terms, body precedence
@mvanhorn mvanhorn requested a review from a team as a code owner April 28, 2026 15:13
Copy link
Copy Markdown
Member

@floatpanebot floatpanebot left a comment

Choose a reason for hiding this comment

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

Hi @mvanhorn! Please fix the following issues with your PR:

  • Title: Is too long (62 characters). The PR title must be strictly under 40 characters.

@github-actions github-actions Bot added bug Something isn't working chore enhancement New feature or request labels Apr 28, 2026
@mvanhorn mvanhorn changed the title feat(search): server-side email search with TUI overlay (#508) feat(search): server-side email search Apr 28, 2026
@floatpanebot floatpanebot dismissed their stale review April 28, 2026 15:15

Formatting issues have been resolved. Thank you!

@andrinoff
Copy link
Copy Markdown
Member

Okay, this is a great issue to implement!

There are a few problems, right off the bat, not looking at code:

  1. Emails are shown for email property, it is not shown by fetch_email. Issue: some accounts (like mine, personally) have separate fetch_email's and I have for all of them shown the same email, even though it is addressed to only 1 of them.
  2. The account filtering selector does not work in the search mode
  3. You removed the quick search, not sure if that is a good decision. The fuzzy search we had before allowed you to search across cached emails, which was pretty useful, I'd leave both of these.
  4. run make lint (the reason CI is failing)
  5. Finally, the emails that are not loaded cannot be opened (you need to fetch the UUID's if you don't already, I haven't checked out the code yet)

Please, fix these things first, and I'll get around checking this PR and refining if needed later (may take several days, quite busy atm)

@andrinoff andrinoff removed bug Something isn't working chore labels Apr 28, 2026
@github-actions github-actions Bot added bug Something isn't working chore labels Apr 28, 2026
mvanhorn added a commit to mvanhorn/matcha that referenced this pull request Apr 28, 2026
Per @andrinoff's review:

1. Account label in search/All-Accounts views now resolves to the
   To: recipient that matches the account's FetchEmail (parsed via
   net/mail), so multi-account setups where one upstream mailbox
   serves multiple FetchEmails can see which account each result
   was actually addressed to. Falls back to the account's
   FetchEmail when no recipient matches. Also fixes SetEmails
   which was tabbing accounts by acc.Email instead of FetchEmail.
2. Switching account tabs while search is active now filters
   searchResults in memory (filteredSearchResults), so the cross-
   account search executed against "All Accounts" can be narrowed
   to a single account without re-running the query.
3. Restored the client-side bubble-list filter under the new
   inbox.filter keybind (default 'f'). '/' continues to launch
   the server-side search overlay. Both surface in the keymap;
   help text shows 'f filter' alongside '/ search'.

Plus a gofmt fix on screenshots/cmd/search_view/main.go that the
lint job flagged.

Tests: TestInboxSearchResultsFilterByActiveAccountTab,
TestInboxAccountLabelUsesMatchingRecipient,
TestInboxClientSideFilterKeyStartsListFilter,
TestInboxMultiAccountTabs - all pass on macOS. Full TUI suite
passes (go test ./tui/...).
@mvanhorn
Copy link
Copy Markdown
Contributor Author

Pushed cff22ef addressing all three points:

  1. Search results and the All-Accounts view now resolve the per-row label to the To: recipient that matches the account's FetchEmail (parsed via net/mail), so when a single upstream mailbox serves multiple FetchEmails the row shows which one it was actually addressed to. Falls back to the account's FetchEmail when no recipient matches. Also fixed a smaller bug in SetEmails which was using acc.Email instead of FetchEmail for tab labels.
  2. Switching the account tab while search is active now narrows searchResults in-memory (new filteredSearchResults helper), so the cross-account search you run from All-Accounts can be filtered to one account without re-running the query.
  3. Restored the client-side bubble-list filter under a new inbox.filter keybind (default f). / still launches the server-side search overlay. The two are complementary: f for instant in-memory filtering of what's loaded, / for going back to the server.

Verified: go test ./tui/... passes locally. Also fixed the lint failure on screenshots/cmd/search_view/main.go. Let me know if you want any of these tuned differently, particularly the keybind choice for filter.

@andrinoff
Copy link
Copy Markdown
Member

  1. Search results and the All-Accounts view now resolve the per-row label to the To: recipient that matches the account's FetchEmail (parsed via net/mail), so when a single upstream mailbox serves multiple FetchEmails the row shows which one it was actually addressed to. Falls back to the account's FetchEmail when no recipient matches. Also fixed a smaller bug in SetEmails which was using acc.Email instead of FetchEmail for tab labels.

this still doesnt work

image

as well as that, still cannot open emails that are not loaded in the cache.

mvanhorn added a commit to mvanhorn/matcha that referenced this pull request Apr 28, 2026
Followup on @andrinoff's review of floatpane#1186:

1. All-Accounts view and search results now dedupe by Message-ID (with
   a From|Subject|Date.UnixNano() fallback for malformed messages).
   When one upstream mailbox serves multiple FetchEmails, the same
   message no longer shows up once per account; the surviving row
   prefers the copy whose owning account's FetchEmail matches a To:
   recipient, and accountLabelForEmail now resolves across the full
   accounts list. Per-account tab views are unchanged.

5. ViewEmailMsg now carries an optional *fetcher.Email. The Inbox
   embeds it when opening a row from search results, so opening a
   search hit that wasn't in the local cache no longer silently
   drops the keypress: main.go prefers msg.Email, adds the email to
   m.emailsByAcct/m.emails when missing (so subsequent navigation
   works), and falls through to the existing FetchEmailBody path.

Tests: TestInboxAllAccountsDedupesSharedMailboxByMessageID,
TestInboxSearchResultsDedupedAcrossAccounts,
TestInboxAllAccountsDoesNotDedupeWhenMessageIDDiffers,
TestInboxAccountLabelUsesMatchingRecipient (updated for cross-
account match), TestInboxOpenSearchResultEmbedsEmailInViewMsg.
go test ./... is green locally.
@mvanhorn
Copy link
Copy Markdown
Contributor Author

Pushed 870aa1d addressing the two remaining points.

  1. The All-Accounts view and search results now dedupe by Message-ID (with a From|Subject|Date.UnixNano() fallback for malformed messages). When one upstream mailbox serves multiple FetchEmails the same message shows up once instead of once per account; the surviving row prefers the copy whose owning account's FetchEmail matches a To: recipient, and accountLabelForEmail resolves across the full accounts list instead of just the fetching account. With your edu/me/business@andrinoff.com setup the message in your screenshot should now appear once, labeled business@andrinoff.com. Per-account tab views are unchanged.

  2. ViewEmailMsg now carries an optional *fetcher.Email. The Inbox embeds it when opening a row from search results, so opening a search hit that wasn't in the local cache no longer silently drops the keypress. main.go prefers msg.Email, adds the email to m.emailsByAcct/m.emails when missing (so next/prev navigation still works), and falls through to the existing FetchEmailBody path.

Verified locally: go test ./... green, gofmt -l . clean. New tests cover the dedup and uncached-open paths: TestInboxAllAccountsDedupesSharedMailboxByMessageID, TestInboxSearchResultsDedupedAcrossAccounts, TestInboxAllAccountsDoesNotDedupeWhenMessageIDDiffers, TestInboxOpenSearchResultEmbedsEmailInViewMsg. TestInboxAccountLabelUsesMatchingRecipient updated to assert the new cross-account match.

@LeaWhoCodes
Copy link
Copy Markdown
Member

2. ViewEmailMsg now carries an optional *fetcher.Email. The Inbox embeds it when opening a row from search results, so opening a search hit that wasn't in the local cache no longer silently drops the keypress. main.go prefers msg.Email, adds the email to m.emailsByAcct/m.emails when missing (so next/prev navigation still works), and falls through to the existing FetchEmailBody path.

interesting, haven't looked at the diff, but the issue seems to persist with split view mode (#1006), although, it does work in full screen mode, maybe you can find the culprit

@mvanhorn
Copy link
Copy Markdown
Contributor Author

Pushed 8fc9e1d. Root cause was a parallel split-pane code path: OpenSplitPreview discarded ViewEmailMsg.Email and PreviewBodyFetchedMsg.findEmailByUID only searched m.inbox.allEmails, which never contains search hits. Threaded the resolved email through OpenSplitPreview onto FolderInbox.previewSearchEmail and made findEmailByUID fall back to it when allEmails has no match. Verified locally: go test ./... is green, including two new tests in tui/folder_inbox_test.go that cover the cross-folder search-hit case and confirm the live allEmails entry still wins when both are present.

andrinoff pushed a commit to mvanhorn/matcha that referenced this pull request Apr 29, 2026
Per @andrinoff's review:

1. Account label in search/All-Accounts views now resolves to the
   To: recipient that matches the account's FetchEmail (parsed via
   net/mail), so multi-account setups where one upstream mailbox
   serves multiple FetchEmails can see which account each result
   was actually addressed to. Falls back to the account's
   FetchEmail when no recipient matches. Also fixes SetEmails
   which was tabbing accounts by acc.Email instead of FetchEmail.
2. Switching account tabs while search is active now filters
   searchResults in memory (filteredSearchResults), so the cross-
   account search executed against "All Accounts" can be narrowed
   to a single account without re-running the query.
3. Restored the client-side bubble-list filter under the new
   inbox.filter keybind (default 'f'). '/' continues to launch
   the server-side search overlay. Both surface in the keymap;
   help text shows 'f filter' alongside '/ search'.

Plus a gofmt fix on screenshots/cmd/search_view/main.go that the
lint job flagged.

Tests: TestInboxSearchResultsFilterByActiveAccountTab,
TestInboxAccountLabelUsesMatchingRecipient,
TestInboxClientSideFilterKeyStartsListFilter,
TestInboxMultiAccountTabs - all pass on macOS. Full TUI suite
passes (go test ./tui/...).
andrinoff pushed a commit to mvanhorn/matcha that referenced this pull request Apr 29, 2026
Followup on @andrinoff's review of floatpane#1186:

1. All-Accounts view and search results now dedupe by Message-ID (with
   a From|Subject|Date.UnixNano() fallback for malformed messages).
   When one upstream mailbox serves multiple FetchEmails, the same
   message no longer shows up once per account; the surviving row
   prefers the copy whose owning account's FetchEmail matches a To:
   recipient, and accountLabelForEmail now resolves across the full
   accounts list. Per-account tab views are unchanged.

5. ViewEmailMsg now carries an optional *fetcher.Email. The Inbox
   embeds it when opening a row from search results, so opening a
   search hit that wasn't in the local cache no longer silently
   drops the keypress: main.go prefers msg.Email, adds the email to
   m.emailsByAcct/m.emails when missing (so subsequent navigation
   works), and falls through to the existing FetchEmailBody path.

Tests: TestInboxAllAccountsDedupesSharedMailboxByMessageID,
TestInboxSearchResultsDedupedAcrossAccounts,
TestInboxAllAccountsDoesNotDedupeWhenMessageIDDiffers,
TestInboxAccountLabelUsesMatchingRecipient (updated for cross-
account match), TestInboxOpenSearchResultEmbedsEmailInViewMsg.
go test ./... is green locally.
@andrinoff andrinoff force-pushed the feature/508-server-side-search branch from 8fc9e1d to 5cfd64c Compare April 29, 2026 15:27
Per @andrinoff's review:

1. Account label in search/All-Accounts views now resolves to the
   To: recipient that matches the account's FetchEmail (parsed via
   net/mail), so multi-account setups where one upstream mailbox
   serves multiple FetchEmails can see which account each result
   was actually addressed to. Falls back to the account's
   FetchEmail when no recipient matches. Also fixes SetEmails
   which was tabbing accounts by acc.Email instead of FetchEmail.
2. Switching account tabs while search is active now filters
   searchResults in memory (filteredSearchResults), so the cross-
   account search executed against "All Accounts" can be narrowed
   to a single account without re-running the query.
3. Restored the client-side bubble-list filter under the new
   inbox.filter keybind (default 'f'). '/' continues to launch
   the server-side search overlay. Both surface in the keymap;
   help text shows 'f filter' alongside '/ search'.

Plus a gofmt fix on screenshots/cmd/search_view/main.go that the
lint job flagged.

Tests: TestInboxSearchResultsFilterByActiveAccountTab,
TestInboxAccountLabelUsesMatchingRecipient,
TestInboxClientSideFilterKeyStartsListFilter,
TestInboxMultiAccountTabs - all pass on macOS. Full TUI suite
passes (go test ./tui/...).
Followup on @andrinoff's review of floatpane#1186:

1. All-Accounts view and search results now dedupe by Message-ID (with
   a From|Subject|Date.UnixNano() fallback for malformed messages).
   When one upstream mailbox serves multiple FetchEmails, the same
   message no longer shows up once per account; the surviving row
   prefers the copy whose owning account's FetchEmail matches a To:
   recipient, and accountLabelForEmail now resolves across the full
   accounts list. Per-account tab views are unchanged.

5. ViewEmailMsg now carries an optional *fetcher.Email. The Inbox
   embeds it when opening a row from search results, so opening a
   search hit that wasn't in the local cache no longer silently
   drops the keypress: main.go prefers msg.Email, adds the email to
   m.emailsByAcct/m.emails when missing (so subsequent navigation
   works), and falls through to the existing FetchEmailBody path.

Tests: TestInboxAllAccountsDedupesSharedMailboxByMessageID,
TestInboxSearchResultsDedupedAcrossAccounts,
TestInboxAllAccountsDoesNotDedupeWhenMessageIDDiffers,
TestInboxAccountLabelUsesMatchingRecipient (updated for cross-
account match), TestInboxOpenSearchResultEmbedsEmailInViewMsg.
go test ./... is green locally.
OpenSplitPreview now accepts the resolved *fetcher.Email and stores it
on FolderInbox; findEmailByUID falls back to that snapshot when the UID
is not in m.inbox.allEmails (cross-folder or uncached search hits).
Without this, the keypress to open a search result in split-pane mode
was silently dropped because PreviewBodyFetchedMsg looked up the email
via findEmailByUID(allEmails) and got nil.

Tests: TestFolderInboxSplitPreviewRendersSearchHit covers the search-hit
fallback; TestFolderInboxSplitPreviewPrefersAllEmails covers that the
live allEmails entry still wins when present.
@andrinoff andrinoff force-pushed the feature/508-server-side-search branch from 5cfd64c to 3400f03 Compare April 29, 2026 15:29
Signed-off-by: drew <me@andrinoff.com>
Signed-off-by: drew <me@andrinoff.com>
Signed-off-by: drew <me@andrinoff.com>
andrinoff
andrinoff previously approved these changes Apr 29, 2026
Copy link
Copy Markdown
Member

@andrinoff andrinoff left a comment

Choose a reason for hiding this comment

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

lgtm after fixing

Signed-off-by: drew <me@andrinoff.com>
Signed-off-by: drew <me@andrinoff.com>
Copy link
Copy Markdown
Member

@andrinoff andrinoff left a comment

Choose a reason for hiding this comment

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

lgtm

@andrinoff andrinoff removed bug Something isn't working chore labels Apr 29, 2026
@andrinoff
Copy link
Copy Markdown
Member

/approve

Copy link
Copy Markdown
Member

@floatpanebot floatpanebot left a comment

Choose a reason for hiding this comment

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

Approved on behalf of @andrinoff via /approve command.

@andrinoff andrinoff merged commit 72e6d3c into floatpane:master Apr 29, 2026
12 checks passed
@mvanhorn
Copy link
Copy Markdown
Contributor Author

Thanks for the merge run this week (#1007, #1027, #1182, #1190, and now this one). Glad the ESEARCH-with-fallback path landed for older IMAP servers.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

FEAT: Email search functionality

4 participants