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 }