From 21782ecb858101643636daea262b4d50febac3c7 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Tue, 28 Apr 2026 07:45:13 -0700 Subject: [PATCH 01/10] feat(search): server-side email search with TUI overlay 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 #508. Partial: #1131. --- backend/backend.go | 76 ++++++++++++++++++ backend/backend_test.go | 28 +++++++ backend/imap/imap.go | 8 ++ backend/jmap/jmap.go | 87 ++++++++++++++++++++- backend/jmap/jmap_search_test.go | 26 +++++++ backend/pop3/pop3.go | 4 + backend/pop3/pop3_test.go | 11 +++ config/default_keybinds.json | 1 + config/keybinds.go | 2 + fetcher/search.go | 129 +++++++++++++++++++++++++++++++ fetcher/search_test.go | 27 +++++++ main.go | 60 ++++++++++++++ tui/folder_inbox.go | 3 + tui/inbox.go | 72 ++++++++++++++++- tui/messages.go | 19 +++++ tui/search.go | 112 +++++++++++++++++++++++++++ 16 files changed, 659 insertions(+), 6 deletions(-) create mode 100644 backend/backend_test.go create mode 100644 backend/jmap/jmap_search_test.go create mode 100644 fetcher/search.go create mode 100644 fetcher/search_test.go create mode 100644 tui/search.go diff --git a/backend/backend.go b/backend/backend.go index 6c80804..28590bd 100644 --- a/backend/backend.go +++ b/backend/backend.go @@ -4,6 +4,8 @@ package backend import ( "context" "errors" + "strconv" + "strings" "time" ) @@ -15,6 +17,7 @@ type Provider interface { EmailReader EmailWriter EmailSender + EmailSearcher FolderManager Notifier Close() error @@ -45,6 +48,11 @@ type EmailSender interface { SendEmail(ctx context.Context, msg *OutgoingEmail) error } +// EmailSearcher searches emails server-side. +type EmailSearcher interface { + Search(ctx context.Context, folder string, query SearchQuery) ([]Email, error) +} + // FolderManager lists folders/mailboxes. type FolderManager interface { FetchFolders(ctx context.Context) ([]Folder, error) @@ -93,6 +101,74 @@ type Attachment struct { IsPGPEncrypted bool } +// SearchQuery is the parsed form of a user query string. +type SearchQuery struct { + Raw string + From string + To string + Subject string + Body string + Since time.Time + Before time.Time + LargerThan int + Limit uint32 +} + +// ParseSearchQuery parses a compact search DSL into a SearchQuery. +func ParseSearchQuery(s string) SearchQuery { + query := SearchQuery{Raw: s} + var bodyTerms []string + + for _, term := range strings.Fields(s) { + key, value, ok := strings.Cut(term, ":") + value = strings.Trim(value, `"'`) + if !ok || value == "" { + bodyTerms = append(bodyTerms, term) + continue + } + + switch strings.ToLower(key) { + case "from": + query.From = value + case "to": + query.To = value + case "subject": + query.Subject = value + case "body": + query.Body = value + case "since": + if t, ok := parseSearchDate(value); ok { + query.Since = t + } + case "before": + if t, ok := parseSearchDate(value); ok { + query.Before = t + } + case "larger": + if n, err := strconv.Atoi(value); err == nil && n > 0 { + query.LargerThan = n + } + default: + bodyTerms = append(bodyTerms, term) + } + } + + if query.Body == "" && query.From == "" && query.To == "" && query.Subject == "" && len(bodyTerms) > 0 { + query.Body = strings.Join(bodyTerms, " ") + } + + return query +} + +func parseSearchDate(value string) (time.Time, bool) { + for _, layout := range []string{"2006-01-02", time.RFC3339} { + if t, err := time.Parse(layout, value); err == nil { + return t, true + } + } + return time.Time{}, false +} + // Folder represents a mailbox/folder. type Folder struct { Name string diff --git a/backend/backend_test.go b/backend/backend_test.go new file mode 100644 index 0000000..599fe03 --- /dev/null +++ b/backend/backend_test.go @@ -0,0 +1,28 @@ +package backend + +import ( + "testing" + "time" +) + +func TestParseSearchQuery(t *testing.T) { + q := ParseSearchQuery(`from:alice@example.com to:bob@example.com subject:report body:revenue since:2026-01-01 before:2026-02-01 larger:10240`) + if q.From != "alice@example.com" || q.To != "bob@example.com" || q.Subject != "report" || q.Body != "revenue" { + t.Fatalf("parsed fields = %+v", q) + } + if !q.Since.Equal(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)) || !q.Before.Equal(time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC)) { + t.Fatalf("parsed dates = since:%v before:%v", q.Since, q.Before) + } + if q.LargerThan != 10240 || q.Raw == "" { + t.Fatalf("parsed size/raw = larger:%d raw:%q", q.LargerThan, q.Raw) + } +} + +func TestParseSearchQueryBareTerms(t *testing.T) { + if got := ParseSearchQuery("quarterly revenue update").Body; got != "quarterly revenue update" { + t.Fatalf("Body = %q", got) + } + if got := ParseSearchQuery("from:alice@example.com quarterly revenue").Body; got != "" { + t.Fatalf("fielded search Body = %q, want empty", got) + } +} diff --git a/backend/imap/imap.go b/backend/imap/imap.go index f795dab..bb6141e 100644 --- a/backend/imap/imap.go +++ b/backend/imap/imap.go @@ -48,6 +48,14 @@ func (p *Provider) FetchAttachment(_ context.Context, folder string, uid uint32, return fetcher.FetchAttachmentFromMailbox(p.account, folder, uid, partID, encoding) } +func (p *Provider) Search(_ context.Context, folder string, query backend.SearchQuery) ([]backend.Email, error) { + emails, err := fetcher.SearchMailbox(p.account, folder, query) + if err != nil { + return nil, err + } + return toBackendEmails(emails), nil +} + func (p *Provider) MarkAsRead(_ context.Context, folder string, uid uint32) error { return fetcher.MarkEmailAsReadInMailbox(p.account, folder, uid) } diff --git a/backend/jmap/jmap.go b/backend/jmap/jmap.go index 7241bd4..5485ba5 100644 --- a/backend/jmap/jmap.go +++ b/backend/jmap/jmap.go @@ -158,6 +158,55 @@ func (p *Provider) FetchEmails(_ context.Context, folder string, limit, offset u Limit: uint64(limit), }) + req.Invoke(&email.Get{ + Account: p.accountID, + ReferenceIDs: &jmapclient.ResultReference{ + ResultOf: queryCallID, + Name: "Email/query", + Path: "/ids", + }, + Properties: []string{"id", "subject", "from", "to", "replyTo", "receivedAt", "preview", "keywords", "mailboxIds", "hasAttachment", "messageId"}, + }) + + resp, err := p.client.Do(req) + if err != nil { + return nil, fmt.Errorf("jmap fetch: %w", err) + } + + var emails []backend.Email + for _, inv := range resp.Responses { + if r, ok := inv.Args.(*email.GetResponse); ok { + for _, eml := range r.List { + uid := jmapIDToUID(eml.ID) + p.mu.Lock() + p.idToJMAPID[uid] = eml.ID + p.mu.Unlock() + + e := jmapEmailToBackend(eml, uid, p.account.ID) + emails = append(emails, e) + } + } + } + + return emails, nil +} + +func (p *Provider) Search(_ context.Context, folder string, query backend.SearchQuery) ([]backend.Email, error) { + mboxID, err := p.resolveMailboxID(folder) + if err != nil { + return nil, err + } + + req := &jmapclient.Request{} + queryCallID := req.Invoke(&email.Query{ + Account: p.accountID, + Filter: buildSearchFilter(mboxID, query), + Sort: []*email.SortComparator{ + {Property: "receivedAt", IsAscending: false}, + }, + Limit: uint64(searchLimit(query)), + }) + req.Invoke(&email.Get{ Account: p.accountID, ReferenceIDs: &jmapclient.ResultReference{ @@ -174,7 +223,7 @@ func (p *Provider) FetchEmails(_ context.Context, folder string, limit, offset u resp, err := p.client.Do(req) if err != nil { - return nil, fmt.Errorf("jmap fetch: %w", err) + return nil, fmt.Errorf("jmap search: %w", err) } var emails []backend.Email @@ -186,8 +235,7 @@ func (p *Provider) FetchEmails(_ context.Context, folder string, limit, offset u p.idToJMAPID[uid] = eml.ID p.mu.Unlock() - e := jmapEmailToBackend(eml, uid, p.account.ID) - emails = append(emails, e) + emails = append(emails, jmapEmailToBackend(eml, uid, p.account.ID)) } } } @@ -195,6 +243,39 @@ func (p *Provider) FetchEmails(_ context.Context, folder string, limit, offset u return emails, nil } +func buildSearchFilter(mboxID jmapclient.ID, query backend.SearchQuery) *email.FilterCondition { + f := &email.FilterCondition{InMailbox: mboxID} + if query.From != "" { + f.From = query.From + } + if query.To != "" { + f.To = query.To + } + if query.Subject != "" { + f.Subject = query.Subject + } + if query.Body != "" { + f.Body = query.Body + } + if !query.Since.IsZero() { + f.After = &query.Since + } + if !query.Before.IsZero() { + f.Before = &query.Before + } + if query.LargerThan > 0 { + f.MinSize = uint64(query.LargerThan) + } + return f +} + +func searchLimit(query backend.SearchQuery) uint32 { + if query.Limit > 0 { + return query.Limit + } + return 100 +} + func (p *Provider) FetchEmailBody(_ context.Context, _ string, uid uint32) (string, []backend.Attachment, error) { jmapID, err := p.lookupJMAPID(uid) if err != nil { diff --git a/backend/jmap/jmap_search_test.go b/backend/jmap/jmap_search_test.go new file mode 100644 index 0000000..fa0f9c2 --- /dev/null +++ b/backend/jmap/jmap_search_test.go @@ -0,0 +1,26 @@ +package jmap + +import ( + "testing" + "time" + + jmapclient "git.sr.ht/~rockorager/go-jmap" + "github.com/floatpane/matcha/backend" +) + +func TestBuildSearchFilter(t *testing.T) { + since := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + before := time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC) + f := buildSearchFilter(jmapclient.ID("mailbox-id"), backend.SearchQuery{ + From: "alice@example.com", To: "bob@example.com", Subject: "invoice", + Body: "paid", Since: since, Before: before, LargerThan: 4096, + }) + + if f.InMailbox != "mailbox-id" || f.From != "alice@example.com" || f.To != "bob@example.com" || + f.Subject != "invoice" || f.Body != "paid" || f.MinSize != 4096 { + t.Fatalf("filter = %+v", f) + } + if f.After == nil || !f.After.Equal(since) || f.Before == nil || !f.Before.Equal(before) { + t.Fatalf("date filters = after:%v before:%v", f.After, f.Before) + } +} diff --git a/backend/pop3/pop3.go b/backend/pop3/pop3.go index 364bbcd..ffec6c9 100644 --- a/backend/pop3/pop3.go +++ b/backend/pop3/pop3.go @@ -171,6 +171,10 @@ func (p *Provider) FetchAttachment(_ context.Context, _ string, uid uint32, part return findAttachmentData(raw, partID) } +func (p *Provider) Search(_ context.Context, _ string, _ backend.SearchQuery) ([]backend.Email, error) { + return nil, backend.ErrNotSupported +} + func (p *Provider) MarkAsRead(_ context.Context, _ string, _ uint32) error { // POP3 has no concept of read/unread flags — this is a no-op return nil diff --git a/backend/pop3/pop3_test.go b/backend/pop3/pop3_test.go index 7b8ee7d..c6dd402 100644 --- a/backend/pop3/pop3_test.go +++ b/backend/pop3/pop3_test.go @@ -1,9 +1,12 @@ package pop3 import ( + "context" + "errors" "testing" "github.com/emersion/go-message" + "github.com/floatpane/matcha/backend" pop3client "github.com/knadh/go-pop3" ) @@ -107,3 +110,11 @@ func TestEntityToEmail_To(t *testing.T) { }) } } + +func TestSearchNotSupported(t *testing.T) { + p := &Provider{} + _, err := p.Search(context.Background(), "INBOX", backend.SearchQuery{Raw: "subject:test"}) + if !errors.Is(err, backend.ErrNotSupported) { + t.Fatalf("Search error = %v, want ErrNotSupported", err) + } +} diff --git a/config/default_keybinds.json b/config/default_keybinds.json index 54c1f41..1b7e1b2 100644 --- a/config/default_keybinds.json +++ b/config/default_keybinds.json @@ -10,6 +10,7 @@ "delete": "d", "archive": "a", "refresh": "r", + "search": "/", "open": "enter", "next_tab": "l", "prev_tab": "h" diff --git a/config/keybinds.go b/config/keybinds.go index f01d60e..3a2aa57 100644 --- a/config/keybinds.go +++ b/config/keybinds.go @@ -37,6 +37,7 @@ type InboxKeys struct { Delete string `json:"delete"` Archive string `json:"archive"` Refresh string `json:"refresh"` + Search string `json:"search"` Open string `json:"open"` NextTab string `json:"next_tab"` PrevTab string `json:"prev_tab"` @@ -142,6 +143,7 @@ func ValidateKeybinds(kb KeybindsConfig) []string { "delete": kb.Inbox.Delete, "archive": kb.Inbox.Archive, "refresh": kb.Inbox.Refresh, + "search": kb.Inbox.Search, "open": kb.Inbox.Open, "next_tab": kb.Inbox.NextTab, "prev_tab": kb.Inbox.PrevTab, diff --git a/fetcher/search.go b/fetcher/search.go new file mode 100644 index 0000000..c8cd34b --- /dev/null +++ b/fetcher/search.go @@ -0,0 +1,129 @@ +package fetcher + +import ( + "fmt" + "sort" + + "github.com/emersion/go-imap/v2" + "github.com/floatpane/matcha/backend" + "github.com/floatpane/matcha/config" +) + +// SearchMailbox searches a mailbox server-side and fetches matching envelopes. +func SearchMailbox(account *config.Account, folder string, query backend.SearchQuery) ([]Email, error) { + c, err := connect(account) + if err != nil { + return nil, err + } + defer c.Close() + + if _, err := c.Select(folder, nil).Wait(); err != nil { + return nil, err + } + + criteria := buildSearchCriteria(query) + options := (*imap.SearchOptions)(nil) + if caps := c.Caps(); caps.Has(imap.CapESearch) || caps.Has(imap.CapIMAP4rev2) { + options = &imap.SearchOptions{ReturnAll: true} + } + + searchData, err := c.UIDSearch(criteria, options).Wait() + if err != nil && options != nil { + searchData, err = c.UIDSearch(criteria, nil).Wait() + } + if err != nil { + return nil, fmt.Errorf("imap search: %w", err) + } + + uids := searchData.AllUIDs() + if len(uids) == 0 { + return []Email{}, nil + } + + sort.Slice(uids, func(i, j int) bool { + return uids[i] > uids[j] + }) + if limit := searchLimit(query); len(uids) > int(limit) { + uids = uids[:limit] + } + + var uidSet imap.UIDSet + for _, uid := range uids { + uidSet.AddNum(uid) + } + + msgs, err := c.Fetch(uidSet, &imap.FetchOptions{ + Envelope: true, + UID: true, + Flags: true, + }).Collect() + if err != nil { + return nil, fmt.Errorf("imap search fetch: %w", err) + } + + emails := make([]Email, 0, len(msgs)) + for _, msg := range msgs { + if msg.Envelope == nil { + continue + } + email := Email{ + UID: uint32(msg.UID), + Subject: decodeHeader(msg.Envelope.Subject), + Date: msg.Envelope.Date, + IsRead: hasSeenFlag(msg.Flags), + MessageID: msg.Envelope.MessageID, + AccountID: account.ID, + } + if len(msg.Envelope.From) > 0 { + email.From = formatAddress(msg.Envelope.From[0]) + } + for _, addr := range msg.Envelope.To { + email.To = append(email.To, addr.Addr()) + } + for _, addr := range msg.Envelope.Cc { + email.To = append(email.To, addr.Addr()) + } + for _, addr := range msg.Envelope.ReplyTo { + email.ReplyTo = append(email.ReplyTo, addr.Addr()) + } + emails = append(emails, email) + } + sort.Slice(emails, func(i, j int) bool { + return emails[i].UID > emails[j].UID + }) + + return emails, nil +} + +func buildSearchCriteria(query backend.SearchQuery) *imap.SearchCriteria { + criteria := &imap.SearchCriteria{} + if query.From != "" { + criteria.Header = append(criteria.Header, imap.SearchCriteriaHeaderField{Key: "From", Value: query.From}) + } + if query.To != "" { + criteria.Header = append(criteria.Header, imap.SearchCriteriaHeaderField{Key: "To", Value: query.To}) + } + if query.Subject != "" { + criteria.Header = append(criteria.Header, imap.SearchCriteriaHeaderField{Key: "Subject", Value: query.Subject}) + } + if query.Body != "" { + criteria.Body = []string{query.Body} + } + if !query.Since.IsZero() { + criteria.Since = query.Since + } + if !query.Before.IsZero() { + criteria.Before = query.Before + } + if query.LargerThan > 0 { + criteria.Larger = int64(query.LargerThan) + } + return criteria +} + +func searchLimit(query backend.SearchQuery) uint32 { + if query.Limit > 0 { + return query.Limit + } + return 100 +} diff --git a/fetcher/search_test.go b/fetcher/search_test.go new file mode 100644 index 0000000..a01c8c6 --- /dev/null +++ b/fetcher/search_test.go @@ -0,0 +1,27 @@ +package fetcher + +import ( + "testing" + "time" + + "github.com/floatpane/matcha/backend" +) + +func TestBuildSearchCriteria(t *testing.T) { + since := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + before := time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC) + c := buildSearchCriteria(backend.SearchQuery{ + From: "alice@example.com", To: "bob@example.com", Subject: "invoice", + Body: "paid", Since: since, Before: before, LargerThan: 4096, + }) + + if len(c.Header) != 3 || c.Header[0].Key != "From" || c.Header[1].Key != "To" || c.Header[2].Key != "Subject" { + t.Fatalf("headers = %+v", c.Header) + } + if len(c.Body) != 1 || c.Body[0] != "paid" || !c.Since.Equal(since) || !c.Before.Equal(before) || c.Larger != 4096 { + t.Fatalf("criteria = %+v", c) + } + if searchLimit(backend.SearchQuery{}) != 100 || searchLimit(backend.SearchQuery{Limit: 25}) != 25 { + t.Fatal("unexpected search limit") + } +} diff --git a/main.go b/main.go index ad0d853..90d8c80 100644 --- a/main.go +++ b/main.go @@ -18,6 +18,7 @@ import ( "regexp" "runtime" "slices" + "sort" "strings" "sync" "time" @@ -922,6 +923,13 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { fetchFolderEmailsPaginatedCmd(account, folderName, limit, msg.Offset), ) + case tui.SearchRequestedMsg: + folderName := msg.FolderName + if folderName == "" { + folderName = "INBOX" + } + return m, m.searchEmailsCmd(msg.Query, folderName, msg.AccountID) + case tui.EmailsAppendedMsg: if m.emailsByAcct == nil { m.emailsByAcct = make(map[string][]fetcher.Email) @@ -2084,6 +2092,58 @@ func fetchEmailsForMailbox(account *config.Account, limit, offset uint32, mailbo } } +func (m *mainModel) searchEmailsCmd(query backend.SearchQuery, folderName, accountID string) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + var accounts []config.Account + for _, acc := range m.config.Accounts { + if accountID == "" || acc.ID == accountID { + accounts = append(accounts, acc) + } + } + + var results []fetcher.Email + for i := range accounts { + acc := &accounts[i] + p := m.getProvider(acc) + if p == nil { + return tui.SearchResultsMsg{Query: query, Err: fmt.Errorf("provider not found for account %s", acc.ID)} + } + emails, err := p.Search(ctx, folderName, query) + if err != nil { + return tui.SearchResultsMsg{Query: query, Err: err} + } + results = append(results, backendEmailsToFetcher(emails)...) + } + sortFetcherEmails(results) + + return tui.SearchResultsMsg{Query: query, Emails: results} + } +} + +func backendEmailsToFetcher(emails []backend.Email) []fetcher.Email { + result := make([]fetcher.Email, len(emails)) + for i, e := range emails { + result[i] = fetcher.Email{ + UID: e.UID, From: e.From, To: e.To, ReplyTo: e.ReplyTo, + Subject: e.Subject, Body: e.Body, Date: e.Date, IsRead: e.IsRead, + MessageID: e.MessageID, References: e.References, AccountID: e.AccountID, + } + } + return result +} + +func sortFetcherEmails(emails []fetcher.Email) { + sort.Slice(emails, func(i, j int) bool { + if emails[i].Date.Equal(emails[j].Date) { + return emails[i].UID > emails[j].UID + } + return emails[i].Date.After(emails[j].Date) + }) +} + func loadCachedEmails() tea.Cmd { return func() tea.Msg { cache, err := config.LoadEmailCache() diff --git a/tui/folder_inbox.go b/tui/folder_inbox.go index 91e08bd..7c25b47 100644 --- a/tui/folder_inbox.go +++ b/tui/folder_inbox.go @@ -373,6 +373,9 @@ func (m *FolderInbox) wrapInboxCmd(cmd tea.Cmd) tea.Cmd { case RequestRefreshMsg: inner.FolderName = m.currentFolder return inner + case SearchRequestedMsg: + inner.FolderName = m.currentFolder + return inner } return msg } diff --git a/tui/inbox.go b/tui/inbox.go index 731150e..d9d228b 100644 --- a/tui/inbox.go +++ b/tui/inbox.go @@ -44,6 +44,13 @@ func (i item) Title() string { return i.title } func (i item) Description() string { return i.desc } func (i item) FilterValue() string { return i.title + " " + i.desc } +func searchKey() string { + if config.Keybinds.Inbox.Search != "" { + return config.Keybinds.Inbox.Search + } + return "/" +} + type itemDelegate struct { inbox *Inbox } @@ -281,6 +288,10 @@ type Inbox struct { extraShortHelpKeys []key.Binding pluginStatus string // Persistent status text set by plugins pluginKeyBindings []PluginKeyBinding + searchOverlay *SearchOverlay + searchActive bool + searchQuery string + searchResults []fetcher.Email // Visual mode state (Vim-style multi-select) visualMode bool // Whether visual mode is active @@ -375,7 +386,10 @@ func (m *Inbox) updateList() { var displayEmails []fetcher.Email var showAccountLabel bool - if m.currentAccountID == "" { + if m.searchActive { + displayEmails = m.searchResults + showAccountLabel = !(len(m.accounts) <= 1) + } else if m.currentAccountID == "" { // "ALL" view - show all emails sorted by date displayEmails = m.allEmails showAccountLabel = !(len(m.accounts) <= 1) @@ -426,6 +440,7 @@ func (m *Inbox) updateList() { key.NewBinding(key.WithKeys("d"), key.WithHelp("\uf014 d", t("inbox.delete"))), key.NewBinding(key.WithKeys("a"), key.WithHelp("\uea98 a", t("inbox.archive"))), key.NewBinding(key.WithKeys("r"), key.WithHelp("\ue348 r", t("inbox.refresh"))), + key.NewBinding(key.WithKeys(searchKey()), key.WithHelp(searchKey(), "search")), } if len(m.tabs) > 1 { bindings = append(bindings, @@ -467,7 +482,9 @@ func (m *Inbox) updateList() { func (m *Inbox) getTitle() string { var title string - if m.currentAccountID == "" { + if m.searchActive { + title = fmt.Sprintf("Search Results - %s", m.searchQuery) + } else if m.currentAccountID == "" { title = m.getBaseTitle() + " - " + t("inbox.all_accounts") } else { title = m.getBaseTitle() @@ -519,6 +536,14 @@ func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyPressMsg: + if m.searchOverlay != nil { + if msg.String() == config.Keybinds.Global.Cancel { + m.searchOverlay = nil + return m, nil + } + cmd := m.searchOverlay.Update(msg, m.mailbox, m.currentAccountID) + return m, cmd + } if m.list.FilterState() == list.Filtering { // Don't allow visual mode while filtering if m.visualMode { @@ -530,7 +555,11 @@ func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) { break } kb := config.Keybinds + searchBinding := searchKey() switch keypress := msg.String(); keypress { + case searchBinding: + m.searchOverlay = NewSearchOverlay(m.width, m.height) + return m, m.searchOverlay.Init() case kb.Inbox.VisualMode: if !m.visualMode { // Enter visual mode @@ -553,6 +582,13 @@ func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil case kb.Global.Cancel: + if m.searchActive { + m.searchActive = false + m.searchQuery = "" + m.searchResults = nil + m.updateList() + return m, nil + } if m.visualMode { // Exit visual mode on cancel key m.visualMode = false @@ -683,11 +719,31 @@ func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.height = msg.Height m.list.SetWidth(msg.Width) m.list.SetHeight(msg.Height / 2) + if m.searchOverlay != nil { + return m, m.searchOverlay.Update(msg, m.mailbox, m.currentAccountID) + } if m.shouldFetchMore() { return m, tea.Batch(m.fetchMoreCmds()...) } return m, nil + case SearchResultsMsg: + if m.searchOverlay == nil { + return m, nil + } + return m, m.searchOverlay.Update(msg, m.mailbox, m.currentAccountID) + + case ApplySearchResultsMsg: + m.searchOverlay = nil + m.searchActive = true + m.searchQuery = msg.Query.Raw + m.searchResults = msg.Emails + m.visualMode = false + m.selectedUIDs = make(map[uint32]string) + m.selectionOrder = []uint32{} + m.updateList() + return m, nil + case FetchingMoreEmailsMsg: m.isFetching = true m.list.Title = m.getTitle() @@ -752,6 +808,9 @@ func (m *Inbox) shouldFetchMore() bool { if m.isFetching || m.isRefreshing { return false } + if m.searchActive { + return false + } if m.allAccountsExhausted() { return false } @@ -845,6 +904,11 @@ func (m *Inbox) View() tea.View { b.WriteString(m.list.View()) + if m.searchOverlay != nil { + b.WriteString("\n") + b.WriteString(m.searchOverlay.View()) + } + // Ensure we don't start gap calculation on the same line as the list if !strings.HasSuffix(b.String(), "\n") { b.WriteString("\n") @@ -877,7 +941,9 @@ func (m *Inbox) GetCurrentAccountID() string { // GetEmailAtIndex returns the email at the given index for the current view func (m *Inbox) GetEmailAtIndex(index int) *fetcher.Email { var displayEmails []fetcher.Email - if m.currentAccountID == "" { + if m.searchActive { + displayEmails = m.searchResults + } else if m.currentAccountID == "" { displayEmails = m.allEmails } else { displayEmails = m.emailsByAccount[m.currentAccountID] diff --git a/tui/messages.go b/tui/messages.go index 1766e2b..b82b52d 100644 --- a/tui/messages.go +++ b/tui/messages.go @@ -1,6 +1,7 @@ package tui import ( + "github.com/floatpane/matcha/backend" "github.com/floatpane/matcha/calendar" "github.com/floatpane/matcha/config" "github.com/floatpane/matcha/daemonrpc" @@ -101,6 +102,24 @@ type PreviewBodyFetchedMsg struct { type FetchErr error +type SearchRequestedMsg struct { + Query backend.SearchQuery + Mailbox MailboxKind + FolderName string + AccountID string +} + +type SearchResultsMsg struct { + Query backend.SearchQuery + Emails []fetcher.Email + Err error +} + +type ApplySearchResultsMsg struct { + Query backend.SearchQuery + Emails []fetcher.Email +} + type GoToInboxMsg struct{} type GoToSentInboxMsg struct{} diff --git a/tui/search.go b/tui/search.go new file mode 100644 index 0000000..b986ae5 --- /dev/null +++ b/tui/search.go @@ -0,0 +1,112 @@ +package tui + +import ( + "fmt" + "strings" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/floatpane/matcha/backend" + "github.com/floatpane/matcha/fetcher" + "github.com/floatpane/matcha/theme" +) + +type SearchOverlay struct { + input textinput.Model + query backend.SearchQuery + results []fetcher.Email + loading bool + done bool + err string + width int +} + +func NewSearchOverlay(width, height int) *SearchOverlay { + ti := textinput.New() + ti.Placeholder = "from:alice subject:invoice since:2026-01-01" + ti.Prompt = "/ " + ti.CharLimit = 256 + ti.Focus() + ti.SetStyles(ThemedTextInputStyles()) + if width < 44 { + width = 44 + } + return &SearchOverlay{input: ti, width: width} +} + +func (o *SearchOverlay) Init() tea.Cmd { return textinput.Blink } + +func (o *SearchOverlay) Update(msg tea.Msg, mailbox MailboxKind, accountID string) tea.Cmd { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + o.width = msg.Width + return nil + case SearchResultsMsg: + o.loading, o.done, o.err = false, true, "" + o.query = msg.Query + if msg.Err != nil { + o.err = msg.Err.Error() + return nil + } + o.results = msg.Emails + return nil + case tea.KeyPressMsg: + switch msg.String() { + case "enter": + if o.loading { + return nil + } + if o.done { + results := append([]fetcher.Email(nil), o.results...) + query := o.query + return func() tea.Msg { return ApplySearchResultsMsg{Query: query, Emails: results} } + } + raw := o.input.Value() + if raw == "" { + return nil + } + o.loading, o.done, o.err, o.results = true, false, "", nil + query := backend.ParseSearchQuery(raw) + return func() tea.Msg { return SearchRequestedMsg{Query: query, Mailbox: mailbox, AccountID: accountID} } + } + } + + var cmd tea.Cmd + o.input, cmd = o.input.Update(msg) + return cmd +} + +func (o *SearchOverlay) View() string { + boxWidth := o.width - 4 + if boxWidth < 40 { + boxWidth = 40 + } + style := lipgloss.NewStyle().Border(lipgloss.RoundedBorder()). + BorderForeground(theme.ActiveTheme.Accent).Padding(1, 2).Width(boxWidth) + content := "Search mail\n\n" + o.input.View() + if o.loading { + content += "\n\nSearching..." + } + if o.err != "" { + content += "\n\n" + lipgloss.NewStyle().Foreground(theme.ActiveTheme.Danger).Render(o.err) + } + if o.done { + content += fmt.Sprintf("\n\n%d result(s). Press Enter to apply, Esc to dismiss.\n", len(o.results)) + content += o.resultsView() + } + return style.Render(content) +} + +func (o *SearchOverlay) resultsView() string { + limit := len(o.results) + if limit > 10 { + limit = 10 + } + var b strings.Builder + for i := 0; i < limit; i++ { + email := o.results[i] + fmt.Fprintf(&b, "%d. %s - %s\n", i+1, parseSenderName(email.From), email.Subject) + } + return b.String() +} From 2c2bd4bc5694a8403091bc6aa7ae82e9cc66804b Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Tue, 28 Apr 2026 07:55:20 -0700 Subject: [PATCH 02/10] fix(search): address self-review findings - 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 --- backend/backend.go | 41 +++++++++++++++++++++++++++++--- backend/backend_test.go | 52 +++++++++++++++++++++++++++++++++++++++-- main.go | 35 +++++++++++++++++++++++++-- tui/inbox.go | 4 ++++ tui/search.go | 2 +- 5 files changed, 126 insertions(+), 8 deletions(-) diff --git a/backend/backend.go b/backend/backend.go index 28590bd..2e7e63b 100644 --- a/backend/backend.go +++ b/backend/backend.go @@ -7,6 +7,7 @@ import ( "strconv" "strings" "time" + "unicode" ) // ErrNotSupported is returned when a provider does not support an operation. @@ -119,9 +120,8 @@ func ParseSearchQuery(s string) SearchQuery { query := SearchQuery{Raw: s} var bodyTerms []string - for _, term := range strings.Fields(s) { + for _, term := range tokenizeSearchQuery(s) { key, value, ok := strings.Cut(term, ":") - value = strings.Trim(value, `"'`) if !ok || value == "" { bodyTerms = append(bodyTerms, term) continue @@ -153,13 +153,48 @@ func ParseSearchQuery(s string) SearchQuery { } } - if query.Body == "" && query.From == "" && query.To == "" && query.Subject == "" && len(bodyTerms) > 0 { + if query.Body == "" && len(bodyTerms) > 0 { query.Body = strings.Join(bodyTerms, " ") } return query } +func tokenizeSearchQuery(s string) []string { + var tokens []string + var b strings.Builder + var quote rune + + for _, r := range s { + if quote != 0 { + if r == quote { + quote = 0 + continue + } + b.WriteRune(r) + continue + } + if r == '"' || r == '\'' { + quote = r + continue + } + if unicode.IsSpace(r) { + if b.Len() > 0 { + tokens = append(tokens, b.String()) + b.Reset() + } + continue + } + b.WriteRune(r) + } + + if b.Len() > 0 { + tokens = append(tokens, b.String()) + } + + return tokens +} + func parseSearchDate(value string) (time.Time, bool) { for _, layout := range []string{"2006-01-02", time.RFC3339} { if t, err := time.Parse(layout, value); err == nil { diff --git a/backend/backend_test.go b/backend/backend_test.go index 599fe03..0b51ec3 100644 --- a/backend/backend_test.go +++ b/backend/backend_test.go @@ -22,7 +22,55 @@ func TestParseSearchQueryBareTerms(t *testing.T) { if got := ParseSearchQuery("quarterly revenue update").Body; got != "quarterly revenue update" { t.Fatalf("Body = %q", got) } - if got := ParseSearchQuery("from:alice@example.com quarterly revenue").Body; got != "" { - t.Fatalf("fielded search Body = %q, want empty", got) + if got := ParseSearchQuery("from:alice@example.com quarterly revenue").Body; got != "quarterly revenue" { + t.Fatalf("fielded search Body = %q, want quarterly revenue", got) + } +} + +func TestParseSearchQueryQuotedValues(t *testing.T) { + tests := []struct { + name string + input string + from string + subject string + body string + }{ + { + name: "double quoted subject", + input: `subject:"quarterly report"`, + subject: "quarterly report", + }, + { + name: "bare terms after field", + input: `from:alice quarterly revenue`, + from: "alice", + body: "quarterly revenue", + }, + { + name: "body prefix wins over bare terms", + input: `body:foo bar baz`, + body: "foo", + }, + { + name: "single quoted subject", + input: `subject:'quarterly report'`, + subject: "quarterly report", + }, + { + name: "mixed quoted and unquoted", + input: `from:alice subject:"quarterly report" revenue`, + from: "alice", + subject: "quarterly report", + body: "revenue", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + q := ParseSearchQuery(tt.input) + if q.From != tt.from || q.Subject != tt.subject || q.Body != tt.body { + t.Fatalf("ParseSearchQuery(%q) = From:%q Subject:%q Body:%q, want From:%q Subject:%q Body:%q", tt.input, q.From, q.Subject, q.Body, tt.from, tt.subject, tt.body) + } + }) } } diff --git a/main.go b/main.go index 90d8c80..481f6c8 100644 --- a/main.go +++ b/main.go @@ -7,6 +7,7 @@ import ( "context" "encoding/base64" "encoding/json" + "errors" "flag" "fmt" "io" @@ -204,6 +205,18 @@ func (m *mainModel) syncUnreadBadge() { func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd var cmds []tea.Cmd + searchWasActive := false + + if keyMsg, ok := msg.(tea.KeyPressMsg); ok && keyMsg.String() == "esc" { + switch current := m.current.(type) { + case *tui.Inbox: + searchWasActive = current.IsSearchActive() + case *tui.FolderInbox: + if inbox := current.GetInbox(); inbox != nil { + searchWasActive = inbox.IsSearchActive() + } + } + } m.current, cmd = m.current.Update(msg) cmds = append(cmds, cmd) @@ -241,6 +254,9 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case *tui.FilePicker: return m, func() tea.Msg { return tui.CancelFilePickerMsg{} } case *tui.FolderInbox, *tui.Inbox, *tui.Login: + if searchWasActive { + return m, tea.Batch(cmds...) + } m.idleWatcher.StopAll() m.current = tui.NewChoice() m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) @@ -2105,18 +2121,33 @@ func (m *mainModel) searchEmailsCmd(query backend.SearchQuery, folderName, accou } var results []fetcher.Email + var firstErr error + succeeded := false for i := range accounts { acc := &accounts[i] p := m.getProvider(acc) if p == nil { - return tui.SearchResultsMsg{Query: query, Err: fmt.Errorf("provider not found for account %s", acc.ID)} + if firstErr == nil { + firstErr = fmt.Errorf("provider not found for account %s", acc.ID) + } + continue } emails, err := p.Search(ctx, folderName, query) if err != nil { - return tui.SearchResultsMsg{Query: query, Err: err} + if errors.Is(err, backend.ErrNotSupported) { + continue + } + if firstErr == nil { + firstErr = err + } + continue } + succeeded = true results = append(results, backendEmailsToFetcher(emails)...) } + if !succeeded && firstErr != nil { + return tui.SearchResultsMsg{Query: query, Err: firstErr} + } sortFetcherEmails(results) return tui.SearchResultsMsg{Query: query, Emails: results} diff --git a/tui/inbox.go b/tui/inbox.go index d9d228b..72fb589 100644 --- a/tui/inbox.go +++ b/tui/inbox.go @@ -938,6 +938,10 @@ func (m *Inbox) GetCurrentAccountID() string { return m.currentAccountID } +func (m *Inbox) IsSearchActive() bool { + return m != nil && (m.searchOverlay != nil || m.searchActive) +} + // GetEmailAtIndex returns the email at the given index for the current view func (m *Inbox) GetEmailAtIndex(index int) *fetcher.Email { var displayEmails []fetcher.Email diff --git a/tui/search.go b/tui/search.go index b986ae5..f143d36 100644 --- a/tui/search.go +++ b/tui/search.go @@ -43,7 +43,7 @@ func (o *SearchOverlay) Update(msg tea.Msg, mailbox MailboxKind, accountID strin o.width = msg.Width return nil case SearchResultsMsg: - o.loading, o.done, o.err = false, true, "" + o.loading, o.done, o.err = false, msg.Err == nil, "" o.query = msg.Query if msg.Err != nil { o.err = msg.Err.Error() From 7e68501abf1b675f0c1e5e8c3938a99d917d50ee Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Tue, 28 Apr 2026 09:39:25 -0700 Subject: [PATCH 03/10] fix(search): address review feedback from #1186 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/...). --- config/default_keybinds.json | 1 + config/keybinds.go | 2 + tui/inbox.go | 105 +++++++++++++++++++++++++---------- tui/inbox_test.go | 86 +++++++++++++++++++++++++++- 4 files changed, 164 insertions(+), 30 deletions(-) diff --git a/config/default_keybinds.json b/config/default_keybinds.json index 1b7e1b2..8d72b19 100644 --- a/config/default_keybinds.json +++ b/config/default_keybinds.json @@ -11,6 +11,7 @@ "archive": "a", "refresh": "r", "search": "/", + "filter": "f", "open": "enter", "next_tab": "l", "prev_tab": "h" diff --git a/config/keybinds.go b/config/keybinds.go index 3a2aa57..034a8d8 100644 --- a/config/keybinds.go +++ b/config/keybinds.go @@ -38,6 +38,7 @@ type InboxKeys struct { Archive string `json:"archive"` Refresh string `json:"refresh"` Search string `json:"search"` + Filter string `json:"filter"` Open string `json:"open"` NextTab string `json:"next_tab"` PrevTab string `json:"prev_tab"` @@ -144,6 +145,7 @@ func ValidateKeybinds(kb KeybindsConfig) []string { "archive": kb.Inbox.Archive, "refresh": kb.Inbox.Refresh, "search": kb.Inbox.Search, + "filter": kb.Inbox.Filter, "open": kb.Inbox.Open, "next_tab": kb.Inbox.NextTab, "prev_tab": kb.Inbox.PrevTab, diff --git a/tui/inbox.go b/tui/inbox.go index 72fb589..2ce8bcc 100644 --- a/tui/inbox.go +++ b/tui/inbox.go @@ -3,6 +3,7 @@ package tui import ( "fmt" "io" + "net/mail" "strings" "time" @@ -51,6 +52,13 @@ func searchKey() string { return "/" } +func filterKey() string { + if config.Keybinds.Inbox.Filter != "" { + return config.Keybinds.Inbox.Filter + } + return "f" +} + type itemDelegate struct { inbox *Inbox } @@ -336,10 +344,7 @@ func NewInboxWithMailbox(emails []fetcher.Email, accounts []config.Account, mail tabs = []AccountTab{{ID: "", Label: "ALL", Email: ""}} for _, acc := range accounts { // Use FetchEmail for display, fall back to Email if not set - displayEmail := acc.FetchEmail - if displayEmail == "" { - displayEmail = acc.Email - } + displayEmail := accountDisplayEmail(acc) tabs = append(tabs, AccountTab{ID: acc.ID, Label: displayEmail, Email: displayEmail}) } } @@ -383,20 +388,14 @@ func (m *Inbox) updateList() { // Capture current index to restore later currentIndex := m.list.Index() - var displayEmails []fetcher.Email + displayEmails := m.displayEmails() var showAccountLabel bool if m.searchActive { - displayEmails = m.searchResults showAccountLabel = !(len(m.accounts) <= 1) } else if m.currentAccountID == "" { // "ALL" view - show all emails sorted by date - displayEmails = m.allEmails showAccountLabel = !(len(m.accounts) <= 1) - } else { - // Specific account view - displayEmails = m.emailsByAccount[m.currentAccountID] - showAccountLabel = false } m.emailsCount = len(displayEmails) @@ -405,13 +404,7 @@ func (m *Inbox) updateList() { for i, email := range displayEmails { accountEmail := "" if showAccountLabel { - // Find the account email for display - for _, acc := range m.accounts { - if acc.ID == email.AccountID { - accountEmail = acc.FetchEmail - break - } - } + accountEmail = m.accountLabelForEmail(email) } items[i] = item{ @@ -456,6 +449,7 @@ func (m *Inbox) updateList() { } l.KeyMap.Quit.SetEnabled(false) + l.KeyMap.Filter = key.NewBinding(key.WithKeys(filterKey()), key.WithHelp(filterKey(), "filter")) // Disable default help to render it manually at the bottom l.SetShowHelp(false) @@ -480,6 +474,67 @@ func (m *Inbox) updateList() { m.list = l } +func (m *Inbox) displayEmails() []fetcher.Email { + if m.searchActive { + return m.filteredSearchResults() + } + if m.currentAccountID == "" { + return m.allEmails + } + return m.emailsByAccount[m.currentAccountID] +} + +func (m *Inbox) filteredSearchResults() []fetcher.Email { + if m.currentAccountID == "" { + return m.searchResults + } + filtered := make([]fetcher.Email, 0, len(m.searchResults)) + for _, email := range m.searchResults { + if email.AccountID == m.currentAccountID { + filtered = append(filtered, email) + } + } + return filtered +} + +func (m *Inbox) accountLabelForEmail(email fetcher.Email) string { + for _, acc := range m.accounts { + if acc.ID != email.AccountID { + continue + } + fetchEmail := accountDisplayEmail(acc) + for _, recipient := range email.To { + if sameEmailAddress(recipient, fetchEmail) { + return extractEmailAddress(recipient) + } + } + return fetchEmail + } + return "" +} + +func accountDisplayEmail(acc config.Account) string { + if acc.FetchEmail != "" { + return acc.FetchEmail + } + return acc.Email +} + +func sameEmailAddress(a, b string) bool { + return strings.EqualFold(extractEmailAddress(a), extractEmailAddress(b)) +} + +func extractEmailAddress(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + if addr, err := mail.ParseAddress(value); err == nil { + return strings.TrimSpace(addr.Address) + } + return strings.Trim(value, "<>") +} + func (m *Inbox) getTitle() string { var title string if m.searchActive { @@ -493,7 +548,7 @@ func (m *Inbox) getTitle() string { if acc.Name != "" { title = fmt.Sprintf("%s - %s", m.getBaseTitle(), acc.Name) } else { - title = fmt.Sprintf("%s - %s", m.getBaseTitle(), acc.FetchEmail) + title = fmt.Sprintf("%s - %s", m.getBaseTitle(), accountDisplayEmail(acc)) } break } @@ -944,14 +999,7 @@ func (m *Inbox) IsSearchActive() bool { // GetEmailAtIndex returns the email at the given index for the current view func (m *Inbox) GetEmailAtIndex(index int) *fetcher.Email { - var displayEmails []fetcher.Email - if m.searchActive { - displayEmails = m.searchResults - } else if m.currentAccountID == "" { - displayEmails = m.allEmails - } else { - displayEmails = m.emailsByAccount[m.currentAccountID] - } + displayEmails := m.displayEmails() if index >= 0 && index < len(displayEmails) { return &displayEmails[index] @@ -1135,7 +1183,8 @@ func (m *Inbox) SetEmails(emails []fetcher.Email, accounts []config.Account) { } else { tabs = []AccountTab{{ID: "", Label: "ALL", Email: ""}} for _, acc := range accounts { - tabs = append(tabs, AccountTab{ID: acc.ID, Label: acc.FetchEmail, Email: acc.Email}) + displayEmail := accountDisplayEmail(acc) + tabs = append(tabs, AccountTab{ID: acc.ID, Label: displayEmail, Email: displayEmail}) } } m.tabs = tabs diff --git a/tui/inbox_test.go b/tui/inbox_test.go index 5253896..1d33e6f 100644 --- a/tui/inbox_test.go +++ b/tui/inbox_test.go @@ -4,7 +4,9 @@ import ( "testing" "time" + "charm.land/bubbles/v2/list" tea "charm.land/bubbletea/v2" + "github.com/floatpane/matcha/backend" "github.com/floatpane/matcha/config" "github.com/floatpane/matcha/fetcher" ) @@ -88,8 +90,8 @@ func TestInboxUpdate(t *testing.T) { // TestInboxMultiAccountTabs verifies that tabs are created for multiple accounts. func TestInboxMultiAccountTabs(t *testing.T) { accounts := []config.Account{ - {ID: "account-1", Email: "test1@example.com", Name: "User 1"}, - {ID: "account-2", Email: "test2@example.com", Name: "User 2"}, + {ID: "account-1", Email: "mail.example.com", FetchEmail: "test1@example.com", Name: "User 1"}, + {ID: "account-2", Email: "mail.example.com", FetchEmail: "test2@example.com", Name: "User 2"}, } emails := []fetcher.Email{ @@ -110,6 +112,86 @@ func TestInboxMultiAccountTabs(t *testing.T) { if inbox.tabs[0].Label != "ALL" { t.Errorf("Expected first tab label to be 'ALL', got %q", inbox.tabs[0].Label) } + if inbox.tabs[1].Label != "test1@example.com" { + t.Errorf("Expected first account tab to use FetchEmail, got %q", inbox.tabs[1].Label) + } + + inbox.SetEmails(emails, accounts) + if inbox.tabs[1].Label != "test1@example.com" || inbox.tabs[1].Email != "test1@example.com" { + t.Errorf("Expected SetEmails to preserve FetchEmail tab display, got label=%q email=%q", inbox.tabs[1].Label, inbox.tabs[1].Email) + } +} + +func TestInboxSearchResultsFilterByActiveAccountTab(t *testing.T) { + accounts := []config.Account{ + {ID: "account-1", Email: "mail.example.com", FetchEmail: "first@example.com"}, + {ID: "account-2", Email: "mail.example.com", FetchEmail: "second@example.com"}, + } + + inbox := NewInbox(nil, accounts) + query := backend.ParseSearchQuery("quarterly") + results := []fetcher.Email{ + {UID: 1, From: "a@example.com", To: []string{"first@example.com"}, Subject: "First", AccountID: "account-1"}, + {UID: 2, From: "b@example.com", To: []string{"second@example.com"}, Subject: "Second", AccountID: "account-2"}, + } + + model, _ := inbox.Update(ApplySearchResultsMsg{Query: query, Emails: results}) + inbox = model.(*Inbox) + if got := len(inbox.list.Items()); got != 2 { + t.Fatalf("expected all search results initially, got %d", got) + } + + model, _ = inbox.Update(tea.KeyPressMsg{Code: tea.KeyRight, Text: "right"}) + inbox = model.(*Inbox) + if got := len(inbox.list.Items()); got != 1 { + t.Fatalf("expected account-filtered search results after tab switch, got %d", got) + } + item, ok := inbox.list.Items()[0].(item) + if !ok { + t.Fatalf("expected inbox item, got %T", inbox.list.Items()[0]) + } + if item.accountID != "account-1" { + t.Fatalf("expected account-1 result after first account tab, got %q", item.accountID) + } + + email := inbox.GetEmailAtIndex(0) + if email == nil || email.UID != 1 { + t.Fatalf("GetEmailAtIndex should use filtered search results, got %#v", email) + } +} + +func TestInboxAccountLabelUsesMatchingRecipient(t *testing.T) { + accounts := []config.Account{ + {ID: "account-1", Email: "mail.example.com", FetchEmail: "first@example.com"}, + {ID: "account-2", Email: "mail.example.com", FetchEmail: "second@example.com"}, + } + emails := []fetcher.Email{ + {UID: 1, From: "a@example.com", To: []string{"Shared ", "First "}, Subject: "First", AccountID: "account-1"}, + {UID: 2, From: "b@example.com", To: []string{"shared@example.com"}, Subject: "Fallback", AccountID: "account-2"}, + } + + inbox := NewInbox(emails, accounts) + first := inbox.list.Items()[0].(item) + if first.accountEmail != "first@example.com" { + t.Fatalf("expected matching To recipient for account label, got %q", first.accountEmail) + } + second := inbox.list.Items()[1].(item) + if second.accountEmail != "second@example.com" { + t.Fatalf("expected FetchEmail fallback for unmatched recipient, got %q", second.accountEmail) + } +} + +func TestInboxClientSideFilterKeyStartsListFilter(t *testing.T) { + accounts := []config.Account{{ID: "account-1", Email: "test@example.com"}} + emails := []fetcher.Email{{UID: 1, From: "sender@example.com", Subject: "Test", AccountID: "account-1"}} + + inbox := NewInbox(emails, accounts) + model, _ := inbox.Update(tea.KeyPressMsg{Code: 'f', Text: "f"}) + inbox = model.(*Inbox) + + if inbox.list.FilterState() != list.Filtering { + t.Fatalf("expected client-side filter state %s, got %s", list.Filtering, inbox.list.FilterState()) + } } // TestInboxSingleAccount verifies behavior with a single account. From 15e85fa1c801a3c91a937cb40505289818f669a2 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Tue, 28 Apr 2026 11:00:48 -0700 Subject: [PATCH 04/10] fix(search): dedupe shared-mailbox rows + open uncached search hits Followup on @andrinoff's review of #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. --- main.go | 18 ++++++++- tui/inbox.go | 70 ++++++++++++++++++++++++++++---- tui/inbox_test.go | 100 ++++++++++++++++++++++++++++++++++++++++++++-- tui/messages.go | 1 + 4 files changed, 177 insertions(+), 12 deletions(-) diff --git a/main.go b/main.go index 481f6c8..e963d7f 100644 --- a/main.go +++ b/main.go @@ -1189,7 +1189,12 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.current.Init() case tui.ViewEmailMsg: - email := m.getEmailByUIDAndAccount(msg.UID, msg.AccountID, msg.Mailbox) + email := msg.Email + if email == nil { + email = m.getEmailByUIDAndAccount(msg.UID, msg.AccountID, msg.Mailbox) + } else { + m.addEmailToStoresIfMissing(*email, msg.Mailbox) + } if email == nil { return m, nil } @@ -1822,6 +1827,17 @@ func (m *mainModel) updateEmailBodyByUID(uid uint32, accountID string, mailbox t } } +func (m *mainModel) addEmailToStoresIfMissing(email fetcher.Email, mailbox tui.MailboxKind) { + if m.getEmailByUIDAndAccount(email.UID, email.AccountID, mailbox) != nil { + return + } + if m.emailsByAcct == nil { + m.emailsByAcct = make(map[string][]fetcher.Email) + } + m.emailsByAcct[email.AccountID] = append(m.emailsByAcct[email.AccountID], email) + m.emails = flattenAndSort(m.emailsByAcct) +} + func (m *mainModel) markEmailAsReadInStores(uid uint32, accountID string) { for i := range m.emails { if m.emails[i].UID == uid && m.emails[i].AccountID == accountID { diff --git a/tui/inbox.go b/tui/inbox.go index 2ce8bcc..e32ddc8 100644 --- a/tui/inbox.go +++ b/tui/inbox.go @@ -364,7 +364,7 @@ func NewInboxWithMailbox(emails []fetcher.Email, accounts []config.Account, mail inbox := &Inbox{ accounts: accounts, emailsByAccount: emailsByAccount, - allEmails: emails, + allEmails: dedupeEmailsForAccounts(emails, accounts), tabs: tabs, activeTabIndex: 0, currentAccountID: "", @@ -499,20 +499,70 @@ func (m *Inbox) filteredSearchResults() []fetcher.Email { func (m *Inbox) accountLabelForEmail(email fetcher.Email) string { for _, acc := range m.accounts { - if acc.ID != email.AccountID { - continue - } fetchEmail := accountDisplayEmail(acc) for _, recipient := range email.To { if sameEmailAddress(recipient, fetchEmail) { return extractEmailAddress(recipient) } } - return fetchEmail + } + for _, acc := range m.accounts { + if acc.ID == email.AccountID { + return accountDisplayEmail(acc) + } } return "" } +func dedupeEmailsForAccounts(emails []fetcher.Email, accounts []config.Account) []fetcher.Email { + if len(emails) <= 1 { + return emails + } + + accountByID := make(map[string]config.Account, len(accounts)) + for _, acc := range accounts { + accountByID[acc.ID] = acc + } + + deduped := make([]fetcher.Email, 0, len(emails)) + indexByKey := make(map[string]int, len(emails)) + for _, email := range emails { + key := emailDedupKey(email) + if existingIndex, ok := indexByKey[key]; ok { + existing := deduped[existingIndex] + if !emailMatchesOwningAccount(existing, accountByID) && emailMatchesOwningAccount(email, accountByID) { + deduped[existingIndex] = email + } + continue + } + indexByKey[key] = len(deduped) + deduped = append(deduped, email) + } + return deduped +} + +func emailDedupKey(email fetcher.Email) string { + if email.MessageID != "" { + return email.MessageID + } + // Malformed messages can omit Message-ID, so fall back to stable visible metadata. + return fmt.Sprintf("%s|%s|%d", email.From, email.Subject, email.Date.UnixNano()) +} + +func emailMatchesOwningAccount(email fetcher.Email, accountByID map[string]config.Account) bool { + acc, ok := accountByID[email.AccountID] + if !ok { + return false + } + fetchEmail := accountDisplayEmail(acc) + for _, recipient := range email.To { + if sameEmailAddress(recipient, fetchEmail) { + return true + } + } + return false +} + func accountDisplayEmail(acc config.Account) string { if acc.FetchEmail != "" { return acc.FetchEmail @@ -764,8 +814,12 @@ func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) { idx := selectedItem.originalIndex uid := selectedItem.uid accountID := selectedItem.accountID + var email *fetcher.Email + if m.searchActive { + email = m.GetEmailAtIndex(idx) + } return m, func() tea.Msg { - return ViewEmailMsg{Index: idx, UID: uid, AccountID: accountID, Mailbox: m.mailbox} + return ViewEmailMsg{Index: idx, UID: uid, AccountID: accountID, Mailbox: m.mailbox, Email: email} } } } @@ -792,7 +846,7 @@ func (m *Inbox) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.searchOverlay = nil m.searchActive = true m.searchQuery = msg.Query.Raw - m.searchResults = msg.Emails + m.searchResults = dedupeEmailsForAccounts(msg.Emails, m.accounts) m.visualMode = false m.selectedUIDs = make(map[uint32]string) m.selectionOrder = []uint32{} @@ -1173,7 +1227,7 @@ func (m *Inbox) SetPluginKeyBindings(bindings []PluginKeyBinding) { // SetEmails updates all emails (used after fetch) func (m *Inbox) SetEmails(emails []fetcher.Email, accounts []config.Account) { m.accounts = accounts - m.allEmails = emails + m.allEmails = dedupeEmailsForAccounts(emails, accounts) m.noMoreByAccount = make(map[string]bool) // Rebuild tabs: empty for single account, "ALL" + accounts for multiple diff --git a/tui/inbox_test.go b/tui/inbox_test.go index 1d33e6f..43ef8bb 100644 --- a/tui/inbox_test.go +++ b/tui/inbox_test.go @@ -160,20 +160,88 @@ func TestInboxSearchResultsFilterByActiveAccountTab(t *testing.T) { } } +func TestInboxAllAccountsDedupesSharedMailboxByMessageID(t *testing.T) { + accounts := []config.Account{ + {ID: "account-1", Email: "mail.example.com", FetchEmail: "edu@andrinoff.com"}, + {ID: "account-2", Email: "mail.example.com", FetchEmail: "me@andrinoff.com"}, + {ID: "account-3", Email: "mail.example.com", FetchEmail: "business@andrinoff.com"}, + } + emails := []fetcher.Email{ + {UID: 81, MessageID: "", From: "drew@example.com", To: []string{"business@andrinoff.com"}, Subject: "Hey", AccountID: "account-1"}, + {UID: 82, MessageID: "", From: "drew@example.com", To: []string{"business@andrinoff.com"}, Subject: "Hey", AccountID: "account-2"}, + {UID: 83, MessageID: "", From: "drew@example.com", To: []string{"business@andrinoff.com"}, Subject: "Hey", AccountID: "account-3"}, + } + + inbox := NewInbox(emails, accounts) + if got := len(inbox.allEmails); got != 1 { + t.Fatalf("expected all accounts view to dedupe shared mailbox copies, got %d", got) + } + if got := len(inbox.emailsByAccount["account-1"]); got != 1 { + t.Fatalf("expected per-account bucket to remain unchanged, got %d", got) + } + row := inbox.list.Items()[0].(item) + if row.accountEmail != "business@andrinoff.com" { + t.Fatalf("expected deduped row label to match recipient account, got %q", row.accountEmail) + } + if row.accountID != "account-3" { + t.Fatalf("expected canonical row to use matching account copy, got %q", row.accountID) + } +} + +func TestInboxSearchResultsDedupedAcrossAccounts(t *testing.T) { + accounts := []config.Account{ + {ID: "account-1", Email: "mail.example.com", FetchEmail: "edu@andrinoff.com"}, + {ID: "account-2", Email: "mail.example.com", FetchEmail: "business@andrinoff.com"}, + } + inbox := NewInbox(nil, accounts) + query := backend.ParseSearchQuery("osc8") + results := []fetcher.Email{ + {UID: 81, MessageID: "", From: "drew@example.com", To: []string{"business@andrinoff.com"}, Subject: "Hey", AccountID: "account-1"}, + {UID: 82, MessageID: "", From: "drew@example.com", To: []string{"business@andrinoff.com"}, Subject: "Hey", AccountID: "account-2"}, + } + + model, _ := inbox.Update(ApplySearchResultsMsg{Query: query, Emails: results}) + inbox = model.(*Inbox) + if got := len(inbox.searchResults); got != 1 { + t.Fatalf("expected search results to dedupe shared mailbox copies, got %d", got) + } + row := inbox.list.Items()[0].(item) + if row.accountEmail != "business@andrinoff.com" { + t.Fatalf("expected search result label to match recipient account, got %q", row.accountEmail) + } +} + +func TestInboxAllAccountsDoesNotDedupeWhenMessageIDDiffers(t *testing.T) { + date := time.Now() + accounts := []config.Account{ + {ID: "account-1", Email: "mail.example.com", FetchEmail: "first@example.com"}, + {ID: "account-2", Email: "mail.example.com", FetchEmail: "second@example.com"}, + } + emails := []fetcher.Email{ + {UID: 1, MessageID: "", From: "sender@example.com", To: []string{"first@example.com"}, Subject: "Same", Date: date, AccountID: "account-1"}, + {UID: 2, MessageID: "", From: "sender@example.com", To: []string{"second@example.com"}, Subject: "Same", Date: date, AccountID: "account-2"}, + } + + inbox := NewInbox(emails, accounts) + if got := len(inbox.allEmails); got != 2 { + t.Fatalf("expected distinct Message-ID emails to remain visible, got %d", got) + } +} + func TestInboxAccountLabelUsesMatchingRecipient(t *testing.T) { accounts := []config.Account{ {ID: "account-1", Email: "mail.example.com", FetchEmail: "first@example.com"}, {ID: "account-2", Email: "mail.example.com", FetchEmail: "second@example.com"}, } emails := []fetcher.Email{ - {UID: 1, From: "a@example.com", To: []string{"Shared ", "First "}, Subject: "First", AccountID: "account-1"}, + {UID: 1, MessageID: "", From: "a@example.com", To: []string{"Shared ", "Second "}, Subject: "First", AccountID: "account-1"}, {UID: 2, From: "b@example.com", To: []string{"shared@example.com"}, Subject: "Fallback", AccountID: "account-2"}, } inbox := NewInbox(emails, accounts) first := inbox.list.Items()[0].(item) - if first.accountEmail != "first@example.com" { - t.Fatalf("expected matching To recipient for account label, got %q", first.accountEmail) + if first.accountEmail != "second@example.com" { + t.Fatalf("expected cross-account matching To recipient for account label, got %q", first.accountEmail) } second := inbox.list.Items()[1].(item) if second.accountEmail != "second@example.com" { @@ -181,6 +249,32 @@ func TestInboxAccountLabelUsesMatchingRecipient(t *testing.T) { } } +func TestInboxOpenSearchResultEmbedsEmailInViewMsg(t *testing.T) { + accounts := []config.Account{ + {ID: "account-1", Email: "mail.example.com", FetchEmail: "first@example.com"}, + } + inbox := NewInbox(nil, accounts) + searchResult := fetcher.Email{UID: 42, MessageID: "", From: "sender@example.com", To: []string{"first@example.com"}, Subject: "Search", AccountID: "account-1"} + model, _ := inbox.Update(ApplySearchResultsMsg{Query: backend.ParseSearchQuery("search"), Emails: []fetcher.Email{searchResult}}) + inbox = model.(*Inbox) + + _, cmd := inbox.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + if cmd == nil { + t.Fatal("expected open command") + } + msg := cmd() + viewMsg, ok := msg.(ViewEmailMsg) + if !ok { + t.Fatalf("expected ViewEmailMsg, got %T", msg) + } + if viewMsg.Email == nil { + t.Fatal("expected search result email to be embedded") + } + if viewMsg.Email.UID != searchResult.UID || viewMsg.Email.MessageID != searchResult.MessageID { + t.Fatalf("embedded email mismatch: %#v", viewMsg.Email) + } +} + func TestInboxClientSideFilterKeyStartsListFilter(t *testing.T) { accounts := []config.Account{{ID: "account-1", Email: "test@example.com"}} emails := []fetcher.Email{{UID: 1, From: "sender@example.com", Subject: "Test", AccountID: "account-1"}} diff --git a/tui/messages.go b/tui/messages.go index b82b52d..4908267 100644 --- a/tui/messages.go +++ b/tui/messages.go @@ -22,6 +22,7 @@ type ViewEmailMsg struct { UID uint32 AccountID string Mailbox MailboxKind + Email *fetcher.Email } type SendEmailMsg struct { From 3400f038d51bd01adf28427fddf7ec8fed2b998d Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Wed, 29 Apr 2026 06:46:46 -0700 Subject: [PATCH 05/10] fix(search): thread search-hit email into split-pane preview 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. --- main.go | 2 +- tui/folder_inbox.go | 22 ++++++++-- tui/folder_inbox_test.go | 88 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 108 insertions(+), 4 deletions(-) create mode 100644 tui/folder_inbox_test.go diff --git a/main.go b/main.go index e963d7f..1549044 100644 --- a/main.go +++ b/main.go @@ -1208,7 +1208,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } // Split pane mode: open in split view instead of full screen if m.config.EnableSplitPane && m.folderInbox != nil { - m.folderInbox.OpenSplitPreview(msg.UID, msg.AccountID) + m.folderInbox.OpenSplitPreview(msg.UID, msg.AccountID, email) m.current = m.folderInbox // Mark as read if !email.IsRead { diff --git a/tui/folder_inbox.go b/tui/folder_inbox.go index 7c25b47..9b3a7e7 100644 --- a/tui/folder_inbox.go +++ b/tui/folder_inbox.go @@ -101,6 +101,10 @@ type FolderInbox struct { previewPane *EmailView previewedUID uint32 previewedAccountID string + // previewSearchEmail holds an Email handed in by OpenSplitPreview for hits + // that do not live in m.inbox.allEmails (search results across folders). + // findEmailByUID falls back to it when allEmails has no match. + previewSearchEmail *fetcher.Email focusedPane PaneType } @@ -761,11 +765,15 @@ func (m *FolderInbox) renderEmptyPreview() string { return emptyStyle.Render("Loading...") } -// OpenSplitPreview opens the split preview pane for a specific email -func (m *FolderInbox) OpenSplitPreview(uid uint32, accountID string) { +// OpenSplitPreview opens the split preview pane for a specific email. +// email may be non-nil for hits coming from search results (which are not in +// m.inbox.allEmails); when set, it is used as a fallback by findEmailByUID +// so the preview can render without a follow-up lookup. +func (m *FolderInbox) OpenSplitPreview(uid uint32, accountID string, email *fetcher.Email) { m.previewPane = nil // Will be created when body arrives m.previewedUID = uid m.previewedAccountID = accountID + m.previewSearchEmail = email m.focusedPane = FocusPreview // Recalculate inbox width for split mode inboxWidth := m.calculateInboxWidth() @@ -779,6 +787,7 @@ func (m *FolderInbox) closeSplitPreview() { m.previewPane = nil m.previewedUID = 0 m.previewedAccountID = "" + m.previewSearchEmail = nil m.focusedPane = FocusInbox // Restore full inbox width inboxWidth := m.width - sidebarWidth - 3 @@ -789,13 +798,20 @@ func (m *FolderInbox) closeSplitPreview() { m.updateHelpKeys() } -// findEmailByUID finds email in inbox by UID and account ID +// findEmailByUID finds email in inbox by UID and account ID. Falls back to +// the email handed in by OpenSplitPreview so search hits that are not in +// allEmails (cross-folder or uncached) still render in the preview pane. func (m *FolderInbox) findEmailByUID(uid uint32, accountID string) *fetcher.Email { for i := range m.inbox.allEmails { if m.inbox.allEmails[i].UID == uid && m.inbox.allEmails[i].AccountID == accountID { return &m.inbox.allEmails[i] } } + if m.previewSearchEmail != nil && + m.previewSearchEmail.UID == uid && + m.previewSearchEmail.AccountID == accountID { + return m.previewSearchEmail + } return nil } diff --git a/tui/folder_inbox_test.go b/tui/folder_inbox_test.go new file mode 100644 index 0000000..dc17a7f --- /dev/null +++ b/tui/folder_inbox_test.go @@ -0,0 +1,88 @@ +package tui + +import ( + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/floatpane/matcha/config" + "github.com/floatpane/matcha/fetcher" +) + +// TestFolderInboxSplitPreviewRendersSearchHit covers the case Lea reported on +// PR #1186: opening a search result in split-pane mode used to silently drop +// the keypress because the email was not in m.inbox.allEmails. After the fix +// OpenSplitPreview accepts the resolved email and findEmailByUID falls back +// to it, so PreviewBodyFetchedMsg can build the preview pane. +func TestFolderInboxSplitPreviewRendersSearchHit(t *testing.T) { + accounts := []config.Account{ + {ID: "account-1", Email: "host.example.com", FetchEmail: "first@example.com"}, + } + fi := NewFolderInbox([]string{"INBOX", "Archive"}, accounts) + // Force a non-zero canvas so calculate*Width does not panic on Update. + model, _ := fi.Update(tea.WindowSizeMsg{Width: 200, Height: 60}) + fi = model.(*FolderInbox) + + // Search hit lives in a different folder; allEmails is empty. + hit := &fetcher.Email{ + UID: 4242, + AccountID: "account-1", + MessageID: "", + From: "sender@example.com", + To: []string{"first@example.com"}, + Subject: "Search hit", + } + + fi.OpenSplitPreview(hit.UID, hit.AccountID, hit) + + if fi.previewSearchEmail == nil { + t.Fatal("OpenSplitPreview should retain the search hit email") + } + if got := fi.findEmailByUID(hit.UID, hit.AccountID); got == nil { + t.Fatal("findEmailByUID should fall back to the search hit email") + } + + // Simulate the body arriving and verify the preview pane is built. + model, _ = fi.Update(PreviewBodyFetchedMsg{ + UID: hit.UID, + AccountID: hit.AccountID, + Body: "hello body", + }) + fi = model.(*FolderInbox) + + if fi.previewPane == nil { + t.Fatal("expected previewPane to be built from the search hit fallback") + } + + // closeSplitPreview must clear the cached search hit so a later open with + // no email cannot accidentally reuse the stale reference. + fi.closeSplitPreview() + if fi.previewSearchEmail != nil { + t.Fatal("closeSplitPreview should clear previewSearchEmail") + } +} + +// TestFolderInboxSplitPreviewPrefersAllEmails verifies that when the email is +// already known in allEmails, findEmailByUID returns the live entry (so reads +// like IsRead stay current) instead of the snapshot passed via OpenSplitPreview. +func TestFolderInboxSplitPreviewPrefersAllEmails(t *testing.T) { + accounts := []config.Account{ + {ID: "account-1", Email: "host.example.com", FetchEmail: "first@example.com"}, + } + fi := NewFolderInbox([]string{"INBOX"}, accounts) + model, _ := fi.Update(tea.WindowSizeMsg{Width: 200, Height: 60}) + fi = model.(*FolderInbox) + + live := fetcher.Email{UID: 7, AccountID: "account-1", Subject: "live", IsRead: true} + fi.SetEmails([]fetcher.Email{live}, accounts) + + stale := &fetcher.Email{UID: 7, AccountID: "account-1", Subject: "stale", IsRead: false} + fi.OpenSplitPreview(live.UID, live.AccountID, stale) + + got := fi.findEmailByUID(live.UID, live.AccountID) + if got == nil { + t.Fatal("findEmailByUID should resolve the email") + } + if got.Subject != "live" || !got.IsRead { + t.Fatalf("expected the live allEmails entry, got %+v", got) + } +} From 766d08fa30a937deb86922cd9273551a51371079 Mon Sep 17 00:00:00 2001 From: drew Date: Wed, 29 Apr 2026 19:32:51 +0400 Subject: [PATCH 06/10] fix: add translation for filter and search Signed-off-by: drew --- i18n/locales/ar.json | 2 ++ i18n/locales/de.json | 2 ++ i18n/locales/en.json | 2 ++ i18n/locales/es.json | 2 ++ i18n/locales/fr.json | 2 ++ i18n/locales/ja.json | 2 ++ i18n/locales/pl.json | 2 ++ i18n/locales/pt.json | 2 ++ i18n/locales/ru.json | 2 ++ i18n/locales/uk.json | 2 ++ i18n/locales/zh.json | 2 ++ tui/inbox.go | 4 ++-- 12 files changed, 24 insertions(+), 2 deletions(-) diff --git a/i18n/locales/ar.json b/i18n/locales/ar.json index 4d0e084..c1de1c1 100644 --- a/i18n/locales/ar.json +++ b/i18n/locales/ar.json @@ -54,6 +54,8 @@ "delete": "حذف", "archive": "أرشفة", "refresh": "تحديث", + "search": "بحث", + "filter": "تصفية", "reply": "رد", "forward": "إعادة توجيه", "move": "نقل", diff --git a/i18n/locales/de.json b/i18n/locales/de.json index bb8ccc9..2d5f901 100644 --- a/i18n/locales/de.json +++ b/i18n/locales/de.json @@ -54,6 +54,8 @@ "delete": "löschen", "archive": "archivieren", "refresh": "aktualisieren", + "search": "suchen", + "filter": "filtern", "reply": "antworten", "forward": "weiterleiten", "move": "verschieben", diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 5c063a7..f101d96 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -54,6 +54,8 @@ "delete": "delete", "archive": "archive", "refresh": "refresh", + "search": "search", + "filter": "filter", "reply": "reply", "forward": "forward", "move": "move", diff --git a/i18n/locales/es.json b/i18n/locales/es.json index 842b263..0fef00a 100644 --- a/i18n/locales/es.json +++ b/i18n/locales/es.json @@ -54,6 +54,8 @@ "delete": "eliminar", "archive": "archivar", "refresh": "actualizar", + "search": "buscar", + "filter": "filtrar", "reply": "responder", "forward": "reenviar", "move": "mover", diff --git a/i18n/locales/fr.json b/i18n/locales/fr.json index e64a934..d75d3fd 100644 --- a/i18n/locales/fr.json +++ b/i18n/locales/fr.json @@ -54,6 +54,8 @@ "delete": "supprimer", "archive": "archiver", "refresh": "actualiser", + "search": "rechercher", + "filter": "filtrer", "reply": "répondre", "forward": "transférer", "move": "déplacer", diff --git a/i18n/locales/ja.json b/i18n/locales/ja.json index e8c8b7e..c9b720b 100644 --- a/i18n/locales/ja.json +++ b/i18n/locales/ja.json @@ -54,6 +54,8 @@ "delete": "削除", "archive": "アーカイブ", "refresh": "更新", + "search": "検索", + "filter": "フィルタ", "reply": "返信", "forward": "転送", "move": "移動", diff --git a/i18n/locales/pl.json b/i18n/locales/pl.json index 43523b7..2113edc 100644 --- a/i18n/locales/pl.json +++ b/i18n/locales/pl.json @@ -54,6 +54,8 @@ "delete": "usuń", "archive": "archiwizuj", "refresh": "odśwież", + "search": "szukaj", + "filter": "filtruj", "reply": "odpowiedz", "forward": "przekaż", "move": "przenieś", diff --git a/i18n/locales/pt.json b/i18n/locales/pt.json index f802963..97d24e7 100644 --- a/i18n/locales/pt.json +++ b/i18n/locales/pt.json @@ -54,6 +54,8 @@ "delete": "excluir", "archive": "arquivar", "refresh": "atualizar", + "search": "buscar", + "filter": "filtrar", "reply": "responder", "forward": "encaminhar", "move": "mover", diff --git a/i18n/locales/ru.json b/i18n/locales/ru.json index 7a53b31..da64ae1 100644 --- a/i18n/locales/ru.json +++ b/i18n/locales/ru.json @@ -54,6 +54,8 @@ "delete": "удалить", "archive": "архивировать", "refresh": "обновить", + "search": "поиск", + "filter": "фильтр", "reply": "ответить", "forward": "переслать", "move": "переместить", diff --git a/i18n/locales/uk.json b/i18n/locales/uk.json index 797ab98..7ad0961 100644 --- a/i18n/locales/uk.json +++ b/i18n/locales/uk.json @@ -54,6 +54,8 @@ "delete": "видалити", "archive": "архівувати", "refresh": "оновити", + "search": "пошук", + "filter": "фільтр", "reply": "відповісти", "forward": "переслати", "move": "перемістити", diff --git a/i18n/locales/zh.json b/i18n/locales/zh.json index c2ccbb1..1fcaa05 100644 --- a/i18n/locales/zh.json +++ b/i18n/locales/zh.json @@ -54,6 +54,8 @@ "delete": "删除", "archive": "存档", "refresh": "刷新", + "search": "搜索", + "filter": "筛选", "reply": "回复", "forward": "转发", "move": "移动", diff --git a/tui/inbox.go b/tui/inbox.go index e32ddc8..7b0361a 100644 --- a/tui/inbox.go +++ b/tui/inbox.go @@ -433,7 +433,7 @@ func (m *Inbox) updateList() { key.NewBinding(key.WithKeys("d"), key.WithHelp("\uf014 d", t("inbox.delete"))), key.NewBinding(key.WithKeys("a"), key.WithHelp("\uea98 a", t("inbox.archive"))), key.NewBinding(key.WithKeys("r"), key.WithHelp("\ue348 r", t("inbox.refresh"))), - key.NewBinding(key.WithKeys(searchKey()), key.WithHelp(searchKey(), "search")), + key.NewBinding(key.WithKeys(searchKey()), key.WithHelp(searchKey(), t("inbox.search"))), } if len(m.tabs) > 1 { bindings = append(bindings, @@ -449,7 +449,7 @@ func (m *Inbox) updateList() { } l.KeyMap.Quit.SetEnabled(false) - l.KeyMap.Filter = key.NewBinding(key.WithKeys(filterKey()), key.WithHelp(filterKey(), "filter")) + l.KeyMap.Filter = key.NewBinding(key.WithKeys(filterKey()), key.WithHelp(filterKey(), t("inbox.filter"))) // Disable default help to render it manually at the bottom l.SetShowHelp(false) From 5c81dc57f4da33ec8d57c295469f4b586c859023 Mon Sep 17 00:00:00 2001 From: drew Date: Wed, 29 Apr 2026 19:41:58 +0400 Subject: [PATCH 07/10] fix: f keybind doesnt work Signed-off-by: drew --- tui/inbox.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tui/inbox.go b/tui/inbox.go index 7b0361a..4c66f41 100644 --- a/tui/inbox.go +++ b/tui/inbox.go @@ -450,6 +450,8 @@ func (m *Inbox) updateList() { l.KeyMap.Quit.SetEnabled(false) l.KeyMap.Filter = key.NewBinding(key.WithKeys(filterKey()), key.WithHelp(filterKey(), t("inbox.filter"))) + l.KeyMap.NextPage = key.NewBinding(key.WithKeys("pgdown"), key.WithHelp("pgdn", "next page")) + l.KeyMap.PrevPage = key.NewBinding(key.WithKeys("pgup"), key.WithHelp("pgup", "prev page")) // Disable default help to render it manually at the bottom l.SetShowHelp(false) From a01967b5d349ef739a1faa7dc1ed4c4cc0123524 Mon Sep 17 00:00:00 2001 From: drew Date: Wed, 29 Apr 2026 19:42:37 +0400 Subject: [PATCH 08/10] fix: esc handing in filter Signed-off-by: drew --- main.go | 5 ++++- tui/inbox.go | 4 ++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/main.go b/main.go index 1549044..051f082 100644 --- a/main.go +++ b/main.go @@ -206,14 +206,17 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd var cmds []tea.Cmd searchWasActive := false + filterWasActive := false if keyMsg, ok := msg.(tea.KeyPressMsg); ok && keyMsg.String() == "esc" { switch current := m.current.(type) { case *tui.Inbox: searchWasActive = current.IsSearchActive() + filterWasActive = current.IsFilterActive() case *tui.FolderInbox: if inbox := current.GetInbox(); inbox != nil { searchWasActive = inbox.IsSearchActive() + filterWasActive = inbox.IsFilterActive() } } } @@ -254,7 +257,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case *tui.FilePicker: return m, func() tea.Msg { return tui.CancelFilePickerMsg{} } case *tui.FolderInbox, *tui.Inbox, *tui.Login: - if searchWasActive { + if searchWasActive || filterWasActive { return m, tea.Batch(cmds...) } m.idleWatcher.StopAll() diff --git a/tui/inbox.go b/tui/inbox.go index 4c66f41..bbd28b3 100644 --- a/tui/inbox.go +++ b/tui/inbox.go @@ -1053,6 +1053,10 @@ func (m *Inbox) IsSearchActive() bool { return m != nil && (m.searchOverlay != nil || m.searchActive) } +func (m *Inbox) IsFilterActive() bool { + return m != nil && (m.list.FilterState() == list.Filtering || m.list.FilterState() == list.FilterApplied) +} + // GetEmailAtIndex returns the email at the given index for the current view func (m *Inbox) GetEmailAtIndex(index int) *fetcher.Email { displayEmails := m.displayEmails() From 395b8c54327b2eee0b640e2771b3e9991d83c240 Mon Sep 17 00:00:00 2001 From: drew Date: Wed, 29 Apr 2026 19:51:50 +0400 Subject: [PATCH 09/10] chore: use keybinds, instead of hardcoded string Signed-off-by: drew --- main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.go b/main.go index 051f082..aed26e2 100644 --- a/main.go +++ b/main.go @@ -208,7 +208,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { searchWasActive := false filterWasActive := false - if keyMsg, ok := msg.(tea.KeyPressMsg); ok && keyMsg.String() == "esc" { + if keyMsg, ok := msg.(tea.KeyPressMsg); ok && keyMsg.String() == config.Keybinds.Global.Cancel { switch current := m.current.(type) { case *tui.Inbox: searchWasActive = current.IsSearchActive() From 1a746ca9c4d07ae0a2a09a30c70e37b34dce6438 Mon Sep 17 00:00:00 2001 From: drew Date: Wed, 29 Apr 2026 19:54:54 +0400 Subject: [PATCH 10/10] fix: use internal/httpclient Signed-off-by: drew --- internal/httpclient/httpclient.go | 4 ++++ main.go | 8 ++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/internal/httpclient/httpclient.go b/internal/httpclient/httpclient.go index d377d6b..55fd532 100644 --- a/internal/httpclient/httpclient.go +++ b/internal/httpclient/httpclient.go @@ -22,6 +22,10 @@ const ( InstallTimeout = 30 * time.Second // UpdateCheckTimeout bounds version checks and asset downloads from main (main.go). UpdateCheckTimeout = 30 * time.Second + // IMAPBatchActionTimeout bounds bulk IMAP operations (delete/archive/move) from main (main.go). + IMAPBatchActionTimeout = 60 * time.Second + // IMAPSearchTimeout bounds server-side IMAP search queries from main (main.go). + IMAPSearchTimeout = 60 * time.Second ) // New returns an http.Client preconfigured with the given timeout. diff --git a/main.go b/main.go index aed26e2..f47a696 100644 --- a/main.go +++ b/main.go @@ -2129,7 +2129,7 @@ func fetchEmailsForMailbox(account *config.Account, limit, offset uint32, mailbo func (m *mainModel) searchEmailsCmd(query backend.SearchQuery, folderName, accountID string) tea.Cmd { return func() tea.Msg { - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), httpclient.IMAPSearchTimeout) defer cancel() var accounts []config.Account @@ -2781,7 +2781,7 @@ func archiveFolderEmailCmd(account *config.Account, uid uint32, accountID string func (m *mainModel) batchDeleteEmailsCmd(account *config.Account, uids []uint32, accountID, folderName string, mailbox tui.MailboxKind, count int) tea.Cmd { return func() tea.Msg { - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), httpclient.IMAPBatchActionTimeout) defer cancel() p := m.getProvider(account) @@ -2820,7 +2820,7 @@ func (m *mainModel) batchDeleteEmailsCmd(account *config.Account, uids []uint32, func (m *mainModel) batchArchiveEmailsCmd(account *config.Account, uids []uint32, accountID, folderName string, mailbox tui.MailboxKind, count int) tea.Cmd { return func() tea.Msg { - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), httpclient.IMAPBatchActionTimeout) defer cancel() p := m.getProvider(account) @@ -2858,7 +2858,7 @@ func (m *mainModel) batchArchiveEmailsCmd(account *config.Account, uids []uint32 func (m *mainModel) batchMoveEmailsCmd(account *config.Account, uids []uint32, accountID, sourceFolder, destFolder string, count int) tea.Cmd { return func() tea.Msg { - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), httpclient.IMAPBatchActionTimeout) defer cancel() p := m.getProvider(account)