|
|
|
@@ -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}
|
|
|
|
|