package main // The module's tools (novox/hq ADR 0459 §2, issue 1426): the six mail-reading seat verbs — entries, groups, // correct, lessons, undo, status — and the module's own health tool, mail_reader_health (1519). Who calls is // the runtime's word (stdio.CallerOf), never an argument; a mail-reading verb answers only a caller the // trusted setting `callers` names (callers.go), and refuses any other, naming why. mail_reader_health is // asked by the node-engine itself and carries no caller check. // // The six verbs' own behaviour — reading and writing mail entries, mail groups and reading lessons — is // 1521 (entries, groups, status) and 1428 (correct, lessons, undo): all six now answer for real. // mail_reader_health's own answer is 1519's: it reaches the kept health state (store.go, health.go). import ( "context" "errors" "fmt" "sort" "time" stdio "git.novox.be/novox/mesh-sdk/go" ) // Seat tool names (novox/hq ADR 0459 §2): mesh-mail-reader claims the mail-reading seat and serves its six // verbs through these tool names, exactly as mesh-mail's own mail-store seat tools are named "mail-store.". const ( EntriesTool = "mail-reading.entries" GroupsTool = "mail-reading.groups" CorrectTool = "mail-reading.correct" LessonsTool = "mail-reading.lessons" UndoTool = "mail-reading.undo" StatusTool = "mail-reading.status" ) // Bundle is what the tools reach: the trusted setting `callers`, the runtime's own caller, and the index // (store.go), which mail_reader_health reads (1519). Caller is nil outside a test (CallerOf is read from the // runtime). type Bundle struct { Env Env Caller func(args map[string]any) string Store func(ctx context.Context) (Store, error) // callersOverride, when set, is used instead of reading Env.Callers — the tests' own seam. callersOverride func() (Callers, error) } // Env is where the bundle reads its settings from. type Env struct { Callers string // the path MESH_MAIL_READER_CALLERS names. } func (b *Bundle) caller(args map[string]any) string { if b.Caller != nil { return b.Caller(args) } return stdio.CallerOf(args) } func (b *Bundle) callers() (Callers, error) { if b.callersOverride != nil { return b.callersOverride() } return ReadCallers(b.Env.Callers) } // checkCaller refuses args' caller unless the trusted setting `callers` names it (1426/AC4). It is the first // thing every mail-reading verb does: a caller learns it is refused before it learns anything about the verb. func (b *Bundle) checkCaller(args map[string]any) (string, error) { caller := b.caller(args) callers, err := b.callers() if err != nil || !callers.Allows(caller) { return caller, refuseCaller(err, caller) } return caller, nil } // schema, text, integer and boolean mirror mesh-mail's own tools.go helpers (modules/mesh-mail/cmd/mesh-mail, // copied in miniature so mesh-mail-reader never imports mesh-mail's tools package for its schemas alone). 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} } func boolean(d string) map[string]any { return map[string]any{"type": "boolean", "description": d} } // number reads an optional whole-number argument, otherwise when left out. 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 } // only refuses an argument a tool does not take. func only(args map[string]any, takes ...string) error { for k := range args { found := false for _, t := range takes { if t == k { found = true break } } if !found { return errors.New("the argument " + k + " is not taken") } } return nil } // Tools are the module's tools (1426/AC3): the six mail-reading verbs and mail_reader_health, each with its // own argument schema. func (b *Bundle) Tools() []stdio.Tool { return []stdio.Tool{ {Name: EntriesTool, Description: "Every mail entry (novox/hq ADR 0459 §4): the mail's own headers, its mail group, a " + "one-line summary, its mark and why, its due date — never a mail body. Newest first, except a " + "mail that matters comes first, nearest due date first among those (none last). Filtered by mail " + "group (its label) or by mark when asked. Answered only to a caller the setting callers names. " + "Read-only.", Input: schema(nil, map[string]any{ "limit": integer("how many to answer; a default bounds it when left out"), "group": text("only mails of this mail group, by its label"), "mark": text("only mails of this mark: matters, scam warning or neither"), }), Run: b.entriesTool}, {Name: GroupsTool, Description: "Every mail group a batch has made (novox/hq ADR 0459 §4, §5), with how many mails it " + "holds, largest first. Answered only to a caller the setting callers names. Read-only.", Input: schema(nil, map[string]any{ "limit": integer("how many to answer; a default bounds it when left out"), }), Run: b.groupsTool}, {Name: CorrectTool, Description: "Correct one mail entry's mark, mail group, why or due date (novox/hq ADR 0459 §8): " + "kept at once as a reading lesson (the mail's own headers, the change made, when), no operator " + "Approve needed, and outranking that mail's own words in any later batch. Refused for an unknown " + "mail id or mark. Answered only to a caller the setting callers names.", Input: schema([]string{"id"}, map[string]any{ "id": integer("the mail entry's id, as entries answered it"), "mark": text("the corrected mark: matters, scam warning or neither"), "group": text("the corrected mail group, by its label (made if none yet carries it)"), "why": text("the corrected reason, one line"), "due-date": text("the corrected due date (YYYY-MM-DD), or empty to clear it"), }), Run: b.correctTool}, {Name: LessonsTool, Description: "The reading lessons kept (novox/hq ADR 0459 §9): newest first, at most 40 (fewer when " + "asked); undone ones left out unless include-undone asks for them, each then marked undone. A " + "lesson outranks a mail it would otherwise contradict. Answered only to a caller the setting " + "callers names. Read-only.", Input: schema(nil, map[string]any{ "limit": integer("how many to answer, at most 40; a default bounds it when left out"), "include-undone": boolean("also answer undone lessons, each marked undone; left out by default"), }), Run: b.lessonsTool}, {Name: UndoTool, Description: "Undo one reading lesson (novox/hq ADR 0459 §9, issue 1523): it no longer outranks a " + "mail. Answered only to a caller the setting callers names.", Input: schema([]string{"id"}, map[string]any{ "id": integer("the reading lesson's id, as lessons answered it"), }), Run: b.undoTool}, {Name: StatusTool, Description: "How mail reading stands for the page (novox/hq ADR 0459 §10): healthy or not, the cause " + "and since when, and reading lessons per 100 read mails for each of the last 8 weeks (a week with " + "no mail read says so). Answered only to a caller the setting callers names. Read-only.", Input: schema(nil, map[string]any{}), Run: b.statusTool}, {Name: HealthTool, Description: "How mesh-mail-reader is (novox/hq ADR 0459 §12): unhealthy once a batch judgement is " + "refused, times out or answers out of shape, naming which and since when; healthy once one " + "succeeds. Asked by the node-engine; carries no caller check. Read-only.", Input: schema(nil, map[string]any{}), Run: b.healthTool}, } } // entryOut is one mail entry as entriesTool answers it (1521/AC1): account, message id, date, sender name, // subject, group (by its label), summary, mark, why and due date — never a mail body. func entryOut(e Entry, labels map[int64]string) map[string]any { due := "" if !e.DueDate.IsZero() { due = e.DueDate.Format("2006-01-02") } return map[string]any{ "id": e.ID, "account": e.Account, "message-id": e.MessageID, "date": e.Date.UTC().Format(time.RFC3339), "sender-name": e.SenderName, "subject": e.Subject, "group": labels[e.Group], "summary": e.Summary, "mark": string(e.Mark), "why": e.Why, "due-date": due, } } func (b *Bundle) entriesTool(args map[string]any) (any, error) { if _, err := b.checkCaller(args); err != nil { return nil, err } if err := only(args, "limit", "group", "mark"); err != nil { return nil, err } limit, err := number(args, "limit", 0) if err != nil { return nil, err } groupLabel, _ := args["group"].(string) markStr, _ := args["mark"].(string) mark := Mark(markStr) if mark != "" && mark != MarkMatters && mark != MarkScamWarning && mark != MarkNeither { return nil, fmt.Errorf("%s needs mark to be matters, scam warning or neither", EntriesTool) } ctx := context.Background() store, err := b.Store(ctx) if err != nil { return nil, err } groups, err := store.Groups(ctx) if err != nil { return nil, err } labels := map[int64]string{} var groupID int64 for _, g := range groups { labels[g.ID] = g.Label if groupLabel != "" && g.Label == groupLabel { groupID = g.ID } } entries, err := store.Entries(ctx) if err != nil { return nil, err } if groupLabel != "" { entries = FilterEntries(entries, groupID, mark) } else { entries = FilterEntries(entries, 0, mark) } entries = SortEntries(entries) if limit > 0 && len(entries) > limit { entries = entries[:limit] } out := make([]map[string]any, len(entries)) for i, e := range entries { out[i] = entryOut(e, labels) } return map[string]any{"entries": out}, nil } func (b *Bundle) groupsTool(args map[string]any) (any, error) { if _, err := b.checkCaller(args); err != nil { return nil, err } if err := only(args, "limit"); err != nil { return nil, err } limit, err := number(args, "limit", 0) if err != nil { return nil, err } ctx := context.Background() store, err := b.Store(ctx) if err != nil { return nil, err } groups, err := store.Groups(ctx) if err != nil { return nil, err } groups = SortGroups(groups) if limit > 0 && len(groups) > limit { groups = groups[:limit] } out := make([]map[string]any, len(groups)) for i, g := range groups { out[i] = map[string]any{"label": g.Label, "count": g.Count} } return map[string]any{"groups": out}, nil } // 1428/AC5: correct refuses an unknown mark, before the store is ever written to. func validMark(s string) (Mark, error) { m := Mark(s) if m != MarkMatters && m != MarkScamWarning && m != MarkNeither { return "", fmt.Errorf("%s needs mark to be matters, scam warning or neither", CorrectTool) } return m, nil } func (b *Bundle) correctTool(args map[string]any) (any, error) { if _, err := b.checkCaller(args); err != nil { return nil, err } if err := only(args, "id", "mark", "group", "why", "due-date"); err != nil { return nil, err } id, err := number(args, "id", 0) if err != nil { return nil, err } if id <= 0 { return nil, fmt.Errorf("%s needs id", CorrectTool) } ctx := context.Background() store, err := b.Store(ctx) if err != nil { return nil, err } entry, ok, err := store.Entry(ctx, int64(id)) if err != nil { return nil, err } if !ok { // 1428/AC5: an unknown mail id is refused. return nil, fmt.Errorf("%s: no mail entry %d is held; entries answers the ids held", CorrectTool, id) } var c Correction if markStr, has := args["mark"]; has { s, _ := markStr.(string) m, err := validMark(s) if err != nil { return nil, err } c.Mark = m } labels, err := groupLabels(ctx, store) if err != nil { return nil, err } oldGroupLabel := labels[entry.Group] if groupStr, has := args["group"]; has { c.GroupLabel, _ = groupStr.(string) if c.GroupLabel != "" { g, err := store.FindOrCreateGroup(ctx, c.GroupLabel) if err != nil { return nil, err } c.GroupID = g.ID } } if whyStr, has := args["why"]; has { c.Why, _ = whyStr.(string) c.WhySet = true } if dueStr, has := args["due-date"]; has { s, _ := dueStr.(string) if s != "" { d, err := time.Parse("2006-01-02", s) if err != nil { return nil, fmt.Errorf("%s needs due-date to be YYYY-MM-DD, or empty to clear it", CorrectTool) } c.DueDate = d } c.DueDateSet = true } updated, change := ApplyCorrection(entry, c, oldGroupLabel) if change == "" { return nil, fmt.Errorf("%s needs a mark, mail group, why or due-date that actually changes the entry", CorrectTool) } if err := store.UpdateEntry(ctx, updated); err != nil { return nil, err } // Kept at once, no operator Approve (ADR 0459 §8): the mail's own headers, never its body, the change made // and when. 1428/AC1. lesson := Lesson{ Account: entry.Account, Folder: entry.Folder, SenderName: entry.SenderName, SenderAddress: entry.SenderAddress, Subject: entry.Subject, Group: updated.Group, Change: change, Made: time.Now(), } if _, err := store.AddLesson(ctx, lesson); err != nil { return nil, err } newLabels, err := groupLabels(ctx, store) if err != nil { return nil, err } return entryOut(updated, newLabels), nil } func groupLabels(ctx context.Context, store Store) (map[int64]string, error) { groups, err := store.Groups(ctx) if err != nil { return nil, err } out := map[int64]string{} for _, g := range groups { out[g.ID] = g.Label } return out, nil } func (b *Bundle) lessonsTool(args map[string]any) (any, error) { if _, err := b.checkCaller(args); err != nil { return nil, err } if err := only(args, "limit", "include-undone"); err != nil { return nil, err } limit, err := number(args, "limit", MaxLessonsInBatch) if err != nil { return nil, err } if limit <= 0 || limit > MaxLessonsInBatch { limit = MaxLessonsInBatch } includeUndone, _ := args["include-undone"].(bool) ctx := context.Background() store, err := b.Store(ctx) if err != nil { return nil, err } lessons, err := store.Lessons(ctx) if err != nil { return nil, err } var kept []Lesson for _, l := range lessons { if l.Undone && !includeUndone { continue } kept = append(kept, l) } sort.SliceStable(kept, func(i, j int) bool { return kept[i].Made.After(kept[j].Made) }) // 1428/AC2: newest first if len(kept) > limit { kept = kept[:limit] } out := make([]map[string]any, len(kept)) for i, l := range kept { out[i] = map[string]any{ "id": l.ID, "account": l.Account, "folder": l.Folder, "sender-name": l.SenderName, "sender-address": l.SenderAddress, "subject": l.Subject, "change": l.Change, "made": l.Made.UTC().Format(time.RFC3339), "undone": l.Undone, } } return map[string]any{"lessons": out}, nil } func (b *Bundle) undoTool(args map[string]any) (any, error) { if _, err := b.checkCaller(args); err != nil { return nil, err } if err := only(args, "id"); err != nil { return nil, err } id, err := number(args, "id", 0) if err != nil { return nil, err } if id <= 0 { return nil, fmt.Errorf("%s needs id", UndoTool) } ctx := context.Background() store, err := b.Store(ctx) if err != nil { return nil, err } // 1428/AC3: undo removes the lesson from what later batches are given (selectLessons, question.go, skips // an Undone lesson) and leaves the corrected entry exactly as it is — nothing here touches mail_entry. if err := store.UndoLesson(ctx, int64(id)); err != nil { if err == ErrNotFound { return nil, fmt.Errorf("%s: no reading lesson %d is held; lessons answers the ids held", UndoTool, id) } return nil, err } return map[string]any{"undone": id}, nil } func (b *Bundle) statusTool(args map[string]any) (any, error) { if _, err := b.checkCaller(args); err != nil { return nil, err } if err := only(args); err != nil { return nil, err } ctx := context.Background() store, err := b.Store(ctx) if err != nil { return nil, err } h, err := store.ReaderHealth(ctx) if err != nil { return nil, err } entries, err := store.Entries(ctx) if err != nil { return nil, err } lessons, err := store.Lessons(ctx) if err != nil { return nil, err } weeks := WeeklyMeasure(entries, lessons, time.Now()) weeksOut := make([]map[string]any, len(weeks)) for i, w := range weeks { week := map[string]any{"week-start": w.WeekStart.Format("2006-01-02"), "mails-read": w.MailsRead, "lessons": w.Lessons} if w.Rate != nil { week["lessons-per-100"] = *w.Rate } else { week["lessons-per-100"] = nil week["why"] = "no mail was read this week" } weeksOut[i] = week } out := map[string]any{"healthy": h.Healthy, "weeks": weeksOut} if !h.Healthy { out["cause"] = string(h.Cause) out["why"] = h.Why out["since"] = h.Since.UTC().Format(time.RFC3339) } return out, nil } func (b *Bundle) healthTool(args map[string]any) (any, error) { if err := only(args); err != nil { return nil, err } ctx := context.Background() store, err := b.Store(ctx) if err != nil { return nil, err } h, err := store.ReaderHealth(ctx) if err != nil { return nil, err } return h.Answer(nil), nil }