mesh-mail: mail_search's tool makes the query optional and offers every filter and order-by (one schema for mail_search and mail-store.search), validated before the index is ever asked (issue 1232)
This commit is contained in:
@@ -61,7 +61,7 @@ The password of an account is the own secret `mail-credential-<account>`, given
|
||||
| Tool | Answers |
|
||||
|---|---|
|
||||
| `mail_accounts` | each account's kind, state and why, when its server last answered, its folders; the address only to a caller granted it |
|
||||
| `mail_search` | messages of the accounts the caller is granted, by every word of a query, newest first, no body |
|
||||
| `mail_search` | messages of the accounts the caller is granted, no body: optional query words, and every filter combined (account, folder, from/to/cc, a date range, subject/body words, attachment, read/unread, in-reply-to, from-someone-i-wrote-to); ordered by date (default), sender, subject or size, ascending or descending; group-by-sender answers one line per sender instead. The newest mails of the granted accounts with no filter at all |
|
||||
| `mail_read` | one message's metadata and text, or one attachment's bytes, to a caller granted its account |
|
||||
| `mail_health` | healthy; unhealthy naming the account and why (connection down, or no answer for five minutes); waits naming `mail-credential-<account>` while its password is not given |
|
||||
| `mail_send` | raises an ask on the operator channel (the account, the recipients, the subject, the first lines) for a caller granted the account; answers at once with the ask's id. Sends nothing itself |
|
||||
|
||||
@@ -103,6 +103,51 @@ func (a access) refuse(caller, account string) error {
|
||||
return refused(caller, account)
|
||||
}
|
||||
|
||||
// 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",
|
||||
"unread", "order-by", "order", "group-by-sender", "in-reply-to", "from-someone-i-wrote-to",
|
||||
}
|
||||
|
||||
// searchToolSchema is mail_search's one schema (issue 1232: SearchTool and SeatSearchTool share it, not two
|
||||
// written by hand): query is optional, and every filter and order-by names its own form (AC17).
|
||||
func searchToolSchema() map[string]any {
|
||||
return schema(nil, map[string]any{
|
||||
"query": text("words to find across subject, text, sender or recipient, every one required; optional — " +
|
||||
"with no filter at all, the newest mails of your granted accounts"),
|
||||
"account": text("one account by its name in mail_accounts; every account you are granted when left out"),
|
||||
"folder": text("one folder, such as INBOX; every folder when left out"),
|
||||
"limit": integer("how many to answer (lines, with group-by-sender), at most 50 (20 when left out)"),
|
||||
"from": stringArray("filter by sender: one or more addresses or domains (a domain, no \"@\", matches every " +
|
||||
"address of it), case-insensitive; any one of them matching is enough"),
|
||||
"to": text("filter by recipient: an address or a domain, case-insensitive"),
|
||||
"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"),
|
||||
"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-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; " +
|
||||
"combined with attachment-name, either suffices for the same attachment"),
|
||||
"attachment-name": text("only mails carrying an attachment whose name matches this glob (such as \"*.pdf\"), " +
|
||||
"case-insensitive; combined with attachment-type, either suffices for the same attachment"),
|
||||
"unread": boolean("true for only unread mail (no \\Seen flag), false for only read mail; every mail when left out"),
|
||||
"order-by": enumText("sort by this field (date, the default, unless order-by is itself left out)", "date", "sender", "subject", "size"),
|
||||
"order": enumText("ascending or descending; desc when left out (newest or largest first)", "asc", "desc"),
|
||||
"group-by-sender": boolean("answer one line per sender (address, name, count, newest date) instead of the " +
|
||||
"mails themselves, by count descending; limit bounds the lines, not the mails"),
|
||||
"in-reply-to": text("only replies to this exact Message-Id (its In-Reply-To header)"),
|
||||
"from-someone-i-wrote-to": boolean("only mail whose sender is a recipient of a mail you sent, in the same " +
|
||||
"account's Sent folder"),
|
||||
})
|
||||
}
|
||||
|
||||
// Tools are the module's tools.
|
||||
func (b *Bundle) Tools() []stdio.Tool {
|
||||
return []stdio.Tool{
|
||||
@@ -114,17 +159,18 @@ func (b *Bundle) Tools() []stdio.Tool {
|
||||
Input: schema(nil, map[string]any{}),
|
||||
Run: b.accountsTool},
|
||||
{Name: SearchTool,
|
||||
Description: "Search the mail of the accounts you are granted: every word of the query must be in the subject, the " +
|
||||
"text, a sender or a recipient. Answers the newest first, at most 50: account, folder, uid, message id, date, " +
|
||||
"sender, recipients, subject, size, flags and attachment references, never a body. Refused for an account you " +
|
||||
"are not granted, and when you are granted none. Read-only.",
|
||||
Input: schema([]string{"query"}, map[string]any{
|
||||
"query": text("the words to find, at most 200 characters"),
|
||||
"account": text("one account by its name in mail_accounts; every account you are granted when left out"),
|
||||
"folder": text("one folder, such as INBOX; every folder when left out"),
|
||||
"limit": integer("how many to answer, at most 50 (20 when left out)"),
|
||||
}),
|
||||
Run: b.searchTool},
|
||||
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 " +
|
||||
"(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, " +
|
||||
"recipients, subject, size, flags and attachment references, never a body. Refused, naming the argument, for " +
|
||||
"an unknown order-by, a limit over 50, a date that does not parse, or an account you are not granted — before " +
|
||||
"the index is ever asked. Read-only.",
|
||||
Input: searchToolSchema(),
|
||||
Run: b.searchTool},
|
||||
{Name: ReadTool,
|
||||
Description: "Read one message of an account you are granted, as an event or mail_search names it (account, folder, " +
|
||||
"uid): its metadata and its text (the plain part, or the HTML part as text, at most 64 KiB here). With " +
|
||||
@@ -171,13 +217,8 @@ func (b *Bundle) Tools() []stdio.Tool {
|
||||
Run: b.accountsTool},
|
||||
{Name: SeatSearchTool,
|
||||
Description: "The mail-store seat's search verb (novox/hq ADR 0378 §2): the same as mail_search.",
|
||||
Input: schema([]string{"query"}, map[string]any{
|
||||
"query": text("the words to find, at most 200 characters"),
|
||||
"account": text("one account by its name in mail_accounts; every account you are granted when left out"),
|
||||
"folder": text("one folder, such as INBOX; every folder when left out"),
|
||||
"limit": integer("how many to answer, at most 50 (20 when left out)"),
|
||||
}),
|
||||
Run: b.searchTool},
|
||||
Input: searchToolSchema(),
|
||||
Run: b.searchTool},
|
||||
{Name: SeatReadTool,
|
||||
Description: "The mail-store seat's read verb (novox/hq ADR 0378 §2): the same as mail_read.",
|
||||
Input: schema([]string{"account", "folder", "uid"}, map[string]any{
|
||||
@@ -274,13 +315,16 @@ func (b *Bundle) accountsTool(args map[string]any) (any, error) {
|
||||
return answer, nil
|
||||
}
|
||||
|
||||
// searchTool is mail_search and mail-store.search (issue 1232): query is optional (AC1), every filter and
|
||||
// order-by issues 1228-1231 built is offered, and every argument is parsed and validated — naming it — before
|
||||
// the index is ever asked (AC16).
|
||||
func (b *Bundle) searchTool(args map[string]any) (any, error) {
|
||||
if err := only(args, "query", "account", "folder", "limit"); err != nil {
|
||||
if err := only(args, searchArgs...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
query, _ := args["query"].(string)
|
||||
if strings.TrimSpace(query) == "" || len(query) > MostQuery {
|
||||
return nil, fmt.Errorf("%s needs a query of 1 to %d characters", SearchTool, MostQuery)
|
||||
if len(query) > MostQuery {
|
||||
return nil, fmt.Errorf("query is at most %d characters", MostQuery)
|
||||
}
|
||||
account, _ := args["account"].(string)
|
||||
folder, _ := args["folder"].(string)
|
||||
@@ -291,6 +335,64 @@ func (b *Bundle) searchTool(args map[string]any) (any, error) {
|
||||
if limit < 1 || limit > MostResults {
|
||||
return nil, fmt.Errorf("limit is 1 to %d", MostResults)
|
||||
}
|
||||
from, err := stringsArg(args, "from")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
to, _ := args["to"].(string)
|
||||
cc, _ := args["cc"].(string)
|
||||
onOrAfter, err := dateArg(args, "on-or-after")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
before, err := dateArg(args, "before")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
subjectWords, err := stringsArg(args, "subject-words")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
subjectWordsAny, err := flag(args, "subject-words-any")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bodyWords, err := stringsArg(args, "body-words")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bodyWordsAny, err := flag(args, "body-words-any")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hasAttachment, err := flag(args, "has-attachment")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
attachmentType, _ := args["attachment-type"].(string)
|
||||
attachmentName, _ := args["attachment-name"].(string)
|
||||
unread, err := triBool(args, "unread")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
orderBy, err := orderByArg(args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
orderDesc, err := orderArg(args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
groupBySender, err := flag(args, "group-by-sender")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
inReplyTo, _ := args["in-reply-to"].(string)
|
||||
fromSomeoneIWroteTo, err := flag(args, "from-someone-i-wrote-to")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
accounts, grants, err := b.settings()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -315,7 +417,23 @@ func (b *Bundle) searchTool(args map[string]any) (any, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the index cannot be reached: %v", err)
|
||||
}
|
||||
found, err := st.Search(ctx, Query{Text: query, Accounts: granted, Folder: folder, Limit: limit})
|
||||
q := Query{
|
||||
Text: query, Accounts: granted, Folder: folder, Limit: limit,
|
||||
From: from, To: to, Cc: cc, OnOrAfter: onOrAfter, Before: before,
|
||||
SubjectWords: subjectWords, SubjectWordsAny: subjectWordsAny, BodyWords: bodyWords, BodyWordsAny: bodyWordsAny,
|
||||
HasAttachment: hasAttachment, AttachmentType: attachmentType, AttachmentNamePattern: attachmentName,
|
||||
Unread: unread, OrderBy: orderBy, OrderDesc: orderDesc,
|
||||
InReplyTo: inReplyTo, FromSomeoneIWroteTo: fromSomeoneIWroteTo,
|
||||
}
|
||||
// 1230/AC10: group-by-sender answers a different shape, one line per sender, not the mails themselves.
|
||||
if groupBySender {
|
||||
groups, err := st.GroupBySender(ctx, q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"searched": granted, "senders": groups}, nil
|
||||
}
|
||||
found, err := st.Search(ctx, q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -508,8 +626,124 @@ func number(args map[string]any, key string, otherwise int) (int, error) {
|
||||
return int(f), nil
|
||||
}
|
||||
|
||||
func text(d string) map[string]any { return map[string]any{"type": "string", "description": d} }
|
||||
// flag is 1232: a plain optional boolean filter, false (no filter) when left out.
|
||||
func flag(args map[string]any, key string) (bool, error) {
|
||||
v, ok := args[key]
|
||||
if !ok || v == nil {
|
||||
return false, nil
|
||||
}
|
||||
b, ok := v.(bool)
|
||||
if !ok {
|
||||
return false, fmt.Errorf("%s is true or false", key)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// triBool is 1232 (mail_search's unread): nil when left out (no filter), else the given true or false.
|
||||
func triBool(args map[string]any, key string) (*bool, error) {
|
||||
v, ok := args[key]
|
||||
if !ok || v == nil {
|
||||
return nil, nil
|
||||
}
|
||||
b, ok := v.(bool)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s is true or false", key)
|
||||
}
|
||||
return &b, nil
|
||||
}
|
||||
|
||||
// stringsArg is 1232: an optional list of strings (mail_search's from, subject-words, body-words), refusing,
|
||||
// naming key, anything that is not a list of strings, before the index is ever asked (AC16).
|
||||
func stringsArg(args map[string]any, key string) ([]string, error) {
|
||||
v, ok := args[key]
|
||||
if !ok || v == nil {
|
||||
return nil, nil
|
||||
}
|
||||
list, ok := v.([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s is a list of words", key)
|
||||
}
|
||||
out := make([]string, 0, len(list))
|
||||
for _, item := range list {
|
||||
s, ok := item.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s is a list of words", key)
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// dateLayouts is 1232/AC16: the forms on-or-after and before parse, RFC3339 first, then a bare day (the whole
|
||||
// day, UTC).
|
||||
var dateLayouts = []string{time.RFC3339, "2006-01-02"}
|
||||
|
||||
// dateArg is 1232/AC16: a date that does not parse is refused naming key, before the index is ever asked.
|
||||
func dateArg(args map[string]any, key string) (time.Time, error) {
|
||||
v, ok := args[key]
|
||||
if !ok || v == nil {
|
||||
return time.Time{}, nil
|
||||
}
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return time.Time{}, fmt.Errorf("%s is a date", key)
|
||||
}
|
||||
for _, layout := range dateLayouts {
|
||||
if t, err := time.Parse(layout, s); err == nil {
|
||||
return t.UTC(), nil
|
||||
}
|
||||
}
|
||||
return time.Time{}, fmt.Errorf("%s is not a date (RFC3339, or YYYY-MM-DD): %q", key, s)
|
||||
}
|
||||
|
||||
// orderByNames is 1230/AC17's fixed set; order-by outside it is refused by name (AC16), before the index is
|
||||
// ever asked.
|
||||
var orderByNames = map[string]bool{"date": true, "sender": true, "subject": true, "size": true}
|
||||
|
||||
func orderByArg(args map[string]any) (string, error) {
|
||||
v, ok := args["order-by"]
|
||||
if !ok || v == nil {
|
||||
return "", nil
|
||||
}
|
||||
s, ok := v.(string)
|
||||
if !ok || !orderByNames[s] {
|
||||
return "", errors.New("order-by is one of date, sender, subject, size")
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// orderArg is 1230: asc or desc; anything else is refused (AC16), before the index is ever asked.
|
||||
func orderArg(args map[string]any) (bool, error) {
|
||||
v, ok := args["order"]
|
||||
if !ok || v == nil {
|
||||
return false, nil
|
||||
}
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return false, errors.New("order is asc or desc")
|
||||
}
|
||||
switch s {
|
||||
case "asc":
|
||||
return false, nil
|
||||
case "desc":
|
||||
return true, nil
|
||||
default:
|
||||
return false, errors.New("order is asc or desc")
|
||||
}
|
||||
}
|
||||
|
||||
func text(d string) map[string]any { return map[string]any{"type": "string", "description": d} }
|
||||
|
||||
// enumText is 1232/AC17: a string argument whose schema itself names the values it accepts (order-by, order).
|
||||
func enumText(d string, values ...string) map[string]any {
|
||||
return map[string]any{"type": "string", "description": d, "enum": values}
|
||||
}
|
||||
|
||||
func integer(d string) map[string]any { return map[string]any{"type": "integer", "description": d} }
|
||||
func boolean(d string) map[string]any { return map[string]any{"type": "boolean", "description": d} }
|
||||
func stringArray(d string) map[string]any {
|
||||
return map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": d}
|
||||
}
|
||||
|
||||
func schema(required []string, props map[string]any) map[string]any {
|
||||
s := map[string]any{"type": "object", "properties": props, "additionalProperties": false}
|
||||
|
||||
@@ -273,3 +273,125 @@ func TestARefusalSaysWhyTheGrantsCouldNotBeRead(t *testing.T) {
|
||||
t.Fatalf("accounts: %s", text)
|
||||
}
|
||||
}
|
||||
|
||||
// 1228/AC1, 1232: with no filter at all (not even query), mail_search answers the newest mails of the granted
|
||||
// accounts, not a refusal demanding a query.
|
||||
func TestMailSearchWithNoFilterAnswersTheNewestGrantedMail(t *testing.T) {
|
||||
b, _, _ := aBundle(t)
|
||||
rt := startBundle(t, b.Tools())
|
||||
text, isErr := rt.call(SearchTool, "novox.triage", map[string]any{})
|
||||
if isErr || !strings.Contains(text, `"message-id":"att@example.org"`) {
|
||||
t.Fatalf("%v %s", isErr, text)
|
||||
}
|
||||
}
|
||||
|
||||
// 1232/AC16: an unknown order-by, a limit over 50, a date that does not parse, or an account not granted are
|
||||
// each refused naming the argument, and the index is never asked.
|
||||
func TestMailSearchValidatesBeforeAskingTheIndex(t *testing.T) {
|
||||
b, _, _ := aBundle(t)
|
||||
calls := 0
|
||||
index := b.Index
|
||||
b.Index = func(ctx context.Context) (Store, error) { calls++; return index(ctx) }
|
||||
rt := startBundle(t, b.Tools())
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
args map[string]any
|
||||
want string
|
||||
}{
|
||||
{"order-by", map[string]any{"order-by": "popularity"}, "order-by"},
|
||||
{"limit", map[string]any{"limit": 51}, "limit"},
|
||||
{"on-or-after", map[string]any{"on-or-after": "not a date"}, "on-or-after"},
|
||||
{"account", map[string]any{"account": "no-such-account"}, "no-such-account"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
text, isErr := rt.call(SearchTool, "novox.triage", c.args)
|
||||
if !isErr || !strings.Contains(text, c.want) {
|
||||
t.Fatalf("%s: %v %s", c.name, isErr, text)
|
||||
}
|
||||
}
|
||||
if calls != 0 {
|
||||
t.Fatalf("the index was asked %d time(s) before a refusal", calls)
|
||||
}
|
||||
}
|
||||
|
||||
// 1232/AC17: the schema describes every filter and order-by's own form (date, sender, subject, size, each
|
||||
// ascending or descending).
|
||||
func TestMailSearchSchemaOffersEveryFilterAndOrderBy(t *testing.T) {
|
||||
b := &Bundle{}
|
||||
var input map[string]any
|
||||
for _, tool := range b.Tools() {
|
||||
if tool.Name == SearchTool {
|
||||
input = tool.Input
|
||||
}
|
||||
}
|
||||
if input == nil {
|
||||
t.Fatal("no mail_search tool")
|
||||
}
|
||||
props, _ := input["properties"].(map[string]any)
|
||||
for _, name := range searchArgs {
|
||||
if _, ok := props[name]; !ok {
|
||||
t.Fatalf("mail_search's schema does not offer %q", name)
|
||||
}
|
||||
}
|
||||
orderBy, _ := props["order-by"].(map[string]any)
|
||||
if got, _ := orderBy["enum"].([]string); !sameStrings(got, []string{"date", "sender", "subject", "size"}) {
|
||||
t.Fatalf("order-by's enum: %v", orderBy["enum"])
|
||||
}
|
||||
order, _ := props["order"].(map[string]any)
|
||||
if got, _ := order["enum"].([]string); !sameStrings(got, []string{"asc", "desc"}) {
|
||||
t.Fatalf("order's enum: %v", order["enum"])
|
||||
}
|
||||
// mail-store.search carries the very same schema (issue 1232: one schema, not two).
|
||||
var seatInput map[string]any
|
||||
for _, tool := range b.Tools() {
|
||||
if tool.Name == SeatSearchTool {
|
||||
seatInput = tool.Input
|
||||
}
|
||||
}
|
||||
seatProps, _ := seatInput["properties"].(map[string]any)
|
||||
if len(seatProps) != len(props) {
|
||||
t.Fatalf("mail-store.search's schema has %d properties, mail_search's has %d", len(seatProps), len(props))
|
||||
}
|
||||
}
|
||||
|
||||
// 1232 (the end-to-end path of the others): from, unread, order-by/order and group-by-sender, called the way a
|
||||
// caller calls them, through the MCP tool and its JSON arguments, not Go's Query directly.
|
||||
func TestMailSearchEndToEndFiltersAndGroupBySender(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
env := Env{Accounts: filepath.Join(dir, "accounts.json"), Grants: filepath.Join(dir, "grants.json"), Secrets: dir,
|
||||
Status: filepath.Join(dir, "health.json")}
|
||||
writeFile(t, env.Accounts, stageOne)
|
||||
writeFile(t, env.Grants, `{"grants": {"mesh": ["triage"]}}`)
|
||||
st := newMemStore()
|
||||
ctx := context.Background()
|
||||
older := judgedAt.Add(-time.Hour)
|
||||
if err := st.Add(ctx, Message{Account: "mesh", Folder: "INBOX", UID: 1, MessageID: "<a@mesh>", Subject: "from alice",
|
||||
Date: older, From: []Person{{Address: "alice@example.org"}}, To: []Person{{Address: "jschoubben@novox.be"}},
|
||||
Flags: []string{}, Attachments: []AttachmentRef{}, Body: "hi"}, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.Add(ctx, Message{Account: "mesh", Folder: "INBOX", UID: 2, MessageID: "<b@mesh>", Subject: "from alice again",
|
||||
Date: judgedAt, From: []Person{{Address: "alice@example.org"}}, To: []Person{{Address: "jschoubben@novox.be"}},
|
||||
Flags: []string{"\\Seen"}, Attachments: []AttachmentRef{}, Body: "hi again"}, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := &Bundle{Env: env, Index: func(context.Context) (Store, error) { return st, nil }, Now: func() time.Time { return judgedAt }}
|
||||
rt := startBundle(t, b.Tools())
|
||||
|
||||
unread := true
|
||||
text, isErr := rt.call(SearchTool, "novox.triage", map[string]any{"from": []any{"example.org"}, "unread": unread,
|
||||
"order-by": "date", "order": "asc"})
|
||||
if isErr || !strings.Contains(text, "a@mesh") || strings.Contains(text, "b@mesh") {
|
||||
t.Fatalf("%v %s", isErr, text)
|
||||
}
|
||||
|
||||
text, isErr = rt.call(SearchTool, "novox.triage", map[string]any{"group-by-sender": true})
|
||||
var grouped struct {
|
||||
Senders []SenderGroup `json:"senders"`
|
||||
}
|
||||
if isErr || json.Unmarshal([]byte(text), &grouped) != nil || len(grouped.Senders) != 1 ||
|
||||
grouped.Senders[0].Address != "alice@example.org" || grouped.Senders[0].Count != 2 {
|
||||
t.Fatalf("%v %s", isErr, text)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user