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

535 lines
17 KiB
Go

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.<verb>".
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
}