Declares mail-store and mail-webclient (held once for the mesh) and mail-notifier (held once per machine, needing node-notifier) per ADR 0378 as amended by ADR 0379. Claims mail-store and serves its four verbs through the same handlers as the existing mail_* tools. Moves the event shape (EventKey, Changes) into its own file, mailevent.go, so mesh-mail-notifier (issue 800 part B) can carry a byte-identical copy. Adds checks/mailstore, refusing any manifest but mesh-mail's own that claims mail-store (ADR 0378 §3), wired into merge-check.sh.
522 lines
19 KiB
Go
522 lines
19 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"
|
|
)
|
|
|
|
// 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 outgoing dialer. The last three are nil outside a test: the real
|
|
// bus and dialer (sdkAskBus, keptSends, DialSMTP) are used lazily, so building a Bundle for the read-only tools
|
|
// never needs them.
|
|
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
|
|
Dial SMTPDialer
|
|
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)
|
|
}
|
|
|
|
// 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: 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},
|
|
{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; " +
|
|
"waits, naming the account's password, while the operator has not given it at the desk.",
|
|
Input: schema(nil, map[string]any{}),
|
|
Run: func(map[string]any) (any, error) { return ReadStatus(b.Env.Status, b.now()), nil }},
|
|
{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).",
|
|
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: 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},
|
|
{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},
|
|
}
|
|
}
|
|
|
|
func (b *Bundle) caller(args map[string]any) string {
|
|
if b.Caller != nil {
|
|
return b.Caller(args)
|
|
}
|
|
return stdio.CallerOf(args)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
func (b *Bundle) searchTool(args map[string]any) (any, error) {
|
|
if err := only(args, "query", "account", "folder", "limit"); 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)
|
|
}
|
|
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)
|
|
}
|
|
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)
|
|
}
|
|
found, err := st.Search(ctx, Query{Text: query, Accounts: granted, Folder: folder, Limit: limit})
|
|
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)}
|
|
}
|
|
|
|
// lookupAccount resolves an account by name as the settings currently hold it — read fresh at decide time, never
|
|
// the account as it stood when it was asked.
|
|
func (b *Bundle) lookupAccount(name string) (Account, bool) {
|
|
accounts, err := ReadAccounts(b.Env.Accounts)
|
|
if err != nil {
|
|
return Account{}, false
|
|
}
|
|
return accountByName(accounts, name)
|
|
}
|
|
|
|
func accountByName(accounts []Account, name string) (Account, bool) {
|
|
for _, a := range accounts {
|
|
if a.Name == name {
|
|
return a, true
|
|
}
|
|
}
|
|
return Account{}, false
|
|
}
|
|
|
|
// sendActor performs one send on the operator's Approve: it reads the account's own secret itself, so Decide and
|
|
// the `sends` state never hold a password.
|
|
func (b *Bundle) sendActor() SendActor {
|
|
dial := b.Dial
|
|
if dial == nil {
|
|
dial = DialSMTP
|
|
}
|
|
return func(a Account, to []string, subject, body string, now time.Time) error {
|
|
pw, given, err := ReadSecret(b.Env.Secrets, a)
|
|
if err != nil {
|
|
return fmt.Errorf("the account %s's password cannot be read: %w", a.Name, err)
|
|
}
|
|
if !given {
|
|
return fmt.Errorf("the account %s has no password yet; the operator gives it at the desk", a.Name)
|
|
}
|
|
return SendMail(dial, a, pw, to, subject, body, now)
|
|
}
|
|
}
|
|
|
|
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}, 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)
|
|
}
|
|
return ReadSendStatus(b.sends(), b.askBus(), b.lookupAccount, b.sendActor(), id, 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
|
|
}
|
|
|
|
func text(d string) map[string]any { return map[string]any{"type": "string", "description": d} }
|
|
func integer(d string) map[string]any { return map[string]any{"type": "integer", "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
|
|
}
|