Files
mesh-catalog/modules/mesh-mail/cmd/mesh-mail/search_test.go
T
jochen 5c1ff4bb15
mesh/merge-gate head 5c1ff4bb152b merged into trunk b767aac8e072 — pass: builds mesh-mail → novox; no bus step; every machine composes with the change as i…
mesh/repo-check head 5c1ff4bb152b merged into trunk b767aac8e072 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
mesh-mail: dedup-archive picks the same copy in both stores when a message-id also sits in a second non-archive folder (issue 1248, review round 1 finding 1)
2026-10-19 15:33:36 +02:00

855 lines
42 KiB
Go

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"
"strings"
"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},
// 1229/AC5: from a domain (google.com), has-attachment, name *.pdf and the word "factuur" — in the
// subject only (review round 1 finding 3), so Words's "subject or body" is genuinely exercised, not
// merely a body match that would pass even with a subject-only bug.
{Account: "mesh", Folder: "INBOX", UID: 7, MessageID: "<factuur1@mesh>", Subject: "the factuur for Q3",
Date: at(13, 0), From: []Person{{Name: "Erik", Address: "erik@google.com"}}, To: []Person{{Address: "jschoubben@novox.be"}},
Flags: []string{}, Attachments: []AttachmentRef{{Bucket: "mail", Key: "a4", Size: 200, Type: "application/pdf", Name: "factuur.pdf"}},
Body: "see the attached file, due on receipt", Size: 850},
// 1229/AC12: a word with digits and a dash ("4711-882"), matched whole, in the subject only (review
// round 1 finding 3).
{Account: "mesh", Folder: "INBOX", UID: 8, MessageID: "<order1@mesh>", Subject: "order 4711-882 confirmed",
Date: at(14, 0), From: []Person{{Address: "ops@example.org"}}, To: []Person{{Address: "jschoubben@novox.be"}},
Flags: []string{}, Attachments: []AttachmentRef{}, Body: "your order has shipped", Size: 300},
// Control for 1229/AC12: "4711" and "882" appear as separate tokens, never hyphenated together.
{Account: "mesh", Folder: "INBOX", UID: 9, MessageID: "<order2@mesh>", Subject: "order 4711 shipped, item 882 noted",
Date: at(14, 5), From: []Person{{Address: "ops@example.org"}}, To: []Person{{Address: "jschoubben@novox.be"}},
Flags: []string{}, Attachments: []AttachmentRef{}, Body: "item 4711 and item 882 are unrelated", Size: 300},
// 1229/AC15 (the "any" word list): only "verzekering" is present, not "schade" — in the subject only
// (review round 1 finding 3) — with an attachment.
{Account: "mesh", Folder: "INBOX", UID: 10, MessageID: "<polis1@mesh>", Subject: "verzekering aanvraag",
Date: at(14, 10), From: []Person{{Address: "broker@example.org"}}, To: []Person{{Address: "jschoubben@novox.be"}},
Flags: []string{}, Attachments: []AttachmentRef{{Bucket: "mail", Key: "a5", Size: 150, Type: "application/pdf", Name: "polis.pdf"}},
Body: "nieuwe polis aanvraag", Size: 450},
// 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},
// 1229/AC9 (review round 1, "also in this pass"): matches the type-or-name attachment filter by its
// type alone — its name does not match the glob.
{Account: "gmail", Folder: "INBOX", UID: 3, MessageID: "<cal2@gmail>", Subject: "another invite",
Date: time.Date(2026, 10, 19, 9, 0, 0, 0, time.UTC), From: []Person{{Address: "dave@example.org"}},
To: []Person{{Address: "me@gmail.example"}}, Flags: []string{}, Attachments: []AttachmentRef{{Bucket: "mail", Key: "a6", Size: 300, Type: "text/calendar", Name: "event.bin"}},
Body: "a second invite", Size: 700},
// 1229/AC9: matches the same filter by its name alone — its type is not text/calendar.
{Account: "gmail", Folder: "INBOX", UID: 4, MessageID: "<cal3@gmail>", Subject: "yet another invite",
Date: time.Date(2026, 10, 19, 9, 5, 0, 0, time.UTC), From: []Person{{Address: "dave@example.org"}},
To: []Person{{Address: "me@gmail.example"}}, Flags: []string{}, Attachments: []AttachmentRef{{Bucket: "mail", Key: "a7", Size: 300, Type: "application/octet-stream", Name: "invite.ics"}},
Body: "a third invite", Size: 700},
// gmail/[Gmail]/Sent Mail (review round 1 finding 1: gmail's live folder name for Sent, not "Sent") -----------
{Account: "gmail", Folder: "[Gmail]/Sent Mail", 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},
// gmail/INBOX, a mail gmail also keeps a copy of in [Gmail]/All Mail (1248/AC1, AC4) ----------------------
{Account: "gmail", Folder: "INBOX", UID: 5, MessageID: "<gdup1@gmail>", Subject: "dashboard digest",
Date: time.Date(2026, 10, 17, 7, 0, 0, 0, time.UTC), From: []Person{{Name: "Dashboard", Address: "dash@example.org"}},
To: []Person{{Address: "me@gmail.example"}}, Flags: []string{}, Attachments: []AttachmentRef{}, Body: "weekly digest", Size: 500},
// gmail/[Gmail]/All Mail (1248/AC1-AC4): gmail's archive, holding a second copy of every mail. UID 100 is
// gdup1@gmail's own second copy (same message-id as the INBOX one above); UID 101 is a mail archived out
// of the inbox, living only here.
{Account: "gmail", Folder: "[Gmail]/All Mail", UID: 100, MessageID: "<gdup1@gmail>", Subject: "dashboard digest",
Date: time.Date(2026, 10, 17, 7, 0, 0, 0, time.UTC), From: []Person{{Name: "Dashboard", Address: "dash@example.org"}},
To: []Person{{Address: "me@gmail.example"}}, Flags: []string{}, Attachments: []AttachmentRef{}, Body: "weekly digest", Size: 500},
{Account: "gmail", Folder: "[Gmail]/All Mail", UID: 101, MessageID: "<garchived1@gmail>", Subject: "old newsletter",
Date: time.Date(2026, 7, 1, 7, 0, 0, 0, time.UTC), From: []Person{{Name: "Newsletter", Address: "news@example.org"}},
To: []Person{{Address: "me@gmail.example"}}, Flags: []string{"\\Seen"}, Attachments: []AttachmentRef{}, Body: "monthly digest", Size: 400},
// mesh/Junk (review round 1 finding 2): someone@example.org, written to only by gmail's Sent, not mesh's.
{Account: "mesh", Folder: "Junk", UID: 3, MessageID: "<junk3@mesh>", Subject: "a gmail contact's junk mail",
Date: time.Date(2026, 10, 11, 6, 0, 0, 0, time.UTC), From: []Person{{Address: "someone@example.org"}},
To: []Person{{Address: "jschoubben@novox.be"}}, Flags: []string{}, Attachments: []AttachmentRef{}, Body: "unrelated offer", Size: 500},
// gmail/Junk (review round 1 finding 1): the same someone@example.org, but here it is gmail's own
// account, whose "[Gmail]/Sent Mail" did write to this address — so this one must match.
{Account: "gmail", Folder: "Junk", UID: 1, MessageID: "<gjunk1@gmail>", Subject: "a junk mail from a gmail contact",
Date: time.Date(2026, 10, 11, 6, 0, 0, 0, time.UTC), From: []Person{{Address: "someone@example.org"}},
To: []Person{{Address: "me@gmail.example"}}, Flags: []string{}, Attachments: []AttachmentRef{}, Body: "unrelated offer", Size: 500},
}
}
// 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>", "<factuur1@mesh>", "<order1@mesh>", "<order2@mesh>", "<polis1@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: []string{"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>")
})
}
// 1228 (review round 1, finding 4): a domain filter of "%" or "_" is never a LIKE wildcard; it matches nobody,
// the same in both stores (a Postgres-only bug matched every mail in Postgres, since '%@' || '%' is the
// wildcard '%@%', while memStore's strings.HasSuffix never treats '%' specially).
func TestSearchFiltersByDomainNeverTreatsPercentAsAWildcard(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", "gmail"}, From: []string{"%"}, Limit: 50})
if err != nil {
t.Fatal(err)
}
if len(got) != 0 {
t.Fatalf("from:[%%] matched %d mail(s): %v", len(got), got)
}
toUnderscore, err := st.Search(ctx, Query{Accounts: []string{"mesh", "gmail"}, To: "_", Limit: 50})
if err != nil {
t.Fatal(err)
}
if len(toUnderscore) != 0 {
t.Fatalf("to:_ matched %d mail(s): %v", len(toUnderscore), toUnderscore)
}
})
}
// 1229/AC6 (words part): cc <my address> and the word "renovatie" in subject or body, combined with the cc filter
// from 1228: the mail with it only in to is still excluded.
func TestSearchFiltersByCcAndSubjectOrBodyWord(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", Words: []string{"renovatie"}, Limit: 10})
if err != nil {
t.Fatal(err)
}
sameSet(t, got, "<renov1@mesh>")
})
}
// 1229/AC5: from a domain, has-attachment, an attachment name pattern and a word in subject or body all combine.
// The word ("factuur") lives only in the subject of the matching mail (review round 1 finding 3), so this
// genuinely exercises Words's "or body", not merely a body match.
func TestSearchFiltersByDomainAttachmentAndWord(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: []string{"google.com"}, HasAttachment: true,
AttachmentNamePattern: "*.pdf", Words: []string{"factuur"}, Limit: 10})
if err != nil {
t.Fatal(err)
}
// carol@google.com's security-alert mail (uid 4) is from the same domain but has neither an attachment nor
// the word, proving every one of the four conditions is enforced, not just one.
sameSet(t, got, "<factuur1@mesh>")
})
}
// Review round 1, finding 3: SubjectWords and BodyWords together mean "and" (a word in each field, possibly two
// different fields at once) — never a stand-in for "the word in subject or body". factuur1@mesh's "factuur" is
// only in its subject, so the two together find nothing; Words finds it.
func TestWordsIsNotSubjectWordsAndBodyWordsTogether(t *testing.T) {
eachStore(t, func(t *testing.T, st Store) {
ctx := context.Background()
addFixtures(t, ctx, st)
asAnd, err := st.Search(ctx, Query{Accounts: []string{"mesh"}, SubjectWords: []string{"factuur"},
BodyWords: []string{"factuur"}, Limit: 10})
if err != nil {
t.Fatal(err)
}
if len(asAnd) != 0 {
t.Fatalf("subject-words and body-words together: %v", asAnd)
}
asWords, err := st.Search(ctx, Query{Accounts: []string{"mesh"}, Words: []string{"factuur"}, Limit: 10})
if err != nil {
t.Fatal(err)
}
sameSet(t, asWords, "<factuur1@mesh>")
})
}
// 1229/AC9: on-or-after a date and an attachment type or name pattern (either suffices) answers a calendar
// invite. cal2@gmail matches by its type alone (its name does not match the glob) and cal3@gmail by its name
// alone (its type is not text/calendar) — review round 1, "also in this pass": proving each half of the "or"
// independently, not only cal1@mesh, which matches both at once and so would pass even if one half were broken.
func TestSearchFiltersByDateAndAttachmentTypeOrName(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", "gmail"}, OnOrAfter: time.Date(2026, 10, 19, 0, 0, 0, 0, time.UTC),
AttachmentType: "text/calendar", AttachmentNamePattern: "*.ics", Limit: 10})
if err != nil {
t.Fatal(err)
}
sameSet(t, got, "<cal1@mesh>", "<cal2@gmail>", "<cal3@gmail>")
})
}
// 1229/AC12: a word with digits and a dash is matched whole, not as its separate parts. It lives only in the
// subject of the matching mail (review round 1 finding 3), so Words's "subject or body" is genuinely exercised.
func TestSearchFiltersBySubjectOrBodyWordWhole(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", "gmail"}, Words: []string{"4711-882"}, Limit: 10})
if err != nil {
t.Fatal(err)
}
sameSet(t, got, "<order1@mesh>")
})
}
// 1229/AC13: from a domain and the subject words (default "all": every word of the list), across every granted
// account, each answer naming its own account.
func TestSearchFiltersByDomainAndSubjectWordsAcrossAccounts(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", "gmail"}, From: []string{"google.com"},
SubjectWords: []string{"security", "alert"}, Limit: 10})
if err != nil {
t.Fatal(err)
}
sameSet(t, got, "<sec1@mesh>", "<gsec1@gmail>")
accounts := map[string]bool{}
for _, m := range got {
accounts[m.Account] = true
}
if !accounts["mesh"] || !accounts["gmail"] {
t.Fatalf("each answer should name its own account: %+v", got)
}
})
}
// 1229/AC15: a word list marked "any" (schade or verzekering) combined with has-attachment; the word list's "any"
// mode matches a mail holding only one of the words, not both. polis1@mesh's "verzekering" lives only in its
// subject (review round 1 finding 3), so Words's "subject or body" is genuinely exercised.
func TestSearchFiltersByAnyWordsAndHasAttachment(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", "gmail"}, Words: []string{"schade", "verzekering"},
WordsAny: true, HasAttachment: true, Limit: 10})
if err != nil {
t.Fatal(err)
}
sameSet(t, got, "<gschade1@gmail>", "<polis1@mesh>")
// Without "any" (the default, "all"), only the mail holding both words together matches: polis1@mesh has
// "verzekering" alone, so it drops out.
all, err := st.Search(ctx, Query{Accounts: []string{"mesh", "gmail"}, Words: []string{"schade", "verzekering"},
HasAttachment: true, Limit: 10})
if err != nil {
t.Fatal(err)
}
sameSet(t, all, "<gschade1@gmail>")
})
}
// 1229 (unread/read, from 1228/AC2): the flag filter, read or unread, independent of the other filters.
func TestSearchFiltersByUnreadFlag(t *testing.T) {
eachStore(t, func(t *testing.T, st Store) {
ctx := context.Background()
addFixtures(t, ctx, st)
unread := true
got, err := st.Search(ctx, Query{Accounts: []string{"mesh"}, Folder: "INBOX", Unread: &unread, Limit: 20})
if err != nil {
t.Fatal(err)
}
sameSet(t, got, "<invoice1@mesh>", "<reply1@mesh>", "<factuur1@mesh>", "<order1@mesh>", "<order2@mesh>", "<polis1@mesh>")
read := false
gotRead, err := st.Search(ctx, Query{Accounts: []string{"mesh"}, Folder: "INBOX", Unread: &read, Limit: 20})
if err != nil {
t.Fatal(err)
}
sameSet(t, gotRead, "<renov1@mesh>", "<renov2@mesh>", "<sec1@mesh>", "<cal1@mesh>")
})
}
// 1228/AC2 combined with 1229's unread, in one query (review round 1, "also in this pass"): on-or-after today
// and unread together exclude cal1@mesh, dated today but read (\Seen), which either filter alone would include.
func TestSearchFiltersByOnOrAfterAndUnreadTogether(t *testing.T) {
eachStore(t, func(t *testing.T, st Store) {
ctx := context.Background()
addFixtures(t, ctx, st)
unread := true
got, err := st.Search(ctx, Query{Accounts: []string{"mesh"}, Folder: "INBOX",
OnOrAfter: time.Date(2026, 10, 19, 0, 0, 0, 0, time.UTC), Unread: &unread, Limit: 20})
if err != nil {
t.Fatal(err)
}
sameSet(t, got, "<invoice1@mesh>", "<reply1@mesh>", "<factuur1@mesh>", "<order1@mesh>", "<order2@mesh>", "<polis1@mesh>")
})
}
// sameOrder asserts got's message-ids equal want, in that exact order (unlike sameSet, which ignores order).
func sameOrder(t *testing.T, got []Message, want ...string) {
t.Helper()
g := make([]string, 0, len(got))
for _, m := range got {
g = append(g, m.MessageID)
}
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)
}
}
}
// 1230/AC7: a date range ("last month") ordered by size descending, limit bounding the answer.
func TestSearchOrdersBySizeDescending(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"}, OnOrAfter: time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC),
Before: time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC), OrderBy: "size", OrderDesc: true, Limit: 5})
if err != nil {
t.Fatal(err)
}
sameOrder(t, got, "<renov1@mesh>", "<renov2@mesh>")
})
}
// 1230/AC11: from several domains (OR'd) and on-or-after a date, order by date ascending, oldest first; limit
// bounds the answer.
func TestSearchFiltersBySeveralDomainsOrderedAscending(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: []string{"example.org", "google.com"},
OnOrAfter: searchNow.AddDate(0, 0, -90), OrderBy: "date", OrderDesc: false, Limit: 5})
if err != nil {
t.Fatal(err)
}
// junk1/junk3 (example.org, Junk) and sec1 (google.com, INBOX) prove both domains and every folder are
// reached; the ascending order (oldest first) proves OrderDesc: false, not merely the absence of a filter.
sameOrder(t, got, "<renov1@mesh>", "<renov2@mesh>", "<junk1@mesh>", "<junk3@mesh>", "<sec1@mesh>")
})
}
// 1230 (the order-by mechanism beyond date and size, not tied to one AC): sender and subject each sort, and the
// tie-break on (account, folder, uid) is stable when the chosen field is equal.
func TestSearchOrdersBySenderAndSubject(t *testing.T) {
eachStore(t, func(t *testing.T, st Store) {
ctx := context.Background()
addFixtures(t, ctx, st)
bySender, err := st.Search(ctx, Query{Accounts: []string{"mesh"}, Folder: "Sent", OrderBy: "sender", Limit: 10})
if err != nil {
t.Fatal(err)
}
// Both Sent messages share the same sender (jschoubben@novox.be): the tie-break (account, folder, uid)
// orders them by uid ascending.
sameOrder(t, bySender, "<sentreply1@mesh>", "<sent1@mesh>")
bySubject, err := st.Search(ctx, Query{Accounts: []string{"mesh"}, Folder: "Junk", OrderBy: "subject", Limit: 10})
if err != nil {
t.Fatal(err)
}
sameOrder(t, bySubject, "<junk3@mesh>", "<junk1@mesh>", "<junk2@mesh>") // "a gmail..." < "special offer..." < "you won..."
})
}
// 1230/AC10: group by sender, folder INBOX, by count descending (address ascending to break ties); limit bounds
// the number of lines, not the number of messages.
func TestGroupBySenderOrdersByCountDescending(t *testing.T) {
eachStore(t, func(t *testing.T, st Store) {
ctx := context.Background()
addFixtures(t, ctx, st)
got, err := st.GroupBySender(ctx, Query{Accounts: []string{"mesh"}, Folder: "INBOX", Limit: 3})
if err != nil {
t.Fatal(err)
}
if len(got) != 3 {
t.Fatalf("got %+v", got)
}
want := []struct {
address string
count int
}{{"alice@example.org", 2}, {"bob@example.org", 2}, {"ops@example.org", 2}}
for i, w := range want {
if got[i].Address != w.address || got[i].Count != w.count {
t.Fatalf("line %d: got %+v, want %+v", i, got[i], w)
}
}
// bob's two INBOX mails are invoice1 (09:00) and reply1 (12:30): the newest is reply1's.
for _, g := range got {
if g.Address == "bob@example.org" && !g.Newest.Equal(time.Date(2026, 10, 19, 12, 30, 0, 0, time.UTC)) {
t.Fatalf("bob's newest: %v", g.Newest)
}
}
})
}
// 1230 (review round 1 finding 6): the same address, written differently across two mails, is one sender, not
// two — grouped case-insensitively, in both stores. Its displayed address and name are the newest mail's own
// (not max()'s lexicographically largest, and not whichever memStore's map iteration visited first), and
// order-by sender compares case-insensitively too.
func TestGroupBySenderAndOrderBySenderAreCaseInsensitive(t *testing.T) {
eachStore(t, func(t *testing.T, st Store) {
ctx := context.Background()
older := time.Date(2026, 10, 1, 9, 0, 0, 0, time.UTC)
newer := time.Date(2026, 10, 2, 9, 0, 0, 0, time.UTC)
for _, m := range []Message{
{Account: "mixedcase", Folder: "INBOX", UID: 1, MessageID: "<mc1>", Subject: "first", Date: older,
From: []Person{{Name: "Bob", Address: "Bob@Example.ORG"}}, To: []Person{}, Flags: []string{}, Attachments: []AttachmentRef{}},
{Account: "mixedcase", Folder: "INBOX", UID: 2, MessageID: "<mc2>", Subject: "second", Date: newer,
From: []Person{{Name: "Bobby", Address: "bob@example.org"}}, To: []Person{}, Flags: []string{}, Attachments: []AttachmentRef{}},
{Account: "mixedcase", Folder: "INBOX", UID: 3, MessageID: "<mc3>", Subject: "third", Date: older.Add(time.Hour),
From: []Person{{Name: "Alice", Address: "alice@example.org"}}, To: []Person{}, Flags: []string{}, Attachments: []AttachmentRef{}},
} {
if err := st.Add(ctx, m, nil); err != nil {
t.Fatal(err)
}
}
groups, err := st.GroupBySender(ctx, Query{Accounts: []string{"mixedcase"}, Folder: "INBOX", Limit: 10})
if err != nil {
t.Fatal(err)
}
if len(groups) != 2 {
t.Fatalf("Bob@Example.ORG and bob@example.org were not merged into one sender: %+v", groups)
}
var bob *SenderGroup
for i := range groups {
if strings.EqualFold(groups[i].Address, "bob@example.org") {
bob = &groups[i]
}
}
if bob == nil || bob.Count != 2 || bob.Address != "bob@example.org" || bob.Name != "Bobby" || !bob.Newest.Equal(newer) {
t.Fatalf("bob's merged line: %+v", bob)
}
bySender, err := st.Search(ctx, Query{Accounts: []string{"mixedcase"}, Folder: "INBOX", OrderBy: "sender", Limit: 10})
if err != nil {
t.Fatal(err)
}
sameOrder(t, bySender, "<mc3>", "<mc1>", "<mc2>") // alice@... before Bob@.../bob@... (case-insensitive)
})
}
// 1231/AC8: in-reply-to a mail I sent, combined with from (its recipient), answers the replies to it; alone, it
// answers every reply in the thread's direct answer set.
func TestSearchFiltersByInReplyTo(t *testing.T) {
eachStore(t, func(t *testing.T, st Store) {
ctx := context.Background()
addFixtures(t, ctx, st)
withFrom, err := st.Search(ctx, Query{Accounts: []string{"mesh"}, InReplyTo: "<sent1@mesh>",
From: []string{"bob@example.org"}, Limit: 10})
if err != nil {
t.Fatal(err)
}
sameSet(t, withFrom, "<reply1@mesh>")
alone, err := st.Search(ctx, Query{Accounts: []string{"mesh"}, InReplyTo: "<sent1@mesh>", Limit: 10})
if err != nil {
t.Fatal(err)
}
sameSet(t, alone, "<reply1@mesh>")
// <sentreply1@mesh> replies to <invoice1@mesh>, a different mail, so it never answers here.
other, err := st.Search(ctx, Query{Accounts: []string{"mesh"}, InReplyTo: "<invoice1@mesh>", Limit: 10})
if err != nil {
t.Fatal(err)
}
sameSet(t, other, "<sentreply1@mesh>")
})
}
// 1231/AC14: folder Junk and from-someone-I-wrote-to answers only the junk mail whose sender is a recipient of a
// mail in the same account's Sent folder. spam@unknown.com, never written to, is excluded; so is junk3@mesh,
// from someone@example.org, whom only gmail wrote to (gmail/[Gmail]/Sent Mail) and never mesh (review round 1
// finding 2: the subquery must stay scoped to the same account, not leak another account's Sent folder).
func TestSearchFiltersByFromSomeoneIWroteTo(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: "Junk", FromSomeoneIWroteTo: true, Limit: 10})
if err != nil {
t.Fatal(err)
}
sameSet(t, got, "<junk1@mesh>")
})
}
// 1231/AC14 (review round 1 finding 1): gmail's own Sent folder is named "[Gmail]/Sent Mail", not "Sent"; the
// filter must still recognise it as a Sent folder.
func TestSearchFiltersByFromSomeoneIWroteToRecognisesGmailsSentFolderName(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{"gmail"}, Folder: "Junk", FromSomeoneIWroteTo: true, Limit: 10})
if err != nil {
t.Fatal(err)
}
sameSet(t, got, "<gjunk1@gmail>")
})
}
// 1248/AC1: gdup1@gmail lives in both INBOX and [Gmail]/All Mail with the same message-id. Searched with no
// folder, it is answered once, from INBOX, never from the archive.
func TestSearchWithNoFolderAnswersAGmailMailOnceFromInbox(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{"gmail"}, Words: []string{"digest"}, Limit: 10})
if err != nil {
t.Fatal(err)
}
var found []Message
for _, m := range got {
if m.MessageID == "<gdup1@gmail>" {
found = append(found, m)
}
}
if len(found) != 1 {
t.Fatalf("gdup1@gmail answered %d time(s): %+v", len(found), found)
}
if found[0].Folder != "INBOX" {
t.Fatalf("gdup1@gmail answered from %q, want INBOX", found[0].Folder)
}
})
}
// 1248/AC2: garchived1@gmail lives only in [Gmail]/All Mail (archived out of the inbox). Searched with no
// folder, it is still answered once, from the archive, since no other copy exists.
func TestSearchWithNoFolderAnswersAnArchivedOnlyGmailMailFromAllMail(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{"gmail"}, Words: []string{"newsletter"}, Limit: 10})
if err != nil {
t.Fatal(err)
}
if len(got) != 1 {
t.Fatalf("garchived1@gmail answered %d time(s): %+v", len(got), got)
}
if got[0].MessageID != "<garchived1@gmail>" || got[0].Folder != "[Gmail]/All Mail" {
t.Fatalf("got %+v, want <garchived1@gmail> from [Gmail]/All Mail", got[0])
}
})
}
// 1248/AC3: naming the folder [Gmail]/All Mail answers every mail of that folder, never merging it with the
// INBOX copy of the same message-id: gdup1's All Mail copy (uid 100) and garchived1 (uid 101) both come back.
func TestSearchByAllMailFolderNamedAnswersEveryMailOfItUnmerged(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{"gmail"}, Folder: "[Gmail]/All Mail", Limit: 10})
if err != nil {
t.Fatal(err)
}
sameSet(t, got, "<gdup1@gmail>", "<garchived1@gmail>")
})
}
// 1248/AC4: group-by-sender with no folder named counts dash@example.org once, not twice, even though gdup1@gmail
// (its only mail) sits in both INBOX and [Gmail]/All Mail.
func TestGroupBySenderWithNoFolderCountsAGmailMailOnce(t *testing.T) {
eachStore(t, func(t *testing.T, st Store) {
ctx := context.Background()
addFixtures(t, ctx, st)
got, err := st.GroupBySender(ctx, Query{Accounts: []string{"gmail"}, From: []string{"dash@example.org"}, Limit: 10})
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || got[0].Count != 1 {
t.Fatalf("got %+v, want one line with count 1", got)
}
})
}
// 1248/AC5: two mails with an empty message-id are never merged with each other or with anything, even though a
// search with no folder is otherwise asked to answer each message-id once.
func TestSearchWithNoFolderNeverMergesEmptyMessageIDs(t *testing.T) {
eachStore(t, func(t *testing.T, st Store) {
ctx := context.Background()
for _, m := range []Message{
{Account: "noid", Folder: "INBOX", UID: 1, MessageID: "", Subject: "no id one", Date: at1248(9, 0),
From: []Person{{Address: "a@example.org"}}, To: []Person{}, Flags: []string{}, Attachments: []AttachmentRef{}},
{Account: "noid", Folder: "INBOX", UID: 2, MessageID: "", Subject: "no id two", Date: at1248(9, 5),
From: []Person{{Address: "b@example.org"}}, To: []Person{}, Flags: []string{}, Attachments: []AttachmentRef{}},
} {
if err := st.Add(ctx, m, nil); err != nil {
t.Fatal(err)
}
}
got, err := st.Search(ctx, Query{Accounts: []string{"noid"}, Limit: 10})
if err != nil {
t.Fatal(err)
}
if len(got) != 2 {
t.Fatalf("two empty-message-id mails merged: %+v", got)
}
})
}
// 1248/AC6: the same message-id in two accounts (a mail sent from mesh to gmail, each account indexing its own
// copy) is answered once per account: twice in all, never merged across accounts.
func TestSearchWithNoFolderAnswersTheSameMessageIDOncePerAccount(t *testing.T) {
eachStore(t, func(t *testing.T, st Store) {
ctx := context.Background()
for _, m := range []Message{
{Account: "mesh", Folder: "Sent", UID: 50, MessageID: "<cross1@shared>", Subject: "cross-account",
Date: at1248(9, 0), From: []Person{{Address: "jschoubben@novox.be"}}, To: []Person{{Address: "me@gmail.example"}},
Flags: []string{}, Attachments: []AttachmentRef{}},
{Account: "gmail", Folder: "INBOX", UID: 50, MessageID: "<cross1@shared>", Subject: "cross-account",
Date: at1248(9, 1), From: []Person{{Address: "jschoubben@novox.be"}}, To: []Person{{Address: "me@gmail.example"}},
Flags: []string{}, Attachments: []AttachmentRef{}},
} {
if err := st.Add(ctx, m, nil); err != nil {
t.Fatal(err)
}
}
got, err := st.Search(ctx, Query{Accounts: []string{"mesh", "gmail"}, Limit: 10})
if err != nil {
t.Fatal(err)
}
var meshCount, gmailCount int
for _, m := range got {
if m.MessageID != "<cross1@shared>" {
continue
}
if m.Account == "mesh" {
meshCount++
}
if m.Account == "gmail" {
gmailCount++
}
}
if meshCount != 1 || gmailCount != 1 {
t.Fatalf("mesh: %d, gmail: %d, want 1 each: %+v", meshCount, gmailCount, got)
}
})
}
// at1248 is a fixed time for 1248's own dedicated fixtures (AC5-AC7), separate from searchFixtures' at() helper.
func at1248(h, m int) time.Time { return time.Date(2026, 10, 19, h, m, 0, 0, time.UTC) }
// 1248/AC7: limit 5 over mails that each sit in two folders (INBOX and [Gmail]/All Mail) answers 5 different
// mails, in the asked (newest-first default) order — never fewer because duplicates were counted against the
// limit, and never a duplicate taking one of the 5 slots.
func TestSearchWithNoFolderAndLimitAnswersDistinctMailsInOrder(t *testing.T) {
eachStore(t, func(t *testing.T, st Store) {
ctx := context.Background()
ids := []string{"<many1@dup>", "<many2@dup>", "<many3@dup>", "<many4@dup>", "<many5@dup>", "<many6@dup>"}
for i, id := range ids {
date := at1248(8, 0).Add(time.Duration(i) * time.Hour)
for _, folder := range []string{"INBOX", "[Gmail]/All Mail"} {
uid := uint32(200 + i)
if folder == "[Gmail]/All Mail" {
uid += 1000
}
m := Message{Account: "manyfolders", Folder: folder, UID: uid, MessageID: id, Subject: "dup " + id,
Date: date, From: []Person{{Address: "dup@example.org"}}, To: []Person{}, Flags: []string{}, Attachments: []AttachmentRef{}}
if err := st.Add(ctx, m, nil); err != nil {
t.Fatal(err)
}
}
}
got, err := st.Search(ctx, Query{Accounts: []string{"manyfolders"}, Limit: 5})
if err != nil {
t.Fatal(err)
}
// Newest first (the default): many6, many5, many4, many3, many2 — many1 is excluded by the limit.
sameOrder(t, got, "<many6@dup>", "<many5@dup>", "<many4@dup>", "<many3@dup>", "<many2@dup>")
})
}
// 1248/AC1 (review round 1, finding 1): the same message-id sits in two non-archive folders (INBOX and a label
// folder, "INBOX/Telenet", as gmail keeps a label as a second folder) as well as the archive. Searched with no
// folder, it is still answered once, and from INBOX — the fixed tie-break (non-archive before archive, then
// folder ascending, then uid ascending) picks "INBOX" over "INBOX/Telenet" because it sorts first, never by
// whichever copy a Go map's randomised iteration happened to meet first.
func TestSearchWithNoFolderPicksTheSameCopyAsPostgresAmongTwoNonArchiveFolders(t *testing.T) {
eachStore(t, func(t *testing.T, st Store) {
ctx := context.Background()
for _, m := range []Message{
{Account: "labeled", Folder: "INBOX", UID: 1, MessageID: "<labeled1@dup>", Subject: "labeled",
Date: at1248(9, 0), From: []Person{{Address: "a@example.org"}}, To: []Person{}, Flags: []string{}, Attachments: []AttachmentRef{}},
{Account: "labeled", Folder: "INBOX/Telenet", UID: 1, MessageID: "<labeled1@dup>", Subject: "labeled",
Date: at1248(9, 0), From: []Person{{Address: "a@example.org"}}, To: []Person{}, Flags: []string{}, Attachments: []AttachmentRef{}},
{Account: "labeled", Folder: "[Gmail]/All Mail", UID: 1, MessageID: "<labeled1@dup>", Subject: "labeled",
Date: at1248(9, 0), From: []Person{{Address: "a@example.org"}}, To: []Person{}, Flags: []string{}, Attachments: []AttachmentRef{}},
} {
if err := st.Add(ctx, m, nil); err != nil {
t.Fatal(err)
}
}
got, err := st.Search(ctx, Query{Accounts: []string{"labeled"}, Limit: 10})
if err != nil {
t.Fatal(err)
}
if len(got) != 1 {
t.Fatalf("labeled1@dup answered %d time(s): %+v", len(got), got)
}
if got[0].Folder != "INBOX" {
t.Fatalf("got folder %q, want INBOX", got[0].Folder)
}
})
}