Mail seats: mesh-mail declares them, mesh-mail-notifier shows new mail, messenger tells (issues 800, 797) #317
@@ -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,40 @@
|
||||
# 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
|
||||
- of one account's qualifying mail within a sliding minute, the first three are shown one by one; the rest of
|
||||
that minute's mail is folded into one notice naming the count and the latest sender, rather than shown one
|
||||
by one
|
||||
|
||||
A notice `node-notifier.send` refuses stays pending and is tried again, at most once a minute, without holding
|
||||
up any other account's notice; nothing is deleted before it is shown. 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 never counted or shown twice — by the mail's message id wherever it has one, so a
|
||||
UIDVALIDITY reset cannot make an already-shown mail look new.
|
||||
|
||||
## 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` | every account it is granted, when it last showed each one a notice, and any notice still failing to send (its count, last error, since when). Read-only |
|
||||
| `mail_notifier_health` | how it is: unhealthy once a notice has failed to show for more than ten minutes, healthy otherwise, even mid-retry. Not (yet) the node-engine's own check of `notify` — one process cannot be judged only by its own tool (ADR 0227 rule 8); `notify`'s manifest health stays `unit` until the lead decides how a one-process module gets this automatically. 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,62 @@
|
||||
package main
|
||||
|
||||
// mesh-mail-notifier's own health (review round 1 of issue 800, which added this tool beside mail-notifier's
|
||||
// seat verb status, as mesh-mail's own health tool is mail_health beside its seat tools). A notice that fails
|
||||
// to send stays pending and is retried, at most once a minute (Flush, notifier.go); the account's
|
||||
// failing-since stays in the Store, so this tool — its own process, asked separately by the node-engine or by
|
||||
// mesh_call — sees the same account the live notifier does, with no state of its own to lose between asks.
|
||||
//
|
||||
// Unhealthy once an account's notice has failed to show for more than UnhealthyAfter; healthy otherwise, even
|
||||
// while a notice is mid-retry.
|
||||
//
|
||||
// STOP (review round 1 of issue 800, not decided by its note): this tool is served and tested, but module.json
|
||||
// does not name it as the notify process's own health check — mesh-mail-notifier has one process (notify), and
|
||||
// the catalogue's gate refuses a module judged only by its own tool, with no check of another kind beside it
|
||||
// (ADR 0227 rule 8, ADR 0240 rule 2; internal/catalogue/health.go ~297 in mesh-controller). mesh-mail avoids
|
||||
// this because it has two processes — keep (kind unit) beside watch (kind tool, mail_health); mesh-mail-notifier
|
||||
// has only notify, so wiring this tool the same way trips the gate. notify's health stays kind unit (liveness,
|
||||
// as it was before this round) until the lead decides how a one-process module gets an automatic, node-engine
|
||||
// enforced health of this kind: a second process judged some other way, or a change to the gate's rule.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/health"
|
||||
)
|
||||
|
||||
// HealthTool is the module's own health tool the node-engine asks (beside mail-notifier.status, the seat's own verb).
|
||||
const HealthTool = "mail_notifier_health"
|
||||
|
||||
// UnhealthyAfter is how long a notice may fail to send before that is unhealthy, not merely retrying.
|
||||
const UnhealthyAfter = 10 * time.Minute
|
||||
|
||||
// Judge is mesh-mail-notifier's health, from the failures the Store keeps.
|
||||
func Judge(failures map[string]FailureStatus, now time.Time) health.Answer {
|
||||
if len(failures) == 0 {
|
||||
return health.Healthy("every notice shown; none pending a retry")
|
||||
}
|
||||
accounts := make([]string, 0, len(failures))
|
||||
for account := range failures {
|
||||
accounts = append(accounts, account)
|
||||
}
|
||||
sort.Strings(accounts)
|
||||
|
||||
var problems, retrying []string
|
||||
for _, account := range accounts {
|
||||
f := failures[account]
|
||||
age := now.Sub(f.Since).Round(time.Second)
|
||||
if now.Sub(f.Since) > UnhealthyAfter {
|
||||
problems = append(problems, fmt.Sprintf("%s: its notice has failed to show for %s (%d attempt(s), last: %s)",
|
||||
account, age, f.Count, f.LastError))
|
||||
} else {
|
||||
retrying = append(retrying, fmt.Sprintf("%s has been retrying for %s", account, age))
|
||||
}
|
||||
}
|
||||
if len(problems) > 0 {
|
||||
return health.Unhealthy(strings.Join(problems, "; "))
|
||||
}
|
||||
return health.Healthy(strings.Join(retrying, "; "))
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/health"
|
||||
)
|
||||
|
||||
func TestJudgeIsUnhealthyOnlyPastUnhealthyAfter(t *testing.T) {
|
||||
now := time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
if a := Judge(nil, now); a.State != health.StateHealthy {
|
||||
t.Fatalf("no failures: %+v, want healthy", a)
|
||||
}
|
||||
|
||||
recent := map[string]FailureStatus{"work": {Count: 1, LastError: "no answer", Since: now.Add(-2 * time.Minute)}}
|
||||
if a := Judge(recent, now); a.State != health.StateHealthy {
|
||||
t.Fatalf("a failure 2m old: %+v, want healthy (still retrying)", a)
|
||||
}
|
||||
|
||||
stale := map[string]FailureStatus{"work": {Count: 11, LastError: "no answer", Since: now.Add(-11 * time.Minute)}}
|
||||
a := Judge(stale, now)
|
||||
if a.State != health.StateUnhealthy {
|
||||
t.Fatalf("a failure 11m old: %+v, want unhealthy (past UnhealthyAfter)", a)
|
||||
}
|
||||
if !strings.Contains(a.Why, "work") || !strings.Contains(a.Why, "no answer") {
|
||||
t.Errorf("Why = %q, want it to name the account and the last error", a.Why)
|
||||
}
|
||||
|
||||
mixed := map[string]FailureStatus{
|
||||
"work": {Count: 11, LastError: "no answer", Since: now.Add(-11 * time.Minute)},
|
||||
"home": {Count: 1, LastError: "no answer", Since: now.Add(-time.Minute)},
|
||||
}
|
||||
if a := Judge(mixed, now); a.State != health.StateUnhealthy || !strings.Contains(a.Why, "work") {
|
||||
t.Errorf("one stale failure beside one fresh one: %+v, want unhealthy naming work", a)
|
||||
}
|
||||
}
|
||||
@@ -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,213 @@
|
||||
// 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 and the module's own health tool
|
||||
// (mail_notifier_health, review round 1 of issue 800); 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() }},
|
||||
{Name: HealthTool,
|
||||
Description: "How mesh-mail-notifier is: unhealthy once a notice has failed to show for more than " +
|
||||
"ten minutes, healthy otherwise, even mid-retry (review round 1 of issue 800). Read-only.",
|
||||
Run: func(map[string]any) (any, error) {
|
||||
failures, err := busStore{}.Failures()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return Judge(failures, time.Now()), nil
|
||||
}},
|
||||
}
|
||||
if err := stdio.Serve("", tools); err != nil {
|
||||
logf("%v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// watch hears mesh-mail's added events and, once a second, flushes what Notifier.Flush finds ready: an
|
||||
// account's first mails of its window at once, and its folded overflow once the window is over (ADR 0378 §9,
|
||||
// the lead's reading, review round 1 of issue 800).
|
||||
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, each account's last notice, and each account's notice still failing to send.
|
||||
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
|
||||
}
|
||||
|
||||
func (busStore) PutFailure(account string, f FailureStatus) error {
|
||||
_, err := stdio.State("shown").Put("failure_"+stateKey(account), f)
|
||||
return err
|
||||
}
|
||||
|
||||
func (busStore) ClearFailure(account string) error {
|
||||
return stdio.State("shown").Delete("failure_" + stateKey(account))
|
||||
}
|
||||
|
||||
func (busStore) Failures() (map[string]FailureStatus, error) {
|
||||
keys, err := stdio.State("shown").Keys()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]FailureStatus{}
|
||||
for _, k := range keys {
|
||||
account, ok := strings.CutPrefix(k, "failure_")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
e, err := stdio.State("shown").Get(k)
|
||||
if err != nil || e == nil {
|
||||
continue
|
||||
}
|
||||
var f FailureStatus
|
||||
if json.Unmarshal(e.Value, &f) == nil {
|
||||
out[account] = f
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,537 @@
|
||||
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. For each new qualifying mail of an account, arriving at t, N counts that
|
||||
// account's qualifying mail — shown or folded alike, each counted once — that arrived in the 60 seconds up to
|
||||
// and including t: N <= FoldOver is shown at once; N > FoldOver is folded. The first folded mail opens the
|
||||
// account's fold; every folded mail joins it; the fold is shown, as one notice naming how many it holds and
|
||||
// the latest sender, once Window has passed since its own first mail — after which a new fold may open. The
|
||||
// count is a true sliding window of arrivals, not a per-session quota that only resets after a quiet Window:
|
||||
// review round 2's reading kept a session's count alive, and so folding, for as long as any gap stayed under
|
||||
// Window, which folds a steady trickle (one mail every 50s, say) forever even though no burst ever happened;
|
||||
// review round 3 of issue 800 replaces that reading with this one. Nothing pending — a folded mail awaiting
|
||||
// its fold's deadline, or a failed send awaiting its retry — is ever dropped (novox/hq ADR 0378 §9, review
|
||||
// round 3 of issue 800, which replaces the round 1 and round 2 readings).
|
||||
//
|
||||
// Nothing here names Betterbird (ADR 0379 §3).
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"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, within one window, are still shown one by one; the rest of
|
||||
// that window is folded into a single notice (novox/hq ADR 0378 §9: "more than three").
|
||||
const FoldOver = 3
|
||||
|
||||
// Window is the trailing 60 seconds ADR 0378 §9 counts a mail's N over (every qualifying mail of the account,
|
||||
// shown or folded alike, that arrived in the 60 seconds up to and including this one), and the time a fold
|
||||
// waits, from its own first mail, before it is shown (review round 3 of issue 800, which replaces the round 1
|
||||
// and round 2 readings: the count is a true sliding window of arrivals, not a per-session quota that stays
|
||||
// alive, and so keeps folding, for as long as any gap between mails stays under Window).
|
||||
const Window = time.Minute
|
||||
|
||||
// RetryBackoff is how long the notifier waits, after an account's notice fails to send, before it tries that
|
||||
// account again: at most once a minute (review round 1 of issue 800), so a send that keeps failing is not
|
||||
// retried on every Flush.
|
||||
const RetryBackoff = time.Minute
|
||||
|
||||
// AccountsCacheFor is how long mail-store.accounts's answer is reused before it is asked again (review round
|
||||
// 1 of issue 800, bullet 5): neither the quiet rule nor status asks it more than once a minute.
|
||||
const AccountsCacheFor = time.Minute
|
||||
|
||||
// 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"`
|
||||
UIDValidity uint32 `json:"uidvalidity"`
|
||||
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"). The message id is the key wherever a message carries one, so a UIDVALIDITY reset — which
|
||||
// changes every UID in a folder — can never make an already-shown mail look new, or a genuinely new one look
|
||||
// already shown; only a message with no message id falls back to its folder, UIDVALIDITY and UID (review
|
||||
// round 1 of issue 800).
|
||||
func (e EventBody) Handle() string {
|
||||
if e.MessageID != "" {
|
||||
return e.Account + "\x1f" + e.MessageID
|
||||
}
|
||||
return e.Account + "\x1f" + e.Folder + "\x1f" +
|
||||
strconv.FormatUint(uint64(e.UIDValidity), 10) + "\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
|
||||
}
|
||||
|
||||
// FailureStatus is what the notifier knows of an account's notice still failing to send (review round 1 of
|
||||
// issue 800): how many attempts, the last error, and since when. Kept in the Store, not only in memory, so
|
||||
// the health tool — its own process, asked separately by the node-engine — sees the same account the live
|
||||
// notifier does.
|
||||
type FailureStatus struct {
|
||||
Count int `json:"count"`
|
||||
LastError string `json:"last-error"`
|
||||
Since time.Time `json:"since"`
|
||||
}
|
||||
|
||||
// Store is what the notifier keeps of its own (the `shown` state the manifest declares): whether a message
|
||||
// was shown already, each account's last notice, and each account's notice still failing to send.
|
||||
type Store interface {
|
||||
HandleSeen(handle string) (bool, error)
|
||||
MarkHandle(handle string) error
|
||||
PutStatus(account string, s AccountStatus) error
|
||||
Statuses() (map[string]AccountStatus, error)
|
||||
PutFailure(account string, f FailureStatus) error
|
||||
ClearFailure(account string) error
|
||||
Failures() (map[string]FailureStatus, error)
|
||||
}
|
||||
|
||||
// AccountStatus is what the notifier last showed of one account.
|
||||
type AccountStatus struct {
|
||||
At time.Time `json:"at"`
|
||||
Summary string `json:"summary"`
|
||||
}
|
||||
|
||||
// accountBuffer is one account's state: history is every qualifying mail's arrival time, kept only for the
|
||||
// trailing Window, that N is counted against (review round 3 of issue 800); immediate holds mail waiting to be
|
||||
// shown one by one at the next Flush; folded holds the account's current fold (its first FoldOver mails within
|
||||
// Window are never folded, so a fold only ever holds the overflow from the fourth qualifying mail of its own
|
||||
// window onward), due as one notice once Window has passed since foldStart, its first mail's arrival.
|
||||
type accountBuffer struct {
|
||||
history []time.Time
|
||||
foldStart time.Time // when the current fold's first mail arrived; zero while nothing is folded
|
||||
immediate []EventBody
|
||||
folded []EventBody
|
||||
}
|
||||
|
||||
// Notifier buffers qualifying mail per account and shows it: the first FoldOver mails of a window one by one,
|
||||
// the rest folded once the window ends. A notice that fails to send is tried again, at most once a minute,
|
||||
// without holding up any other account's notice.
|
||||
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)
|
||||
|
||||
mu sync.Mutex
|
||||
pending map[string]*accountBuffer
|
||||
buffered map[string]bool // a message's Handle while it is waiting to be shown: never counted twice
|
||||
cooldown map[string]time.Time // account -> not retried before this time, after a failed send
|
||||
|
||||
accountsAt time.Time
|
||||
accountsVal map[string]string
|
||||
accountsErr error
|
||||
}
|
||||
|
||||
func (n *Notifier) init() {
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
if n.pending == nil {
|
||||
n.pending = map[string]*accountBuffer{}
|
||||
n.buffered = map[string]bool{}
|
||||
n.cooldown = map[string]time.Time{}
|
||||
}
|
||||
if n.Now == nil {
|
||||
n.Now = time.Now
|
||||
}
|
||||
if n.Logf == nil {
|
||||
n.Logf = func(string, ...any) {}
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
handle := e.Handle()
|
||||
seen, err := n.Store.HandleSeen(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()
|
||||
defer n.mu.Unlock()
|
||||
if n.buffered[handle] {
|
||||
return nil // a redelivery of a message still waiting to be shown: never counted twice
|
||||
}
|
||||
now := n.Now()
|
||||
b := n.pending[e.Account]
|
||||
if b == nil {
|
||||
b = &accountBuffer{}
|
||||
n.pending[e.Account] = b
|
||||
}
|
||||
// N is every qualifying mail of this account — shown or folded alike, each counted once — that arrived in
|
||||
// the 60 seconds up to and including now: a true sliding window over arrivals, not a per-session quota that
|
||||
// stays alive, and so keeps folding, for as long as any gap stays under Window (review round 3 of issue
|
||||
// 800, which replaces the round 1 and round 2 readings; nothing in this count depends on whether an earlier
|
||||
// mail has been shown yet).
|
||||
cutoff := now.Add(-Window)
|
||||
history := make([]time.Time, 0, len(b.history)+1)
|
||||
for _, at := range b.history {
|
||||
if !at.Before(cutoff) {
|
||||
history = append(history, at)
|
||||
}
|
||||
}
|
||||
b.history = append(history, now)
|
||||
n.buffered[handle] = true
|
||||
if len(b.history) <= FoldOver {
|
||||
b.immediate = append(b.immediate, e)
|
||||
} else {
|
||||
if len(b.folded) == 0 {
|
||||
b.foldStart = now
|
||||
}
|
||||
b.folded = append(b.folded, e)
|
||||
}
|
||||
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.readAccounts()
|
||||
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
|
||||
}
|
||||
|
||||
// readAccounts asks mail-store.accounts, reusing its last answer for AccountsCacheFor so neither the quiet
|
||||
// rule nor status reads it more than once a minute (review round 1 of issue 800, bullet 5).
|
||||
func (n *Notifier) readAccounts() (map[string]string, error) {
|
||||
n.mu.Lock()
|
||||
if n.accountsVal != nil && n.Now().Sub(n.accountsAt) < AccountsCacheFor {
|
||||
val, err := n.accountsVal, n.accountsErr
|
||||
n.mu.Unlock()
|
||||
return val, err
|
||||
}
|
||||
n.mu.Unlock()
|
||||
|
||||
val, err := n.Accounts()
|
||||
n.mu.Lock()
|
||||
n.accountsVal, n.accountsErr, n.accountsAt = val, err, n.Now()
|
||||
n.mu.Unlock()
|
||||
return val, err
|
||||
}
|
||||
|
||||
// Flush shows what is ready: each account's first FoldOver mails of its window, one by one, as soon as Flush
|
||||
// runs, and the window's folded overflow once the window has run its minute. Accounts are judged in a sorted
|
||||
// order, not a map's iteration order, so the tests do not depend on it (review round 1 of issue 800, BLOCKING
|
||||
// 1). A notice that fails to send leaves its mail pending, tried again once RetryBackoff has passed, and never
|
||||
// holds up another account's notice (BLOCKING 2): nothing is deleted before it is shown.
|
||||
func (n *Notifier) Flush() error {
|
||||
n.init()
|
||||
now := n.Now()
|
||||
|
||||
n.mu.Lock()
|
||||
accounts := make([]string, 0, len(n.pending))
|
||||
for account := range n.pending {
|
||||
accounts = append(accounts, account)
|
||||
}
|
||||
n.mu.Unlock()
|
||||
sort.Strings(accounts)
|
||||
|
||||
for _, account := range accounts {
|
||||
n.flushAccount(account, now)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// flushAccount ships one account's ready notices (its immediate mail, and its fold once Window has passed
|
||||
// since the fold's own first mail), and keeps whatever fails to send pending, under cooldown, for the next
|
||||
// Flush. Nothing is deleted before it is shown (review round 1 BLOCKING 2), and nothing pending is ever lost
|
||||
// (review round 2 BLOCKING, kept under review round 3's rule; see Add).
|
||||
func (n *Notifier) flushAccount(account string, now time.Time) {
|
||||
n.mu.Lock()
|
||||
if cd, ok := n.cooldown[account]; ok && now.Before(cd) {
|
||||
n.mu.Unlock()
|
||||
return // backing off after a failed send; tried again once RetryBackoff has passed
|
||||
}
|
||||
b := n.pending[account]
|
||||
if b == nil {
|
||||
n.mu.Unlock()
|
||||
return
|
||||
}
|
||||
immediate := append([]EventBody(nil), b.immediate...)
|
||||
// The fold is due Window after its own first mail (review round 3 of issue 800).
|
||||
foldDue := len(b.folded) > 0 && now.Sub(b.foldStart) >= Window
|
||||
var fold []EventBody
|
||||
if foldDue {
|
||||
fold = append([]EventBody(nil), b.folded...)
|
||||
}
|
||||
n.mu.Unlock()
|
||||
|
||||
if len(immediate) == 0 && len(fold) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
sentImmediate := 0
|
||||
var failErr error
|
||||
for _, e := range immediate {
|
||||
if err := n.show(n.singleNotice(account, e)); err != nil {
|
||||
failErr = err
|
||||
break
|
||||
}
|
||||
sentImmediate++
|
||||
}
|
||||
foldSent := false
|
||||
if failErr == nil && len(fold) > 0 {
|
||||
if err := n.show(n.foldNotice(account, fold, len(fold))); err != nil {
|
||||
failErr = err
|
||||
} else {
|
||||
foldSent = true
|
||||
}
|
||||
}
|
||||
|
||||
n.mu.Lock()
|
||||
if cur := n.pending[account]; cur == b {
|
||||
cur.immediate = cur.immediate[sentImmediate:]
|
||||
if foldSent {
|
||||
cur.folded = cur.folded[len(fold):]
|
||||
if len(cur.folded) == 0 {
|
||||
cur.foldStart = time.Time{} // nothing left folded: a new fold may open (review round 3 of issue 800)
|
||||
} else {
|
||||
// More mail folded in while this batch was being sent: its own Window starts now, counted from
|
||||
// when it was first noticed. Nothing of it is lost either way.
|
||||
cur.foldStart = now
|
||||
}
|
||||
}
|
||||
}
|
||||
if failErr != nil {
|
||||
n.cooldown[account] = now.Add(RetryBackoff)
|
||||
n.recordFailureLocked(account, failErr, now)
|
||||
} else {
|
||||
delete(n.cooldown, account)
|
||||
n.clearFailureLocked(account)
|
||||
}
|
||||
n.mu.Unlock()
|
||||
}
|
||||
|
||||
// recordFailureLocked keeps an account's failure in the Store (mu held by the caller, released around the
|
||||
// Store calls since they may reach the bus): its count, its last error, and since when, so a later Flush and
|
||||
// the health tool agree on how long it has been failing.
|
||||
func (n *Notifier) recordFailureLocked(account string, sendErr error, now time.Time) {
|
||||
n.mu.Unlock()
|
||||
defer n.mu.Lock()
|
||||
since := now
|
||||
count := 1
|
||||
if prev, err := n.Store.Failures(); err == nil {
|
||||
if p, ok := prev[account]; ok {
|
||||
since = p.Since
|
||||
count = p.Count + 1
|
||||
}
|
||||
}
|
||||
if err := n.Store.PutFailure(account, FailureStatus{Count: count, LastError: sendErr.Error(), Since: since}); err != nil {
|
||||
n.Logf("[mesh-mail-notifier] a failure could not be kept (%v): the health tool may not see it", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (n *Notifier) clearFailureLocked(account string) {
|
||||
n.mu.Unlock()
|
||||
defer n.mu.Lock()
|
||||
if err := n.Store.ClearFailure(account); err != nil {
|
||||
n.Logf("[mesh-mail-notifier] a cleared failure could not be kept cleared: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// singleNotice is one mail shown on its own: one of an account's first FoldOver mails of its window.
|
||||
func (n *Notifier) singleNotice(account string, e EventBody) Notice {
|
||||
return Notice{
|
||||
Account: account,
|
||||
Args: map[string]any{
|
||||
"summary": e.Subject,
|
||||
"body": e.sender() + " — " + account,
|
||||
"app_name": "mesh-mail",
|
||||
},
|
||||
Handles: []string{e.Handle()},
|
||||
Latest: e,
|
||||
}
|
||||
}
|
||||
|
||||
// foldNotice is one notice for everything the account's current fold holds — its qualifying mail from the
|
||||
// fourth within some trailing 60 seconds onward — naming how many mail that fold holds (not the account's
|
||||
// whole history of arrivals) and the latest sender (novox/hq ADR 0378 §9, review round 3 of issue 800, which
|
||||
// replaces the round 1 and round 2 readings: "<k> more new mail(s) in <account>, latest from <sender of the
|
||||
// last>", "mail" for one and "mails" for more).
|
||||
func (n *Notifier) foldNotice(account string, events []EventBody, count int) Notice {
|
||||
latest := events[len(events)-1]
|
||||
notice := Notice{
|
||||
Account: account,
|
||||
Args: map[string]any{
|
||||
"summary": strconv.Itoa(count) + " more new " + mailWord(count) + " in " + account,
|
||||
"body": "latest from " + latest.sender(),
|
||||
"app_name": "mesh-mail",
|
||||
},
|
||||
Latest: latest,
|
||||
}
|
||||
for _, e := range events {
|
||||
notice.Handles = append(notice.Handles, e.Handle())
|
||||
}
|
||||
return notice
|
||||
}
|
||||
|
||||
// mailWord says "mail" for a count of one, "mails" for anything else (review round 2 of issue 800, bullet 3;
|
||||
// kept by review round 3).
|
||||
func mailWord(count int) string {
|
||||
if count == 1 {
|
||||
return "mail"
|
||||
}
|
||||
return "mails"
|
||||
}
|
||||
|
||||
// show sends one notice, marks every message it accounts for seen, keeps the account's status, and emits
|
||||
// `shown`. Nothing is marked, kept or emitted when Send fails: the caller keeps the notice pending instead.
|
||||
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
|
||||
}
|
||||
n.mu.Lock()
|
||||
for _, h := range notice.Handles {
|
||||
delete(n.buffered, h)
|
||||
}
|
||||
n.mu.Unlock()
|
||||
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"; review round 1 of issue 800: the accounts
|
||||
// mail-store.accounts gives it, and the failures it is retrying).
|
||||
func (n *Notifier) Status() (map[string]any, error) {
|
||||
statuses, err := n.Store.Statuses()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
failures, err := n.Store.Failures()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
known, knownErr := n.readAccounts()
|
||||
accounts := map[string]any{}
|
||||
for account := range known {
|
||||
accounts[account] = map[string]any{}
|
||||
}
|
||||
for account, s := range statuses {
|
||||
accounts[account] = map[string]any{"at": s.At.UTC().Format(time.RFC3339), "shown": s.Summary}
|
||||
}
|
||||
out := 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",
|
||||
"of one account's qualifying mail, one arriving when fewer than three of the account's mails fell in the 60 seconds before it is shown at once; the rest is folded and shown, as one notice naming how many it holds and the latest sender, 60 seconds after the fold's own first mail",
|
||||
},
|
||||
}
|
||||
if knownErr != nil {
|
||||
out["accounts-error"] = knownErr.Error()
|
||||
}
|
||||
if len(failures) > 0 {
|
||||
failing := map[string]any{}
|
||||
for account, f := range failures {
|
||||
failing[account] = map[string]any{
|
||||
"count": f.Count,
|
||||
"last-error": f.LastError,
|
||||
"since": f.Since.UTC().Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
out["failing"] = failing
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,569 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"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
|
||||
failures map[string]FailureStatus
|
||||
}
|
||||
|
||||
func newFakeStore() *fakeStore {
|
||||
return &fakeStore{
|
||||
handles: map[string]bool{},
|
||||
statuses: map[string]AccountStatus{},
|
||||
failures: map[string]FailureStatus{},
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func (s *fakeStore) PutFailure(account string, f FailureStatus) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.failures[account] = f
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *fakeStore) ClearFailure(account string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
delete(s.failures, account)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *fakeStore) Failures() (map[string]FailureStatus, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := map[string]FailureStatus{}
|
||||
for k, v := range s.failures {
|
||||
out[k] = v
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// fakeClock is a clock the test moves by hand, so Flush's window 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)
|
||||
}
|
||||
|
||||
func newTestNotifier(t *testing.T, clock *fakeClock, store *fakeStore, sent *[]map[string]any) *Notifier {
|
||||
t.Helper()
|
||||
return &Notifier{
|
||||
Now: clock.Now,
|
||||
Send: func(args map[string]any) error {
|
||||
*sent = append(*sent, args)
|
||||
return nil
|
||||
},
|
||||
Accounts: func() (map[string]string, error) { return map[string]string{}, nil },
|
||||
Store: store,
|
||||
EmitShown: func(string, string) error { return nil },
|
||||
Logf: t.Logf,
|
||||
}
|
||||
}
|
||||
|
||||
func mail(account string, uid int, messageID, subject, senderName string) EventBody {
|
||||
return EventBody{Account: account, Folder: "INBOX", UID: uint32(uid), MessageID: messageID, Subject: subject,
|
||||
From: []Person{{Name: senderName, Address: "s@example.com"}}}
|
||||
}
|
||||
|
||||
// TestFlushShowsAccountsInASortedOrderNotAMapsIterationOrder is the review's first test for BLOCKING 1: Flush
|
||||
// used to walk a map, so which account's notice went out first was whichever order Go's map iteration gave
|
||||
// that run. Run with -count=50: green every time only once Flush sorts the accounts itself.
|
||||
func TestFlushShowsAccountsInASortedOrderNotAMapsIterationOrder(t *testing.T) {
|
||||
clock := &fakeClock{now: time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC)}
|
||||
store := newFakeStore()
|
||||
var sent []map[string]any
|
||||
n := newTestNotifier(t, clock, store, &sent)
|
||||
|
||||
for _, account := range []string{"zeta", "mango", "alpha", "delta"} {
|
||||
if err := n.Add(mail(account, 1, account+"-m1", account, "Someone")); err != nil {
|
||||
t.Fatalf("Add(%s): %v", account, err)
|
||||
}
|
||||
}
|
||||
if err := n.Flush(); err != nil {
|
||||
t.Fatalf("Flush: %v", err)
|
||||
}
|
||||
|
||||
want := []string{"alpha", "delta", "mango", "zeta"}
|
||||
if len(sent) != len(want) {
|
||||
t.Fatalf("sent %d notice(s), want %d: %+v", len(sent), len(want), sent)
|
||||
}
|
||||
for i, account := range want {
|
||||
if sent[i]["summary"] != account {
|
||||
t.Fatalf("sent[%d]'s summary is %v, want %q: accounts must be flushed in sorted order: %+v",
|
||||
i, sent[i]["summary"], account, sent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAFailedSendIsRetriedWithoutLosingItOrHoldingUpOtherAccounts is the review's second test for BLOCKING 2:
|
||||
// a notice node-notifier.send refuses stays pending (nothing is deleted before it is shown), is tried again
|
||||
// at the next Flush no sooner than RetryBackoff, and another account's notice still goes out meanwhile.
|
||||
func TestAFailedSendIsRetriedWithoutLosingItOrHoldingUpOtherAccounts(t *testing.T) {
|
||||
clock := &fakeClock{now: time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC)}
|
||||
store := newFakeStore()
|
||||
var sent []map[string]any
|
||||
failSend := true
|
||||
n := &Notifier{
|
||||
Now: clock.Now,
|
||||
Send: func(args map[string]any) error {
|
||||
if args["summary"] == "Broken" && failSend {
|
||||
return errors.New("node-notifier.send: no answer")
|
||||
}
|
||||
sent = append(sent, args)
|
||||
return nil
|
||||
},
|
||||
Accounts: func() (map[string]string, error) { return map[string]string{}, nil },
|
||||
Store: store,
|
||||
EmitShown: func(string, string) error { return nil },
|
||||
Logf: t.Logf,
|
||||
}
|
||||
|
||||
if err := n.Add(mail("broke", 1, "b1", "Broken", "Someone")); err != nil {
|
||||
t.Fatalf("Add(broke): %v", err)
|
||||
}
|
||||
if err := n.Add(mail("ok", 1, "o1", "Fine", "Someone")); err != nil {
|
||||
t.Fatalf("Add(ok): %v", err)
|
||||
}
|
||||
if err := n.Flush(); err != nil {
|
||||
t.Fatalf("Flush: %v", err)
|
||||
}
|
||||
if len(sent) != 1 || sent[0]["summary"] != "Fine" {
|
||||
t.Fatalf("sent = %+v, want only ok's notice: broke's failure must not hold it up", sent)
|
||||
}
|
||||
if failures, err := store.Failures(); err != nil || failures["broke"].Count != 1 {
|
||||
t.Fatalf("Store.Failures() = %+v, %v; want broke recorded once", failures, err)
|
||||
}
|
||||
|
||||
// Before RetryBackoff has passed, broke's notice is not retried yet.
|
||||
clock.Advance(30 * time.Second)
|
||||
if err := n.Flush(); err != nil {
|
||||
t.Fatalf("Flush: %v", err)
|
||||
}
|
||||
if len(sent) != 1 {
|
||||
t.Fatalf("sent %d notice(s) before RetryBackoff elapsed, want 1 (broke not retried yet): %+v", len(sent), sent)
|
||||
}
|
||||
|
||||
// Once RetryBackoff has passed, the next Flush tries it again; nothing was lost.
|
||||
failSend = false
|
||||
clock.Advance(RetryBackoff)
|
||||
if err := n.Flush(); err != nil {
|
||||
t.Fatalf("Flush: %v", err)
|
||||
}
|
||||
if len(sent) != 2 || sent[1]["summary"] != "Broken" {
|
||||
t.Fatalf("sent = %+v, want broke's notice too, after RetryBackoff", sent)
|
||||
}
|
||||
if failures, _ := store.Failures(); len(failures) != 0 {
|
||||
t.Errorf("Store.Failures() = %+v, want it cleared once broke's notice was shown", failures)
|
||||
}
|
||||
}
|
||||
|
||||
// TestABurstShowsTheFirstThreeAtOnceAndFoldsTheRestAtTheWindowsEnd is the review's third test (BLOCKING 3,
|
||||
// ADR 0378 §9, the lead's reading, the fold notice's wording updated by review round 3 of issue 800): the
|
||||
// first three of an account's qualifying mail within a minute are shown one by one as Flush runs; the fourth
|
||||
// onward is folded and shown as one notice once the window — a minute from the first of them — is over.
|
||||
func TestABurstShowsTheFirstThreeAtOnceAndFoldsTheRestAtTheWindowsEnd(t *testing.T) {
|
||||
clock := &fakeClock{now: time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC)}
|
||||
store := newFakeStore()
|
||||
var sent []map[string]any
|
||||
n := newTestNotifier(t, clock, store, &sent)
|
||||
|
||||
account := "home"
|
||||
for i := 1; i <= 4; i++ {
|
||||
if err := n.Add(mail(account, i, fmt.Sprintf("m%d", i), fmt.Sprintf("Mail %d", i), fmt.Sprintf("Sender %d", i))); err != nil {
|
||||
t.Fatalf("Add mail %d: %v", i, err)
|
||||
}
|
||||
if err := n.Flush(); err != nil {
|
||||
t.Fatalf("Flush: %v", err)
|
||||
}
|
||||
clock.Advance(10 * time.Second)
|
||||
}
|
||||
if len(sent) != 3 {
|
||||
t.Fatalf("4 mails 10s apart: sent %d notice(s) before the window ends, want 3 shown one by one: %+v", len(sent), sent)
|
||||
}
|
||||
for i, want := range []string{"Mail 1", "Mail 2", "Mail 3"} {
|
||||
if sent[i]["summary"] != want {
|
||||
t.Errorf("sent[%d] = %+v, want summary %q", i, sent[i], want)
|
||||
}
|
||||
}
|
||||
|
||||
// The window is over a minute after it opened (the first mail): the fourth, folded, is shown now.
|
||||
clock.Advance(Window)
|
||||
if err := n.Flush(); err != nil {
|
||||
t.Fatalf("Flush: %v", err)
|
||||
}
|
||||
if len(sent) != 4 {
|
||||
t.Fatalf("after the window ended, sent %d notice(s), want 4 (the folded fourth mail too): %+v", len(sent), sent)
|
||||
}
|
||||
if sent[3]["summary"] != "1 more new mail in home" || !strings.Contains(sent[3]["body"].(string), "Sender 4") {
|
||||
t.Errorf("the fold notice is %+v, want it to count only what the fold holds (1), naming Sender 4", sent[3])
|
||||
}
|
||||
}
|
||||
|
||||
// TestARedeliveredEventIsNeverShownOrCountedTwice is the review's fourth test: a redelivery of a mail already
|
||||
// shown is dropped (the Store knows it), and a redelivery of a mail still buffered, waiting to be folded, is
|
||||
// never counted a second time toward the fold.
|
||||
func TestARedeliveredEventIsNeverShownOrCountedTwice(t *testing.T) {
|
||||
clock := &fakeClock{now: time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC)}
|
||||
store := newFakeStore()
|
||||
var sent []map[string]any
|
||||
n := newTestNotifier(t, clock, store, &sent)
|
||||
|
||||
account := "home"
|
||||
one := mail(account, 1, "m1", "Hi", "Alice")
|
||||
if err := n.Add(one); err != nil {
|
||||
t.Fatalf("Add: %v", err)
|
||||
}
|
||||
if err := n.Flush(); err != nil {
|
||||
t.Fatalf("Flush: %v", err)
|
||||
}
|
||||
if len(sent) != 1 {
|
||||
t.Fatalf("sent %d notice(s), want 1", len(sent))
|
||||
}
|
||||
if err := n.Add(one); err != nil { // redelivered after it was shown
|
||||
t.Fatalf("Add (redelivered, already shown): %v", err)
|
||||
}
|
||||
if err := n.Flush(); err != nil {
|
||||
t.Fatalf("Flush: %v", err)
|
||||
}
|
||||
if len(sent) != 1 {
|
||||
t.Fatalf("a redelivered, already-shown event was shown again: sent now %+v", sent)
|
||||
}
|
||||
|
||||
for i := 2; i <= 4; i++ {
|
||||
if err := n.Add(mail(account, i, fmt.Sprintf("m%d", i), "Hi", fmt.Sprintf("Sender %d", i))); err != nil {
|
||||
t.Fatalf("Add mail %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
fourth := mail(account, 4, "m4", "Hi", "Sender 4")
|
||||
if err := n.Add(fourth); err != nil { // redelivered while still buffered, folded, not yet shown
|
||||
t.Fatalf("Add (redelivered, buffered): %v", err)
|
||||
}
|
||||
|
||||
clock.Advance(Window)
|
||||
if err := n.Flush(); err != nil {
|
||||
t.Fatalf("Flush: %v", err)
|
||||
}
|
||||
if len(sent) != 4 {
|
||||
t.Fatalf("sent %d notice(s), want 4 (one, mail 2, mail 3, and one fold notice): %+v", len(sent), sent)
|
||||
}
|
||||
if sent[3]["summary"] != "1 more new mail in home" {
|
||||
t.Errorf("the fold notice is %+v, want it to count the redelivered fourth mail once, not twice, among the fold's 1", sent[3])
|
||||
}
|
||||
}
|
||||
|
||||
// TestDedupeKeyIsTheMessageIDAcrossAUIDVALIDITYChange is the review's fifth test: the dedupe key is the
|
||||
// account and the message id wherever one is given, so a UIDVALIDITY reset — which changes every UID in a
|
||||
// folder — cannot make an already-shown mail look new. Only a message with no message id falls back to its
|
||||
// folder, UIDVALIDITY and UID, and that fallback still tells two different mails apart.
|
||||
func TestDedupeKeyIsTheMessageIDAcrossAUIDVALIDITYChange(t *testing.T) {
|
||||
clock := &fakeClock{now: time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC)}
|
||||
store := newFakeStore()
|
||||
var sent []map[string]any
|
||||
n := newTestNotifier(t, clock, store, &sent)
|
||||
|
||||
account := "home"
|
||||
first := EventBody{Account: account, Folder: "INBOX", UID: 5, UIDValidity: 1, MessageID: "stable-id",
|
||||
Subject: "Hi", From: []Person{{Address: "a@example.com"}}}
|
||||
if err := n.Add(first); err != nil {
|
||||
t.Fatalf("Add: %v", err)
|
||||
}
|
||||
if err := n.Flush(); err != nil {
|
||||
t.Fatalf("Flush: %v", err)
|
||||
}
|
||||
if len(sent) != 1 {
|
||||
t.Fatalf("sent %d, want 1", len(sent))
|
||||
}
|
||||
|
||||
// The same message, redelivered after a UIDVALIDITY reset changed its UID: same message id, not shown again.
|
||||
resent := EventBody{Account: account, Folder: "INBOX", UID: 99, UIDValidity: 2, MessageID: "stable-id",
|
||||
Subject: "Hi", From: []Person{{Address: "a@example.com"}}}
|
||||
if err := n.Add(resent); err != nil {
|
||||
t.Fatalf("Add (UIDVALIDITY reset, same message id): %v", err)
|
||||
}
|
||||
if err := n.Flush(); err != nil {
|
||||
t.Fatalf("Flush: %v", err)
|
||||
}
|
||||
if len(sent) != 1 {
|
||||
t.Fatalf("a mail with the same message id after a UIDVALIDITY reset was shown again: sent now %+v", sent)
|
||||
}
|
||||
|
||||
// Two different mails with no message id, at the same UID but a different UIDVALIDITY, are not confused.
|
||||
noID1 := EventBody{Account: account, Folder: "INBOX", UID: 7, UIDValidity: 1, Subject: "No id",
|
||||
From: []Person{{Address: "a@example.com"}}}
|
||||
noID2 := EventBody{Account: account, Folder: "INBOX", UID: 7, UIDValidity: 2, Subject: "No id, new validity",
|
||||
From: []Person{{Address: "a@example.com"}}}
|
||||
if err := n.Add(noID1); err != nil {
|
||||
t.Fatalf("Add noID1: %v", err)
|
||||
}
|
||||
if err := n.Flush(); err != nil {
|
||||
t.Fatalf("Flush: %v", err)
|
||||
}
|
||||
if err := n.Add(noID2); err != nil {
|
||||
t.Fatalf("Add noID2: %v", err)
|
||||
}
|
||||
if err := n.Flush(); err != nil {
|
||||
t.Fatalf("Flush: %v", err)
|
||||
}
|
||||
if len(sent) != 3 {
|
||||
t.Fatalf("sent %d, want 3: noID1 and noID2 are different mails, their UIDVALIDITY differs: %+v", len(sent), sent)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQualifyingRulesStillHold keeps the original qualifying rules proven (an already-read mail, a mail from
|
||||
// the account's own address, and a mail outside the inbox are never shown), now that buffering is per window
|
||||
// rather than per quiet period.
|
||||
func TestQualifyingRulesStillHold(t *testing.T) {
|
||||
clock := &fakeClock{now: time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC)}
|
||||
store := newFakeStore()
|
||||
var sent []map[string]any
|
||||
n := &Notifier{
|
||||
Now: clock.Now,
|
||||
Send: func(args map[string]any) error { sent = append(sent, args); return nil },
|
||||
Accounts: func() (map[string]string, error) { return map[string]string{"work": "me@work.example"}, nil },
|
||||
Store: store,
|
||||
EmitShown: func(string, string) error { return nil },
|
||||
Logf: t.Logf,
|
||||
}
|
||||
already := EventBody{Account: "work", Folder: "INBOX", UID: 2, MessageID: "m2", Subject: "Old",
|
||||
From: []Person{{Address: "bob@example.com"}}, Flags: []string{"\\Seen"}}
|
||||
own := EventBody{Account: "work", Folder: "INBOX", UID: 3, MessageID: "m3", Subject: "Sent",
|
||||
From: []Person{{Address: "me@work.example"}}}
|
||||
elsewhere := EventBody{Account: "work", Folder: "Archive", UID: 4, MessageID: "m4", Subject: "Filed",
|
||||
From: []Person{{Address: "carol@example.com"}}}
|
||||
for _, e := range []EventBody{already, own, elsewhere} {
|
||||
if err := n.Add(e); err != nil {
|
||||
t.Fatalf("Add(%+v): %v", e, err)
|
||||
}
|
||||
}
|
||||
if err := n.Flush(); err != nil {
|
||||
t.Fatalf("Flush: %v", err)
|
||||
}
|
||||
if len(sent) != 0 {
|
||||
t.Fatalf("sent %+v, want nothing: an already-read mail, the account's own mail and mail outside the inbox never qualify", sent)
|
||||
}
|
||||
}
|
||||
|
||||
// TestABufferIsNeverReplacedWhileItHoldsAnUnsentMail is review round 2 of issue 800's first test (BLOCKING):
|
||||
// a send that fails keeps its mail pending; once the cooldown has passed, but before the next Flush retries
|
||||
// it, a new qualifying mail arrives. Neither mail is lost: both are shown once the retry succeeds.
|
||||
func TestABufferIsNeverReplacedWhileItHoldsAnUnsentMail(t *testing.T) {
|
||||
clock := &fakeClock{now: time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC)}
|
||||
store := newFakeStore()
|
||||
var sent []map[string]any
|
||||
failSend := true
|
||||
n := &Notifier{
|
||||
Now: clock.Now,
|
||||
Send: func(args map[string]any) error {
|
||||
if failSend {
|
||||
return errors.New("node-notifier.send: no answer")
|
||||
}
|
||||
sent = append(sent, args)
|
||||
return nil
|
||||
},
|
||||
Accounts: func() (map[string]string, error) { return map[string]string{}, nil },
|
||||
Store: store,
|
||||
EmitShown: func(string, string) error { return nil },
|
||||
Logf: t.Logf,
|
||||
}
|
||||
|
||||
account := "home"
|
||||
if err := n.Add(mail(account, 1, "m1", "Mail 1", "Sender 1")); err != nil {
|
||||
t.Fatalf("Add mail 1: %v", err)
|
||||
}
|
||||
if err := n.Flush(); err != nil {
|
||||
t.Fatalf("Flush: %v", err)
|
||||
}
|
||||
if len(sent) != 0 {
|
||||
t.Fatalf("sent %d notice(s), want 0: the send failed", len(sent))
|
||||
}
|
||||
|
||||
// The cooldown passes, but no retry has happened yet (Flush is not called again before the new mail
|
||||
// arrives): a buffer this old must not be thrown away for a fresh one.
|
||||
clock.Advance(RetryBackoff)
|
||||
if err := n.Add(mail(account, 2, "m2", "Mail 2", "Sender 2")); err != nil {
|
||||
t.Fatalf("Add mail 2: %v", err)
|
||||
}
|
||||
|
||||
failSend = false
|
||||
if err := n.Flush(); err != nil {
|
||||
t.Fatalf("Flush: %v", err)
|
||||
}
|
||||
if len(sent) != 2 {
|
||||
t.Fatalf("sent %d notice(s), want 2 (mail 1's retry and mail 2, neither lost): %+v", len(sent), sent)
|
||||
}
|
||||
for i, want := range []string{"Mail 1", "Mail 2"} {
|
||||
if sent[i]["summary"] != want {
|
||||
t.Errorf("sent[%d] = %+v, want summary %q", i, sent[i], want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestTheFoldFollowsATrueSlidingWindowOfArrivalsNotASessionQuota is review round 3 of issue 800's first test,
|
||||
// replacing round 2's (which contradicted round 3's rule): N, for a mail arriving at t, is every qualifying
|
||||
// mail of the account — shown or folded alike — that arrived in the 60 seconds up to and including t. Mails at
|
||||
// 0, 50, 55, 70, 75 and 80 seconds: at 70s the preceding 60 seconds (from 10s) hold 50, 55 and 70 themselves
|
||||
// (0s has aged out), N=3, so 70 is still shown at once, not folded; at 75s the preceding 60 seconds (from 15s)
|
||||
// still hold 50 too, so it, 55, 70 and 75 themselves make N=4, and 75 folds; 80 folds with it (its own
|
||||
// preceding 60 seconds hold 50, 55, 70, 75 and 80, N=5). The fold is due 60 seconds after its own first mail
|
||||
// (75s), at 135s, as one notice naming only what it holds (2) and the latest sender (80s's).
|
||||
func TestTheFoldFollowsATrueSlidingWindowOfArrivalsNotASessionQuota(t *testing.T) {
|
||||
clock := &fakeClock{now: time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC)}
|
||||
store := newFakeStore()
|
||||
var sent []map[string]any
|
||||
n := newTestNotifier(t, clock, store, &sent)
|
||||
|
||||
account := "home"
|
||||
deltas := []time.Duration{0, 50 * time.Second, 5 * time.Second, 15 * time.Second, 5 * time.Second, 5 * time.Second}
|
||||
for i, d := range deltas {
|
||||
clock.Advance(d)
|
||||
if err := n.Add(mail(account, i+1, fmt.Sprintf("m%d", i+1), fmt.Sprintf("Mail %d", i+1), fmt.Sprintf("Sender %d", i+1))); err != nil {
|
||||
t.Fatalf("Add mail %d: %v", i+1, err)
|
||||
}
|
||||
if err := n.Flush(); err != nil {
|
||||
t.Fatalf("Flush at mail %d: %v", i+1, err)
|
||||
}
|
||||
}
|
||||
if len(sent) != 4 {
|
||||
t.Fatalf("sent %d notice(s), want 4 shown one by one (0s, 50s, 55s, 70s): %+v", len(sent), sent)
|
||||
}
|
||||
for i, want := range []string{"Mail 1", "Mail 2", "Mail 3", "Mail 4"} {
|
||||
if sent[i]["summary"] != want {
|
||||
t.Errorf("sent[%d] = %+v, want summary %q", i, sent[i], want)
|
||||
}
|
||||
}
|
||||
|
||||
// The fold's own window — a minute from its first mail, 75s — ends at 135s.
|
||||
clock.Advance(55 * time.Second) // now at 80s + 55s = 135s
|
||||
if err := n.Flush(); err != nil {
|
||||
t.Fatalf("Flush: %v", err)
|
||||
}
|
||||
if len(sent) != 5 {
|
||||
t.Fatalf("sent %d notice(s), want 5 (75s and 80s folded into one, at 135s): %+v", len(sent), sent)
|
||||
}
|
||||
if sent[4]["summary"] != "2 more new mails in home" || !strings.Contains(sent[4]["body"].(string), "Sender 6") {
|
||||
t.Errorf("the fold notice is %+v, want it to count only what it holds (2), naming Sender 6 (80s, the latest)", sent[4])
|
||||
}
|
||||
}
|
||||
|
||||
// TestASteadyTrickleNeverFoldsForever is review round 3 of issue 800's second test: the bug the round found
|
||||
// (a session that only resets after a quiet minute folds a steady stream forever, even without a burst). One
|
||||
// mail every 50 seconds, 20 of them: each one's preceding 60 seconds holds only the one before it, so N never
|
||||
// exceeds 2, and all 20 are shown at once — none ever fold.
|
||||
func TestASteadyTrickleNeverFoldsForever(t *testing.T) {
|
||||
clock := &fakeClock{now: time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC)}
|
||||
store := newFakeStore()
|
||||
var sent []map[string]any
|
||||
n := newTestNotifier(t, clock, store, &sent)
|
||||
|
||||
account := "home"
|
||||
for i := 1; i <= 20; i++ {
|
||||
if i > 1 {
|
||||
clock.Advance(50 * time.Second)
|
||||
}
|
||||
if err := n.Add(mail(account, i, fmt.Sprintf("m%d", i), fmt.Sprintf("Mail %d", i), fmt.Sprintf("Sender %d", i))); err != nil {
|
||||
t.Fatalf("Add mail %d: %v", i, err)
|
||||
}
|
||||
if err := n.Flush(); err != nil {
|
||||
t.Fatalf("Flush at mail %d: %v", i, err)
|
||||
}
|
||||
if len(sent) != i {
|
||||
t.Fatalf("after mail %d, sent %d notice(s), want %d: a steady trickle must never fold: %+v", i, len(sent), i, sent)
|
||||
}
|
||||
}
|
||||
for i, got := range sent {
|
||||
want := fmt.Sprintf("Mail %d", i+1)
|
||||
if got["summary"] != want {
|
||||
t.Errorf("sent[%d] = %+v, want summary %q", i, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFiveMailsWithinTenSecondsFoldAfterThree is review round 3 of issue 800's third test: five mails within
|
||||
// ten seconds — the first three shown at once, the fourth opens a fold, the fifth joins it, and the fold is
|
||||
// shown 60 seconds after the fourth (the fold's own first mail) as one notice.
|
||||
func TestFiveMailsWithinTenSecondsFoldAfterThree(t *testing.T) {
|
||||
clock := &fakeClock{now: time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC)}
|
||||
store := newFakeStore()
|
||||
var sent []map[string]any
|
||||
n := newTestNotifier(t, clock, store, &sent)
|
||||
|
||||
account := "home"
|
||||
for i := 1; i <= 5; i++ {
|
||||
if i > 1 {
|
||||
clock.Advance(2 * time.Second)
|
||||
}
|
||||
if err := n.Add(mail(account, i, fmt.Sprintf("m%d", i), fmt.Sprintf("Mail %d", i), fmt.Sprintf("Sender %d", i))); err != nil {
|
||||
t.Fatalf("Add mail %d: %v", i, err)
|
||||
}
|
||||
if err := n.Flush(); err != nil {
|
||||
t.Fatalf("Flush at mail %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
if len(sent) != 3 {
|
||||
t.Fatalf("sent %d notice(s), want 3 shown one by one, the fourth and fifth folded, not yet due: %+v", len(sent), sent)
|
||||
}
|
||||
|
||||
// The fold is due 60 seconds after its own first mail (the fourth, at 6s).
|
||||
clock.Advance(Window)
|
||||
if err := n.Flush(); err != nil {
|
||||
t.Fatalf("Flush: %v", err)
|
||||
}
|
||||
if len(sent) != 4 {
|
||||
t.Fatalf("sent %d notice(s), want 4 (one fold notice carrying the fourth and fifth): %+v", len(sent), sent)
|
||||
}
|
||||
if sent[3]["summary"] != "2 more new mails in home" || !strings.Contains(sent[3]["body"].(string), "Sender 5") {
|
||||
t.Errorf("the fold notice is %+v, want it to count only what it holds (2), naming Sender 5 (the latest)", sent[3])
|
||||
}
|
||||
}
|
||||
@@ -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,80 @@
|
||||
{
|
||||
"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",
|
||||
"mail_notifier_health"
|
||||
],
|
||||
"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"
|
||||
|
||||
@@ -86,7 +86,9 @@ func (h *Holder) choices(r *Record, kind string) []Choice {
|
||||
if rec := h.openAsk(r); rec != nil {
|
||||
out = h.offers(rec, kind)
|
||||
}
|
||||
if r.Origin != "condition" || len(out) >= MostActions {
|
||||
// A condition offers Details so the operator can read more; a tell does too (ADR 0380 §8: "Details is
|
||||
// offered as for any message") — it is its own message, so Details just shows it again.
|
||||
if (r.Origin != "condition" && r.Origin != "tell") || len(out) >= MostActions {
|
||||
return out
|
||||
}
|
||||
now := h.Now()
|
||||
|
||||
@@ -321,30 +321,33 @@ type Holder struct {
|
||||
work sync.Mutex // one event, call or tick at a time
|
||||
mu sync.Mutex // what the status reads
|
||||
|
||||
open map[string]*Record
|
||||
recent []Sent
|
||||
refusals []RefusalNote
|
||||
outbox map[string][]item
|
||||
lastSend map[string]time.Time
|
||||
lastWarn map[string]time.Time
|
||||
lastTry map[string]time.Time
|
||||
capSaid map[string]time.Time
|
||||
chanErr map[string]string
|
||||
chanErrAt map[string]time.Time
|
||||
chanOK map[string]time.Time
|
||||
unreadable int
|
||||
lastBad string
|
||||
lastBadAt time.Time
|
||||
saidOnce map[string]time.Time
|
||||
storeErr string
|
||||
heard map[string]int
|
||||
lastHeard time.Time
|
||||
started time.Time
|
||||
syncedAt time.Time
|
||||
syncErr string
|
||||
wantSync bool
|
||||
history int
|
||||
stale int
|
||||
open map[string]*Record
|
||||
recent []Sent
|
||||
refusals []RefusalNote
|
||||
outbox map[string][]item
|
||||
lastSend map[string]time.Time
|
||||
lastWarn map[string]time.Time
|
||||
lastTry map[string]time.Time
|
||||
capSaid map[string]time.Time
|
||||
tellCapSaid map[string]time.Time
|
||||
chanErr map[string]string
|
||||
chanErrAt map[string]time.Time
|
||||
chanOK map[string]time.Time
|
||||
unreadable int
|
||||
lastBad string
|
||||
lastBadAt time.Time
|
||||
saidOnce map[string]time.Time
|
||||
storeErr string
|
||||
heard map[string]int
|
||||
lastHeard time.Time
|
||||
started time.Time
|
||||
syncedAt time.Time
|
||||
syncErr string
|
||||
wantSync bool
|
||||
history int
|
||||
stale int
|
||||
// tellSeq makes every tell's key its own, even when two calls land in the same instant.
|
||||
tellSeq int
|
||||
// answered is what came of each answer event, by its id: a redelivered one is acted on once.
|
||||
answered map[string]string
|
||||
// asks are the asks this router holds, by `<asker>.<id>`, and tickets each ask's tickets to it.
|
||||
@@ -374,6 +377,7 @@ func (h *Holder) init() {
|
||||
h.lastWarn = map[string]time.Time{}
|
||||
h.lastTry = map[string]time.Time{}
|
||||
h.capSaid = map[string]time.Time{}
|
||||
h.tellCapSaid = map[string]time.Time{}
|
||||
h.chanErr = map[string]string{}
|
||||
h.chanErrAt = map[string]time.Time{}
|
||||
h.chanOK = map[string]time.Time{}
|
||||
|
||||
@@ -524,6 +524,161 @@ func TestNotifyIsKeptApartFromConditions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestATellReachesEveryChannelOnceAndOffersOnlyDetails(t *testing.T) {
|
||||
h, tg, dt, c, _ := newHolder(t)
|
||||
c.pass(14 * time.Hour) // well past midnight: it is not held for being "night" — there is no such hold
|
||||
out, err := h.Tell("new mail arrived for ace", []string{"One message from the inbox you watch."}, "mail")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(tg.sends) != 1 || len(dt.sends) != 1 {
|
||||
t.Fatalf("telegram %d, desktop %d sends", len(tg.sends), len(dt.sends))
|
||||
}
|
||||
for _, m := range []Message{tg.sends[0], dt.sends[0]} {
|
||||
if len(m.Actions) != 1 || m.Actions[0].Label != DetailsLabel {
|
||||
t.Fatalf("actions %+v", m.Actions)
|
||||
}
|
||||
}
|
||||
if sent, _ := out["sent_on"].([]string); len(sent) != 2 {
|
||||
t.Fatalf("sent_on %v", out)
|
||||
}
|
||||
if len(h.Open()) != 0 {
|
||||
t.Fatalf("a tell left an open record: %+v", h.Open())
|
||||
}
|
||||
c.pass(RemindUrgent + time.Minute)
|
||||
h.Tick()
|
||||
if len(tg.sends) != 1 || len(dt.sends) != 1 {
|
||||
t.Fatalf("a tell was reminded: telegram %d, desktop %d", len(tg.sends), len(dt.sends))
|
||||
}
|
||||
}
|
||||
|
||||
func TestATellIsDroppedPastItsOwnHourlyCap(t *testing.T) {
|
||||
h, tg, _, _, _ := newHolder(t)
|
||||
h.Desk = nil
|
||||
for i := 0; i < TellCapPerHour; i++ {
|
||||
if _, err := h.Tell("mail", []string{"line"}, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if len(tg.sends) != TellCapPerHour {
|
||||
t.Fatalf("sent %d of %d", len(tg.sends), TellCapPerHour)
|
||||
}
|
||||
out, err := h.Tell("mail", []string{"line"}, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(tg.sends) != TellCapPerHour {
|
||||
t.Fatalf("the 11th tell was sent: %d", len(tg.sends))
|
||||
}
|
||||
dropped, _ := out["dropped"].([]string)
|
||||
if len(dropped) != 1 || dropped[0] != "telegram" {
|
||||
t.Fatalf("dropped %v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestATellDoesNotCountTowardTheConditionsCap(t *testing.T) {
|
||||
h, tg, _, c, _ := newHolder(t)
|
||||
h.Desk = nil
|
||||
// Bring conditions close to, but under, their own cap: CapPerHour-5.
|
||||
for i := 0; i < CapPerHour-5; i++ {
|
||||
h.Condition(EventRaised, cond(fmt.Sprintf("machine.m%d.silent", i), Urgent, "a machine is silent"))
|
||||
c.pass(BurstWindow + time.Second)
|
||||
h.Flush()
|
||||
}
|
||||
if len(tg.sends) != CapPerHour-5 {
|
||||
t.Fatalf("sent %d conditions, want %d", len(tg.sends), CapPerHour-5)
|
||||
}
|
||||
// A full hour of tells: these must not be counted against the conditions' cap (ADR 0380 §8).
|
||||
for i := 0; i < TellCapPerHour; i++ {
|
||||
if _, err := h.Tell("mail", []string{"line"}, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
h.Condition(EventRaised, cond("machine.mlast.silent", Urgent, "a machine is silent"))
|
||||
c.pass(BurstWindow + time.Second)
|
||||
h.Flush()
|
||||
want := CapPerHour - 5 + TellCapPerHour + 1
|
||||
if len(tg.sends) != want {
|
||||
t.Fatalf("sent %d, want %d (the condition after the tells still sent)", len(tg.sends), want)
|
||||
}
|
||||
if last := tg.sends[len(tg.sends)-1]; strings.HasPrefix(last.Title, "Mesh messages paused for this hour") {
|
||||
t.Fatalf("the condition was held behind the tells' sends: %q", last.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestATellCapDropIsLoggedOnceAnHour(t *testing.T) {
|
||||
h, tg, _, _, _ := newHolder(t)
|
||||
h.Desk = nil
|
||||
var logs []string
|
||||
h.Logf = func(format string, args ...any) { logs = append(logs, fmt.Sprintf(format, args...)) }
|
||||
for i := 0; i < TellCapPerHour; i++ {
|
||||
if _, err := h.Tell("mail", []string{"line"}, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
if _, err := h.Tell("mail", []string{"line"}, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if len(tg.sends) != TellCapPerHour {
|
||||
t.Fatalf("sent %d of %d", len(tg.sends), TellCapPerHour)
|
||||
}
|
||||
dropLogs := 0
|
||||
for _, l := range logs {
|
||||
if strings.Contains(l, "dropped") {
|
||||
dropLogs++
|
||||
}
|
||||
}
|
||||
if dropLogs != 1 {
|
||||
t.Fatalf("logged the drop %d times, want 1", dropLogs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAFloodOfDroppedTellsDoesNotEvictTheConditionsCapHistory is review round 2 of issue 800's messenger
|
||||
// finding (issue 797's area): a dropped tell used to be kept in the same bounded send history the conditions'
|
||||
// cap counts from (KeptSends); a flood of them could evict real, still-recent sends from it, undercounting
|
||||
// sentLastHour and letting the conditions' cap stop holding. Here the cap is filled exactly, then 300 tells
|
||||
// are dropped — far more than KeptSends — and the next condition must still be held, exactly as it would be
|
||||
// without the flood.
|
||||
func TestAFloodOfDroppedTellsDoesNotEvictTheConditionsCapHistory(t *testing.T) {
|
||||
h, tg, _, c, _ := newHolder(t)
|
||||
h.Desk = nil
|
||||
// Fill the conditions' cap exactly: CapPerHour sent, none held yet.
|
||||
for i := 0; i < CapPerHour; i++ {
|
||||
h.Condition(EventRaised, cond(fmt.Sprintf("machine.m%d.silent", i), Urgent, "a machine is silent"))
|
||||
c.pass(BurstWindow + time.Second)
|
||||
h.Flush()
|
||||
}
|
||||
if len(tg.sends) != CapPerHour {
|
||||
t.Fatalf("sent %d conditions, want the cap of %d", len(tg.sends), CapPerHour)
|
||||
}
|
||||
|
||||
// Use up the tell cap, then flood 300 more: all past TellCapPerHour are dropped, far more than KeptSends.
|
||||
for i := 0; i < TellCapPerHour; i++ {
|
||||
if _, err := h.Tell("mail", []string{"line"}, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for i := 0; i < 300; i++ {
|
||||
if _, err := h.Tell("mail", []string{"line"}, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// One more condition, past the cap: it must still be held, as it would be without the flood of drops.
|
||||
h.Condition(EventRaised, cond("machine.mlast.silent", Urgent, "a machine is silent"))
|
||||
c.pass(BurstWindow + time.Second)
|
||||
h.Flush()
|
||||
want := CapPerHour + TellCapPerHour + 1 // the cap's own conditions, the tells that did send, and the cap message
|
||||
if len(tg.sends) != want {
|
||||
t.Fatalf("sent %d, want %d (the cap still held after a flood of dropped tells)", len(tg.sends), want)
|
||||
}
|
||||
if last := tg.sends[len(tg.sends)-1]; !strings.HasPrefix(last.Title, "Mesh messages paused for this hour") {
|
||||
t.Fatalf("the cap was not said after the flood of dropped tells: %q", last.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestATestMessageRespectsTheRule(t *testing.T) {
|
||||
h, tg, dt, _, _ := newHolder(t)
|
||||
if out := h.Test("telegram", "see https://example.org"); out["refused"] == "" || len(tg.sends) != 0 {
|
||||
|
||||
@@ -645,6 +645,27 @@ func tools(h *Holder, l *listening) []stdio.Tool {
|
||||
return h.Notify(strArg(a, "key"), strArg(a, "severity"), strArg(a, "summary"), strArg(a, "subject"), clear,
|
||||
strArg(a, "headline"), strArg(a, "explanation"), strArg(a, "resolved"))
|
||||
}},
|
||||
{Name: "operator-channel.tell",
|
||||
Description: "Tell the operator a plain notice that needs no answer and opens no condition (ADR 0380): a " +
|
||||
"headline and one or two lines, said at once on every channel, held to the content rule as notify's " +
|
||||
"words are, and offering Details like any message. Never escalated, never kept open, never reminded; " +
|
||||
"its own cap of 10 a channel an hour, apart from the conditions' and notify's.",
|
||||
Input: map[string]any{
|
||||
"headline": str("a few plain words the operator reads as the title, e.g. new mail arrived for ace"),
|
||||
"lines": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "one or two plain lines"},
|
||||
"subject": str("what it is about: a machine's role, a module, a plan"),
|
||||
},
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
var lines []string
|
||||
if raw, ok := a["lines"].([]any); ok {
|
||||
for _, l := range raw {
|
||||
if s, ok := l.(string); ok {
|
||||
lines = append(lines, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
return h.Tell(strArg(a, "headline"), lines, strArg(a, "subject"))
|
||||
}},
|
||||
{Name: "messenger_status",
|
||||
Description: "Whether the operator can be reached, and why not: each channel kind the mesh holds — can it " +
|
||||
"send, what it said of itself, its last error, how many it sent in the last hour against the cap, how " +
|
||||
|
||||
@@ -62,7 +62,7 @@ func readManifest(t *testing.T) (manifest, string) {
|
||||
|
||||
func TestItDeclaresTheSeatsOfAnAskAndHoldsTheOperatorChannel(t *testing.T) {
|
||||
m, _ := readManifest(t)
|
||||
verbs := []string{"open", "history", "notify", "asks"}
|
||||
verbs := []string{"open", "history", "notify", "tell", "asks"}
|
||||
want := []seat{
|
||||
{Name: asks.Seat, Scope: "mesh", Serves: verbs, Accepts: []string{"ask", "cancel"}, Emits: []string{"decided"},
|
||||
ByCaller: []string{"ask", "cancel", "decided"}, Records: []string{"asks"}},
|
||||
|
||||
@@ -609,7 +609,9 @@ func (h *Holder) sendNow(ch Channel, key, what string, m Message) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// allow says whether a channel is under its cap: messages sent in the last hour, as recorded.
|
||||
// allow says whether a channel is under its cap: messages sent in the last hour, as recorded. A
|
||||
// tell is not counted here: it has its own cap (TellCapPerHour) so a burst of tells cannot crowd
|
||||
// out a condition or an ask (ADR 0380 §8).
|
||||
func (h *Holder) allow(channel string) bool {
|
||||
return h.sentLastHour(channel) < CapPerHour
|
||||
}
|
||||
@@ -620,7 +622,30 @@ func (h *Holder) sentLastHour(channel string) int {
|
||||
since := h.Now().Add(-time.Hour)
|
||||
n := 0
|
||||
for _, s := range h.recent {
|
||||
if s.Channel == channel && s.Outcome == "sent" && s.At.After(since) {
|
||||
if s.Channel == channel && s.What != "tell" && s.Outcome == "sent" && s.At.After(since) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// TellCapPerHour: at most this many tells reach one channel in an hour — the tell verb's own cap
|
||||
// (ADR 0380 §8), never shared with CapPerHour, the conditions' and notify's, so a burst of plain
|
||||
// notices cannot crowd out a condition or an ask.
|
||||
const TellCapPerHour = 10
|
||||
|
||||
// allowTell says whether a channel is under tell's own cap.
|
||||
func (h *Holder) allowTell(channel string) bool {
|
||||
return h.tellSentLastHour(channel) < TellCapPerHour
|
||||
}
|
||||
|
||||
func (h *Holder) tellSentLastHour(channel string) int {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
since := h.Now().Add(-time.Hour)
|
||||
n := 0
|
||||
for _, s := range h.recent {
|
||||
if s.Channel == channel && s.What == "tell" && s.Outcome == "sent" && s.At.After(since) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -309,3 +311,90 @@ func (h *Holder) Notify(key, severity, summary, subject string, clear bool, plai
|
||||
held := h.queued(key)
|
||||
return map[string]any{"key": key, "sent_on": sent, "held": held, "refused": refused, "unsent": pending}, nil
|
||||
}
|
||||
|
||||
// Tell sends a plain notice that needs no answer and opens no condition (ADR 0380 §8): one message with a
|
||||
// headline and one or two lines, said at once on every channel the router sends on — bypassing the severity
|
||||
// routing, the burst window and the walk hold that a condition's message goes through, and never reminded,
|
||||
// escalated or kept open. Composed and held to the content rule exactly as notify's words are (allowedWords'
|
||||
// reduction, withheld parts as today). It offers Details, like any message, and is kept in the history so
|
||||
// `history` shows it — but its record is already marked cleared, so `open` never shows it and it is forgotten
|
||||
// after ReopenWindow, as any cleared record is. Its cap, TellCapPerHour a channel an hour, is its own and
|
||||
// never shared with CapPerHour: past it, a tell is dropped, said once an hour in the log and named in this
|
||||
// call's own "dropped" answer, but never kept in the bounded send history the conditions' cap counts from
|
||||
// (KeptSends, outbox.go's sentLastHour) — a flood of drops must never evict a real, still-recent send from it
|
||||
// and let that cap stop holding (review round 2 of issue 800, issue 797's area).
|
||||
func (h *Holder) Tell(headline string, lines []string, subject string) (map[string]any, error) {
|
||||
headline = strings.TrimSpace(headline)
|
||||
if headline == "" {
|
||||
return nil, errorf("headline is required")
|
||||
}
|
||||
if len(lines) == 0 || len(lines) > 2 {
|
||||
return nil, errorf("lines takes one or two lines")
|
||||
}
|
||||
clean := make([]string, 0, len(lines))
|
||||
for _, l := range lines {
|
||||
l = strings.TrimSpace(l)
|
||||
if l == "" {
|
||||
return nil, errorf("a line is empty")
|
||||
}
|
||||
clean = append(clean, l)
|
||||
}
|
||||
h.work.Lock()
|
||||
defer h.work.Unlock()
|
||||
h.mu.Lock()
|
||||
h.init()
|
||||
h.mu.Unlock()
|
||||
now := h.Now()
|
||||
h.mu.Lock()
|
||||
h.tellSeq++
|
||||
seq := h.tellSeq
|
||||
h.mu.Unlock()
|
||||
key := fmt.Sprintf("tell.%d.%d", now.UnixNano(), seq)
|
||||
rec := &Record{Key: key, Kind: "tell", Subject: subject, Origin: "tell", Headline: headline,
|
||||
Explanation: strings.Join(clean, "\n"), Summary: headline, Raised: now, Seen: now, Count: 1,
|
||||
Sent: map[string]string{}, Cleared: now}
|
||||
h.mu.Lock()
|
||||
h.open[key] = rec
|
||||
h.mu.Unlock()
|
||||
var sentOn, dropped []string
|
||||
for _, ch := range h.channels() {
|
||||
name := ch.Name()
|
||||
if ch.Ready() != nil {
|
||||
continue
|
||||
}
|
||||
if !h.allowTell(name) {
|
||||
dropped = append(dropped, name)
|
||||
h.mu.Lock()
|
||||
said := h.tellCapSaid[name]
|
||||
sayIt := said.IsZero() || now.Sub(said) >= time.Hour
|
||||
if sayIt {
|
||||
h.tellCapSaid[name] = now
|
||||
}
|
||||
h.mu.Unlock()
|
||||
if sayIt {
|
||||
h.Logf("[messenger] a tell was dropped on %s: it is at its own cap of %d tells an hour", name, TellCapPerHour)
|
||||
}
|
||||
// Not h.record'd: that history is bounded (KeptSends) and is what the conditions' cap counts from
|
||||
// (sentLastHour, outbox.go); a flood of drops must never evict a real send from it and let the
|
||||
// conditions' cap stop holding (review round 2 of issue 800, issue 797's area). This call's own
|
||||
// "dropped" answer, and the once-an-hour log line above, already say it happened.
|
||||
continue
|
||||
}
|
||||
m := h.sayOn(rec, "tell", name)
|
||||
id, err := ch.Send(m)
|
||||
h.noteChannel(name, err)
|
||||
if err != nil {
|
||||
h.record(Sent{At: now, Channel: name, Key: key, What: "tell", Outcome: "failed: " + err.Error()})
|
||||
continue
|
||||
}
|
||||
h.mu.Lock()
|
||||
rec.Sent[name] = id
|
||||
h.mu.Unlock()
|
||||
sentOn = append(sentOn, name)
|
||||
h.record(Sent{At: now, Channel: name, Key: key, What: "tell", Outcome: "sent"})
|
||||
}
|
||||
h.persist(rec)
|
||||
sort.Strings(sentOn)
|
||||
sort.Strings(dropped)
|
||||
return map[string]any{"key": key, "sent_on": sentOn, "dropped": dropped, "refused": rec.Refused}, nil
|
||||
}
|
||||
|
||||
@@ -63,6 +63,10 @@ func compose(r *Record, what string, now time.Time, loc *time.Location, withhold
|
||||
if urgent {
|
||||
title = "Urgent: " + title
|
||||
}
|
||||
case "tell":
|
||||
// A plain notice (ADR 0380 §1, §8): the headline and its one or two lines, nothing else — no
|
||||
// "since", no severity, no reminder: it waits for no answer and is never said again.
|
||||
return Message{Title: headline, Body: explanation}
|
||||
default:
|
||||
title = headline
|
||||
if urgent {
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"open",
|
||||
"history",
|
||||
"notify",
|
||||
"tell",
|
||||
"asks"
|
||||
],
|
||||
"accepts": [
|
||||
@@ -62,6 +63,7 @@
|
||||
"open",
|
||||
"history",
|
||||
"notify",
|
||||
"tell",
|
||||
"asks"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user