Files
mesh-catalog/modules/mesh-mail-web/internal/web/tools.go
T
jochen ad4f312263 issue 1859: open the mail page on the operator's list (1859.1)
ADR 0515 §1, §5: / greets the operator with what the mail signal told today and when they last looked, then
shows today, this week, later, the promises by kind, good to know, read later, handled today and the scam
warnings, from mail-reading.list; it records the page opened. The classical list moves to /all, as All mail.
2026-10-22 20:03:21 +02:00

299 lines
11 KiB
Go

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
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} }