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:
@@ -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>")
|
||||
})
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user