Feature 57 (config and provisioning): modules declare config and provisions, their files rendered from templates; synced with main (issues of feature 57, 874)
This commit is contained in:
@@ -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 — this is one process, judged only by its own tool (ADR 0227 rule 8); a watching process of its own is issue 841, not yet decided. 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,232 @@
|
||||
// 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 one process serves mail-notifier.status and the module's own health tool (mail_notifier_health,
|
||||
// review round 1 of issue 800), and, beside stdio.Serve, hears mesh-mail's added events and shows them
|
||||
// (exactly as modules/mesh-knowledge's serve starts go hear(k) and go background(k) before stdio.Serve) — not
|
||||
// a separate `notify` process, which systemd gave an empty stdin and so exited at once, every five seconds
|
||||
// (novox/hq issue 859). What this module says, it says on stderr; stdout is the MCP channel.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"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(sendKey(), 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()
|
||||
|
||||
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 := run(n, watch, os.Stdin, os.Stdout, tools); err != nil {
|
||||
logf("%v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// sendKey is the ask key Send uses: this machine's own node-notifier, named exactly as desk-channel asks
|
||||
// another machine's ("seat:node-notifier.send@"+machine) and as the SDK's own Emit reads this machine's name
|
||||
// (MESH_NODE, which the runtime sets) — not the bare seat, which any machine's node-notifier may answer
|
||||
// first, leaving this one's notices undelivered (novox/hq issue 865).
|
||||
func sendKey() string { return "seat:node-notifier.send@" + os.Getenv("MESH_NODE") }
|
||||
|
||||
// run is main's startup path, factored so a test can run it: the watcher started before stdio.Serve, exactly
|
||||
// as mesh-knowledge's serve starts go hear(k) and go background(k) before stdio.Serve — so the watcher keeps
|
||||
// running whatever stdio.Serve does with the stream it is given, including returning at once when stdin is
|
||||
// already at EOF, as systemd gave the old separate `notify` process (novox/hq issue 859). The node's runtime
|
||||
// launches this one process and keeps it alive.
|
||||
func run(n *Notifier, watcher func(*Notifier), in io.Reader, out io.Writer, tools []stdio.Tool) error {
|
||||
go watcher(n)
|
||||
return stdio.ServeOn("", tools, in, out)
|
||||
}
|
||||
|
||||
// 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) { watchWith(n, stdio.Subscribe, time.Sleep) }
|
||||
|
||||
// watchWith is watch with its subscribe and sleep factored out, so a test can make a subscribe fail without
|
||||
// the real bus and without the real wait. A subscribe the runtime refuses is retried, 2 seconds doubling to
|
||||
// at most a minute, logging each refusal — exactly as modules/mesh-knowledge's hear does — never fatal: a
|
||||
// refusal here must never take mail-notifier.status and mail_notifier_health down with it (novox/hq issue
|
||||
// 859, review round 1; the old os.Exit(1) did exactly that).
|
||||
func watchWith(n *Notifier, subscribe func(string, func(stdio.Envelope) error) error, sleep func(time.Duration)) {
|
||||
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
|
||||
sleep(wait)
|
||||
err := 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 {
|
||||
break
|
||||
}
|
||||
logf("[mesh-mail-notifier] does not hear %s yet (%v); asking again in %s", AddedPattern, err, wait)
|
||||
}
|
||||
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,38 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The manifest's own shape (novox/hq issue 859): the watch loop runs in the base invocation beside
|
||||
// stdio.Serve, so no resource declares a separate `notify` process any more — the thing that exited at once,
|
||||
// every five seconds, because a systemd-run process's stdin is empty and stdio.Serve returns at EOF.
|
||||
|
||||
type manifestResources struct {
|
||||
Resources []map[string]any `json:"resources"`
|
||||
}
|
||||
|
||||
func readManifestResources(t *testing.T) manifestResources {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m manifestResources
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatalf("module.json: %v", err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func TestNoProcessResourceIsDeclared(t *testing.T) {
|
||||
m := readManifestResources(t)
|
||||
for _, r := range m.Resources {
|
||||
if r["type"] == "process" {
|
||||
t.Fatalf("module.json still declares a process resource (%v): the watcher runs in the base invocation now, not a separate process (novox/hq issue 859)", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,114 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// TestRunStartsTheWatcherEvenWhenStdinIsAtEOF proves the watcher runs beside stdio.Serve in the one process the
|
||||
// node's runtime launches (novox/hq ADR 0378 §9, as mesh-knowledge's serve starts go hear(k) and go
|
||||
// background(k) before stdio.Serve), not only in a separate process something keeps asking over stdin. run is
|
||||
// given stdin already at EOF, exactly what systemd gave the old separate `notify` process: it exited at once,
|
||||
// every five seconds (novox/hq issue 859). The watcher must still be running once run has returned — proved by
|
||||
// making it actually answer after run returns, not by the absence of a signal that nothing but the test's own
|
||||
// cleanup could ever raise.
|
||||
func TestRunStartsTheWatcherEvenWhenStdinIsAtEOF(t *testing.T) {
|
||||
started := make(chan struct{})
|
||||
ping := make(chan struct{})
|
||||
pong := make(chan struct{})
|
||||
|
||||
fakeWatcher := func(n *Notifier) {
|
||||
close(started)
|
||||
for range ping {
|
||||
pong <- struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
n := &Notifier{Store: newFakeStore()}
|
||||
n.init()
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- run(n, fakeWatcher, strings.NewReader(""), io.Discard, nil) }()
|
||||
|
||||
select {
|
||||
case <-started:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("the watcher was not started before stdio.Serve returned")
|
||||
}
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatalf("run over stdin at EOF: %v", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("run did not return once stdin reached EOF, as systemd gives a process nobody talks to")
|
||||
}
|
||||
|
||||
// run has returned (stdio.Serve hit EOF at once); the watcher must still be live and answering, not merely
|
||||
// not yet torn down. A watcher that stopped when run returned would never take this ping.
|
||||
select {
|
||||
case ping <- struct{}{}:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("the watcher is no longer running once run returned: it must not stop just because stdio.Serve did")
|
||||
}
|
||||
select {
|
||||
case <-pong:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("the watcher did not answer once run returned")
|
||||
}
|
||||
close(ping)
|
||||
}
|
||||
|
||||
// TestWatchRetriesASubscribeRefusalWithBackoffAndNeverExits proves watch no longer exits the process on a
|
||||
// subscribe the runtime refuses — the old os.Exit(1) took mail-notifier.status and mail_notifier_health down
|
||||
// with it — but retries with backoff and logs each refusal, as modules/mesh-knowledge's hear does (novox/hq
|
||||
// issue 859, review round 1). A subscribe that fails twice then succeeds must leave the process serving (run
|
||||
// still returns normally over stdin at EOF, never os.Exit) and the watcher subscribed (its third, successful
|
||||
// call).
|
||||
func TestWatchRetriesASubscribeRefusalWithBackoffAndNeverExits(t *testing.T) {
|
||||
var calls int32
|
||||
subscribed := make(chan struct{})
|
||||
fakeSubscribe := func(pattern string, handle func(stdio.Envelope) error) error {
|
||||
if atomic.AddInt32(&calls, 1) <= 2 {
|
||||
return errors.New("the runtime refused this subscribe")
|
||||
}
|
||||
close(subscribed)
|
||||
return nil
|
||||
}
|
||||
noSleep := func(time.Duration) {}
|
||||
|
||||
n := &Notifier{Store: newFakeStore()}
|
||||
n.init()
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- run(n, func(n *Notifier) { watchWith(n, fakeSubscribe, noSleep) },
|
||||
strings.NewReader(""), io.Discard, nil)
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatalf("run over stdin at EOF: %v", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("run did not return once stdin reached EOF: a subscribe refusal must not hold up or kill the process")
|
||||
}
|
||||
|
||||
select {
|
||||
case <-subscribed:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("the watcher never subscribed after its first two refusals")
|
||||
}
|
||||
|
||||
if got := atomic.LoadInt32(&calls); got != 3 {
|
||||
t.Fatalf("subscribe was called %d time(s), want 3 (two refusals, then the one that succeeded)", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestSendKeyNamesThisMachine proves Send asks its own machine's node-notifier, not the bare seat: a bare
|
||||
// "seat:node-notifier.send" is answered by whichever machine's node-notifier takes it first, so notices sent
|
||||
// from g14 and shanks were lost to "503 no responders" or another machine's desktop (novox/hq issue 865).
|
||||
// desk-channel names the machine it asks the same way ("seat:node-notifier.send@"+machine,
|
||||
// modules/desk-channel/cmd/desk-channel/main.go); sendKey must read this machine's own name the same way the
|
||||
// SDK's own Emit does, from MESH_NODE, which the runtime sets.
|
||||
func TestSendKeyNamesThisMachine(t *testing.T) {
|
||||
t.Setenv("MESH_NODE", "g14")
|
||||
|
||||
if got, want := sendKey(), "seat:node-notifier.send@g14"; got != want {
|
||||
t.Fatalf("sendKey() = %q, want %q (it must name this machine, as desk-channel does)", got, want)
|
||||
}
|
||||
}
|
||||
@@ -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,67 @@
|
||||
{
|
||||
"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": "."
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "tools",
|
||||
"kind": "bundle",
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"from": "cmd/mesh-mail-notifier",
|
||||
"binary": "mesh-mail-notifier",
|
||||
"loads": [
|
||||
"mesh-mail-notifier"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user