diff --git a/modules/mesh-mail-reader/README.md b/modules/mesh-mail-reader/README.md index 48350f75..88f4d8f9 100644 --- a/modules/mesh-mail-reader/README.md +++ b/modules/mesh-mail-reader/README.md @@ -5,28 +5,33 @@ mesh-mail-notifier (novox/hq ADR 0459). One instance, held once for the mesh, on on, under its own account (`runs-as`) — it hears mesh-mail's restricted stream `MAIL`, which the operator account never does. It reads mail; it moves, flags, deletes and sends nothing (ADR 0459 §11). -## What it does, and what is still to land +## What it does - **Claims `mail-reading`** (held once for the mesh), declared in mesh-mail's own manifest beside its three - mail seats, serving six verbs: `entries`, `groups`, `correct`, `lessons`, `undo`, `status` (ADR 0459 §2). - Each answers only a caller the trusted setting `callers` names (default `["mesh-mail-web"]`); any other - caller is refused, naming why. -- **Hears mesh-mail's added events** and judges a batch of up to 6 at a time (ADR 0516 §3) against a question put to - `claude_code_judge`, keeping an entry per mail — never its body — in its own database (issue 1518, 1427; - not yet built). + mail seats, serving twelve verbs (ADR 0459 §2, ADR 0517 §4). Each answers only a caller the trusted setting + `callers` names (default `["mesh-mail-web"]`); any other caller is refused, naming why. +- **Hears mesh-mail's added events** and judges a batch of up to 6 at a time (ADR 0516 §3) against a question + put to `claude_code_judge`, keeping one entry per message — never its body — in its own database: one + Gmail message in INBOX and All Mail is one entry, by its Message-ID, else its folder and UID (issue 1849). +- **Notes what each mail asks** (ADR 0516, issue 1857): at most 3 tasks (a line and a due date), 3 promises + (you promised, waiting on others, on its way; who and a date) and whether it is read later, each line the + model's own words of at most 120 characters; a line sharing 8 words in a row with the mail fails the batch. +- **Reads the last 14 days once more** after delivery (ADR 0516 §5): the inbox mail of each granted account, + in ordinary batches after any mail just heard; kept as done, and the journal says its start, end and count. +- **Keeps the operator's handling and an action log** (ADR 0517, issue 1858): an item (a task, a promise or + the mail itself) handled, undone until the day ends, or reminded until the next day (Europe/Brussels); one + line per act — what, mail id, item, mode, when, never mail text — kept 365 days, removed each night. - **Keeps a health of its own**, `mail_reader_health`: unhealthy once a judgement is refused, times out or - answers out of shape; healthy again once one succeeds (issue 1519; not yet built). + answers out of shape; healthy once one succeeds, saying when, or that no batch has run yet (issue 1580). - **Tells the operator** at most once a batch when a mail matters, and corrects itself at once, keeping a - reading lesson, when the operator says it judged wrong (issue 1428, 1429, 1522; not yet built). - -Until each of those issues lands, its own verb answers that it is not built yet, once the caller check above -has already passed or refused. + reading lesson, when the operator says it judged wrong (issue 1428, 1429, 1522). ## Its database -Its own `postgres-database` (issue 1517; not yet declared in the manifest until then): mail entries, mail -groups, reading lessons, the mails heard but not yet read, and its own health state. No column holds a mail -body or text taken from one. +Its own `postgres-database`: mail entries with their tasks and promises, mail groups, reading lessons, the +mails heard but not yet read, its health state, whether the 14-day re-reading was done, each item's handling +and the action log. No column holds a mail body or text taken from one. The schema is numbered migrations +(`migrations/`) and the consolidated `schema.sql`; a unit test proves the two agree. ## Tools @@ -34,11 +39,17 @@ body or text taken from one. |---|---| | `mail-reading.entries` | every mail entry: headers, mail group, summary, mark and why, due date. Read-only | | `mail-reading.groups` | every mail group a batch has made, largest first. Read-only | -| `mail-reading.correct` | correct one entry's mark, why or due date; kept at once as a reading lesson | +| `mail-reading.correct` | correct one entry's mark, mail group, why or due date, say a task is not one, remove a promise, set or clear read later; kept at once as a reading lesson, with one corrected line | | `mail-reading.lessons` | the reading lessons kept, at most 40. Read-only | | `mail-reading.undo` | undo one reading lesson | -| `mail-reading.status` | lessons kept, undone, and per 100 mails read this week. Read-only | -| `mail_reader_health` | how mesh-mail-reader is: unhealthy once a judgement is refused, times out or answers out of shape, naming which and since when; healthy once one succeeds. Read-only | +| `mail-reading.status` | healthy or not, when a batch last succeeded (or that none has run yet), lessons per 100 mails read each week. Read-only | +| `mail-reading.list` | the operator's list for a mode: today, this week, later, the promises by kind, good to know, read later, handled today, scam warnings, and when the operator last looked. Read-only | +| `mail-reading.handle` | mark an item handled (the mail itself handles all its items); one handled line | +| `mail-reading.unhandle` | undo a handling of today; one undone line | +| `mail-reading.remind` | hide an item until the next day, when it is under today; one reminded line | +| `mail-reading.record` | one line for an act that changes no entry: opened, mode, page opened | +| `mail-reading.actions` | the action log, newest first. Read-only | +| `mail_reader_health` | how mesh-mail-reader is: unhealthy once a judgement is refused, times out or answers out of shape, naming which and since when; healthy, saying when the last batch succeeded or that none has run yet. Read-only | ## Where it runs diff --git a/modules/mesh-mail-reader/cmd/mesh-mail-reader/actionlog_test.go b/modules/mesh-mail-reader/cmd/mesh-mail-reader/actionlog_test.go new file mode 100644 index 00000000..80aa305a --- /dev/null +++ b/modules/mesh-mail-reader/cmd/mesh-mail-reader/actionlog_test.go @@ -0,0 +1,143 @@ +package main + +// Issue 1858 (novox/hq ADR 0517 §4, §5): correct writes its corrected line beside its reading lesson, in one +// transaction; no code but the acts writes the action log; and every new verb answers only a caller the +// operator grants it. + +import ( + "context" + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// 1858/AC4: correct writes exactly one corrected line — its mail id, the item it corrected, the mode and when. +func TestCorrectWritesOneCorrectedLine(t *testing.T) { + store := newMemStore() + id := entryWithItems(t, store) + now := brusselsAt(2026, 10, 22, 9, 0) + b := listBundle(store, &now) + for i, tc := range []struct { + args map[string]any + item string + }{ + {map[string]any{"mark": "neither", "mode": "work"}, ItemMail}, + {map[string]any{"not-a-task": float64(2), "mode": "work"}, "task 2"}, + {map[string]any{"remove-promise": float64(1), "mode": "work"}, "promise 1"}, + } { + tc.args["id"] = float64(id) + if _, err := b.correctTool(tc.args); err != nil { + t.Fatal(err) + } + got := lines(t, store) + want := ActionLine{ID: got[0].ID, What: ActCorrected, Entry: id, Item: tc.item, Mode: "work", At: now} + if len(got) != i+1 || got[0] != want { + t.Fatalf("after correct %v: lines %+v, want %d, the newest %+v", tc.args, got, i+1, want) + } + } +} + +// 1858/AC4: a correction that fails in the store keeps no lesson, writes no line and leaves the entry. +func TestAFailedCorrectionWritesNothing(t *testing.T) { + ctx := context.Background() + store := newMemStore() + id := entryWithItems(t, store) + store.failAct = errStoreFake + if _, err := testBundle(store, "mesh-mail-web").correctTool(map[string]any{"id": float64(id), "mark": "neither"}); err == nil { + t.Fatal("correct with a failing store: no error") + } + e, _, _ := store.Entry(ctx, id) + lessons, _ := store.Lessons(ctx) + if e.Mark != MarkMatters || len(lessons) != 0 || len(lines(t, store)) != 0 { + t.Fatalf("a failed correction left mark %q, lessons %+v, lines %+v", e.Mark, lessons, lines(t, store)) + } +} + +// 1858/AC4: no other code writes the action log. The store's writes (Act, Record, CorrectEntry) are called by +// the verbs alone, each act by its own verb; the one SQL statement that inserts a line is insertLine's, +// called by the store's three writes alone. +func TestOnlyTheActsWriteTheActionLog(t *testing.T) { + allowed := map[string]map[string]bool{ + "Act": {"act": true}, + "Record": {"recordTool": true}, + "CorrectEntry": {"correctTool": true}, + "insertLine": {"Act": true, "Record": true, "CorrectEntry": true}, + } + files, err := filepath.Glob("*.go") + if err != nil { + t.Fatal(err) + } + fset := token.NewFileSet() + for _, name := range files { + if strings.HasSuffix(name, "_test.go") { + continue + } + src, err := os.ReadFile(name) + if err != nil { + t.Fatal(err) + } + if n := strings.Count(string(src), "insert into action_line"); n > 0 && (name != "pgstore.go" || n != 1) { + t.Fatalf("%s inserts into action_line %d time(s); only insertLine, in pgstore.go, may", name, n) + } + file, err := parser.ParseFile(fset, name, src, 0) + if err != nil { + t.Fatal(err) + } + for _, decl := range file.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok || fn.Body == nil { + continue + } + ast.Inspect(fn.Body, func(n ast.Node) bool { + call, ok := n.(*ast.CallExpr) + if !ok { + return true + } + var called string + switch f := call.Fun.(type) { + case *ast.SelectorExpr: + called = f.Sel.Name + case *ast.Ident: + called = f.Name + } + if callers, watched := allowed[called]; watched && !callers[fn.Name.Name] { + t.Errorf("%s: %s calls %s, which writes the action log; only %v may", name, fn.Name.Name, called, callers) + } + return true + }) + } + } +} + +// 1858/AC8: a caller the operator did not grant is refused each new verb, before anything else. +func TestTheNewVerbsCheckTheirCaller(t *testing.T) { + store := newMemStore() + id := entryWithItems(t, store) + now := time.Now() + for _, verb := range []struct { + name string + args map[string]any + }{ + {ListTool, map[string]any{}}, + {HandleTool, map[string]any{"id": float64(id)}}, + {UnhandleTool, map[string]any{"id": float64(id)}}, + {RemindTool, map[string]any{"id": float64(id)}}, + {RecordTool, map[string]any{"what": "page opened"}}, + {ActionsTool, map[string]any{}}, + } { + b := listBundle(store, &now) + b.Caller = func(map[string]any) string { return "person.someoneelse" } + _, err := callTool(t, b, verb.name, verb.args) + if err == nil || !strings.Contains(err.Error(), "refused") || !strings.Contains(err.Error(), "person.someoneelse") { + t.Errorf("%s by an ungranted caller: %v, want a refusal naming it", verb.name, err) + } + } + if got := lines(t, store); len(got) != 0 { + t.Fatalf("refused callers wrote %+v", got) + } +} diff --git a/modules/mesh-mail-reader/cmd/mesh-mail-reader/callers.go b/modules/mesh-mail-reader/cmd/mesh-mail-reader/callers.go index 6d666e91..5f197509 100644 --- a/modules/mesh-mail-reader/cmd/mesh-mail-reader/callers.go +++ b/modules/mesh-mail-reader/cmd/mesh-mail-reader/callers.go @@ -2,7 +2,7 @@ package main // Callers is the trusted setting `callers` (lead-m2@g14's decision at this milestone's prepare step, said to // the operator; novox/hq ADR 0459 §2: "each answers only a caller the operator grants it"): who may call one -// of mail-reading's six verbs. An entry is a bare module name (granted wherever it runs, on any machine), +// of mail-reading's verbs. An entry is a bare module name (granted wherever it runs, on any machine), // exactly ., or exactly person. — the same shapes mesh-mail's own trusted setting // `grants` matches (modules/mesh-mail/cmd/mesh-mail/config.go, Grants.Allows), copied here in miniature (one // flat list, not one per account) so mesh-mail-reader never imports mesh-mail. Nobody beyond the list is diff --git a/modules/mesh-mail-reader/cmd/mesh-mail-reader/main.go b/modules/mesh-mail-reader/cmd/mesh-mail-reader/main.go index 9f88737f..0ae8835b 100644 --- a/modules/mesh-mail-reader/cmd/mesh-mail-reader/main.go +++ b/modules/mesh-mail-reader/cmd/mesh-mail-reader/main.go @@ -1,12 +1,12 @@ // mesh-mail-reader: the default claimant of mail-reading (novox/hq ADR 0459 §1), built from mesh-mail's own // source, like mesh-mail-web and mesh-mail-notifier. One instance, held once for the mesh, on the machine // mesh-mail runs on. It hears mesh-mail's added events under its own runs-as account (ADR 0459 §3, issue -// 1518), judges a batch of them at a time (1427, 1520), and serves the six mail-reading verbs (entries, -// groups, correct, lessons, undo, status) and its own health tool, mail_reader_health (1519). It reads mail; -// it moves, flags, deletes and sends nothing (novox/hq ADR 0459 §11). +// 1518), judges a batch of them at a time (1427, 1520), and serves the twelve mail-reading verbs (entries, +// groups, correct, lessons, undo, status; list, handle, unhandle, remind, record, actions, issue 1858) and +// its own health tool, mail_reader_health (1519). It reads mail; it moves, flags, deletes and sends nothing (novox/hq ADR 0459 §11). // // mesh-mail-reader the bundle the node's runtime launches: the hearing loop (hear.go), the -// batch loop (batch.go) and the six mail-reading verbs plus mail_reader_health, +// batch loop (batch.go) and the mail-reading verbs plus mail_reader_health, // served over stdio (tools.go) // mesh-mail-reader prepare the preparation step: the index's schema (store.go, pgstore.go) brought to // this version's (issue 1517) @@ -88,7 +88,7 @@ func bundleAsk(key string, body map[string]any) (json.RawMessage, error) { retur // batchLoop runs the reader's batches (RunBatches, batch.go) against the module's own store, judging on this // machine (MESH_NODE, the lead's decision at this milestone's prepare step). Started before stdio.ServeOn, -// exactly as hearLoop is: a batch failure must never take the six mail-reading verbs or mail_reader_health +// exactly as hearLoop is: a batch failure must never take the mail-reading verbs or mail_reader_health // down with it (RunBatches logs and keeps going; see its own doc comment). func batchLoop(storeFor func(context.Context) (Store, error)) { store, err := storeFor(context.Background()) @@ -139,7 +139,7 @@ const hearFirstRetryWait = 250 * time.Millisecond // hearLoop subscribes to mesh-mail's added events and hears each one (hear.go), mirroring mesh-mail-notifier's // watchWith (issue 1518): a subscribe the runtime refuses is retried, starting at hearFirstRetryWait and -// doubling to at most a minute, logging each refusal, never fatal — a refusal here must never take the six +// doubling to at most a minute, logging each refusal, never fatal — a refusal here must never take the // mail-reading verbs or mail_reader_health down with it. Started before stdio.ServeOn, so it keeps running // whatever stdio.ServeOn does with the stream it is given. func hearLoop(storeFor func(context.Context) (Store, error), subscribe func(string, func(stdio.Envelope) error) error, sleep func(time.Duration)) { diff --git a/modules/mesh-mail-reader/cmd/mesh-mail-reader/tools.go b/modules/mesh-mail-reader/cmd/mesh-mail-reader/tools.go index 8ca9061b..73fa7392 100644 --- a/modules/mesh-mail-reader/cmd/mesh-mail-reader/tools.go +++ b/modules/mesh-mail-reader/cmd/mesh-mail-reader/tools.go @@ -1,13 +1,15 @@ 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 module's tools (novox/hq ADR 0459 §2, issue 1426; ADR 0517 §4, issue 1858): the mail-reading seat +// verbs — entries, groups, correct, lessons, undo, status, list, handle, unhandle, remind, record, actions — +// 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. +// 1521 (entries, groups, status) and 1428 (correct, lessons, undo): all six now answer for real. The operator's +// list and what is done with it — list, handle, unhandle, remind, record, actions — are 1858's (listverbs.go). // mail_reader_health's own answer is 1519's: it reaches the kept health state (store.go, health.go). import ( @@ -20,7 +22,7 @@ import ( 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 +// Seat tool names (novox/hq ADR 0459 §2): mesh-mail-reader claims the mail-reading seat and serves its // verbs through these tool names, exactly as mesh-mail's own mail-store seat tools are named "mail-store.". const ( EntriesTool = "mail-reading.entries" @@ -133,7 +135,7 @@ func only(args map[string]any, takes ...string) error { return nil } -// Tools are the module's tools (1426/AC3): the six mail-reading verbs and mail_reader_health, each with its +// Tools are the module's tools (1426/AC3): the mail-reading verbs and mail_reader_health, each with its // own argument schema. func (b *Bundle) Tools() []stdio.Tool { return []stdio.Tool{ @@ -160,7 +162,8 @@ func (b *Bundle) Tools() []stdio.Tool { Description: "Correct one mail entry's mark, mail group, why or due date, say one of its tasks is not a " + "task, remove one of its promises, or set or clear read later (novox/hq ADR 0459 §8, ADR 0516 §4): " + "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 " + + "Approve needed, and outranking that mail's own words in any later batch; one corrected line in the " + + "action log (ADR 0517 §5). 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"), @@ -171,6 +174,7 @@ func (b *Bundle) Tools() []stdio.Tool { "not-a-task": integer("the number of a task of this entry that is not a task"), "remove-promise": integer("the number of a promise of this entry to remove"), "read-later": boolean("whether the mail is read later"), + "mode": text("the mode the page is in, for the corrected line: work, personal or everything (when left out)"), }), Run: b.correctTool}, {Name: LessonsTool, @@ -377,7 +381,11 @@ 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", "not-a-task", "remove-promise", "read-later"); err != nil { + if err := only(args, "id", "mark", "group", "why", "due-date", "not-a-task", "remove-promise", "read-later", "mode"); err != nil { + return nil, err + } + mode, err := modeArg(args) + if err != nil { return nil, err } id, err := number(args, "id", 0) @@ -458,16 +466,26 @@ func (b *Bundle) correctTool(args map[string]any) (any, error) { if change == "" { return nil, fmt.Errorf("%s needs a mark, mail group, why, due-date, not-a-task, remove-promise or read-later 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. + // and when (1428/AC1); with the corrected line of the action log, in the same transaction as the entry's + // change and the lesson (ADR 0517 §5, 1858/AC4): the item corrected, or the mail itself. + now := b.now() 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(), + Subject: entry.Subject, Group: updated.Group, Change: change, Made: now, } - if _, err := store.AddLesson(ctx, lesson); err != nil { + item := ItemMail + switch { + case c.NotATask != 0 && c.RemovePromise == 0: + item = fmt.Sprintf("task %d", c.NotATask) + case c.RemovePromise != 0 && c.NotATask == 0: + item = fmt.Sprintf("promise %d", c.RemovePromise) + } + line := ActionLine{What: ActCorrected, Entry: entry.ID, Item: item, Mode: mode, At: now} + if _, err := store.CorrectEntry(ctx, updated, lesson, line); err != nil { + if err == ErrNotFound { + return nil, fmt.Errorf("%s: no mail entry %d is held; entries answers the ids held", CorrectTool, id) + } return nil, err } newLabels, err := groupLabels(ctx, store) diff --git a/modules/mesh-mail-reader/cmd/mesh-mail-reader/tools_test.go b/modules/mesh-mail-reader/cmd/mesh-mail-reader/tools_test.go index c05c5401..eb9e29cf 100644 --- a/modules/mesh-mail-reader/cmd/mesh-mail-reader/tools_test.go +++ b/modules/mesh-mail-reader/cmd/mesh-mail-reader/tools_test.go @@ -21,7 +21,10 @@ func TestToolsListsTheSixVerbsAndHealth(t *testing.T) { } } want := []string{ - "mail-reading.entries", "mail-reading.groups", "mail-reading.correct", "mail-reading.lessons", "mail-reading.undo", "mail-reading.status", "mail-reading.list", "mail-reading.handle", "mail-reading.unhandle", "mail-reading.remind", "mail-reading.record", "mail-reading.actions", "mail_reader_health", + "mail-reading.entries", "mail-reading.groups", "mail-reading.correct", "mail-reading.lessons", + "mail-reading.undo", "mail-reading.status", "mail-reading.list", "mail-reading.handle", + "mail-reading.unhandle", "mail-reading.remind", "mail-reading.record", "mail-reading.actions", + "mail_reader_health", } if len(names) != len(want) { t.Fatalf("Tools() = %v, want %v", names, want) diff --git a/modules/mesh-mail-reader/module.json b/modules/mesh-mail-reader/module.json index bcc3207a..e17dbda3 100644 --- a/modules/mesh-mail-reader/module.json +++ b/modules/mesh-mail-reader/module.json @@ -36,7 +36,7 @@ "config": { "callers": { "type": "list", - "description": "Who may call one of mail-reading's six verbs (entries, groups, correct, lessons, undo, status): a module's name (on every machine), ., or person. (novox/hq ADR 0459 §2). mesh-mail-web alone by default.", + "description": "Who may call one of mail-reading's verbs (entries, groups, correct, lessons, undo, status, list, handle, unhandle, remind, record, actions): a module's name (on every machine), ., or person. (novox/hq ADR 0459 §2). mesh-mail-web alone by default.", "trusted": true, "default": ["mesh-mail-web"] }