mesh-mail: mail_search's Query gains from, to, cc and a date range, so a sender, a recipient or a date can filter mails without query words (issue 1228)

This commit is contained in:
jochen
2026-10-19 14:22:14 +02:00
parent 7bb588ca1b
commit 20342f2172
3 changed files with 268 additions and 3 deletions
+40 -3
View File
@@ -395,14 +395,51 @@ func (s *PgStore) Get(ctx context.Context, account, folder string, uid uint32) (
return m, err == nil, err
}
// addressFilterSQL is 1228/AC2, 1228/AC3, 1228/AC4, 1228/AC6(cc): an EXISTS over the jsonb array column (sender,
// recipients or copied), matched against one already-lowercased parameter either as a whole address or, with no
// "@", as a domain (every address under it). column names a fixed set the Go code chooses, never caller input.
func addressFilterSQL(column, param string) string {
return fmt.Sprintf("exists (select 1 from jsonb_array_elements(%s) el where lower(el->>'address') = %s or "+
"lower(el->>'address') like '%%@' || %s)", column, param, param)
}
func (s *PgStore) Search(ctx context.Context, q Query) ([]Message, error) {
limit := q.Limit
if limit <= 0 {
limit = 20
}
rows, err := s.pool.Query(ctx, `select `+messageColumns+` from message
where account = any($1) and ($2 = '' or folder = $2) and search @@ plainto_tsquery('simple'::regconfig, $3)
order by date desc limit $4`, q.Accounts, q.Folder, q.Text, limit)
var args []any
// param appends one value and answers its placeholder; every value from the query reaches SQL this way, never
// concatenated (only fixed column names and SQL text are built as strings).
param := func(v any) string {
args = append(args, v)
return fmt.Sprintf("$%d", len(args))
}
where := []string{"account = any(" + param(q.Accounts) + ")"}
if q.Folder != "" {
where = append(where, "folder = "+param(q.Folder))
}
if q.Text != "" {
where = append(where, "search @@ plainto_tsquery('simple'::regconfig, "+param(q.Text)+")")
}
if q.From != "" {
where = append(where, addressFilterSQL("sender", param(strings.ToLower(q.From))))
}
if q.To != "" {
where = append(where, addressFilterSQL("recipients", param(strings.ToLower(q.To))))
}
if q.Cc != "" {
where = append(where, addressFilterSQL("copied", param(strings.ToLower(q.Cc))))
}
if !q.OnOrAfter.IsZero() {
where = append(where, "date >= "+param(q.OnOrAfter))
}
if !q.Before.IsZero() {
where = append(where, "date < "+param(q.Before))
}
sqlText := "select " + messageColumns + " from message where " + strings.Join(where, " and ") +
" order by date desc limit " + param(limit)
rows, err := s.pool.Query(ctx, sqlText, args...)
if err != nil {
return nil, err
}
@@ -0,0 +1,191 @@
package main
// Fixtures and store-level tests for mail_search's filters (issues 1228-1232, novox/hq mesh-catalog.mail-search).
// Built once as fixture Messages (store.go's own type) across two accounts (mesh, gmail) and the folders INBOX,
// Sent, Junk, then reused by every issue's tests, run through eachStore (the memory store and Postgres,
// kit_test.go:170-254). "today" for these fixtures is fixed at searchNow, never time.Now(), so a run on any day
// answers the same thing.
import (
"context"
"sort"
"testing"
"time"
)
// searchNow is "today" for the fixtures below (AC2's "on-or-after today", AC7's "last month", AC9's "this week's
// Monday", AC11's "90 days ago"): a fixed instant, not time.Now(), so the tests are deterministic.
var searchNow = time.Date(2026, 10, 19, 12, 0, 0, 0, time.UTC)
// searchFixtures is one small mailbox: two accounts, three folders each. Every field's shape is store.go's own
// Message (store.go:33-50): From/To/Cc are []Person{Name, Address} (store.go:17-20, as imap.go's "people" builds
// them from an envelope, imap.go:507-515); Attachments are []AttachmentRef{Bucket, Key, Size, Type, Name}
// (store.go:24-30); Flags are []string of IMAP flag names such as "\Seen" (store.go:46, imap.go:517-527); Date is
// the parsed Date header (store.go:40, imap.go:483-485); MessageID/InReplyTo are the Message-Id and In-Reply-To
// headers verbatim (store.go:38-39).
func searchFixtures() []Message {
at := func(h int, m int) time.Time { return time.Date(2026, 10, 19, h, m, 0, 0, time.UTC) }
return []Message{
// mesh/INBOX ------------------------------------------------------------------------------------------------
{Account: "mesh", Folder: "INBOX", UID: 1, MessageID: "<invoice1@mesh>", Subject: "October invoice",
Date: at(9, 0), From: []Person{{Name: "Bob", Address: "bob@example.org"}}, To: []Person{{Address: "jschoubben@novox.be"}},
Flags: []string{}, Attachments: []AttachmentRef{{Bucket: "mail", Key: "a1", Size: 1200, Type: "application/pdf", Name: "invoice.pdf"}},
Body: "please settle the invoice, due at month end", Size: 1500},
{Account: "mesh", Folder: "INBOX", UID: 2, MessageID: "<renov1@mesh>", Subject: "renovatie plannen",
Date: time.Date(2026, 9, 1, 8, 0, 0, 0, time.UTC), From: []Person{{Name: "Alice", Address: "alice@example.org"}},
To: []Person{{Address: "bob@example.org"}}, Cc: []Person{{Address: "jschoubben@novox.be"}},
Flags: []string{"\\Seen"}, Attachments: []AttachmentRef{}, Body: "over de renovatie van de keuken", Size: 900},
{Account: "mesh", Folder: "INBOX", UID: 3, MessageID: "<renov2@mesh>", Subject: "renovatie herinnering",
Date: time.Date(2026, 9, 2, 8, 0, 0, 0, time.UTC), From: []Person{{Name: "Alice", Address: "alice@example.org"}},
To: []Person{{Address: "jschoubben@novox.be"}}, Flags: []string{"\\Seen"}, Attachments: []AttachmentRef{},
Body: "nog een vraag over de renovatie", Size: 800},
{Account: "mesh", Folder: "INBOX", UID: 4, MessageID: "<sec1@mesh>", Subject: "security alert",
Date: time.Date(2026, 10, 15, 7, 0, 0, 0, time.UTC), From: []Person{{Name: "Carol", Address: "carol@google.com"}},
To: []Person{{Address: "jschoubben@novox.be"}}, Flags: []string{"\\Seen"}, Attachments: []AttachmentRef{},
Body: "someone logged into your account from a new device", Size: 600},
{Account: "mesh", Folder: "INBOX", UID: 5, MessageID: "<cal1@mesh>", Subject: "team meeting invite",
Date: at(8, 0), From: []Person{{Name: "Dave", Address: "dave@example.org"}}, To: []Person{{Address: "jschoubben@novox.be"}},
Flags: []string{"\\Seen"}, Attachments: []AttachmentRef{{Bucket: "mail", Key: "a2", Size: 300, Type: "text/calendar", Name: "meeting.ics"}},
Body: "please join the planning meeting", Size: 700},
{Account: "mesh", Folder: "INBOX", UID: 6, MessageID: "<reply1@mesh>", InReplyTo: "<sent1@mesh>", Subject: "Re: quick note",
Date: at(12, 30), From: []Person{{Name: "Bob", Address: "bob@example.org"}}, To: []Person{{Address: "jschoubben@novox.be"}},
Flags: []string{}, Attachments: []AttachmentRef{}, Body: "sounds good, thanks for the note", Size: 400},
// mesh/Sent ---------------------------------------------------------------------------------------------------
{Account: "mesh", Folder: "Sent", UID: 1, MessageID: "<sentreply1@mesh>", InReplyTo: "<invoice1@mesh>", Subject: "Re: October invoice",
Date: at(10, 0), From: []Person{{Address: "jschoubben@novox.be"}}, To: []Person{{Address: "bob@example.org"}},
Flags: []string{"\\Seen"}, Attachments: []AttachmentRef{}, Body: "paid, thank you", Size: 300},
{Account: "mesh", Folder: "Sent", UID: 2, MessageID: "<sent1@mesh>", Subject: "quick note",
Date: time.Date(2026, 10, 18, 9, 0, 0, 0, time.UTC), From: []Person{{Address: "jschoubben@novox.be"}},
To: []Person{{Address: "bob@example.org"}}, Flags: []string{"\\Seen"}, Attachments: []AttachmentRef{}, Body: "just checking in", Size: 250},
// mesh/Junk ---------------------------------------------------------------------------------------------------
{Account: "mesh", Folder: "Junk", UID: 1, MessageID: "<junk1@mesh>", Subject: "special offer just for you",
Date: time.Date(2026, 10, 10, 6, 0, 0, 0, time.UTC), From: []Person{{Address: "bob@example.org"}},
To: []Person{{Address: "jschoubben@novox.be"}}, Flags: []string{}, Attachments: []AttachmentRef{}, Body: "buy now and save big", Size: 500},
{Account: "mesh", Folder: "Junk", UID: 2, MessageID: "<junk2@mesh>", Subject: "you won the lottery",
Date: time.Date(2026, 10, 10, 6, 5, 0, 0, time.UTC), From: []Person{{Address: "spam@unknown.com"}},
To: []Person{{Address: "jschoubben@novox.be"}}, Flags: []string{}, Attachments: []AttachmentRef{}, Body: "claim your prize now", Size: 500},
// gmail/INBOX -------------------------------------------------------------------------------------------------
{Account: "gmail", Folder: "INBOX", UID: 1, MessageID: "<gsec1@gmail>", Subject: "security alert for your account",
Date: time.Date(2026, 10, 16, 7, 0, 0, 0, time.UTC), From: []Person{{Name: "Google", Address: "security@google.com"}},
To: []Person{{Address: "me@gmail.example"}}, Flags: []string{"\\Seen"}, Attachments: []AttachmentRef{},
Body: "new sign-in detected from a new device", Size: 650},
{Account: "gmail", Folder: "INBOX", UID: 2, MessageID: "<gschade1@gmail>", Subject: "schade melding woning",
Date: time.Date(2026, 8, 1, 7, 0, 0, 0, time.UTC), From: []Person{{Name: "Someone", Address: "someone@example.org"}},
To: []Person{{Address: "me@gmail.example"}}, Flags: []string{}, Attachments: []AttachmentRef{{Bucket: "mail", Key: "a3", Size: 400, Type: "application/pdf", Name: "schade.pdf"}},
Body: "ik heb verzekering nodig voor de schade aan het dak", Size: 1100},
// gmail/Sent ----------------------------------------------------------------------------------------------------
{Account: "gmail", Folder: "Sent", UID: 1, MessageID: "<gsentreply1@gmail>", InReplyTo: "<gschade1@gmail>", Subject: "Re: schade melding woning",
Date: time.Date(2026, 8, 2, 7, 0, 0, 0, time.UTC), From: []Person{{Address: "me@gmail.example"}},
To: []Person{{Address: "someone@example.org"}}, Flags: []string{"\\Seen"}, Attachments: []AttachmentRef{}, Body: "will send the claim form soon", Size: 350},
}
}
// addFixtures indexes every fixture message into the store.
func addFixtures(t *testing.T, ctx context.Context, st Store) {
t.Helper()
for _, m := range searchFixtures() {
if err := st.Add(ctx, m, nil); err != nil {
t.Fatal(err)
}
}
}
func uids(ms []Message) []string {
out := make([]string, 0, len(ms))
for _, m := range ms {
out = append(out, m.MessageID)
}
sort.Strings(out)
return out
}
func sameSet(t *testing.T, got []Message, want ...string) {
t.Helper()
sort.Strings(want)
g := uids(got)
if len(g) != len(want) {
t.Fatalf("got %v, want %v", g, want)
}
for i := range g {
if g[i] != want[i] {
t.Fatalf("got %v, want %v", g, want)
}
}
}
// 1228/AC2: account, folder and on-or-after combine (unread is 1229's, so this covers only the date/account/folder
// part this issue carries).
func TestSearchFiltersByAccountFolderAndOnOrAfter(t *testing.T) {
eachStore(t, func(t *testing.T, st Store) {
ctx := context.Background()
addFixtures(t, ctx, st)
got, err := st.Search(ctx, Query{Accounts: []string{"mesh"}, Folder: "INBOX", OnOrAfter: time.Date(2026, 10, 19, 0, 0, 0, 0, time.UTC), Limit: 10})
if err != nil {
t.Fatal(err)
}
sameSet(t, got, "<invoice1@mesh>", "<cal1@mesh>", "<reply1@mesh>")
})
}
// 1228/AC3: from <address> and on-or-after 1 January answers only that sender's mails of this year.
func TestSearchFiltersByFromAndOnOrAfter(t *testing.T) {
eachStore(t, func(t *testing.T, st Store) {
ctx := context.Background()
addFixtures(t, ctx, st)
got, err := st.Search(ctx, Query{Accounts: []string{"mesh"}, From: "bob@example.org",
OnOrAfter: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), Limit: 10})
if err != nil {
t.Fatal(err)
}
sameSet(t, got, "<invoice1@mesh>", "<reply1@mesh>", "<junk1@mesh>")
})
}
// 1228/AC4: folder Sent, to <address or domain> and a date range; a domain matches every address of it.
func TestSearchFiltersByFolderToAndDateRange(t *testing.T) {
eachStore(t, func(t *testing.T, st Store) {
ctx := context.Background()
addFixtures(t, ctx, st)
byAddress, err := st.Search(ctx, Query{Accounts: []string{"mesh"}, Folder: "Sent", To: "bob@example.org",
OnOrAfter: time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC), Before: time.Date(2026, 11, 1, 0, 0, 0, 0, time.UTC), Limit: 10})
if err != nil {
t.Fatal(err)
}
sameSet(t, byAddress, "<sentreply1@mesh>", "<sent1@mesh>")
byDomain, err := st.Search(ctx, Query{Accounts: []string{"mesh"}, Folder: "Sent", To: "example.org",
OnOrAfter: time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC), Before: time.Date(2026, 11, 1, 0, 0, 0, 0, time.UTC), Limit: 10})
if err != nil {
t.Fatal(err)
}
sameSet(t, byDomain, "<sentreply1@mesh>", "<sent1@mesh>")
// Before Oct 1 excludes both, proving the range's ends are honoured, not only the address.
none, err := st.Search(ctx, Query{Accounts: []string{"mesh"}, Folder: "Sent", To: "bob@example.org",
Before: time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC), Limit: 10})
if err != nil {
t.Fatal(err)
}
if len(none) != 0 {
t.Fatalf("before the range: %v", none)
}
})
}
// 1228/AC6 (cc part): cc <my address> answers only the mail with that address in cc, not the one with it only in
// to (words are 1229's; this covers the cc field alone).
func TestSearchFiltersByCcNotTo(t *testing.T) {
eachStore(t, func(t *testing.T, st Store) {
ctx := context.Background()
addFixtures(t, ctx, st)
got, err := st.Search(ctx, Query{Accounts: []string{"mesh"}, Cc: "jschoubben@novox.be", Limit: 10})
if err != nil {
t.Fatal(err)
}
sameSet(t, got, "<renov1@mesh>")
})
}
+37
View File
@@ -90,6 +90,17 @@ type Query struct {
Accounts []string
Folder string
Limit int
// From, To and Cc (issue 1228, AC2-AC4, AC6's cc part) are each an address or a domain, case-insensitive; a
// domain (no "@") matches every address of it. Empty means no filter on that field.
From string
To string
Cc string
// OnOrAfter and Before (issue 1228, AC2-AC4) bound Date (the Date header, imap.go:483-485): on-or-after is
// inclusive, before is exclusive. A zero time means no bound.
OnOrAfter time.Time
Before time.Time
}
// Store is the index.
@@ -239,6 +250,16 @@ func (s *memStore) Search(_ context.Context, q Query) ([]Message, error) {
if !contains(q.Accounts, m.Account) || (q.Folder != "" && q.Folder != m.Folder) {
continue
}
// 1228/AC2, 1228/AC3, 1228/AC4, 1228/AC6(cc): the address and date-range filters, mirroring PgStore.Search.
if !addressMatches(m.From, q.From) || !addressMatches(m.To, q.To) || !addressMatches(m.Cc, q.Cc) {
continue
}
if !q.OnOrAfter.IsZero() && m.Date.Before(q.OnOrAfter) {
continue
}
if !q.Before.IsZero() && !m.Date.Before(q.Before) {
continue
}
hay := strings.ToLower(m.Subject + " " + m.Body + " " + peopleText(m.From) + " " + peopleText(m.To) + " " + peopleText(m.Cc))
all := true
for _, w := range words {
@@ -321,6 +342,22 @@ func contains(list []string, s string) bool {
return false
}
// addressMatches is 1228/AC2, 1228/AC3, 1228/AC4, 1228/AC6(cc): filter is an address (matched exactly) or a domain
// (matched as the suffix after "@"), case-insensitive; empty filter matches every message.
func addressMatches(ps []Person, filter string) bool {
if filter == "" {
return true
}
f := strings.ToLower(filter)
for _, p := range ps {
addr := strings.ToLower(p.Address)
if addr == f || strings.HasSuffix(addr, "@"+f) {
return true
}
}
return false
}
func peopleText(ps []Person) string {
var parts []string
for _, p := range ps {