Files
mesh-catalog/modules/mesh-mail/cmd/mesh-mail/tools.go
T

778 lines
30 KiB
Go

package main
// The module's tools (novox/hq ADR 0359 §5, ADR 0361 §2): mail_accounts, mail_search, mail_read, mail_health,
// mail_send and mail_send_status. Who calls is the runtime's word (stdio.CallerOf), never an argument; a caller
// reads an account's mail, or sends from it, only when the trusted setting `grants` names it for that account (§4).
// Nothing here writes to a mailbox; mail_send writes nothing itself either (send.go) — it only ever reaches the
// account's outgoing server on the operator's own Approve.
import (
"context"
"encoding/base64"
"errors"
"fmt"
"strings"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// Tool names, as the manifest declares them.
const (
AccountsTool = "mail_accounts"
SearchTool = "mail_search"
ReadTool = "mail_read"
SendTool = "mail_send"
SendStatusTool = "mail_send_status"
)
// Seat tool names: mesh-mail claims the mail-store seat (novox/hq ADR 0378 §2, §3; issue 800 part A) and serves
// its verbs through the same handlers as the mail_* tools above — the seat's name is the one a caller grants
// reach the same account's mail through, whichever name it calls.
const (
SeatAccountsTool = "mail-store.accounts"
SeatSearchTool = "mail-store.search"
SeatReadTool = "mail-store.read"
SeatSendTool = "mail-store.send"
SeatSendStatusTool = "mail-store.send-status"
)
// Bounds of what a tool answers.
const (
MostResults = 50
MostQuery = 200
MostBodyAnswered = 64 << 10
MostAttachment = 8 << 20
)
// Bundle is what the tools reach: the settings, the index, the attachment store, and — for mail_send — the
// operator channel, the `sends` state and the work-queue publisher. The last three are nil outside a test: the
// real bus (sdkAskBus, keptSends, sdkQueuePublish) is used lazily, so building a Bundle for the read-only tools
// never needs them. Nothing here reads a password: the tools record the operator's Approve and queue the send
// for the sender, which alone performs it (novox/hq ADR 0436 §12, issue 1193, 1194).
type Bundle struct {
Env Env
Index func(ctx context.Context) (Store, error)
Objects func() (Objects, error)
Caller func(args map[string]any) string
Now func() time.Time
Asks AskBus
Sends SendStore
Queue QueuePublish
Logf func(format string, a ...any)
}
func (b *Bundle) now() time.Time {
if b.Now != nil {
return b.Now()
}
return time.Now()
}
// settings are the accounts and the grants. A grants setting that cannot be read grants nothing, and its reason is
// kept, so a refusal says why rather than only that the caller is not granted.
func (b *Bundle) settings() ([]Account, access, error) {
accounts, err := ReadAccounts(b.Env.Accounts)
if err != nil {
return nil, access{}, err
}
grants, err := ReadGrants(b.Env.Grants)
if err != nil {
return accounts, access{grants: Grants{}, unread: err}, nil
}
return accounts, access{grants: grants}, nil
}
// access is the grants as read, and why they could not be, when they could not.
type access struct {
grants Grants
unread error
}
func (a access) Allows(caller, account string) bool { return a.grants.Allows(caller, account) }
func (a access) Granted(caller string, accounts []Account) []string {
return a.grants.Granted(caller, accounts)
}
// refuse is the refusal of a caller for an account, saying why: the grants unread, or the caller not in them.
func (a access) refuse(caller, account string) error {
if a.unread != nil {
return fmt.Errorf("refused: the setting grants cannot be read, so it grants nobody: %v", a.unread)
}
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", "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",
}
// 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)"),
"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 (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; " +
"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, from the " +
"same account's own Sent folder (\"Sent\", or \"[Gmail]/Sent Mail\" for a gmail account)"),
})
}
// Tools are the module's tools.
func (b *Bundle) Tools() []stdio.Tool {
return []stdio.Tool{
{Name: AccountsTool,
Description: "The operator's mail accounts you are granted, each with its kind, its address, its state (syncing, " +
"current, waits-for-sign-in or failing, and why), when its server last answered, when it was last brought up to " +
"date, and its folders with how many messages each holds. An account you are not granted is not listed, and " +
"nothing of it is said. How the module as a whole is, is mail_health's to say. Read-only.",
Input: schema(nil, map[string]any{}),
Run: b.accountsTool},
{Name: 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, " +
"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, " +
"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 " +
"attachment (its key, the SHA-256 the message's references name), that one attachment's bytes, base64, at most " +
"8 MiB. Refused for an account you are not granted. Never marks a message seen. Read-only.",
Input: schema([]string{"account", "folder", "uid"}, map[string]any{
"account": text("the account, by its name in mail_accounts"),
"folder": text("the folder, such as INBOX"),
"uid": integer("the message's uid in that folder"),
"attachment": text("an attachment's key, the SHA-256 the message's references name, to answer its bytes"),
}),
Run: b.readTool},
{Name: HealthTool,
Description: "How mesh-mail is, as its watch last saw it: healthy when every account's server answered within five " +
"minutes; unhealthy, naming the account and why, when a connection is down or its IDLE on the inbox went silent, " +
"or an approved send has waited 10 minutes or more for its sender; waits, naming the account's password, while " +
"the operator has not given it at the desk.",
Input: schema(nil, map[string]any{}),
Run: b.healthTool},
{Name: SendTool,
Description: "Send mail from an account you are granted (the same check as mail_read, novox/hq ADR 0361 §2). It " +
"sends nothing itself: it raises an ask on the operator channel — the account, the recipients, the subject and " +
"the first lines of the body — and answers at once with the ask's id. The mail reaches the account's outgoing " +
"server only once the operator's Approve comes back on a channel that proves who answered, and once; on " +
"Decline, or no answer before it expires, nothing is sent. Refused for an account you are not granted, or one " +
"with no outgoing server. mail_send_status, with this id, says where it stands.",
Input: schema([]string{"account", "to", "subject", "body"}, map[string]any{
"account": text("the account to send from, by its name in mail_accounts"),
"to": map[string]any{"type": "array", "items": map[string]any{"type": "string"},
"description": "the recipients' mail addresses, at most 20"},
"subject": text("the message's subject, at most 200 characters"),
"body": text("the message's plain-text body"),
}),
Run: b.sendTool},
{Name: SendStatusTool,
Description: "Where a send asked with mail_send stands, by its ask id: waiting for the operator, sent, declined, " +
"ended without a choice (expired, refused, cancelled), or not proven. Read from the router's own record, held " +
"to the request mail_send asked (so a warrant for another request, or one changed since, sends nothing here). " +
"Answered only to the caller that asked for the send: an id this caller did not ask for, known or not, " +
"answers status unknown and nothing else of it (novox/hq ADR 0440 §7, issue 1261).",
Input: schema([]string{"id"}, map[string]any{"id": text("the ask id mail_send gave")}),
Run: b.sendStatusTool},
{Name: SeatAccountsTool,
Description: "The mail-store seat's accounts verb (novox/hq ADR 0378 §2): the same as mail_accounts.",
Input: schema(nil, map[string]any{}),
Run: b.accountsTool},
{Name: SeatSearchTool,
Description: "The mail-store seat's search verb (novox/hq ADR 0378 §2): the same as mail_search.",
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{
"account": text("the account, by its name in mail_accounts"),
"folder": text("the folder, such as INBOX"),
"uid": integer("the message's uid in that folder"),
"attachment": text("an attachment's key, the SHA-256 the message's references name, to answer its bytes"),
}),
Run: b.readTool},
{Name: SeatSendTool,
Description: "The mail-store seat's send verb (novox/hq ADR 0378 §2): the same as mail_send.",
Input: schema([]string{"account", "to", "subject", "body"}, map[string]any{
"account": text("the account to send from, by its name in mail_accounts"),
"to": map[string]any{"type": "array", "items": map[string]any{"type": "string"},
"description": "the recipients' mail addresses, at most 20"},
"subject": text("the message's subject, at most 200 characters"),
"body": text("the message's plain-text body"),
}),
Run: b.sendTool},
{Name: SeatSendStatusTool,
Description: "The mail-store seat's send-status verb (novox/hq ADR 0378 §2, issue 1261): the same as mail_send_status.",
Input: schema([]string{"id"}, map[string]any{"id": text("the ask id mail_send gave")}),
Run: b.sendStatusTool},
}
}
func (b *Bundle) caller(args map[string]any) string {
if b.Caller != nil {
return b.Caller(args)
}
return stdio.CallerOf(args)
}
// healthTool folds an overdue send (novox/hq ADR 0436 clause 11, issue 1199) into the watch's own verdict:
// unhealthy, naming the send, no matter what the watch itself said. Overdue reads `sends` directly — the
// watch process has no access to it — so a send stuck past its own 10 minutes is caught even while every
// account's own connection is fine.
func (b *Bundle) healthTool(map[string]any) (any, error) {
base := ReadStatus(b.Env.Status, b.now())
overdue, err := Overdue(b.sends(), b.now())
if err != nil {
if b.Logf != nil {
b.Logf("[mesh-mail] mail_health: the overdue check failed: %v", err)
}
return base, nil
}
return WithOverdue(base, overdue), nil
}
func (b *Bundle) accountsTool(args map[string]any) (any, error) {
if err := only(args); err != nil {
return nil, err
}
accounts, grants, err := b.settings()
if err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
st, err := b.Index(ctx)
if err != nil {
return nil, fmt.Errorf("the index cannot be reached: %v", err)
}
states, err := st.States(ctx)
if err != nil {
return nil, err
}
caller := b.caller(args)
out := []map[string]any{}
for _, a := range accounts {
// Only the accounts the caller is granted: of any other it learns nothing, not even that it exists.
if !grants.Allows(caller, a.Name) {
continue
}
entry := map[string]any{"account": a.Name, "kind": a.Kind, "address": a.Address}
if s, ok := states[a.Name]; ok {
entry["state"], entry["why"] = s.State, s.Why
if !s.Heard.IsZero() {
entry["heard"] = s.Heard.UTC().Format(time.RFC3339)
}
if !s.Synced.IsZero() {
entry["synced"] = s.Synced.UTC().Format(time.RFC3339)
}
} else {
entry["state"], entry["why"] = "unknown", "its keeper has said nothing of it yet"
}
folders, err := st.Folders(ctx, a.Name)
if err != nil {
return nil, err
}
entry["folders"] = folders
out = append(out, entry)
}
answer := map[string]any{"accounts": out, "caller": caller}
if grants.unread != nil {
answer["why"] = "the setting grants cannot be read, so it grants nobody: " + grants.unread.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, searchArgs...); err != nil {
return nil, err
}
query, _ := args["query"].(string)
if len(query) > MostQuery {
return nil, fmt.Errorf("query is at most %d characters", MostQuery)
}
account, _ := args["account"].(string)
folder, _ := args["folder"].(string)
limit, err := number(args, "limit", 20)
if err != nil {
return nil, err
}
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
}
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
}
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
}
caller := b.caller(args)
granted := grants.Granted(caller, accounts)
if account != "" {
if !grants.Allows(caller, account) {
return nil, grants.refuse(caller, account)
}
granted = []string{account}
}
if len(granted) == 0 {
if grants.unread != nil {
return nil, grants.refuse(caller, "")
}
return nil, fmt.Errorf("refused: %s is granted no mail account; the operator grants one through the setting grants", caller)
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
st, err := b.Index(ctx)
if err != nil {
return nil, fmt.Errorf("the index cannot be reached: %v", err)
}
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,
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
}
out := make([]map[string]any, 0, len(found))
for _, m := range found {
out = append(out, metadata(m))
}
return map[string]any{"searched": granted, "found": out}, nil
}
func (b *Bundle) readTool(args map[string]any) (any, error) {
if err := only(args, "account", "folder", "uid", "attachment"); err != nil {
return nil, err
}
account, _ := args["account"].(string)
folder, _ := args["folder"].(string)
attachment, _ := args["attachment"].(string)
uid, err := number(args, "uid", 0)
if err != nil {
return nil, err
}
if account == "" || folder == "" || uid <= 0 {
return nil, fmt.Errorf("%s needs an account, a folder and a uid", ReadTool)
}
_, grants, err := b.settings()
if err != nil {
return nil, err
}
caller := b.caller(args)
if !grants.Allows(caller, account) {
return nil, grants.refuse(caller, account)
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
st, err := b.Index(ctx)
if err != nil {
return nil, fmt.Errorf("the index cannot be reached: %v", err)
}
m, ok, err := st.Get(ctx, account, folder, uint32(uid))
if err != nil {
return nil, err
}
if !ok {
return nil, fmt.Errorf("the account %s holds no message %d in %s", account, uid, folder)
}
if attachment != "" {
var ref *AttachmentRef
for i := range m.Attachments {
if m.Attachments[i].Key == attachment {
ref = &m.Attachments[i]
}
}
if ref == nil {
return nil, fmt.Errorf("the message names no attachment %s", attachment)
}
if ref.Size > MostAttachment {
return nil, fmt.Errorf("the attachment %s is %d bytes, more than the %d this tool answers", ref.Name, ref.Size, MostAttachment)
}
o, err := b.Objects()
if err != nil {
return nil, err
}
data, err := o.Get(ctx, ref.Key)
if err != nil {
return nil, err
}
return map[string]any{"attachment": ref, "base64": base64.StdEncoding.EncodeToString(data)}, nil
}
out := metadata(m)
body := m.Body
if len(body) > MostBodyAnswered {
body = strings.ToValidUTF8(body[:MostBodyAnswered], "") + "\n[cut at 64 KiB]"
}
out["text"] = body
return out, nil
}
func refused(caller, account string) error {
return fmt.Errorf("refused: %s is not granted the mail account %s; the operator grants it through the setting grants", caller, account)
}
// ---- mail_send, mail_send_status ---------------------------------------------------------------------------------
// askBus is the operator channel this bundle reaches: the bus's own, unless a test gave one.
func (b *Bundle) askBus() AskBus {
if b.Asks != nil {
return b.Asks
}
return sdkAskBus{}
}
// sends is the `sends` state this bundle keeps: the bus's own, unless a test gave one.
func (b *Bundle) sends() SendStore {
if b.Sends != nil {
return b.Sends
}
return keptSends{stdio.State(SendsState)}
}
// queuePublish is how this bundle publishes onto the module's own work-queue stream: the bus's own, unless a
// test gave one. Nothing here reads a password (novox/hq ADR 0436 §12).
func (b *Bundle) queuePublish() QueuePublish {
if b.Queue != nil {
return b.Queue
}
return sdkQueuePublish
}
func accountByName(accounts []Account, name string) (Account, bool) {
for _, a := range accounts {
if a.Name == name {
return a, true
}
}
return Account{}, false
}
func stringList(v any) []string {
list, _ := v.([]any)
out := make([]string, 0, len(list))
for _, item := range list {
if s, ok := item.(string); ok {
out = append(out, s)
}
}
return out
}
func (b *Bundle) sendTool(args map[string]any) (any, error) {
if err := only(args, "account", "to", "subject", "body"); err != nil {
return nil, err
}
account, _ := args["account"].(string)
subject, _ := args["subject"].(string)
body, _ := args["body"].(string)
to := stringList(args["to"])
if account == "" {
return nil, fmt.Errorf("%s needs account, by its name in mail_accounts", SendTool)
}
accounts, grants, err := b.settings()
if err != nil {
return nil, err
}
caller := b.caller(args)
// The same grants check as mail_read (novox/hq ADR 0359 §4, ADR 0361 §2): an ungranted caller is refused, and
// nothing is asked.
if !grants.Allows(caller, account) {
return nil, grants.refuse(caller, account)
}
acc, found := accountByName(accounts, account)
if !found {
return nil, fmt.Errorf("no account %q is held; mail_accounts lists the accounts", account)
}
return AskMailSend(SendInput{Account: acc, To: to, Subject: subject, Body: body, Caller: caller, Accounts: accounts},
b.sends(), b.askBus(), b.now())
}
func (b *Bundle) sendStatusTool(args map[string]any) (any, error) {
if err := only(args, "id"); err != nil {
return nil, err
}
id, _ := args["id"].(string)
if id == "" {
return nil, fmt.Errorf("%s needs id, the ask %s gave", SendStatusTool, SendTool)
}
caller := b.caller(args)
return ReadSendStatus(b.sends(), b.askBus(), b.queuePublish(), id, caller, b.now())
}
// ---- arguments ----------------------------------------------------------------------------------------------------
// only refuses an argument the tool does not take: never ignored, and none can say who calls.
func only(args map[string]any, takes ...string) error {
for k := range args {
if !contains(takes, k) {
return fmt.Errorf("the argument %q is not taken", k)
}
}
return nil
}
func number(args map[string]any, key string, otherwise int) (int, error) {
v, ok := args[key]
if !ok || v == nil {
return otherwise, nil
}
f, ok := v.(float64)
if !ok || f != float64(int(f)) {
return 0, errors.New(key + " is a whole number")
}
return int(f), nil
}
// 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; desc (newest, largest, ...) when left out, as the schema says (review round 1
// finding 5: this returned ascending on nothing given, contradicting its own description); 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 true, 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}
if len(required) > 0 {
s["required"] = required
}
return s
}