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

Matt Van Horn , Matt Van Horn , and drew created

## What?

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

## Why?

Matches the spec from issue #508 directly:

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

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

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

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

---------

Signed-off-by: drew <me@andrinoff.com>
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: drew <me@andrinoff.com>

Change summary

backend/backend.go                | 111 +++++++++++++++
backend/backend_test.go           |  76 ++++++++++
backend/imap/imap.go              |   8 +
backend/jmap/jmap.go              |  87 +++++++++++
backend/jmap/jmap_search_test.go  |  26 +++
backend/pop3/pop3.go              |   4 
backend/pop3/pop3_test.go         |  11 +
config/default_keybinds.json      |   2 
config/keybinds.go                |   4 
fetcher/search.go                 | 129 +++++++++++++++++
fetcher/search_test.go            |  27 +++
i18n/locales/ar.json              |   2 
i18n/locales/de.json              |   2 
i18n/locales/en.json              |   2 
i18n/locales/es.json              |   2 
i18n/locales/fr.json              |   2 
i18n/locales/ja.json              |   2 
i18n/locales/pl.json              |   2 
i18n/locales/pt.json              |   2 
i18n/locales/ru.json              |   2 
i18n/locales/uk.json              |   2 
i18n/locales/zh.json              |   2 
internal/httpclient/httpclient.go |   4 
main.go                           | 120 +++++++++++++++
tui/folder_inbox.go               |  25 +++
tui/folder_inbox_test.go          |  88 ++++++++++++
tui/inbox.go                      | 239 ++++++++++++++++++++++++++++----
tui/inbox_test.go                 | 180 ++++++++++++++++++++++++
tui/messages.go                   |  20 ++
tui/search.go                     | 112 +++++++++++++++
30 files changed, 1,252 insertions(+), 43 deletions(-)

Detailed changes

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

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)
+			}
+		})
+	}
+}

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)
 }

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 {

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)
+	}
+}

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

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)
+	}
+}

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"

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,

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
+}

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")
+	}
+}

i18n/locales/ar.json 🔗

@@ -54,6 +54,8 @@
       "delete": "حذف",
       "archive": "أرشفة",
       "refresh": "تحديث",
+      "search": "بحث",
+      "filter": "تصفية",
       "reply": "رد",
       "forward": "إعادة توجيه",
       "move": "نقل",

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",

i18n/locales/en.json 🔗

@@ -54,6 +54,8 @@
       "delete": "delete",
       "archive": "archive",
       "refresh": "refresh",
+      "search": "search",
+      "filter": "filter",
       "reply": "reply",
       "forward": "forward",
       "move": "move",

i18n/locales/es.json 🔗

@@ -54,6 +54,8 @@
       "delete": "eliminar",
       "archive": "archivar",
       "refresh": "actualizar",
+      "search": "buscar",
+      "filter": "filtrar",
       "reply": "responder",
       "forward": "reenviar",
       "move": "mover",

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",

i18n/locales/ja.json 🔗

@@ -54,6 +54,8 @@
       "delete": "削除",
       "archive": "アーカイブ",
       "refresh": "更新",
+      "search": "検索",
+      "filter": "フィルタ",
       "reply": "返信",
       "forward": "転送",
       "move": "移動",

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ś",

i18n/locales/pt.json 🔗

@@ -54,6 +54,8 @@
       "delete": "excluir",
       "archive": "arquivar",
       "refresh": "atualizar",
+      "search": "buscar",
+      "filter": "filtrar",
       "reply": "responder",
       "forward": "encaminhar",
       "move": "mover",

i18n/locales/ru.json 🔗

@@ -54,6 +54,8 @@
       "delete": "удалить",
       "archive": "архивировать",
       "refresh": "обновить",
+      "search": "поиск",
+      "filter": "фильтр",
       "reply": "ответить",
       "forward": "переслать",
       "move": "переместить",

i18n/locales/uk.json 🔗

@@ -54,6 +54,8 @@
       "delete": "видалити",
       "archive": "архівувати",
       "refresh": "оновити",
+      "search": "пошук",
+      "filter": "фільтр",
       "reply": "відповісти",
       "forward": "переслати",
       "move": "перемістити",

i18n/locales/zh.json 🔗

@@ -54,6 +54,8 @@
       "delete": "删除",
       "archive": "存档",
       "refresh": "刷新",
+      "search": "搜索",
+      "filter": "筛选",
       "reply": "回复",
       "forward": "转发",
       "move": "移动",

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.

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)
@@ -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)

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
 }
 

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: "<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)
+	}
+}

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

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: "<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.

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{}

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()
+}