Declare the seat mail-reading and the module mesh-mail-reader's skeleton (issue 1426)

This commit is contained in:
jochen
2026-10-20 23:50:02 +02:00
parent a67f01963f
commit d17b7e832d
13 changed files with 750 additions and 3 deletions
+48
View File
@@ -0,0 +1,48 @@
# mesh-mail-reader — the default holder of mail-reading
Design 59, milestone 2 (mail reading); built from mesh-mail's own source, like mesh-mail-web and
mesh-mail-notifier (novox/hq ADR 0459). One instance, held once for the mesh, on the machine mesh-mail runs
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
- **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 10 at a time 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).
- **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).
- **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.
## 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.
## Tools
| tool | does |
|---|---|
| `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.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 |
## Where it runs
**Its own account** (`runs-as`), because it hears mesh-mail's restricted stream `MAIL` (novox/hq ADR 0376
§2). Held once for the mesh, on the machine mesh-mail runs on; it is granted reading only, after issue 1227's
split of mesh-mail's grants is delivered (ADR 0472: that split is won't-fix — mesh-mail's `grants` stays one
list per account, and mesh-mail-reader is put on it after delivery, never asking to send).
@@ -0,0 +1,84 @@
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),
// exactly <machine>.<module>, or exactly person.<name> — 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
// granted, a wildcard in the list is no entry, and an unproven caller never matches. The setting's default is
// ["mesh-mail-web"].
import (
"encoding/json"
"errors"
"fmt"
"os"
"strings"
)
// Unproven is the caller the runtime proved nobody to be (stdio.Unproven; copied so this file reads alone).
const Unproven = "unproven"
// Callers is the setting's shape once read: a flat list of entries.
type Callers []string
// callersFile is the shape of the setting as the mesh writes it.
type callersFile struct {
Callers Callers `json:"callers"`
}
// ReadCallers reads the callers setting. A setting that cannot be read allows nobody, and says why: a caller
// refused for a reason it can read is better than one let through because the setting was unreadable.
func ReadCallers(path string) (Callers, error) {
if path == "" {
return nil, errors.New("the module was started without its callers setting (MESH_MAIL_READER_CALLERS)")
}
raw, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("the callers setting cannot be read: %w", err)
}
return ParseCallers(raw)
}
// ParseCallers reads the callers setting's content.
func ParseCallers(raw []byte) (Callers, error) {
var f callersFile
dec := json.NewDecoder(strings.NewReader(string(raw)))
dec.DisallowUnknownFields()
if err := dec.Decode(&f); err != nil {
return nil, fmt.Errorf("the callers setting is not the module's: %v", err)
}
return f.Callers, nil
}
// Allows says whether the caller may call a mail-reading verb: a bare module name (on any machine, as long as
// the caller is not person.<name>), exactly <machine>.<module>, or exactly person.<name>. Nobody is granted by
// default, an unproven caller never, and a wildcard entry is no entry (mesh-mail's own Grants.Allows mirrored).
func (c Callers) Allows(caller string) bool {
if caller == "" || caller == Unproven {
return false
}
for _, entry := range c {
if entry == "" || strings.ContainsAny(entry, "*>") {
continue
}
if entry == caller {
return true
}
if !strings.Contains(entry, ".") {
if _, module, ok := strings.Cut(caller, "."); ok && module == entry && !strings.HasPrefix(caller, "person.") {
return true
}
}
}
return false
}
// refuseCaller says why the caller is refused a mail-reading verb: the setting unread, or simply not named in it.
func refuseCaller(readErr error, caller string) error {
if readErr != nil {
return fmt.Errorf("refused: the setting callers cannot be read, so it allows nobody: %w", readErr)
}
return fmt.Errorf("refused: %s is not named in the setting callers; the operator grants it there", caller)
}
@@ -0,0 +1,52 @@
package main
// 1426/AC4: the trusted setting callers, per shape, and an unproven caller.
import "testing"
func TestCallersAllowsEachShape(t *testing.T) {
c := Callers{"mesh-mail-web", "novox.mesh-knowledge", "person.jochen"}
cases := []struct {
caller string
want bool
}{
{"mesh-mail-web", true}, // a bare module name
{"shanks.mesh-mail-web", true}, // ... on any machine
{"novox.mesh-knowledge", true}, // exactly <machine>.<module>
{"shanks.mesh-knowledge", false}, // ... but not on another machine
{"person.jochen", true}, // exactly person.<name>
{"person.someoneelse", false}, // ... but not another person
{"mesh-mail-notifier", false}, // not named at all
{"", false},
{Unproven, false}, // 1426/AC4: unproven never
}
for _, c2 := range cases {
if got := c.Allows(c2.caller); got != c2.want {
t.Errorf("Allows(%q) = %v, want %v", c2.caller, got, c2.want)
}
}
}
// 1426/AC4: a wildcard entry is no entry (mirrors mesh-mail's own Grants.Allows).
func TestCallersWildcardEntryIsNoEntry(t *testing.T) {
c := Callers{"*", "mesh-mail.>"}
if c.Allows("anything") {
t.Fatal("a wildcard entry granted a caller")
}
}
func TestParseCallersDefault(t *testing.T) {
c, err := ParseCallers([]byte(`{"callers": ["mesh-mail-web"]}`))
if err != nil {
t.Fatal(err)
}
if len(c) != 1 || c[0] != "mesh-mail-web" {
t.Fatalf("callers = %v", c)
}
}
func TestReadCallersEmptyPath(t *testing.T) {
if _, err := ReadCallers(""); err == nil {
t.Fatal("no error reading callers with no path")
}
}
@@ -0,0 +1,37 @@
// 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 — not yet built), judges a batch of them at a time (1427, 1520 — not yet built), and serves the six
// mail-reading verbs (entries, groups, correct, lessons, undo, status) and its own health tool,
// mail_reader_health (1519 — not yet built). 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 six mail-reading verbs plus
// mail_reader_health, served over stdio (tools.go)
//
// stdout is the bundle's MCP channel; what this module says, it says on stderr.
package main
import (
"fmt"
"os"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// Module is this module's name.
const Module = "mesh-mail-reader"
func logf(format string, a ...any) { fmt.Fprintf(os.Stderr, format+"\n", a...) }
func envFromOS() Env {
return Env{Callers: os.Getenv("MESH_MAIL_READER_CALLERS")}
}
func main() {
b := &Bundle{Env: envFromOS()}
if err := stdio.ServeOn("", b.Tools(), os.Stdin, os.Stdout); err != nil {
logf("[mesh-mail-reader] %v", err)
os.Exit(1)
}
}
@@ -0,0 +1,82 @@
package main
// The manifest says what the code does (novox/hq ADR 0459 §1, §2; issue 1426): the seat it claims and the
// verbs it serves, and that it invokes mail-store's read-only verbs alone — never one that changes the
// mailbox (ADR 0472: mesh-mail-reader is granted reading only, and its manifest invokes no send verb).
import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"testing"
)
type seat struct {
Name string `json:"name"`
Scope string `json:"scope"`
Serves []string `json:"serves"`
}
type manifest struct {
Module string `json:"module"`
RunsAs string `json:"runs-as"`
Claims []seat `json:"claims"`
Invokes []string `json:"invokes"`
Tools []string `json:"tools"`
}
func readManifest(t *testing.T) manifest {
t.Helper()
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
if err != nil {
t.Fatal(err)
}
var m manifest
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
return m
}
// 1426/AC2: runs-as mesh-mail-reader, claims mail-reading (scope mesh) with the six verbs, and invokes
// seat:mail-store.read and seat:mail-store.accounts but no send, send-status or any verb that changes the
// mailbox.
func TestTheManifestClaimsMailReadingAndInvokesReadOnly(t *testing.T) {
m := readManifest(t)
if m.Module != Module {
t.Fatalf("module %q, want %q", m.Module, Module)
}
if m.RunsAs != Module {
t.Fatalf("runs-as %q, want %q", m.RunsAs, Module)
}
wantClaims := []seat{
{Name: "mail-reading", Scope: "mesh", Serves: []string{"entries", "groups", "correct", "lessons", "undo", "status"}},
}
if !reflect.DeepEqual(m.Claims, wantClaims) {
t.Fatalf("claims %+v, want %+v", m.Claims, wantClaims)
}
wantInvokes := []string{"seat:mail-store.read", "seat:mail-store.accounts"}
if !reflect.DeepEqual(m.Invokes, wantInvokes) {
t.Fatalf("invokes %v, want exactly %v (no send, send-status, or any verb that changes the mailbox)", m.Invokes, wantInvokes)
}
for _, forbidden := range []string{"seat:mail-store.send", "seat:mail-store.send-status"} {
for _, inv := range m.Invokes {
if inv == forbidden {
t.Fatalf("invokes names %s, which changes the mailbox (ADR 0472)", forbidden)
}
}
}
}
// 1426/AC3 (the manifest's own half): the manifest's tools match the bundle's Tools(), in the same order.
func TestTheManifestListsTheSameToolsAsTheBundle(t *testing.T) {
m := readManifest(t)
var names []string
for _, tool := range (&Bundle{}).Tools() {
names = append(names, tool.Name)
}
if !reflect.DeepEqual(m.Tools, names) {
t.Fatalf("manifest tools %v, the code serves %v", m.Tools, names)
}
}
@@ -0,0 +1,239 @@
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); mail_reader_health's own answer is 1519.
// Until each lands, its tool answers that it is not built yet, after the caller check has already passed or
// refused, so a caller never learns whether a verb is built before learning whether it is granted.
import (
"errors"
"fmt"
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"
)
// HealthTool is the module's own health tool (1519): the manifest's runtime-health, and the seventh of its
// seven tools. Defined here, not in a health.go of its own yet, so Tools() below compiles before 1519 lands.
const HealthTool = "mail_reader_health"
// Bundle is what the tools reach: the trusted setting `callers`, and the runtime's own caller. Caller is nil
// outside a test (CallerOf is read from the runtime). The index (store.go) is 1517; the health tool reaches it
// from 1519 on.
type Bundle struct {
Env Env
Caller func(args map[string]any) string
// 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
}
// notBuiltYet is what a mail-reading verb answers once its caller is granted, until its own issue lands
// (1426/AC3's note: this is not acceptable at the end of the milestone, by which every verb answers; it is
// acceptable now, while 1521 and 1428 are still to come).
func notBuiltYet(tool, issue string) error {
return fmt.Errorf("%s is not built yet (issue %s)", tool, issue)
}
// 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} }
// 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. Answered only to a caller " +
"the setting callers names. Read-only.",
Input: schema(nil, map[string]any{
"limit": integer("how many to answer, newest first; a default bounds it when left out"),
}),
Run: b.entriesTool},
{Name: GroupsTool,
Description: "Every mail group a batch has made (novox/hq ADR 0459 §4, §5), 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, why or due date (novox/hq ADR 0459 §8): kept at once as " +
"a reading lesson (its headers, the change, when), no operator Approve needed. 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"),
"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): at most 40, the sender or group's own " +
"first, then newest; 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; a default bounds it, at most 40, when left out"),
}),
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): lessons kept, lessons " +
"undone, and lessons per 100 mails read this week. 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},
}
}
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"); err != nil {
return nil, err
}
return nil, notBuiltYet(EntriesTool, "1521")
}
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
}
return nil, notBuiltYet(GroupsTool, "1521")
}
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", "why", "due-date"); err != nil {
return nil, err
}
return nil, notBuiltYet(CorrectTool, "1428")
}
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"); err != nil {
return nil, err
}
return nil, notBuiltYet(LessonsTool, "1428")
}
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
}
return nil, notBuiltYet(UndoTool, "1428")
}
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
}
return nil, notBuiltYet(StatusTool, "1521")
}
func (b *Bundle) healthTool(args map[string]any) (any, error) {
if err := only(args); err != nil {
return nil, err
}
return nil, notBuiltYet(HealthTool, "1519")
}
@@ -0,0 +1,100 @@
package main
import (
"strings"
"testing"
)
// 1426/AC3: the bundle's Tools() lists the six mail-reading verbs and mail_reader_health, each with its own
// argument schema.
func TestToolsListsTheSixVerbsAndHealth(t *testing.T) {
b := &Bundle{}
var names []string
for _, tool := range b.Tools() {
names = append(names, tool.Name)
if tool.Input == nil {
t.Errorf("%s has no argument schema", tool.Name)
}
if tool.Description == "" {
t.Errorf("%s has no description", tool.Name)
}
}
want := []string{
"mail-reading.entries", "mail-reading.groups", "mail-reading.correct", "mail-reading.lessons",
"mail-reading.undo", "mail-reading.status", "mail_reader_health",
}
if len(names) != len(want) {
t.Fatalf("Tools() = %v, want %v", names, want)
}
for i, n := range want {
if names[i] != n {
t.Fatalf("Tools()[%d] = %q, want %q", i, names[i], n)
}
}
}
// 1426/AC4: a mail-reading verb answers a caller the setting callers names, and refuses any other, naming why.
func TestAMailReadingVerbChecksItsCaller(t *testing.T) {
granted := func(map[string]any) string { return "mesh-mail-web" }
refused := func(map[string]any) string { return "person.someoneelse" }
unproven := func(map[string]any) string { return Unproven }
for _, tool := range []struct {
name string
run func(*Bundle) (any, error)
}{
{EntriesTool, func(b *Bundle) (any, error) { return b.entriesTool(map[string]any{}) }},
{GroupsTool, func(b *Bundle) (any, error) { return b.groupsTool(map[string]any{}) }},
{CorrectTool, func(b *Bundle) (any, error) { return b.correctTool(map[string]any{"id": float64(1)}) }},
{LessonsTool, func(b *Bundle) (any, error) { return b.lessonsTool(map[string]any{}) }},
{UndoTool, func(b *Bundle) (any, error) { return b.undoTool(map[string]any{"id": float64(1)}) }},
{StatusTool, func(b *Bundle) (any, error) { return b.statusTool(map[string]any{}) }},
} {
callers := func() (Callers, error) { return Callers{"mesh-mail-web"}, nil }
b := &Bundle{Caller: granted, callersOverride: callers}
_, err := tool.run(b)
if err == nil || !strings.Contains(err.Error(), "not built yet") {
t.Errorf("%s granted: %v, want it to pass the caller check and answer not built yet", tool.name, err)
}
b = &Bundle{Caller: refused, callersOverride: callers}
_, err = tool.run(b)
if err == nil || !strings.Contains(err.Error(), "refused") || !strings.Contains(err.Error(), "person.someoneelse") {
t.Errorf("%s refused caller: %v, want a refusal naming person.someoneelse", tool.name, err)
}
b = &Bundle{Caller: unproven, callersOverride: callers}
_, err = tool.run(b)
if err == nil || !strings.Contains(err.Error(), "refused") {
t.Errorf("%s unproven caller: %v, want a refusal", tool.name, err)
}
}
}
// 1426/AC4: a setting that cannot be read allows nobody, and says why.
func TestAMailReadingVerbRefusesWhenCallersCannotBeRead(t *testing.T) {
b := &Bundle{
Caller: func(map[string]any) string { return "mesh-mail-web" },
callersOverride: func() (Callers, error) { return nil, errFake },
}
_, err := b.entriesTool(map[string]any{})
if err == nil || !strings.Contains(err.Error(), "cannot be read") {
t.Fatalf("entriesTool with an unreadable setting: %v", err)
}
}
// mail_reader_health carries no caller check (1426: "mail_reader_health is asked by the node-engine itself").
func TestHealthToolHasNoCallerCheck(t *testing.T) {
b := &Bundle{callersOverride: func() (Callers, error) { return nil, errFake }}
_, err := b.healthTool(map[string]any{})
if err == nil || strings.Contains(err.Error(), "refused") {
t.Fatalf("mail_reader_health: %v, want it to skip the caller check entirely", err)
}
}
type fakeErr struct{}
func (fakeErr) Error() string { return "the setting cannot be read (fake)" }
var errFake = fakeErr{}
@@ -0,0 +1,3 @@
{
"callers": ["mesh-mail-web"]
}
+5
View File
@@ -0,0 +1,5 @@
module mesh-mail-reader
go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019062050-34392e74916c
+2
View File
@@ -0,0 +1,2 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019062050-34392e74916c h1:/f6f2ugss172hDJSL3EFZqb9PdfO5DKzYVfPiX2LdAM=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019062050-34392e74916c/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
+88
View File
@@ -0,0 +1,88 @@
{
"module": "mesh-mail-reader",
"version": "1",
"runs-as": "mesh-mail-reader",
"claims": [
{
"name": "mail-reading",
"scope": "mesh",
"serves": ["entries", "groups", "correct", "lessons", "undo", "status"]
}
],
"invokes": [
"seat:mail-store.read",
"seat:mail-store.accounts"
],
"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), <machine>.<module>, or person.<name> (novox/hq ADR 0459 §2). mesh-mail-web alone by default.",
"trusted": true,
"default": ["mesh-mail-web"]
}
},
"tools": [
"mail-reading.entries",
"mail-reading.groups",
"mail-reading.correct",
"mail-reading.lessons",
"mail-reading.undo",
"mail-reading.status",
"mail_reader_health"
],
"own-secrets": {
"broker": "${dir:state}/broker"
},
"secrets-owner": "mesh-mail-reader",
"resources": [
{
"id": "account",
"type": "user",
"name": "mesh-mail-reader",
"shell": "/usr/bin/nologin",
"home": "/var/lib/mesh-mail-reader"
},
{
"id": "state",
"type": "directory",
"mode": "0700",
"owner": "mesh-mail-reader",
"place": ".",
"unknown": "program"
},
{
"id": "callers",
"trusted": true,
"type": "file",
"path": "${dir:state}/callers.json",
"mode": "0600",
"owner": "mesh-mail-reader",
"merge": "json",
"template": "files/callers.json",
"takes-config": ["callers"]
}
],
"build": {
"artifacts": [
{
"name": "tools",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/mesh-mail-reader",
"binary": "mesh-mail-reader",
"loads": [
"mesh-mail-reader"
],
"env": {
"MESH_MAIL_READER_CALLERS": "${dir:state}/callers.json"
}
},
{
"name": "files",
"kind": "archive",
"from": "files"
}
]
}
}
@@ -140,13 +140,15 @@ func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) {
if len(m.Consumes)+len(m.Invokes) != 0 {
t.Fatalf("consumes %v, invokes %v", m.Consumes, m.Invokes)
}
// It declares exactly the three mail seats (ADR 0378 §1 as amended by ADR 0379 §4) — mail-store and
// mail-webclient each held once for the mesh, mail-notifier held once per machine and needing node-notifier
// there — and claims only mail-store, serving the same four verbs as its own tools (§2, §3).
// It declares the three mail seats (ADR 0378 §1 as amended by ADR 0379 §4) — mail-store and mail-webclient
// each held once for the mesh, mail-notifier held once per machine and needing node-notifier there — plus
// mail-reading (novox/hq ADR 0459 §1, issue 1426), held once for the mesh and claimed by mesh-mail-reader,
// never by mesh-mail itself; and claims only mail-store, serving the same five verbs as its own tools (§2, §3).
wantSeats := []seat{
{Name: "mail-store", Scope: "mesh", Serves: []string{"accounts", "search", "read", "send", "send-status"}},
{Name: "mail-webclient", Scope: "mesh", Serves: []string{"open", "status"}},
{Name: "mail-notifier", Scope: "node", Serves: []string{"status"}, Needs: []string{"node-notifier"}},
{Name: "mail-reading", Scope: "mesh", Serves: []string{"entries", "groups", "correct", "lessons", "undo", "status"}},
}
if !reflect.DeepEqual(m.Seats, wantSeats) {
t.Fatalf("seats %+v, want %+v", m.Seats, wantSeats)
+5
View File
@@ -18,6 +18,11 @@
"scope": "node",
"needs": ["node-notifier"],
"serves": ["status"]
},
{
"name": "mail-reading",
"scope": "mesh",
"serves": ["entries", "groups", "correct", "lessons", "undo", "status"]
}
],
"claims": [