package web // 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 // MailEntries, MailGroups and MailStatus are mail-reading's own read verbs the accounts page shows above // the classical list (novox/hq ADR 0459 §13, issue 1430); MailCorrect and MailUndo are its writes, and // MailLessons its lessons list (issue 1523). nil answers mail-reading as unavailable (1430/AC3) in every // test that builds a Bundle directly without one — the same seam Search, Read, Send and SendStatus use. MailEntries SeatAsk MailGroups SeatAsk MailStatus SeatAsk MailCorrect SeatAsk MailLessons SeatAsk MailUndo SeatAsk // MailList, MailHandle, MailUnhandle, MailRemind and MailRecord are mail-reading's verbs of the // operator's list (novox/hq ADR 0517 §4, issue 1859): the list page and its acts. MailList SeatAsk MailHandle SeatAsk MailUnhandle SeatAsk MailRemind SeatAsk MailRecord SeatAsk // ModesSetting answers the setting modes; nil reads it from Env.Modes (modes.go). The local run's seam. ModesSetting func() (map[string][]string, error) 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) mailEntriesAsk(ctx context.Context, args map[string]any) (json.RawMessage, error) { if b.MailEntries != nil { return b.MailEntries(ctx, args) } return sdkSeatAsk("mail-reading.entries")(ctx, args) } func (b *Bundle) mailGroupsAsk(ctx context.Context, args map[string]any) (json.RawMessage, error) { if b.MailGroups != nil { return b.MailGroups(ctx, args) } return sdkSeatAsk("mail-reading.groups")(ctx, args) } func (b *Bundle) mailStatusAsk(ctx context.Context, args map[string]any) (json.RawMessage, error) { if b.MailStatus != nil { return b.MailStatus(ctx, args) } return sdkSeatAsk("mail-reading.status")(ctx, args) } func (b *Bundle) mailCorrectAsk(ctx context.Context, args map[string]any) (json.RawMessage, error) { if b.MailCorrect != nil { return b.MailCorrect(ctx, args) } return sdkSeatAsk("mail-reading.correct")(ctx, args) } func (b *Bundle) mailLessonsAsk(ctx context.Context, args map[string]any) (json.RawMessage, error) { if b.MailLessons != nil { return b.MailLessons(ctx, args) } return sdkSeatAsk("mail-reading.lessons")(ctx, args) } func (b *Bundle) mailUndoAsk(ctx context.Context, args map[string]any) (json.RawMessage, error) { if b.MailUndo != nil { return b.MailUndo(ctx, args) } return sdkSeatAsk("mail-reading.undo")(ctx, args) } func (b *Bundle) mailListAsk(ctx context.Context, args map[string]any) (json.RawMessage, error) { return askOr(b.MailList, "mail-reading.list")(ctx, args) } func (b *Bundle) mailHandleAsk(ctx context.Context, args map[string]any) (json.RawMessage, error) { return askOr(b.MailHandle, "mail-reading.handle")(ctx, args) } func (b *Bundle) mailUnhandleAsk(ctx context.Context, args map[string]any) (json.RawMessage, error) { return askOr(b.MailUnhandle, "mail-reading.unhandle")(ctx, args) } func (b *Bundle) mailRemindAsk(ctx context.Context, args map[string]any) (json.RawMessage, error) { return askOr(b.MailRemind, "mail-reading.remind")(ctx, args) } func (b *Bundle) mailRecordAsk(ctx context.Context, args map[string]any) (json.RawMessage, error) { return askOr(b.MailRecord, "mail-reading.record")(ctx, args) } // askOr is given, or the seat verb over the bus when a test or the local run gave none. func askOr(given SeatAsk, verb string) SeatAsk { if given != nil { return given } return sdkSeatAsk(verb) } 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} }