Merge branch 'feat/mail-seats' into ms-797
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=""
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# mesh-mail-notifier — the default holder of mail-notifier
|
||||
|
||||
Built from mesh-mail's source (novox/hq ADR 0378 §9, as amended by ADR 0379, issue 800 part B): it carries a
|
||||
byte-identical copy of mesh-mail's event shape (`mailevent.go`, pinned by `copies_test.go`, the way
|
||||
`modules/networkmanager`'s `uplink-tools` carries `ifwatchd`'s own file, ADR 0241). Nothing here names
|
||||
Betterbird (ADR 0379 §3), and nothing here reaches a mailbox: it only hears what mesh-mail already said.
|
||||
|
||||
## What it does
|
||||
|
||||
It claims `mail-notifier` (held once per machine, needing `node-notifier` there) and shows each new inbox mail
|
||||
on this machine's notifier, through `node-notifier.send`, at most once:
|
||||
|
||||
- quiet during a first sync or a resync (mesh-mail already says nothing then, `imap.go` ~387)
|
||||
- quiet for a mail from the account's own address (its address, from `mail-store.accounts`; not known, the
|
||||
notice is shown)
|
||||
- quiet for a mail already marked read (`\Seen` in its flags)
|
||||
- quiet for anything outside the inbox
|
||||
- more than three new mails from one account arriving close together become one notice naming the count and
|
||||
the latest sender, rather than one notice each
|
||||
|
||||
Each notice shown emits this module's own event `shown` (account and message id, never sender or subject), and
|
||||
is kept in its own state (`shown`) so a redelivered event is not shown twice.
|
||||
|
||||
## Where it runs
|
||||
|
||||
**Its own account** (`runs-as`), because it hears mesh-mail's restricted stream `MAIL` (novox/hq ADR 0376 §2):
|
||||
the machine's runtime runs as the operator's account, which every agent shares and which never hears a
|
||||
restricted stream. Held once per machine (wherever `mail-notifier` is assigned); the operator grants it the
|
||||
accounts it announces, in mesh-mail's trusted setting `grants` (`mail-store`'s stream).
|
||||
|
||||
## Tools
|
||||
|
||||
| tool | does |
|
||||
|---|---|
|
||||
| `mail-notifier.status` | the accounts it announces on this machine, and when it last showed a notice; its rules. Read-only |
|
||||
@@ -0,0 +1,31 @@
|
||||
package main
|
||||
|
||||
// mesh-mail-notifier carries a byte-identical copy of mesh-mail's event shape (novox/hq ADR 0378 §9, issue 800
|
||||
// part B), the way modules/networkmanager's uplink-tools carries ifwatchd's own file (ADR 0241, copies_test.go):
|
||||
// each builds alone, so each holds its own copy, and this test holds them to one text.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// source is where mesh-mail's own copy of the file lives.
|
||||
var source = filepath.Join("..", "..", "..", "mesh-mail", "cmd", "mesh-mail")
|
||||
|
||||
func TestCarriesTheSameMailEventAsMeshMail(t *testing.T) {
|
||||
for _, f := range []string{"mailevent.go"} {
|
||||
mine, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
theirs, err := os.ReadFile(filepath.Join(source, f))
|
||||
if err != nil {
|
||||
t.Fatalf("mesh-mail's copy of %s could not be read: %v", f, err)
|
||||
}
|
||||
if !bytes.Equal(mine, theirs) {
|
||||
t.Errorf("this copy of %s differs from mesh-mail's: change both together", f)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"}
|
||||
@@ -0,0 +1,167 @@
|
||||
// mesh-mail-notifier: the default claimant of mail-notifier, built from mesh-mail's own event shape (novox/hq
|
||||
// ADR 0378 §9, issue 800 part B). A Go bundle the node's runtime launches, its own account (runs-as, ADR 0376
|
||||
// §2): the base invocation serves mail-notifier.status; a separate process (`notify`) hears mesh-mail's added
|
||||
// events and shows them. What this module says, it says on stderr; stdout is the MCP channel.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
func logf(format string, a ...any) { fmt.Fprintf(os.Stderr, format+"\n", a...) }
|
||||
|
||||
// AddedPattern is the mesh-mail stream's event this module consumes (its own `consumes` entry, novox/hq ADR
|
||||
// 0363, ADR 0378 §9): a new, changed or removed message's `added` only.
|
||||
const AddedPattern = "mesh-mail.mail.*.message.added"
|
||||
|
||||
// StatusTool is the mail-notifier seat's one verb this module serves.
|
||||
const StatusTool = "mail-notifier.status"
|
||||
|
||||
// ShownEvent is this module's own event (its `emits`), one per notice shown.
|
||||
const ShownEvent = "shown"
|
||||
|
||||
func main() {
|
||||
n := &Notifier{
|
||||
Send: func(args map[string]any) error {
|
||||
_, err := stdio.Ask("seat:node-notifier.send", args)
|
||||
return err
|
||||
},
|
||||
Accounts: readAccounts,
|
||||
Store: busStore{},
|
||||
EmitShown: func(account, messageID string) error {
|
||||
return stdio.Emit(ShownEvent, map[string]any{"account": account, "message-id": messageID})
|
||||
},
|
||||
Logf: logf,
|
||||
}
|
||||
n.init()
|
||||
|
||||
if len(os.Args) > 1 && os.Args[1] == "notify" {
|
||||
go watch(n)
|
||||
if err := stdio.Serve("", nil); err != nil {
|
||||
logf("%v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
tools := []stdio.Tool{{Name: StatusTool,
|
||||
Description: "The accounts mesh-mail-notifier announces on this machine, and when it last showed a " +
|
||||
"notice of new mail (novox/hq ADR 0378 §7). Read-only.",
|
||||
Run: func(map[string]any) (any, error) { return n.Status() }}}
|
||||
if err := stdio.Serve("", tools); err != nil {
|
||||
logf("%v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// watch hears mesh-mail's added events and flushes what it buffered once each account has been quiet a
|
||||
// moment (ADR 0378 §9: folding a burst into one notice).
|
||||
func watch(n *Notifier) {
|
||||
time.Sleep(500 * time.Millisecond) // Serve first: the bus is reached through it
|
||||
err := stdio.Subscribe(AddedPattern, func(e stdio.Envelope) error {
|
||||
var body EventBody
|
||||
if err := json.Unmarshal(e.Body, &body); err != nil {
|
||||
logf("[mesh-mail-notifier] an added event could not be read; taken and dropped: %v", err)
|
||||
return nil
|
||||
}
|
||||
return n.Add(body)
|
||||
})
|
||||
if err != nil {
|
||||
logf("[mesh-mail-notifier] could not hear %s: %v", AddedPattern, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
ticker := time.NewTicker(1 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
if err := n.Flush(); err != nil {
|
||||
logf("[mesh-mail-notifier] a buffered notice could not be shown: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// readAccounts asks mail-store.accounts for the accounts this module is granted, by name to own address.
|
||||
func readAccounts() (map[string]string, error) {
|
||||
raw, err := stdio.Ask("seat:mail-store.accounts", map[string]any{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var answer struct {
|
||||
Accounts []struct {
|
||||
Account string `json:"account"`
|
||||
Address string `json:"address"`
|
||||
} `json:"accounts"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &answer); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]string{}
|
||||
for _, a := range answer.Accounts {
|
||||
out[a.Account] = a.Address
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// busStore keeps the notifier's own state (the `shown` bucket the manifest declares): which messages were
|
||||
// shown already, and each account's last notice.
|
||||
type busStore struct{}
|
||||
|
||||
func stateKey(parts ...string) string {
|
||||
words := make([]string, len(parts))
|
||||
for i, p := range parts {
|
||||
var b strings.Builder
|
||||
for _, r := range p {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-':
|
||||
b.WriteRune(r)
|
||||
default:
|
||||
b.WriteRune('_')
|
||||
}
|
||||
}
|
||||
words[i] = b.String()
|
||||
}
|
||||
return strings.Join(words, "_")
|
||||
}
|
||||
|
||||
func (busStore) HandleSeen(handle string) (bool, error) {
|
||||
e, err := stdio.State("shown").Get("handle_" + stateKey(strings.Split(handle, "\x1f")...))
|
||||
return e != nil, err
|
||||
}
|
||||
|
||||
func (busStore) MarkHandle(handle string) error {
|
||||
_, err := stdio.State("shown").Put("handle_"+stateKey(strings.Split(handle, "\x1f")...), true)
|
||||
return err
|
||||
}
|
||||
|
||||
func (busStore) PutStatus(account string, s AccountStatus) error {
|
||||
_, err := stdio.State("shown").Put("status_"+stateKey(account), s)
|
||||
return err
|
||||
}
|
||||
|
||||
func (busStore) Statuses() (map[string]AccountStatus, error) {
|
||||
keys, err := stdio.State("shown").Keys()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]AccountStatus{}
|
||||
for _, k := range keys {
|
||||
account, ok := strings.CutPrefix(k, "status_")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
e, err := stdio.State("shown").Get(k)
|
||||
if err != nil || e == nil {
|
||||
continue
|
||||
}
|
||||
var s AccountStatus
|
||||
if json.Unmarshal(e.Value, &s) == nil {
|
||||
out[account] = s
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
package main
|
||||
|
||||
// mesh-mail-notifier: the default holder of mail-notifier (novox/hq ADR 0378 §7, as amended by ADR 0379 §4,
|
||||
// issue 800 part B). It hears mesh-mail's added events on its own account (runs-as, ADR 0376 §2) and shows each
|
||||
// new inbox mail at most once, through node-notifier.send: quiet during a first sync (mesh-mail already says
|
||||
// nothing then, imap.go ~387), for mail from the account's own address, for mail already marked read (\Seen),
|
||||
// and for anything outside the inbox. More than three new mails from one account arriving close together are
|
||||
// folded into one notice naming the count and the latest sender, rather than shown one by one.
|
||||
//
|
||||
// Nothing here names Betterbird (ADR 0379 §3).
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Inbox is the one folder a new mail is shown for.
|
||||
const Inbox = "INBOX"
|
||||
|
||||
// Seen is the flag that means a mail was already read: shown for nothing it marks.
|
||||
const SeenFlag = "\\Seen"
|
||||
|
||||
// FoldOver is how many new mails from one account, arriving close together, are still shown one by one; one
|
||||
// more than that and they become a single notice (novox/hq ADR 0378 §9: "more than three").
|
||||
const FoldOver = 3
|
||||
|
||||
// BurstWindow is how long the notifier waits, after the last qualifying mail of an account, before it decides
|
||||
// whether to show what it buffered one by one or as a single notice. Well under the minute the decision is
|
||||
// judged over (ADR 0378 §9), so a burst within that minute is still buffered together.
|
||||
const BurstWindow = 5 * time.Second
|
||||
|
||||
// Person is a sender or a recipient, as mesh-mail's event says it.
|
||||
type Person struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Address string `json:"address"`
|
||||
}
|
||||
|
||||
// EventBody is what mesh-mail's added event says of a message (metadata only; mailevent.go names where it is
|
||||
// said). Unknown fields are ignored: the notifier reads only what it needs.
|
||||
type EventBody struct {
|
||||
Account string `json:"account"`
|
||||
Folder string `json:"folder"`
|
||||
UID uint32 `json:"uid"`
|
||||
MessageID string `json:"message-id"`
|
||||
Subject string `json:"subject"`
|
||||
From []Person `json:"from"`
|
||||
Flags []string `json:"flags"`
|
||||
}
|
||||
|
||||
// Handle names one message, stably, for the dedupe state (novox/hq ADR 0378 §9: "a redelivered event is not
|
||||
// shown twice").
|
||||
func (e EventBody) Handle() string {
|
||||
return e.Account + "\x1f" + e.Folder + "\x1f" + strconv.FormatUint(uint64(e.UID), 10)
|
||||
}
|
||||
|
||||
func (e EventBody) hasFlag(flag string) bool {
|
||||
for _, f := range e.Flags {
|
||||
if strings.EqualFold(f, flag) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// sender is how a message's first sender reads in a notice: its name and address, or its address alone.
|
||||
func (e EventBody) sender() string {
|
||||
if len(e.From) == 0 {
|
||||
return "(unknown sender)"
|
||||
}
|
||||
p := e.From[0]
|
||||
if p.Name == "" {
|
||||
return p.Address
|
||||
}
|
||||
return p.Name + " <" + p.Address + ">"
|
||||
}
|
||||
|
||||
func (e EventBody) fromAddress() string {
|
||||
if len(e.From) == 0 {
|
||||
return ""
|
||||
}
|
||||
return e.From[0].Address
|
||||
}
|
||||
|
||||
// Notice is one notification the notifier shows: its args for node-notifier.send, and the message its `shown`
|
||||
// event names (the latest one, for a folded notice).
|
||||
type Notice struct {
|
||||
Account string
|
||||
Args map[string]any
|
||||
Handles []string // every message this notice accounts for, marked seen once shown
|
||||
Latest EventBody
|
||||
}
|
||||
|
||||
// Store is what the notifier keeps of its own (the `shown` state the manifest declares): whether a message
|
||||
// was shown already, and each account's last notice, for its status tool.
|
||||
type Store interface {
|
||||
HandleSeen(handle string) (bool, error)
|
||||
MarkHandle(handle string) error
|
||||
PutStatus(account string, s AccountStatus) error
|
||||
Statuses() (map[string]AccountStatus, error)
|
||||
}
|
||||
|
||||
// AccountStatus is what the notifier last showed of one account.
|
||||
type AccountStatus struct {
|
||||
At time.Time `json:"at"`
|
||||
Summary string `json:"summary"`
|
||||
}
|
||||
|
||||
// Notifier buffers qualifying mail per account and decides, once no more of that account's mail arrives for a
|
||||
// moment, how to show what it buffered.
|
||||
type Notifier struct {
|
||||
// Send calls node-notifier.send on this machine.
|
||||
Send func(args map[string]any) error
|
||||
// Accounts reads the accounts the notifier is granted, by mail-store.accounts: each one's own address, for
|
||||
// the quiet rule (novox/hq ADR 0378 §9's LEAD note). An account not known there is shown anyway.
|
||||
Accounts func() (map[string]string, error)
|
||||
Store Store
|
||||
// EmitShown says mesh-mail-notifier's own event `shown`: account and message id, never sender or subject.
|
||||
EmitShown func(account, messageID string) error
|
||||
Now func() time.Time
|
||||
Logf func(string, ...any)
|
||||
Debounce time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
pending map[string][]EventBody
|
||||
last map[string]time.Time
|
||||
}
|
||||
|
||||
func (n *Notifier) init() {
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
if n.pending == nil {
|
||||
n.pending = map[string][]EventBody{}
|
||||
n.last = map[string]time.Time{}
|
||||
}
|
||||
if n.Now == nil {
|
||||
n.Now = time.Now
|
||||
}
|
||||
if n.Logf == nil {
|
||||
n.Logf = func(string, ...any) {}
|
||||
}
|
||||
if n.Debounce == 0 {
|
||||
n.Debounce = BurstWindow
|
||||
}
|
||||
}
|
||||
|
||||
// Add takes one added event. It returns an error only to have the event offered again (the handler
|
||||
// Subscribe wraps this in follows that rule).
|
||||
func (n *Notifier) Add(e EventBody) error {
|
||||
n.init()
|
||||
seen, err := n.Store.HandleSeen(e.Handle())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if seen {
|
||||
return nil // a redelivery of a message already shown (or already judged not to be)
|
||||
}
|
||||
own, known, err := n.ownAddress(e.Account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !n.qualifies(e, own, known) {
|
||||
return nil
|
||||
}
|
||||
n.mu.Lock()
|
||||
n.pending[e.Account] = append(n.pending[e.Account], e)
|
||||
n.last[e.Account] = n.Now()
|
||||
n.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// qualifies says whether a mail is shown at all: a new inbox mail, not the account's own, not already read.
|
||||
func (n *Notifier) qualifies(e EventBody, ownAddress string, ownKnown bool) bool {
|
||||
if !strings.EqualFold(e.Folder, Inbox) {
|
||||
return false
|
||||
}
|
||||
if e.hasFlag(SeenFlag) {
|
||||
return false
|
||||
}
|
||||
if ownKnown && ownAddress != "" && strings.EqualFold(e.fromAddress(), ownAddress) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (n *Notifier) ownAddress(account string) (string, bool, error) {
|
||||
accounts, err := n.Accounts()
|
||||
if err != nil {
|
||||
// Not known: ADR 0378 §9's LEAD note says the notice is shown when the address cannot be read. Logged,
|
||||
// not retried: mail-store being unreachable is not this event's fault.
|
||||
n.Logf("[mesh-mail-notifier] the account's own address could not be read (%v); the notice is shown", err)
|
||||
return "", false, nil
|
||||
}
|
||||
addr, ok := accounts[account]
|
||||
return addr, ok, nil
|
||||
}
|
||||
|
||||
// Flush decides every account whose buffer has been quiet for Debounce: one notice each mail it holds, or
|
||||
// one notice folding them all when there are more than FoldOver.
|
||||
func (n *Notifier) Flush() error {
|
||||
n.init()
|
||||
now := n.Now()
|
||||
n.mu.Lock()
|
||||
var ready []string
|
||||
for account, at := range n.last {
|
||||
if now.Sub(at) >= n.Debounce {
|
||||
ready = append(ready, account)
|
||||
}
|
||||
}
|
||||
batches := map[string][]EventBody{}
|
||||
for _, account := range ready {
|
||||
batches[account] = n.pending[account]
|
||||
delete(n.pending, account)
|
||||
delete(n.last, account)
|
||||
}
|
||||
n.mu.Unlock()
|
||||
for account, events := range batches {
|
||||
if err := n.decide(account, events); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// decide shows what one account buffered, and emits `shown` for the notice.
|
||||
func (n *Notifier) decide(account string, events []EventBody) error {
|
||||
if len(events) == 0 {
|
||||
return nil
|
||||
}
|
||||
latest := events[len(events)-1]
|
||||
var notice Notice
|
||||
if len(events) > FoldOver {
|
||||
notice = Notice{
|
||||
Account: account,
|
||||
Args: map[string]any{
|
||||
"summary": strconv.Itoa(len(events)) + " new mails",
|
||||
"body": "latest from " + latest.sender() + " — " + account,
|
||||
"app_name": "mesh-mail",
|
||||
},
|
||||
Latest: latest,
|
||||
}
|
||||
for _, e := range events {
|
||||
notice.Handles = append(notice.Handles, e.Handle())
|
||||
}
|
||||
if err := n.show(notice); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
for _, e := range events {
|
||||
notice := Notice{
|
||||
Account: account,
|
||||
Args: map[string]any{
|
||||
"summary": e.Subject,
|
||||
"body": e.sender() + " — " + account,
|
||||
"app_name": "mesh-mail",
|
||||
},
|
||||
Handles: []string{e.Handle()},
|
||||
Latest: e,
|
||||
}
|
||||
if err := n.show(notice); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// show sends one notice, marks every message it accounts for seen, keeps the account's status, and emits
|
||||
// `shown`.
|
||||
func (n *Notifier) show(notice Notice) error {
|
||||
if err := n.Send(notice.Args); err != nil {
|
||||
n.Logf("[mesh-mail-notifier] %s's notice could not be shown: %v", notice.Account, err)
|
||||
return err
|
||||
}
|
||||
for _, h := range notice.Handles {
|
||||
if err := n.Store.MarkHandle(h); err != nil {
|
||||
n.Logf("[mesh-mail-notifier] shown, but not kept as shown (%v): a redelivery may show it again", err)
|
||||
}
|
||||
}
|
||||
summary, _ := notice.Args["summary"].(string)
|
||||
if err := n.Store.PutStatus(notice.Account, AccountStatus{At: n.Now(), Summary: summary}); err != nil {
|
||||
n.Logf("[mesh-mail-notifier] shown, but the status could not be kept: %v", err)
|
||||
}
|
||||
return n.EmitShown(notice.Account, notice.Latest.MessageID)
|
||||
}
|
||||
|
||||
// Status is the notifier's account of itself, for mail-notifier.status (novox/hq ADR 0378 §7: "accounts it
|
||||
// announces there, when it last showed a notice").
|
||||
func (n *Notifier) Status() (map[string]any, error) {
|
||||
statuses, err := n.Store.Statuses()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
accounts := map[string]any{}
|
||||
for account, s := range statuses {
|
||||
accounts[account] = map[string]any{"at": s.At.UTC().Format(time.RFC3339), "shown": s.Summary}
|
||||
}
|
||||
return map[string]any{
|
||||
"accounts": accounts,
|
||||
"rules": []string{
|
||||
"it shows each new inbox mail at most once, through node-notifier.send on this machine (novox/hq ADR 0378 §7)",
|
||||
"quiet during a first sync, for mail from the account's own address, for mail already marked read, and for anything outside the inbox",
|
||||
"more than three new mails from one account arriving close together become one notice naming the count and the latest sender",
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakeStore is Store kept in memory, for the test alone.
|
||||
type fakeStore struct {
|
||||
mu sync.Mutex
|
||||
handles map[string]bool
|
||||
statuses map[string]AccountStatus
|
||||
}
|
||||
|
||||
func newFakeStore() *fakeStore {
|
||||
return &fakeStore{handles: map[string]bool{}, statuses: map[string]AccountStatus{}}
|
||||
}
|
||||
|
||||
func (s *fakeStore) HandleSeen(handle string) (bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.handles[handle], nil
|
||||
}
|
||||
|
||||
func (s *fakeStore) MarkHandle(handle string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.handles[handle] = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *fakeStore) PutStatus(account string, st AccountStatus) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.statuses[account] = st
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *fakeStore) Statuses() (map[string]AccountStatus, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := map[string]AccountStatus{}
|
||||
for k, v := range s.statuses {
|
||||
out[k] = v
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// fakeClock is a clock the test moves by hand, so Flush's debounce needs no real sleep.
|
||||
type fakeClock struct {
|
||||
mu sync.Mutex
|
||||
now time.Time
|
||||
}
|
||||
|
||||
func (c *fakeClock) Now() time.Time {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.now
|
||||
}
|
||||
|
||||
func (c *fakeClock) Advance(d time.Duration) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.now = c.now.Add(d)
|
||||
}
|
||||
|
||||
// TestFirstMailOfAnAccountIsShownOnceEachAndABurstIsFolded is the issue's first test (novox/hq ADR 0378 §9,
|
||||
// issue 800 part B): given a new inbox mail from another sender, one already read, one from the account's own
|
||||
// address, one in another folder, and five within a minute from one account, it sends exactly one notice,
|
||||
// nothing, nothing, nothing, and one notice folding the five and naming the latest sender; it fails today
|
||||
// because the module does not exist.
|
||||
func TestFirstMailOfAnAccountIsShownOnceEachAndABurstIsFolded(t *testing.T) {
|
||||
clock := &fakeClock{now: time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC)}
|
||||
store := newFakeStore()
|
||||
var mu sync.Mutex
|
||||
var sent []map[string]any
|
||||
var shown []struct{ account, id string }
|
||||
|
||||
n := &Notifier{
|
||||
Now: clock.Now,
|
||||
Send: func(args map[string]any) error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
sent = append(sent, args)
|
||||
return nil
|
||||
},
|
||||
Accounts: func() (map[string]string, error) {
|
||||
return map[string]string{"work": "me@work.example", "home": "me@home.example"}, nil
|
||||
},
|
||||
Store: store,
|
||||
EmitShown: func(account, messageID string) error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
shown = append(shown, struct{ account, id string }{account, messageID})
|
||||
return nil
|
||||
},
|
||||
Logf: t.Logf,
|
||||
}
|
||||
|
||||
// 1. A new inbox mail from another sender: shown.
|
||||
one := EventBody{Account: "work", Folder: "INBOX", UID: 1, MessageID: "m1", Subject: "Hi",
|
||||
From: []Person{{Name: "Alice", Address: "alice@example.com"}}}
|
||||
// 2. Already read.
|
||||
two := EventBody{Account: "work", Folder: "INBOX", UID: 2, MessageID: "m2", Subject: "Old",
|
||||
From: []Person{{Address: "bob@example.com"}}, Flags: []string{"\\Seen"}}
|
||||
// 3. From the account's own address.
|
||||
three := EventBody{Account: "work", Folder: "INBOX", UID: 3, MessageID: "m3", Subject: "Sent",
|
||||
From: []Person{{Address: "me@work.example"}}}
|
||||
// 4. In another folder.
|
||||
four := EventBody{Account: "work", Folder: "Archive", UID: 4, MessageID: "m4", Subject: "Filed",
|
||||
From: []Person{{Address: "carol@example.com"}}}
|
||||
for _, e := range []EventBody{one, two, three, four} {
|
||||
if err := n.Add(e); err != nil {
|
||||
t.Fatalf("Add(%+v): %v", e, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Five new inbox mails from another account within a minute.
|
||||
var burst []EventBody
|
||||
for i := 1; i <= 5; i++ {
|
||||
e := EventBody{Account: "home", Folder: "INBOX", UID: uint32(i), MessageID: "burst" + string(rune('0'+i)),
|
||||
Subject: "Mail", From: []Person{{Name: "Sender " + string(rune('0'+i)), Address: "s@example.com"}}}
|
||||
burst = append(burst, e)
|
||||
clock.Advance(time.Second)
|
||||
if err := n.Add(e); err != nil {
|
||||
t.Fatalf("Add(%+v): %v", e, err)
|
||||
}
|
||||
}
|
||||
latest := burst[len(burst)-1]
|
||||
|
||||
clock.Advance(BurstWindow + time.Second)
|
||||
if err := n.Flush(); err != nil {
|
||||
t.Fatalf("Flush: %v", err)
|
||||
}
|
||||
|
||||
if len(sent) != 2 {
|
||||
t.Fatalf("sent %d notice(s): %+v", len(sent), sent)
|
||||
}
|
||||
if sent[0]["summary"] != "Hi" || !strings.Contains(sent[0]["body"].(string), "Alice") {
|
||||
t.Errorf("the first notice is %+v, want Alice's mail Hi", sent[0])
|
||||
}
|
||||
if sent[1]["summary"] != "5 new mails" || !strings.Contains(sent[1]["body"].(string), latest.sender()) {
|
||||
t.Errorf("the folded notice is %+v, want 5 new mails naming %s", sent[1], latest.sender())
|
||||
}
|
||||
|
||||
if len(shown) != 2 {
|
||||
t.Fatalf("emitted shown %d time(s): %+v", len(shown), shown)
|
||||
}
|
||||
if shown[0].account != "work" || shown[0].id != "m1" {
|
||||
t.Errorf("the first shown is %+v, want work/m1", shown[0])
|
||||
}
|
||||
if shown[1].account != "home" || shown[1].id != latest.MessageID {
|
||||
t.Errorf("the folded shown is %+v, want home/%s (the latest)", shown[1], latest.MessageID)
|
||||
}
|
||||
|
||||
// Redelivering the first event again shows nothing more: it was marked seen.
|
||||
if err := n.Add(one); err != nil {
|
||||
t.Fatalf("Add(one) again: %v", err)
|
||||
}
|
||||
clock.Advance(BurstWindow + time.Second)
|
||||
if err := n.Flush(); err != nil {
|
||||
t.Fatalf("Flush: %v", err)
|
||||
}
|
||||
if len(sent) != 2 {
|
||||
t.Fatalf("a redelivered event was shown again: sent now %+v", sent)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module mesh-mail-notifier
|
||||
|
||||
go 1.22
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44
|
||||
@@ -0,0 +1,2 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44 h1:r5xIo5qWOXQdCSMxvqJy8kV4U+P/QWXFK6971Ha6wU8=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
@@ -0,0 +1,79 @@
|
||||
{
|
||||
"module": "mesh-mail-notifier",
|
||||
"version": "1",
|
||||
"runs-as": "mesh-mail-notifier",
|
||||
"claims": [
|
||||
{
|
||||
"name": "mail-notifier",
|
||||
"scope": "node",
|
||||
"serves": ["status"]
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
"mesh-mail.mail.*.message.added"
|
||||
],
|
||||
"invokes": [
|
||||
"seat:node-notifier.send",
|
||||
"seat:mail-store.accounts"
|
||||
],
|
||||
"emits": [
|
||||
"shown"
|
||||
],
|
||||
"state": [
|
||||
{
|
||||
"name": "shown",
|
||||
"ttl-seconds": 2592000
|
||||
}
|
||||
],
|
||||
"tools": [
|
||||
"mail-notifier.status"
|
||||
],
|
||||
"own-secrets": {
|
||||
"broker": "${dir:state}/broker"
|
||||
},
|
||||
"secrets-owner": "mesh-mail-notifier",
|
||||
"resources": [
|
||||
{
|
||||
"id": "account",
|
||||
"type": "user",
|
||||
"name": "mesh-mail-notifier",
|
||||
"shell": "/usr/bin/nologin",
|
||||
"home": "/var/lib/mesh-mail-notifier"
|
||||
},
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"mode": "0700",
|
||||
"owner": "mesh-mail-notifier",
|
||||
"place": "."
|
||||
},
|
||||
{
|
||||
"id": "notify",
|
||||
"type": "process",
|
||||
"name": "mesh-mail-notifier-notify",
|
||||
"artifact": "tools",
|
||||
"run": [
|
||||
"./mesh-mail-notifier",
|
||||
"notify"
|
||||
],
|
||||
"health": {
|
||||
"kind": "unit"
|
||||
}
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "tools",
|
||||
"kind": "bundle",
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"from": "cmd/mesh-mail-notifier",
|
||||
"binary": "mesh-mail-notifier",
|
||||
"loads": [
|
||||
"mesh-mail-notifier"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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: []string{"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