mesh-mail: mail_search gains words/words-any, a word found in subject or body, since subject-words and body-words together only ever meant and (issue 1229, review finding 3; also proves AC9's type-or-name independently and AC2+unread combined)
This commit is contained in:
@@ -420,18 +420,25 @@ func addressFilterSQLAny(column string, filters []string, param func(any) string
|
||||
"lower(el->>'address') = f or right(lower(el->>'address'), length(f) + 1) = '@' || f)", column, p)
|
||||
}
|
||||
|
||||
// wordFilterSQL is 1229/AC5, AC6(words), AC9, AC12, AC13, AC15: one condition per word of the list, each a
|
||||
// whole-word, case-insensitive match (Postgres's \y, its own word boundary) against column, so a hyphenated word
|
||||
// such as "4711-882" is never matched by only its digits (AC12); joined with "and" ("all", the default) or "or"
|
||||
// ("any"). Empty answers "" (no filter). The word itself always reaches SQL as param's parameter, never
|
||||
// concatenated; only its \y anchors are fixed SQL text.
|
||||
func wordFilterSQL(column string, words []string, any bool, param func(any) string) string {
|
||||
// wordFilterSQL is 1229/AC13 (one column: SubjectWords or BodyWords); AC5, AC6(words), AC12, AC15 (review round
|
||||
// 1 finding 3: columns holds both subject and body for Words, so a word counts in either — "the word in subject
|
||||
// or body"). One condition per word of the list, each a whole-word, case-insensitive match (Postgres's \y, its
|
||||
// own word boundary) against any one of columns, so a hyphenated word such as "4711-882" is never matched by
|
||||
// only its digits (AC12); the per-word conditions join with "and" ("all", the default) or "or" ("any"). Empty
|
||||
// answers "" (no filter). The word itself always reaches SQL as param's parameter, never concatenated; only its
|
||||
// \y anchors and the column names (a fixed set the Go code chooses) are fixed SQL text.
|
||||
func wordFilterSQL(columns []string, words []string, any bool, param func(any) string) string {
|
||||
if len(words) == 0 {
|
||||
return ""
|
||||
}
|
||||
parts := make([]string, 0, len(words))
|
||||
for _, w := range words {
|
||||
parts = append(parts, fmt.Sprintf(`%s ~* ('\y' || %s || '\y')`, column, param(quoteRegex(w))))
|
||||
p := param(quoteRegex(w))
|
||||
cols := make([]string, len(columns))
|
||||
for i, col := range columns {
|
||||
cols[i] = fmt.Sprintf(`%s ~* ('\y' || %s || '\y')`, col, p)
|
||||
}
|
||||
parts = append(parts, "("+strings.Join(cols, " or ")+")")
|
||||
}
|
||||
op := " and "
|
||||
if any {
|
||||
@@ -508,10 +515,14 @@ func searchWhere(q Query) (whereSQL string, args []any) {
|
||||
if !q.Before.IsZero() {
|
||||
where = append(where, "date < "+param(q.Before))
|
||||
}
|
||||
if c := wordFilterSQL("subject", q.SubjectWords, q.SubjectWordsAny, param); c != "" {
|
||||
if c := wordFilterSQL([]string{"subject"}, q.SubjectWords, q.SubjectWordsAny, param); c != "" {
|
||||
where = append(where, c)
|
||||
}
|
||||
if c := wordFilterSQL("body", q.BodyWords, q.BodyWordsAny, param); c != "" {
|
||||
if c := wordFilterSQL([]string{"body"}, q.BodyWords, q.BodyWordsAny, param); c != "" {
|
||||
where = append(where, c)
|
||||
}
|
||||
// 1229/AC5, AC6(words), AC12, AC15 (review round 1 finding 3): a word found in subject or body.
|
||||
if c := wordFilterSQL([]string{"subject", "body"}, q.Words, q.WordsAny, param); c != "" {
|
||||
where = append(where, c)
|
||||
}
|
||||
if q.HasAttachment {
|
||||
|
||||
@@ -50,24 +50,28 @@ func searchFixtures() []Message {
|
||||
{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".
|
||||
// 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 factuur, due on receipt", Size: 850},
|
||||
// 1229/AC12: a word with digits and a dash ("4711-882"), matched whole.
|
||||
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 4711-882 has shipped", Size: 300},
|
||||
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", with an attachment.
|
||||
// 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 for verzekering", Size: 450},
|
||||
Body: "nieuwe polis aanvraag", Size: 450},
|
||||
|
||||
// mesh/Sent ---------------------------------------------------------------------------------------------------
|
||||
{Account: "mesh", Folder: "Sent", UID: 1, MessageID: "<sentreply1@mesh>", InReplyTo: "<invoice1@mesh>", Subject: "Re: October invoice",
|
||||
@@ -94,6 +98,17 @@ func searchFixtures() []Message {
|
||||
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",
|
||||
@@ -249,7 +264,7 @@ 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", BodyWords: []string{"renovatie"}, Limit: 10})
|
||||
got, err := st.Search(ctx, Query{Accounts: []string{"mesh"}, Cc: "jschoubben@novox.be", Words: []string{"renovatie"}, Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -258,12 +273,14 @@ func TestSearchFiltersByCcAndSubjectOrBodyWord(t *testing.T) {
|
||||
}
|
||||
|
||||
// 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", BodyWords: []string{"factuur"}, Limit: 10})
|
||||
AttachmentNamePattern: "*.pdf", Words: []string{"factuur"}, Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -273,26 +290,53 @@ func TestSearchFiltersByDomainAttachmentAndWord(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// 1229/AC9: on-or-after a date and an attachment type or name pattern (either suffices) answers a calendar invite.
|
||||
// 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"}, OnOrAfter: time.Date(2026, 10, 19, 0, 0, 0, 0, time.UTC),
|
||||
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>")
|
||||
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.
|
||||
// 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"}, BodyWords: []string{"4711-882"}, Limit: 10})
|
||||
got, err := st.Search(ctx, Query{Accounts: []string{"mesh", "gmail"}, Words: []string{"4711-882"}, Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -323,13 +367,14 @@ func TestSearchFiltersByDomainAndSubjectWordsAcrossAccounts(t *testing.T) {
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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"}, BodyWords: []string{"schade", "verzekering"},
|
||||
BodyWordsAny: true, HasAttachment: true, Limit: 10})
|
||||
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)
|
||||
}
|
||||
@@ -337,7 +382,7 @@ func TestSearchFiltersByAnyWordsAndHasAttachment(t *testing.T) {
|
||||
|
||||
// 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"}, BodyWords: []string{"schade", "verzekering"},
|
||||
all, err := st.Search(ctx, Query{Accounts: []string{"mesh", "gmail"}, Words: []string{"schade", "verzekering"},
|
||||
HasAttachment: true, Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -367,6 +412,22 @@ func TestSearchFiltersByUnreadFlag(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// 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()
|
||||
|
||||
@@ -106,15 +106,22 @@ type Query struct {
|
||||
OnOrAfter time.Time
|
||||
Before time.Time
|
||||
|
||||
// SubjectWords and BodyWords (issue 1229, AC5, AC6's words part, AC12, AC13, AC15) are each a whole word,
|
||||
// matched case-insensitively against Subject or Body respectively (a word with digits and a dash, such as
|
||||
// "4711-882", is matched whole, never by its separate parts, AC12). By default every word of the list must be
|
||||
// found ("all"); the *WordsAny flag switches that list to "any of them" (AC15). Empty means no filter.
|
||||
// SubjectWords and BodyWords (issue 1229, AC13) are each a whole word, matched case-insensitively against
|
||||
// Subject or Body respectively, and only that field (a word with digits and a dash, such as "4711-882", is
|
||||
// matched whole, never by its separate parts, AC12). By default every word of the list must be found ("all");
|
||||
// the *WordsAny flag switches that list to "any of them" (AC15). Empty means no filter.
|
||||
SubjectWords []string
|
||||
SubjectWordsAny bool
|
||||
BodyWords []string
|
||||
BodyWordsAny bool
|
||||
|
||||
// Words (issue 1229, AC5, AC6's words part, AC12, AC15; review round 1 finding 3) is the same whole-word,
|
||||
// case-insensitive match as SubjectWords/BodyWords, but each word may be found in either field, Subject or
|
||||
// Body: "the word in subject or body", as the acceptance criteria ask it, where SubjectWords/BodyWords alone
|
||||
// would wrongly need it in one specific field. WordsAny is the same "any" switch. Empty means no filter.
|
||||
Words []string
|
||||
WordsAny bool
|
||||
|
||||
// HasAttachment, AttachmentType and AttachmentNamePattern (issue 1229, AC5, AC9) filter by attachment:
|
||||
// HasAttachment requires at least one; AttachmentType (an exact media type, case-insensitive) and
|
||||
// AttachmentNamePattern (a shell glob such as "*.pdf", case-insensitive) each require one attachment matching
|
||||
@@ -321,11 +328,16 @@ func matchesQuery(m Message, q Query, words []string, wroteTo map[string]map[str
|
||||
if !q.Before.IsZero() && !m.Date.Before(q.Before) {
|
||||
return false
|
||||
}
|
||||
// 1229/AC5, 1229/AC6(words), 1229/AC9, 1229/AC12, 1229/AC13, 1229/AC15: the word, attachment and
|
||||
// read/unread filters, mirroring PgStore.Search.
|
||||
if !wordMatches(m.Subject, q.SubjectWords, q.SubjectWordsAny) || !wordMatches(m.Body, q.BodyWords, q.BodyWordsAny) {
|
||||
// 1229/AC13: the subject-only and body-only word filters, mirroring PgStore.Search.
|
||||
if !wordMatches([]string{m.Subject}, q.SubjectWords, q.SubjectWordsAny) ||
|
||||
!wordMatches([]string{m.Body}, q.BodyWords, q.BodyWordsAny) {
|
||||
return false
|
||||
}
|
||||
// 1229/AC5, AC6(words), AC12, AC15 (review round 1 finding 3): a word found in subject or body.
|
||||
if !wordMatches([]string{m.Subject, m.Body}, q.Words, q.WordsAny) {
|
||||
return false
|
||||
}
|
||||
// 1229/AC9: the attachment and read/unread filters, mirroring PgStore.Search.
|
||||
if !attachmentMatches(m.Attachments, q.HasAttachment, q.AttachmentType, q.AttachmentNamePattern) {
|
||||
return false
|
||||
}
|
||||
@@ -621,14 +633,24 @@ func wordRegexp(word string) *regexp.Regexp {
|
||||
return regexp.MustCompile(`(?i)\b` + regexp.QuoteMeta(word) + `\b`)
|
||||
}
|
||||
|
||||
// wordMatches is 1229/AC5, AC6(words), AC9, AC12, AC13, AC15: an empty list matches every message; otherwise
|
||||
// every word of the list must be found in text ("all"), or at least one when any is true.
|
||||
func wordMatches(text string, words []string, any bool) bool {
|
||||
// wordMatches is 1229/AC5, AC6(words), AC9, AC12, AC13, AC15; review round 1 finding 3: an empty list matches
|
||||
// every message; otherwise every word of the list must be found in at least one of texts ("all"), or at least
|
||||
// one word found in at least one text when any is true. SubjectWords/BodyWords call it with one text (their own
|
||||
// field, and only that field); Words calls it with both Subject and Body, so a word counts in either — "the
|
||||
// word in subject or body", as the acceptance criteria ask it.
|
||||
func wordMatches(texts []string, words []string, any bool) bool {
|
||||
if len(words) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, w := range words {
|
||||
found := wordRegexp(w).MatchString(text)
|
||||
re := wordRegexp(w)
|
||||
found := false
|
||||
for _, t := range texts {
|
||||
if re.MatchString(t) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if any && found {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -106,8 +106,8 @@ func (a access) refuse(caller, account string) error {
|
||||
// searchArgs names mail_search's own arguments, used both for its schema (issue 1232, AC17) and for only's
|
||||
// guard, so the two can never drift apart.
|
||||
var searchArgs = []string{
|
||||
"query", "account", "folder", "limit", "from", "to", "cc", "on-or-after", "before", "subject-words",
|
||||
"subject-words-any", "body-words", "body-words-any", "has-attachment", "attachment-type", "attachment-name",
|
||||
"query", "account", "folder", "limit", "from", "to", "cc", "on-or-after", "before", "words", "words-any",
|
||||
"subject-words", "subject-words-any", "body-words", "body-words-any", "has-attachment", "attachment-type", "attachment-name",
|
||||
"unread", "order-by", "order", "group-by-sender", "in-reply-to", "from-someone-i-wrote-to",
|
||||
}
|
||||
|
||||
@@ -126,11 +126,14 @@ func searchToolSchema() map[string]any {
|
||||
"cc": text("filter by a cc recipient: an address or a domain, case-insensitive"),
|
||||
"on-or-after": text("only mails dated on or after this (RFC3339, or YYYY-MM-DD for the whole day)"),
|
||||
"before": text("only mails dated before this (RFC3339, or YYYY-MM-DD for the whole day)"),
|
||||
"subject-words": stringArray("every one of these whole words (case-insensitive) must be in the subject, " +
|
||||
"unless subject-words-any"),
|
||||
"words": stringArray("every one of these whole words (case-insensitive) must be in the subject or the " +
|
||||
"body (either counts), unless words-any"),
|
||||
"words-any": boolean("words matches when any one of them is found, not every one"),
|
||||
"subject-words": stringArray("every one of these whole words (case-insensitive) must be in the subject " +
|
||||
"(only the subject; words, above, takes the subject or the body), unless subject-words-any"),
|
||||
"subject-words-any": boolean("subject-words matches when any one of them is found, not every one"),
|
||||
"body-words": stringArray("every one of these whole words (case-insensitive) must be in the text, unless " +
|
||||
"body-words-any"),
|
||||
"body-words": stringArray("every one of these whole words (case-insensitive) must be in the text (only " +
|
||||
"the body; words, above, takes the subject or the body), unless body-words-any"),
|
||||
"body-words-any": boolean("body-words matches when any one of them is found, not every one"),
|
||||
"has-attachment": boolean("only mails carrying at least one attachment"),
|
||||
"attachment-type": text("only mails carrying an attachment of this exact media type, case-insensitive; " +
|
||||
@@ -162,7 +165,8 @@ func (b *Bundle) Tools() []stdio.Tool {
|
||||
Description: "Search the mail of the accounts you are granted. Query is optional: with no filter at all, the " +
|
||||
"newest mails of your granted accounts. Every filter is optional and combines with the others (and): account, " +
|
||||
"folder, from/to/cc (an address or a domain; from takes several, any one matching), a date range, " +
|
||||
"subject-words/body-words (each all unless its own -any), has-attachment, attachment-type/attachment-name " +
|
||||
"words (a whole word in the subject or the body, either counts), subject-words/body-words (only that one " +
|
||||
"field; each all unless its own -any), has-attachment, attachment-type/attachment-name " +
|
||||
"(either matching an attachment suffices), unread, in-reply-to, from-someone-i-wrote-to. Order by date (the " +
|
||||
"default), sender, subject or size, asc or desc; group-by-sender answers one line per sender (address, name, " +
|
||||
"count, newest) instead. At most 50 (20 default). Answers account, folder, uid, message id, date, sender, " +
|
||||
@@ -349,6 +353,14 @@ func (b *Bundle) searchTool(args map[string]any) (any, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
words, err := stringsArg(args, "words")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
wordsAny, err := flag(args, "words-any")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
subjectWords, err := stringsArg(args, "subject-words")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -420,6 +432,7 @@ func (b *Bundle) searchTool(args map[string]any) (any, error) {
|
||||
q := Query{
|
||||
Text: query, Accounts: granted, Folder: folder, Limit: limit,
|
||||
From: from, To: to, Cc: cc, OnOrAfter: onOrAfter, Before: before,
|
||||
Words: words, WordsAny: wordsAny,
|
||||
SubjectWords: subjectWords, SubjectWordsAny: subjectWordsAny, BodyWords: bodyWords, BodyWordsAny: bodyWordsAny,
|
||||
HasAttachment: hasAttachment, AttachmentType: attachmentType, AttachmentNamePattern: attachmentName,
|
||||
Unread: unread, OrderBy: orderBy, OrderDesc: orderDesc,
|
||||
|
||||
Reference in New Issue
Block a user