Detailed changes
@@ -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
@@ -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)
+ }
+ })
+ }
+}
@@ -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)
}
@@ -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 {
@@ -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)
+ }
+}
@@ -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
@@ -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)
+ }
+}
@@ -10,6 +10,8 @@
"delete": "d",
"archive": "a",
"refresh": "r",
+ "search": "/",
+ "filter": "f",
"open": "enter",
"next_tab": "l",
"prev_tab": "h"
@@ -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,
@@ -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
+}
@@ -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")
+ }
+}
@@ -54,6 +54,8 @@
"delete": "حذف",
"archive": "أرشفة",
"refresh": "تحديث",
+ "search": "بحث",
+ "filter": "تصفية",
"reply": "رد",
"forward": "إعادة توجيه",
"move": "نقل",
@@ -54,6 +54,8 @@
"delete": "löschen",
"archive": "archivieren",
"refresh": "aktualisieren",
+ "search": "suchen",
+ "filter": "filtern",
"reply": "antworten",
"forward": "weiterleiten",
"move": "verschieben",
@@ -54,6 +54,8 @@
"delete": "delete",
"archive": "archive",
"refresh": "refresh",
+ "search": "search",
+ "filter": "filter",
"reply": "reply",
"forward": "forward",
"move": "move",
@@ -54,6 +54,8 @@
"delete": "eliminar",
"archive": "archivar",
"refresh": "actualizar",
+ "search": "buscar",
+ "filter": "filtrar",
"reply": "responder",
"forward": "reenviar",
"move": "mover",
@@ -54,6 +54,8 @@
"delete": "supprimer",
"archive": "archiver",
"refresh": "actualiser",
+ "search": "rechercher",
+ "filter": "filtrer",
"reply": "répondre",
"forward": "transférer",
"move": "déplacer",
@@ -54,6 +54,8 @@
"delete": "削除",
"archive": "アーカイブ",
"refresh": "更新",
+ "search": "検索",
+ "filter": "フィルタ",
"reply": "返信",
"forward": "転送",
"move": "移動",
@@ -54,6 +54,8 @@
"delete": "usuń",
"archive": "archiwizuj",
"refresh": "odśwież",
+ "search": "szukaj",
+ "filter": "filtruj",
"reply": "odpowiedz",
"forward": "przekaż",
"move": "przenieś",
@@ -54,6 +54,8 @@
"delete": "excluir",
"archive": "arquivar",
"refresh": "atualizar",
+ "search": "buscar",
+ "filter": "filtrar",
"reply": "responder",
"forward": "encaminhar",
"move": "mover",
@@ -54,6 +54,8 @@
"delete": "удалить",
"archive": "архивировать",
"refresh": "обновить",
+ "search": "поиск",
+ "filter": "фильтр",
"reply": "ответить",
"forward": "переслать",
"move": "переместить",
@@ -54,6 +54,8 @@
"delete": "видалити",
"archive": "архівувати",
"refresh": "оновити",
+ "search": "пошук",
+ "filter": "фільтр",
"reply": "відповісти",
"forward": "переслати",
"move": "перемістити",
@@ -54,6 +54,8 @@
"delete": "删除",
"archive": "存档",
"refresh": "刷新",
+ "search": "搜索",
+ "filter": "筛选",
"reply": "回复",
"forward": "转发",
"move": "移动",
@@ -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.
@@ -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)
@@ -1173,7 +1200,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
}
@@ -1187,7 +1219,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 {
@@ -1806,6 +1838,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 {
@@ -2092,6 +2135,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()
@@ -2679,7 +2789,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)
@@ -2718,7 +2828,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)
@@ -2756,7 +2866,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)
@@ -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
}
@@ -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: "<search-hit@example.com>",
+ 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)
+ }
+}
@@ -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
@@ -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: "<shared@example.com>", From: "drew@example.com", To: []string{"business@andrinoff.com"}, Subject: "Hey", AccountID: "account-1"},
+ {UID: 82, MessageID: "<shared@example.com>", From: "drew@example.com", To: []string{"business@andrinoff.com"}, Subject: "Hey", AccountID: "account-2"},
+ {UID: 83, MessageID: "<shared@example.com>", 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: "<shared@example.com>", From: "drew@example.com", To: []string{"business@andrinoff.com"}, Subject: "Hey", AccountID: "account-1"},
+ {UID: 82, MessageID: "<shared@example.com>", 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: "<one@example.com>", From: "sender@example.com", To: []string{"first@example.com"}, Subject: "Same", Date: date, AccountID: "account-1"},
+ {UID: 2, MessageID: "<two@example.com>", 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: "<first@example.com>", From: "a@example.com", To: []string{"Shared <shared@example.com>", "Second <second@example.com>"}, 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: "<search@example.com>", 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.
@@ -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{}
@@ -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()
+}