212 lines
7.6 KiB
Go
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} }
|