mesh-mail declares the mail seats and claims mail-store (issue 800 part A)
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.
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
module git.novox.be/novox/mesh-catalog/checks/mailstore
|
||||
|
||||
go 1.22
|
||||
@@ -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 . <catalogue root>
|
||||
//
|
||||
// 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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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=""
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -537,13 +537,7 @@ func sameStrings(a, b []string) bool {
|
||||
}
|
||||
|
||||
// ---- the events ---------------------------------------------------------------------------------------------------
|
||||
|
||||
// EventKey is where an account's change is said: mail.<account>.message.<added|changed|removed> (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 {
|
||||
|
||||
@@ -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.<account>.message.<added|changed|removed> (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"}
|
||||
@@ -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":{}}`} {
|
||||
|
||||
@@ -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},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user