mesh-mail: mail_search finds a thread's replies and mail from someone the operator wrote to, so a reply or a known correspondent's junk mail can be found (issue 1231)

This commit is contained in:
jochen
2026-10-19 14:39:59 +02:00
parent fc10031d22
commit 4a60deb4a8
3 changed files with 115 additions and 4 deletions
@@ -516,6 +516,18 @@ func searchWhere(q Query) (whereSQL string, args []any) {
if q.Unread != nil {
where = append(where, fmt.Sprintf("(%s = any(flags)) = %s", param(`\Seen`), param(!*q.Unread)))
}
if q.InReplyTo != "" {
where = append(where, "in_reply_to = "+param(q.InReplyTo))
}
if q.FromSomeoneIWroteTo {
// 1231/AC14: some sender address of this message is a recipient (to or cc) of a mail in the same
// account's Sent folder (matched case-insensitively by name — "Sent" is the only name any account holds
// today; a future account naming it otherwise is the tool layer's "say what you chose", not this query).
// No value here needs a parameter: the fixed string 'sent' is structural SQL, like the column names.
where = append(where, `exists (select 1 from jsonb_array_elements(sender) s where exists (
select 1 from message m2, jsonb_array_elements(m2.recipients || m2.copied) r
where m2.account = message.account and lower(m2.folder) = 'sent' and lower(r->>'address') = lower(s->>'address')))`)
}
return strings.Join(where, " and "), args
}
@@ -432,3 +432,45 @@ func TestGroupBySenderOrdersByCountDescending(t *testing.T) {
}
})
}
// 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.
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>")
})
}
+61 -4
View File
@@ -134,6 +134,15 @@ type Query struct {
// break ascending on (account, folder, uid). GroupBySender ignores both: its own order is fixed (AC10).
OrderBy string
OrderDesc bool
// InReplyTo (issue 1231, AC8) matches the In-Reply-To header (in_reply_to, schema.sql) exactly; empty means
// no filter. Combined with From, it finds the replies from one recipient; alone, every reply in the thread's
// direct answer set.
InReplyTo string
// FromSomeoneIWroteTo (issue 1231, AC14) is true when the sender's address is a recipient (to or cc) of some
// mail in the same account's Sent folder; false (the default) means no filter.
FromSomeoneIWroteTo bool
}
// SenderGroup is one line of mail_search's group-by-sender answer (issue 1230, AC10): the sender's address and
@@ -287,11 +296,20 @@ func (s *memStore) Get(_ context.Context, account, folder string, uid uint32) (M
}
// matchesQuery is every filter of Query but its order and its limit, shared by Search and GroupBySender so the
// two never drift apart (mirrored, condition for condition, by PgStore's own WHERE builder, pgstore.go).
func matchesQuery(m Message, q Query, words []string) bool {
// two never drift apart (mirrored, condition for condition, by PgStore's own WHERE builder, pgstore.go). wroteTo
// is wroteToByAccount's answer, needed only when q.FromSomeoneIWroteTo is set.
func matchesQuery(m Message, q Query, words []string, wroteTo map[string]map[string]bool) bool {
if !contains(q.Accounts, m.Account) || (q.Folder != "" && q.Folder != m.Folder) {
return false
}
// 1231/AC8: the In-Reply-To header, matched exactly.
if q.InReplyTo != "" && m.InReplyTo != q.InReplyTo {
return false
}
// 1231/AC14: the sender's address is a recipient (to or cc) of some mail in the same account's Sent folder.
if q.FromSomeoneIWroteTo && !anyAddressIn(m.From, wroteTo[m.Account]) {
return false
}
// 1228/AC2, 1228/AC3, 1228/AC4, 1228/AC6(cc); 1230/AC11 (From's several domains): the address and date-range
// filters, mirroring PgStore.Search.
if !addressMatchesAny(m.From, q.From) || !addressMatches(m.To, q.To) || !addressMatches(m.Cc, q.Cc) {
@@ -327,9 +345,10 @@ func (s *memStore) Search(_ context.Context, q Query) ([]Message, error) {
s.mu.Lock()
defer s.mu.Unlock()
words := strings.Fields(strings.ToLower(q.Text))
wroteTo := s.wroteToByAccount(q.FromSomeoneIWroteTo)
var out []Message
for _, m := range s.messages {
if matchesQuery(m, q, words) {
if matchesQuery(m, q, words, wroteTo) {
m.Body = ""
out = append(out, m)
}
@@ -413,10 +432,11 @@ func (s *memStore) GroupBySender(_ context.Context, q Query) ([]SenderGroup, err
s.mu.Lock()
defer s.mu.Unlock()
words := strings.Fields(strings.ToLower(q.Text))
wroteTo := s.wroteToByAccount(q.FromSomeoneIWroteTo)
byAddress := map[string]*SenderGroup{}
var order []string
for _, m := range s.messages {
if !matchesQuery(m, q, words) {
if !matchesQuery(m, q, words, wroteTo) {
continue
}
for _, p := range m.From {
@@ -544,6 +564,43 @@ func addressMatchesAny(ps []Person, filters []string) bool {
return false
}
// wroteToByAccount is 1231/AC14's own data: for each account, every address (lowercased) that account has
// written to (as a "to" or a "cc") from a folder named "Sent", case-insensitively — the only name any account
// holds today; a future account naming its sent folder otherwise is for the tool layer to surface (the context's
// "say what you chose"), not this store. Computed only when needed (asked is false otherwise), since it walks
// every message.
func (s *memStore) wroteToByAccount(asked bool) map[string]map[string]bool {
if !asked {
return nil
}
out := map[string]map[string]bool{}
for _, m := range s.messages {
if !strings.EqualFold(m.Folder, "Sent") {
continue
}
set, ok := out[m.Account]
if !ok {
set = map[string]bool{}
out[m.Account] = set
}
for _, p := range append(append([]Person{}, m.To...), m.Cc...) {
set[strings.ToLower(p.Address)] = true
}
}
return out
}
// anyAddressIn is 1231/AC14: whether any of ps's addresses (case-insensitive) is in set; an unset or empty set
// (the account wrote to nobody, or wroteTo was never asked for) matches nobody.
func anyAddressIn(ps []Person, set map[string]bool) bool {
for _, p := range ps {
if set[strings.ToLower(p.Address)] {
return true
}
}
return false
}
// wordRegexp is a whole-word, case-insensitive match (issue 1229, AC12): \b, Go's RE2 word boundary, treats a
// dash as a non-word character, so a hyphenated word such as "4711-882" is bounded at both ends and never matched
// by only its digits.