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

212 lines
7.6 KiB
Go

package main
// The module's seat verbs (novox/hq ADR 0440 §8): mail-webclient.status (whether it serves, and which accounts
// it is granted) and mail-webclient.open (issue 1283: the page's address). mail_web_health is its own health
// tool, read-only, naming nothing of a page or an account.
import (
"context"
"encoding/json"
"fmt"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// Tool names, as the manifest declares them.
const (
StatusTool = "mail-webclient.status"
HealthTool = "mail_web_health"
)
// AccountsAsk is how the Bundle reaches seat:mail-store.accounts: the bus's own stdio.Ask, unless a test gives
// its own fake (so a unit test needs no runtime).
type AccountsAsk func(ctx context.Context) (json.RawMessage, error)
func sdkAccountsAsk(ctx context.Context) (json.RawMessage, error) {
return stdio.Ask("seat:mail-store.accounts", map[string]any{})
}
// SeatAsk is how the Bundle reaches one of mail-store's other verbs (search, read, send, send-status): the
// bus's own stdio.Ask, unless a test gives its own fake.
type SeatAsk func(ctx context.Context, args map[string]any) (json.RawMessage, error)
func sdkSeatAsk(verb string) SeatAsk {
return func(ctx context.Context, args map[string]any) (json.RawMessage, error) {
return stdio.Ask("seat:"+verb, args)
}
}
// AccountsBucketGet is how the Bundle reads mesh-mail's own account bucket (novox/hq ADR 0439 §2, issue 1255),
// one account at a time (issue 1264/AC2: never Keys(), never an account mail-store did not itself answer).
type AccountsBucketGet func(account string) (*stdio.StateEntry, error)
func sdkAccountsBucketGet(account string) (*stdio.StateEntry, error) {
return stdio.State("mesh-mail.accounts").Get(account)
}
// Bundle is what the tools reach: the module's own settings, and the seats it calls.
type Bundle struct {
Env Env
Accounts AccountsAsk
Search SeatAsk
Read SeatAsk
Send SeatAsk
SendStatus SeatAsk
AccountBucket AccountsBucketGet
Now func() time.Time
// HTTP is serveHTTP's own state (main.go, review round 1 of issue 1283, finding 4): nil answers
// HTTPListening (every test that builds a Bundle directly, never through main's own serveHTTP, means this
// module's HTTP endpoint whole).
HTTP *HTTPStatus
}
// httpPhase is b.HTTP.Get(), with no HTTP given read as HTTPListening (every unit test that builds a Bundle
// directly rather than through main's own serveHTTP).
func (b *Bundle) httpPhase() (phase string, err error) {
if b.HTTP == nil {
return HTTPListening, nil
}
return b.HTTP.Get()
}
func (b *Bundle) accountsAsk(ctx context.Context) (json.RawMessage, error) {
if b.Accounts != nil {
return b.Accounts(ctx)
}
return sdkAccountsAsk(ctx)
}
func (b *Bundle) searchAsk(ctx context.Context, args map[string]any) (json.RawMessage, error) {
if b.Search != nil {
return b.Search(ctx, args)
}
return sdkSeatAsk("mail-store.search")(ctx, args)
}
func (b *Bundle) readAsk(ctx context.Context, args map[string]any) (json.RawMessage, error) {
if b.Read != nil {
return b.Read(ctx, args)
}
return sdkSeatAsk("mail-store.read")(ctx, args)
}
func (b *Bundle) sendAsk(ctx context.Context, args map[string]any) (json.RawMessage, error) {
if b.Send != nil {
return b.Send(ctx, args)
}
return sdkSeatAsk("mail-store.send")(ctx, args)
}
func (b *Bundle) sendStatusAsk(ctx context.Context, args map[string]any) (json.RawMessage, error) {
if b.SendStatus != nil {
return b.SendStatus(ctx, args)
}
return sdkSeatAsk("mail-store.send-status")(ctx, args)
}
func (b *Bundle) accountBucketGet(account string) (*stdio.StateEntry, error) {
if b.AccountBucket != nil {
return b.AccountBucket(account)
}
return sdkAccountsBucketGet(account)
}
func (b *Bundle) now() time.Time {
if b.Now != nil {
return b.Now()
}
return time.Now()
}
// Tools are the module's tools.
func (b *Bundle) Tools() []stdio.Tool {
return []stdio.Tool{
{Name: OpenTool,
Description: "The mail web client's own address (novox/hq ADR 0440 §8): with nothing given, the " +
"page's root; with account, folder and uid together (mail_read's own addressing), that mail's " +
"own page. Read-only; answers a URL, never a page's content.",
Input: schema(nil, 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"),
}),
Run: b.openTool},
{Name: StatusTool,
Description: "Whether mesh-mail-web serves, and the mail accounts it is granted through " +
"seat:mail-store.accounts (novox/hq ADR 0440 §8). Read-only.",
Input: schema(nil, map[string]any{}),
Run: b.statusTool},
{Name: HealthTool,
Description: "How mesh-mail-web is: healthy once its own settings can be read, unhealthy naming why " +
"otherwise. Read-only.",
Input: schema(nil, map[string]any{}),
Run: b.healthTool},
}
}
// statusTool is issue 1262/AC2, AC3: mail-store's own accounts answer, read as the granted accounts; an ask
// mail-store does not answer, or refuses, answers "it does not serve" and why, never a crash. Finding 4 of
// review round 1: it also says so whenever the HTTP endpoint itself is not actually listening (still setting
// up sign-in, or ended on an error) — serving stdio's own seat verbs is not the same as serving mail-webclient
// whole.
func (b *Bundle) statusTool(map[string]any) (any, error) {
if phase, httpErr := b.httpPhase(); phase != HTTPListening {
return map[string]any{"serves": false, "why": httpStatusWhy(phase, httpErr)}, nil
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
raw, err := b.accountsAsk(ctx)
if err != nil {
return map[string]any{"serves": false, "why": fmt.Sprintf("mail-store does not answer: %v", err)}, nil
}
var answer struct {
Accounts []struct {
Account string `json:"account"`
} `json:"accounts"`
}
if err := json.Unmarshal(raw, &answer); err != nil {
return map[string]any{"serves": false, "why": fmt.Sprintf("mail-store's answer could not be read: %v", err)}, nil
}
names := make([]string, 0, len(answer.Accounts))
for _, a := range answer.Accounts {
names = append(names, a.Account)
}
return map[string]any{"serves": true, "accounts": names}, nil
}
// healthTool: finding 4 of review round 1 — unhealthy, naming the HTTP endpoint's own phase, whenever it is
// not actually listening, beside its existing check that the operators setting can still be read.
func (b *Bundle) healthTool(map[string]any) (any, error) {
if phase, httpErr := b.httpPhase(); phase != HTTPListening {
return map[string]any{"healthy": false, "why": httpStatusWhy(phase, httpErr)}, nil
}
if _, err := ReadOperators(b.Env.Operators); err != nil {
return map[string]any{"healthy": false, "why": err.Error()}, nil
}
return map[string]any{"healthy": true}, nil
}
// httpStatusWhy is the one sentence statusTool and healthTool alike say when the HTTP endpoint is not
// HTTPListening: its own phase, and its error once it has one.
func httpStatusWhy(phase string, err error) string {
why := "its HTTP endpoint is " + phase
if err != nil {
why += fmt.Sprintf(": %v", err)
}
return why
}
// ---- schema helpers (mirroring mesh-mail's tools.go) -----------------------------------------------------------
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
}
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} }