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