From b60ee706d40fbd89e7dd810e25c48ce83ab2396a Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 19 Oct 2026 14:46:58 +0200 Subject: [PATCH] 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) --- modules/mesh-mail/README.md | 2 +- modules/mesh-mail/cmd/mesh-mail/tools.go | 280 ++++++++++++++++-- modules/mesh-mail/cmd/mesh-mail/tools_test.go | 122 ++++++++ 3 files changed, 380 insertions(+), 24 deletions(-) diff --git a/modules/mesh-mail/README.md b/modules/mesh-mail/README.md index f7b77338..142579c0 100644 --- a/modules/mesh-mail/README.md +++ b/modules/mesh-mail/README.md @@ -61,7 +61,7 @@ The password of an account is the own secret `mail-credential-`, 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-` 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 | diff --git a/modules/mesh-mail/cmd/mesh-mail/tools.go b/modules/mesh-mail/cmd/mesh-mail/tools.go index 51c434df..ef6596b6 100644 --- a/modules/mesh-mail/cmd/mesh-mail/tools.go +++ b/modules/mesh-mail/cmd/mesh-mail/tools.go @@ -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} diff --git a/modules/mesh-mail/cmd/mesh-mail/tools_test.go b/modules/mesh-mail/cmd/mesh-mail/tools_test.go index fbc9c6c7..e81c1f2d 100644 --- a/modules/mesh-mail/cmd/mesh-mail/tools_test.go +++ b/modules/mesh-mail/cmd/mesh-mail/tools_test.go @@ -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: "", 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: "", 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) + } +}