Skip to content
111 changes: 111 additions & 0 deletions backend/backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ package backend
import (
"context"
"errors"
"strconv"
"strings"
"time"
"unicode"
)

// ErrNotSupported is returned when a provider does not support an operation.
Expand All @@ -15,6 +18,7 @@ type Provider interface {
EmailReader
EmailWriter
EmailSender
EmailSearcher
FolderManager
Notifier
Close() error
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
76 changes: 76 additions & 0 deletions backend/backend_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
8 changes: 8 additions & 0 deletions backend/imap/imap.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
87 changes: 84 additions & 3 deletions backend/jmap/jmap.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand All @@ -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
Expand All @@ -186,15 +235,47 @@ 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))
}
}
}

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 {
Expand Down
26 changes: 26 additions & 0 deletions backend/jmap/jmap_search_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
4 changes: 4 additions & 0 deletions backend/pop3/pop3.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading