778 lines
30 KiB
Go
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
|
|
}
|