diff --git a/backend/backend.go b/backend/backend.go index 6c80804..2e7e63b 100644 --- a/backend/backend.go +++ b/backend/backend.go @@ -4,7 +4,10 @@ package backend import ( "context" "errors" + "strconv" + "strings" "time" + "unicode" ) // ErrNotSupported is returned when a provider does not support an operation. @@ -15,6 +18,7 @@ type Provider interface { EmailReader EmailWriter EmailSender + EmailSearcher FolderManager Notifier Close() error @@ -45,6 +49,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 +102,108 @@ 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 tokenizeSearchQuery(s) { + key, value, ok := strings.Cut(term, ":") + 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 == "" && 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 { + 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..0b51ec3 --- /dev/null +++ b/backend/backend_test.go @@ -0,0 +1,76 @@ +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 != "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/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..8d72b19 100644 --- a/config/default_keybinds.json +++ b/config/default_keybinds.json @@ -10,6 +10,8 @@ "delete": "d", "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 f01d60e..034a8d8 100644 --- a/config/keybinds.go +++ b/config/keybinds.go @@ -37,6 +37,8 @@ type InboxKeys struct { Delete string `json:"delete"` 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"` @@ -142,6 +144,8 @@ func ValidateKeybinds(kb KeybindsConfig) []string { "delete": kb.Inbox.Delete, "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/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/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/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 ad0d853..f47a696 100644 --- a/main.go +++ b/main.go @@ -7,6 +7,7 @@ import ( "context" "encoding/base64" "encoding/json" + "errors" "flag" "fmt" "io" @@ -18,6 +19,7 @@ import ( "regexp" "runtime" "slices" + "sort" "strings" "sync" "time" @@ -203,6 +205,21 @@ 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 + filterWasActive := false + + if keyMsg, ok := msg.(tea.KeyPressMsg); ok && keyMsg.String() == config.Keybinds.Global.Cancel { + 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() + } + } + } m.current, cmd = m.current.Update(msg) cmds = append(cmds, cmd) @@ -240,6 +257,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 || filterWasActive { + 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}) @@ -922,6 +942,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) @@ -1165,7 +1192,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 } @@ -1179,7 +1211,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 { @@ -1798,6 +1830,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 { @@ -2084,6 +2127,73 @@ 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(), httpclient.IMAPSearchTimeout) + 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 + var firstErr error + succeeded := false + for i := range accounts { + acc := &accounts[i] + p := m.getProvider(acc) + if p == nil { + 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 { + 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} + } +} + +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() @@ -2671,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) @@ -2710,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) @@ -2748,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) diff --git a/tui/folder_inbox.go b/tui/folder_inbox.go index 91e08bd..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 } @@ -373,6 +377,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 } @@ -758,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() @@ -776,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 @@ -786,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) + } +} diff --git a/tui/inbox.go b/tui/inbox.go index 731150e..bbd28b3 100644 --- a/tui/inbox.go +++ b/tui/inbox.go @@ -3,6 +3,7 @@ package tui import ( "fmt" "io" + "net/mail" "strings" "time" @@ -44,6 +45,20 @@ 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 "/" +} + +func filterKey() string { + if config.Keybinds.Inbox.Filter != "" { + return config.Keybinds.Inbox.Filter + } + return "f" +} + type itemDelegate struct { inbox *Inbox } @@ -281,6 +296,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 @@ -325,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}) } } @@ -348,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: "", @@ -372,17 +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.currentAccountID == "" { + if m.searchActive { + 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) @@ -391,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{ @@ -426,6 +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(), t("inbox.search"))), } if len(m.tabs) > 1 { bindings = append(bindings, @@ -441,6 +449,9 @@ 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) @@ -465,9 +476,122 @@ 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 { + fetchEmail := accountDisplayEmail(acc) + for _, recipient := range email.To { + if sameEmailAddress(recipient, fetchEmail) { + return extractEmailAddress(recipient) + } + } + } + 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 + } + 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.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() @@ -476,7 +600,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 } @@ -519,6 +643,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 +662,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 +689,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 @@ -673,8 +816,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} } } } @@ -683,11 +830,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 = dedupeEmailsForAccounts(msg.Emails, m.accounts) + 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 +919,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 +1015,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") @@ -874,14 +1049,17 @@ func (m *Inbox) GetCurrentAccountID() string { return m.currentAccountID } +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 { - var displayEmails []fetcher.Email - if m.currentAccountID == "" { - displayEmails = m.allEmails - } else { - displayEmails = m.emailsByAccount[m.currentAccountID] - } + displayEmails := m.displayEmails() if index >= 0 && index < len(displayEmails) { return &displayEmails[index] @@ -1055,7 +1233,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 @@ -1065,7 +1243,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..43ef8bb 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,180 @@ 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 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, 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 != "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" { + t.Fatalf("expected FetchEmail fallback for unmatched recipient, got %q", second.accountEmail) + } +} + +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"}} + + 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. diff --git a/tui/messages.go b/tui/messages.go index 1766e2b..4908267 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" @@ -21,6 +22,7 @@ type ViewEmailMsg struct { UID uint32 AccountID string Mailbox MailboxKind + Email *fetcher.Email } type SendEmailMsg struct { @@ -101,6 +103,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..f143d36 --- /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, msg.Err == nil, "" + 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() +}