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:
2026-10-16 07:37:43 +00:00
parent 1a2c917abf
commit 9652b2d5ce
771 changed files with 52063 additions and 1774 deletions
+40
View File
@@ -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)
}
}
+5
View File
@@ -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
+2
View File
@@ -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=
+67
View File
@@ -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"
]
}
]
}
}