535 lines
17 KiB
Go
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
|
|
}
|