From 6c10288f0b182a0095094a8816bfd4d25c565702 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 15 Oct 2026 17:16:53 +0200 Subject: [PATCH] mesh-mail declares the mail seats and claims mail-store (issue 800 part A) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Declares mail-store and mail-webclient (held once for the mesh) and mail-notifier (held once per machine, needing node-notifier) per ADR 0378 as amended by ADR 0379. Claims mail-store and serves its four verbs through the same handlers as the existing mail_* tools. Moves the event shape (EventKey, Changes) into its own file, mailevent.go, so mesh-mail-notifier (issue 800 part B) can carry a byte-identical copy. Adds checks/mailstore, refusing any manifest but mesh-mail's own that claims mail-store (ADR 0378 §3), wired into merge-check.sh. --- checks/mailstore/go.mod | 3 + checks/mailstore/main.go | 81 +++++++++++++++++++ checks/mailstore/main_test.go | 43 ++++++++++ merge-check.sh | 3 + modules/mesh-mail/README.md | 16 ++++ modules/mesh-mail/cmd/mesh-mail/imap.go | 8 +- modules/mesh-mail/cmd/mesh-mail/mailevent.go | 13 +++ .../mesh-mail/cmd/mesh-mail/manifest_test.go | 49 ++++++++--- modules/mesh-mail/cmd/mesh-mail/tools.go | 42 ++++++++++ modules/mesh-mail/module.json | 31 ++++++- 10 files changed, 272 insertions(+), 17 deletions(-) create mode 100644 checks/mailstore/go.mod create mode 100644 checks/mailstore/main.go create mode 100644 checks/mailstore/main_test.go create mode 100644 modules/mesh-mail/cmd/mesh-mail/mailevent.go diff --git a/checks/mailstore/go.mod b/checks/mailstore/go.mod new file mode 100644 index 00000000..0d161eef --- /dev/null +++ b/checks/mailstore/go.mod @@ -0,0 +1,3 @@ +module git.novox.be/novox/mesh-catalog/checks/mailstore + +go 1.22 diff --git a/checks/mailstore/main.go b/checks/mailstore/main.go new file mode 100644 index 00000000..589d49dd --- /dev/null +++ b/checks/mailstore/main.go @@ -0,0 +1,81 @@ +// Command mailstore refuses any manifest but modules/mesh-mail claiming the mail-store seat (novox/hq ADR 0378 +// §3, issue 800 part A): the operator's mail is read, searched and sent through this one seat, and a second +// claimant would be a second path to the same mail that ADR 0378 never accounted for. +// +// go run . +// +// Exits 1 on a finding. +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" +) + +// MailStoreSeat is the seat only mesh-mail may claim. +const MailStoreSeat = "mail-store" + +// MailStoreModule is the one module allowed to claim it. +const MailStoreModule = "mesh-mail" + +// Finding is one manifest refused: a module other than mesh-mail claiming mail-store. +type Finding struct { + Module string +} + +// Check reads every manifest under root/modules and answers which ones it refuses. +func Check(root string) ([]Finding, error) { + manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json")) + if err != nil { + return nil, err + } + sort.Strings(manifests) + var out []Finding + for _, path := range manifests { + raw, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var m struct { + Module string `json:"module"` + Claims []struct { + Name string `json:"name"` + } `json:"claims"` + } + if err := json.Unmarshal(raw, &m); err != nil { + return nil, fmt.Errorf("%s: %w", path, err) + } + if m.Module == MailStoreModule { + continue + } + for _, c := range m.Claims { + if c.Name == MailStoreSeat { + out = append(out, Finding{Module: m.Module}) + break + } + } + } + return out, nil +} + +func main() { + root := "." + if len(os.Args) > 1 { + root = os.Args[1] + } + found, err := Check(root) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + for _, f := range found { + fmt.Printf("%s claims the mail-store seat: only %s may (novox/hq ADR 0378 §3)\n", f.Module, MailStoreModule) + } + if len(found) > 0 { + os.Exit(1) + } + fmt.Println("mail-store: claimed only by mesh-mail") +} diff --git a/checks/mailstore/main_test.go b/checks/mailstore/main_test.go new file mode 100644 index 00000000..e33470da --- /dev/null +++ b/checks/mailstore/main_test.go @@ -0,0 +1,43 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func manifest(t *testing.T, root, module, claims string) { + t.Helper() + dir := filepath.Join(root, "modules", module) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + body := `{"module":"` + module + `","claims":[` + claims + `]}` + if err := os.WriteFile(filepath.Join(dir, "module.json"), []byte(body), 0o644); err != nil { + t.Fatal(err) + } +} + +func TestAnotherModuleClaimingMailStoreIsRefused(t *testing.T) { + root := t.TempDir() + manifest(t, root, "mesh-mail", `{"name":"mail-store","scope":"mesh"}`) + manifest(t, root, "betterbird", `{"name":"mail-store","scope":"mesh"}`) + manifest(t, root, "dunst", `{"name":"node-notifier","scope":"node"}`) + found, err := Check(root) + if err != nil { + t.Fatal(err) + } + if len(found) != 1 || found[0].Module != "betterbird" { + t.Fatalf("refused %+v", found) + } +} + +func TestTheCatalogueHasNone(t *testing.T) { + found, err := Check("../..") + if err != nil { + t.Fatal(err) + } + if len(found) != 0 { + t.Fatalf("%+v", found) + } +} diff --git a/merge-check.sh b/merge-check.sh index 59f73b1e..549c22b0 100755 --- a/merge-check.sh +++ b/merge-check.sh @@ -33,11 +33,14 @@ # 6. the secrets (novox/hq issue 342): no mergeable file fills a key with a vault secret that a setting could # replace — the key is `protected`, or left out of the file's `takes-settings`. checks/secrets/known names # the nested ones the controller cannot protect alone yet, and only shrinks. +# 7. the mail-store seat (novox/hq ADR 0378 §3, issue 800): no manifest but modules/mesh-mail claims it. set -eu (cd checks/words && go test -count=1 . && go run . ../..) # The agent's home (novox/hq ADR 0266): no archive, and no file written into, under it. (cd checks/agenthome && go test -count=1 . && go run . ../..) +# The mail-store seat (novox/hq ADR 0378 §3): only mesh-mail may claim it. +(cd checks/mailstore && go test -count=1 . && go run . ../..) # The secrets (novox/hq issue 342): a password is not a setting, so no setting can replace the vault's. The # known list only shrinks: judged against the trunk's copy at the merge base, a line it does not hold is refused. secrets_base="" diff --git a/modules/mesh-mail/README.md b/modules/mesh-mail/README.md index c5104514..51d133d9 100644 --- a/modules/mesh-mail/README.md +++ b/modules/mesh-mail/README.md @@ -6,6 +6,18 @@ Reading is the first stage: accounts of the kind `imap`, read-only — nothing h Sending is `mail_send` alone (novox/hq ADR 0361 §2): it sends nothing itself, and reaches an account's outgoing server only once the operator approves it on a channel that proves who answered. +## Its seats (novox/hq ADR 0378 as amended by ADR 0379, issue 800 part A) + +It declares three mail seats and claims the one it holds: + +- **mail-store** (held once for the mesh) — serves `accounts`, `search`, `read` and `send`; mesh-mail claims it, + through the same handlers as its own `mail_*` tools. The catalogue's own check (`checks/mailstore`) refuses any + other manifest that claims it. +- **mail-webclient** (held once for the mesh) — serves `open` and `status`; unheld until issue 580. +- **mail-notifier** (held once per machine, needing `node-notifier` there) — serves `status`; held by + mesh-mail-notifier (issue 800 part B), a module of its own built from this one's source, which tells the + operator of each new inbox mail through `node-notifier.send`. + ## What it does - **Watches.** For each account it keeps one connection: IDLE on the inbox, ended and begun again every two minutes @@ -59,6 +71,10 @@ The caller is the runtime's word, never an argument. `mail_send` sends through t once the operator's Approve comes back on a channel that proves who answered, and once: a warrant delivered twice never sends twice (novox/hq ADR 0361 §2). +The mail-store seat's four verbs, `mail-store.accounts`, `mail-store.search`, `mail-store.read` and +`mail-store.send`, answer through the same handlers as `mail_accounts`, `mail_search`, `mail_read` and `mail_send` +above — the same grants, the same refusals, the same ask-then-Approve for a send. + ## Its processes `prepare` brings the index's schema to this version's (numbered migrations, and `schema.sql` for a fresh database). diff --git a/modules/mesh-mail/cmd/mesh-mail/imap.go b/modules/mesh-mail/cmd/mesh-mail/imap.go index 5583781c..5488e535 100644 --- a/modules/mesh-mail/cmd/mesh-mail/imap.go +++ b/modules/mesh-mail/cmd/mesh-mail/imap.go @@ -537,13 +537,7 @@ func sameStrings(a, b []string) bool { } // ---- the events --------------------------------------------------------------------------------------------------- - -// EventKey is where an account's change is said: mail..message. (research 049 -// document 02 §5), the account by its name in the setting, never its address. -func EventKey(account, change string) string { return "mail." + account + ".message." + change } - -// Changes are the three things said of a message. -var Changes = []string{"added", "changed", "removed"} +// EventKey and Changes, the event's shape, now live in mailevent.go of their own (issue 800 part A). // metadata is what an event says of a message: never its body, never an attachment's bytes (ADR 0359 §2). func metadata(m Message) map[string]any { diff --git a/modules/mesh-mail/cmd/mesh-mail/mailevent.go b/modules/mesh-mail/cmd/mesh-mail/mailevent.go new file mode 100644 index 00000000..fb9596fb --- /dev/null +++ b/modules/mesh-mail/cmd/mesh-mail/mailevent.go @@ -0,0 +1,13 @@ +package main + +// The shape of a mail-store message event (novox/hq ADR 0378 §2, issue 800 part A), moved here on its own so +// mesh-mail-notifier (issue 800 part B) carries a byte-identical copy, the way modules/networkmanager's +// uplink-tools carries ifwatchd's own file (novox/hq ADR 0241, copies_test.go). Nothing here reaches a mailbox or +// a server; it only says where an account's change is told on the bus and what the three changes are called. + +// EventKey is where an account's change is said: mail..message. (research 049 +// document 02 §5), the account by its name in the setting, never its address. +func EventKey(account, change string) string { return "mail." + account + ".message." + change } + +// Changes are the three things said of a message. +var Changes = []string{"added", "changed", "removed"} diff --git a/modules/mesh-mail/cmd/mesh-mail/manifest_test.go b/modules/mesh-mail/cmd/mesh-mail/manifest_test.go index d6563479..5f0a2c9a 100644 --- a/modules/mesh-mail/cmd/mesh-mail/manifest_test.go +++ b/modules/mesh-mail/cmd/mesh-mail/manifest_test.go @@ -11,11 +11,20 @@ import ( // The manifest says what the code does (novox/hq ADR 0359): the database and the bucket it requires (§3), the own // secret family issued outside the mesh (§5, ADR 0283 §6), the two trusted settings (§4), the tools (§5), the events, -// and its two long-running processes with their health (§6) — and it is assigned to no machine by itself. +// its two long-running processes with their health (§6), and — ADR 0378 as amended by ADR 0379, issue 800 part A — +// the three mail seats it declares and the one, mail-store, it claims. It is assigned to no machine by itself. + +type seat struct { + Name string `json:"name"` + Scope string `json:"scope"` + Serves []string `json:"serves"` + Needs string `json:"needs"` +} type manifest struct { Module string `json:"module"` Slug string `json:"slug"` + Seats []seat `json:"seats"` Requires []string `json:"requires"` Contributes map[string]map[string]any `json:"contributes"` OwnSecrets map[string]map[string]string `json:"own-secrets"` @@ -30,10 +39,10 @@ type manifest struct { Events string `json:"events"` } `json:"grant"` } `json:"streams"` - Consumes []string `json:"consumes"` - Invokes []string `json:"invokes"` - Claims []json.RawMessage `json:"claims"` - Resources []map[string]any `json:"resources"` + Consumes []string `json:"consumes"` + Invokes []string `json:"invokes"` + Claims []seat `json:"claims"` + Resources []map[string]any `json:"resources"` Build struct { Artifacts []struct { From string `json:"from"` @@ -78,7 +87,11 @@ func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) { for _, tool := range (&Bundle{}).Tools() { names = append(names, tool.Name) } - if !reflect.DeepEqual(m.Tools, names) || !reflect.DeepEqual(names, []string{"mail_accounts", "mail_search", "mail_read", "mail_health", "mail_send", "mail_send_status"}) { + wantTools := []string{ + "mail_accounts", "mail_search", "mail_read", "mail_health", "mail_send", "mail_send_status", + "mail-store.accounts", "mail-store.search", "mail-store.read", "mail-store.send", + } + if !reflect.DeepEqual(m.Tools, names) || !reflect.DeepEqual(names, wantTools) { t.Fatalf("tools %v, the code serves %v", m.Tools, names) } // Its events go to its own stream MAIL, kept a week, each account's part heard only by the consumers the trusted @@ -91,9 +104,27 @@ func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) { m.Streams[0].MaxAgeDays != 7 || m.Streams[0].Grant.Config != "grants" || m.Streams[0].Grant.Events != "mail.{key}.**" { t.Fatalf("emits %v, streams %+v; the code says %v", m.Emits, m.Streams, events) } - // It reads its own mail and calls nothing, consumes nothing and claims no seat. - if len(m.Consumes)+len(m.Invokes)+len(m.Claims) != 0 { - t.Fatalf("consumes %v, invokes %v, claims %v", m.Consumes, m.Invokes, m.Claims) + // It calls nothing and consumes nothing: mesh-mail-notifier (issue 800 part B) is the one that invokes and + // consumes, not the store. + 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). + wantSeats := []seat{ + {Name: "mail-store", Scope: "mesh", Serves: []string{"accounts", "search", "read", "send"}}, + {Name: "mail-webclient", Scope: "mesh", Serves: []string{"open", "status"}}, + {Name: "mail-notifier", Scope: "node", Serves: []string{"status"}, Needs: "node-notifier"}, + } + if !reflect.DeepEqual(m.Seats, wantSeats) { + t.Fatalf("seats %+v, want %+v", m.Seats, wantSeats) + } + wantClaims := []seat{ + {Name: "mail-store", Scope: "mesh", Serves: []string{"accounts", "search", "read", "send"}}, + } + if !reflect.DeepEqual(m.Claims, wantClaims) { + t.Fatalf("claims %+v, want %+v", m.Claims, wantClaims) } // The two settings are trusted, and their defaults are an empty account list and no grant. for id, want := range map[string]string{"accounts": `{"accounts":{}}`, "grants": `{"grants":{}}`} { diff --git a/modules/mesh-mail/cmd/mesh-mail/tools.go b/modules/mesh-mail/cmd/mesh-mail/tools.go index 3f99c806..b957f0c0 100644 --- a/modules/mesh-mail/cmd/mesh-mail/tools.go +++ b/modules/mesh-mail/cmd/mesh-mail/tools.go @@ -26,6 +26,16 @@ const ( SendStatusTool = "mail_send_status" ) +// Seat tool names: mesh-mail claims the mail-store seat (novox/hq ADR 0378 §2, §3; issue 800 part A) and serves +// its verbs through the same handlers as the mail_* tools above — the seat's name is the one a caller grants +// reach the same account's mail through, whichever name it calls. +const ( + SeatAccountsTool = "mail-store.accounts" + SeatSearchTool = "mail-store.search" + SeatReadTool = "mail-store.read" + SeatSendTool = "mail-store.send" +) + // Bounds of what a tool answers. const ( MostResults = 50 @@ -153,6 +163,38 @@ func (b *Bundle) Tools() []stdio.Tool { "to the request mail_send asked (so a warrant for another request, or one changed since, sends nothing here).", Input: schema([]string{"id"}, map[string]any{"id": text("the ask id mail_send gave")}), Run: b.sendStatusTool}, + {Name: SeatAccountsTool, + Description: "The mail-store seat's accounts verb (novox/hq ADR 0378 §2): the same as mail_accounts.", + Input: schema(nil, map[string]any{}), + Run: b.accountsTool}, + {Name: SeatSearchTool, + Description: "The mail-store seat's search verb (novox/hq ADR 0378 §2): the same as mail_search.", + Input: schema([]string{"query"}, map[string]any{ + "query": text("the words to find, at most 200 characters"), + "account": text("one account by its name in mail_accounts; every account you are granted when left out"), + "folder": text("one folder, such as INBOX; every folder when left out"), + "limit": integer("how many to answer, at most 50 (20 when left out)"), + }), + Run: b.searchTool}, + {Name: SeatReadTool, + Description: "The mail-store seat's read verb (novox/hq ADR 0378 §2): the same as mail_read.", + Input: schema([]string{"account", "folder", "uid"}, map[string]any{ + "account": text("the account, by its name in mail_accounts"), + "folder": text("the folder, such as INBOX"), + "uid": integer("the message's uid in that folder"), + "attachment": text("an attachment's key, the SHA-256 the message's references name, to answer its bytes"), + }), + Run: b.readTool}, + {Name: SeatSendTool, + Description: "The mail-store seat's send verb (novox/hq ADR 0378 §2): the same as mail_send.", + Input: schema([]string{"account", "to", "subject", "body"}, map[string]any{ + "account": text("the account to send from, by its name in mail_accounts"), + "to": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, + "description": "the recipients' mail addresses, at most 20"}, + "subject": text("the message's subject, at most 200 characters"), + "body": text("the message's plain-text body"), + }), + Run: b.sendTool}, } } diff --git a/modules/mesh-mail/module.json b/modules/mesh-mail/module.json index 5969c5df..3f3bb3f9 100644 --- a/modules/mesh-mail/module.json +++ b/modules/mesh-mail/module.json @@ -2,6 +2,31 @@ "module": "mesh-mail", "version": "1", "slug": "mail", + "seats": [ + { + "name": "mail-store", + "scope": "mesh", + "serves": ["accounts", "search", "read", "send"] + }, + { + "name": "mail-webclient", + "scope": "mesh", + "serves": ["open", "status"] + }, + { + "name": "mail-notifier", + "scope": "node", + "needs": "node-notifier", + "serves": ["status"] + } + ], + "claims": [ + { + "name": "mail-store", + "scope": "mesh", + "serves": ["accounts", "search", "read", "send"] + } + ], "requires": [ "postgres-database", "s3-bucket" @@ -32,7 +57,11 @@ "mail_read", "mail_health", "mail_send", - "mail_send_status" + "mail_send_status", + "mail-store.accounts", + "mail-store.search", + "mail-store.read", + "mail-store.send" ], "uses": [ "operator-channel"