diff --git a/modules/mesh-mail/cmd/mesh-mail/pgstore.go b/modules/mesh-mail/cmd/mesh-mail/pgstore.go index 0c63fec9..f91f257d 100644 --- a/modules/mesh-mail/cmd/mesh-mail/pgstore.go +++ b/modules/mesh-mail/cmd/mesh-mail/pgstore.go @@ -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 } diff --git a/modules/mesh-mail/cmd/mesh-mail/search_test.go b/modules/mesh-mail/cmd/mesh-mail/search_test.go new file mode 100644 index 00000000..1fdbe4b5 --- /dev/null +++ b/modules/mesh-mail/cmd/mesh-mail/search_test.go @@ -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: "", 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: "", 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: "", 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: "", 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: "", 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: "", InReplyTo: "", 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: "", InReplyTo: "", 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: "", 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: "", 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: "", 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: "", 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: "", 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: "", InReplyTo: "", 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, "", "", "") + }) +} + +// 1228/AC3: from
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, "", "", "") + }) +} + +// 1228/AC4: folder Sent, to
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, "", "") + + 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, "", "") + + // 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 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, "") + }) +} diff --git a/modules/mesh-mail/cmd/mesh-mail/store.go b/modules/mesh-mail/cmd/mesh-mail/store.go index 7444ddf9..a24a4eda 100644 --- a/modules/mesh-mail/cmd/mesh-mail/store.go +++ b/modules/mesh-mail/cmd/mesh-mail/store.go @@ -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 {