diff --git a/modules/mesh-mail/cmd/mesh-mail/pgstore.go b/modules/mesh-mail/cmd/mesh-mail/pgstore.go index 7e8e8139..a02cf46c 100644 --- a/modules/mesh-mail/cmd/mesh-mail/pgstore.go +++ b/modules/mesh-mail/cmd/mesh-mail/pgstore.go @@ -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 } diff --git a/modules/mesh-mail/cmd/mesh-mail/search_test.go b/modules/mesh-mail/cmd/mesh-mail/search_test.go index a76856c6..9c69879e 100644 --- a/modules/mesh-mail/cmd/mesh-mail/search_test.go +++ b/modules/mesh-mail/cmd/mesh-mail/search_test.go @@ -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: "", + From: []string{"bob@example.org"}, Limit: 10}) + if err != nil { + t.Fatal(err) + } + sameSet(t, withFrom, "") + + alone, err := st.Search(ctx, Query{Accounts: []string{"mesh"}, InReplyTo: "", Limit: 10}) + if err != nil { + t.Fatal(err) + } + sameSet(t, alone, "") + + // replies to , a different mail, so it never answers here. + other, err := st.Search(ctx, Query{Accounts: []string{"mesh"}, InReplyTo: "", Limit: 10}) + if err != nil { + t.Fatal(err) + } + sameSet(t, other, "") + }) +} + +// 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, "") + }) +} diff --git a/modules/mesh-mail/cmd/mesh-mail/store.go b/modules/mesh-mail/cmd/mesh-mail/store.go index 5253c4ee..dd548749 100644 --- a/modules/mesh-mail/cmd/mesh-mail/store.go +++ b/modules/mesh-mail/cmd/mesh-mail/store.go @@ -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.