issue 800: fix review round 1 findings in mesh-mail-notifier

Flush now sorts accounts instead of walking a map, so notice order is
stable. A failed node-notifier.send keeps its mail pending and retries
at most once a minute, without holding up other accounts' notices, and
the failure is kept in the Store for a new health tool
(mail_notifier_health) that is unhealthy past ten minutes of failure.
The burst rule now follows the lead's reading of ADR 0378 S9: within an
account's sliding minute, the first three qualifying mails are shown
one by one and the rest are folded into one notice at the window's
end; there is no separate quiet-gap timer. The dedupe key is the
account and message id, falling back to folder/uidvalidity/uid only
when no message id is given, so a UIDVALIDITY reset cannot suppress a
new mail, and a redelivered event already buffered is never recounted.
status now lists every granted account, not only those with a notice,
and caches mail-store.accounts for a minute.
This commit is contained in:
jochen
2026-10-15 17:54:27 +02:00
parent 0993c31ad4
commit ed66a2e873
7 changed files with 762 additions and 185 deletions
+10 -5
View File
@@ -15,11 +15,15 @@ on this machine's notifier, through `node-notifier.send`, at most once:
notice is shown)
- quiet for a mail already marked read (`\Seen` in its flags)
- quiet for anything outside the inbox
- more than three new mails from one account arriving close together become one notice naming the count and
the latest sender, rather than one notice each
- 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
Each notice shown emits this module's own event `shown` (account and message id, never sender or subject), and
is kept in its own state (`shown`) so a redelivered event is not shown twice.
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
@@ -32,4 +36,5 @@ accounts it announces, in mesh-mail's trusted setting `grants` (`mail-store`'s s
| tool | does |
|---|---|
| `mail-notifier.status` | the accounts it announces on this machine, and when it last showed a notice; its rules. Read-only |
| `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. Read-only |
@@ -0,0 +1,53 @@
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 —
// 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.
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)
}
}
@@ -1,6 +1,7 @@
// mesh-mail-notifier: the default claimant of mail-notifier, built from mesh-mail's own event shape (novox/hq
// ADR 0378 §9, issue 800 part B). A Go bundle the node's runtime launches, its own account (runs-as, ADR 0376
// §2): the base invocation serves mail-notifier.status; a separate process (`notify`) hears mesh-mail's added
// §2): the base invocation serves mail-notifier.status and the module's own health tool
// (mail_notifier_health, review round 1 of issue 800); a separate process (`notify`) hears mesh-mail's added
// events and shows them. What this module says, it says on stderr; stdout is the MCP channel.
package main
@@ -50,18 +51,31 @@ func main() {
return
}
tools := []stdio.Tool{{Name: StatusTool,
Description: "The accounts mesh-mail-notifier announces on this machine, and when it last showed a " +
"notice of new mail (novox/hq ADR 0378 §7). Read-only.",
Run: func(map[string]any) (any, error) { return n.Status() }}}
tools := []stdio.Tool{
{Name: StatusTool,
Description: "The accounts mesh-mail-notifier announces on this machine, and when it last showed a " +
"notice of new mail (novox/hq ADR 0378 §7). Read-only.",
Run: func(map[string]any) (any, error) { return n.Status() }},
{Name: HealthTool,
Description: "How mesh-mail-notifier is: unhealthy once a notice has failed to show for more than " +
"ten minutes, healthy otherwise, even mid-retry (review round 1 of issue 800). Read-only.",
Run: func(map[string]any) (any, error) {
failures, err := busStore{}.Failures()
if err != nil {
return nil, err
}
return Judge(failures, time.Now()), nil
}},
}
if err := stdio.Serve("", tools); err != nil {
logf("%v", err)
os.Exit(1)
}
}
// watch hears mesh-mail's added events and flushes what it buffered once each account has been quiet a
// moment (ADR 0378 §9: folding a burst into one notice).
// watch hears mesh-mail's added events and, once a second, flushes what Notifier.Flush finds ready: an
// account's first mails of its window at once, and its folded overflow once the window is over (ADR 0378 §9,
// the lead's reading, review round 1 of issue 800).
func watch(n *Notifier) {
time.Sleep(500 * time.Millisecond) // Serve first: the bus is reached through it
err := stdio.Subscribe(AddedPattern, func(e stdio.Envelope) error {
@@ -108,7 +122,7 @@ func readAccounts() (map[string]string, error) {
}
// busStore keeps the notifier's own state (the `shown` bucket the manifest declares): which messages were
// shown already, and each account's last notice.
// shown already, each account's last notice, and each account's notice still failing to send.
type busStore struct{}
func stateKey(parts ...string) string {
@@ -165,3 +179,35 @@ func (busStore) Statuses() (map[string]AccountStatus, error) {
}
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
}
@@ -4,12 +4,15 @@ package main
// issue 800 part B). It hears mesh-mail's added events on its own account (runs-as, ADR 0376 §2) and shows each
// new inbox mail at most once, through node-notifier.send: quiet during a first sync (mesh-mail already says
// nothing then, imap.go ~387), for mail from the account's own address, for mail already marked read (\Seen),
// and for anything outside the inbox. More than three new mails from one account arriving close together are
// folded into one notice naming the count and the latest sender, rather than shown one by one.
// and 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 (novox/hq ADR 0378 §9, the lead's reading, review round 1 of
// issue 800).
//
// Nothing here names Betterbird (ADR 0379 §3).
import (
"sort"
"strconv"
"strings"
"sync"
@@ -22,14 +25,23 @@ const Inbox = "INBOX"
// Seen is the flag that means a mail was already read: shown for nothing it marks.
const SeenFlag = "\\Seen"
// FoldOver is how many new mails from one account, arriving close together, are still shown one by one; one
// more than that and they become a single notice (novox/hq ADR 0378 §9: "more than three").
// 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
// BurstWindow is how long the notifier waits, after the last qualifying mail of an account, before it decides
// whether to show what it buffered one by one or as a single notice. Well under the minute the decision is
// judged over (ADR 0378 §9), so a burst within that minute is still buffered together.
const BurstWindow = 5 * time.Second
// Window is the sliding minute ADR 0378 §9 judges a burst over: an account's first qualifying mail opens it,
// and everything that account qualifies for until it closes, FoldOver mails onward, is one window's count.
// There is no separate quiet-gap timer (review round 1 of issue 800: BurstWindow goes).
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 {
@@ -40,19 +52,27 @@ type Person struct {
// EventBody is what mesh-mail's added event says of a message (metadata only; mailevent.go names where it is
// said). Unknown fields are ignored: the notifier reads only what it needs.
type EventBody struct {
Account string `json:"account"`
Folder string `json:"folder"`
UID uint32 `json:"uid"`
MessageID string `json:"message-id"`
Subject string `json:"subject"`
From []Person `json:"from"`
Flags []string `json:"flags"`
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").
// 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 {
return e.Account + "\x1f" + e.Folder + "\x1f" + strconv.FormatUint(uint64(e.UID), 10)
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 {
@@ -92,13 +112,26 @@ type Notice struct {
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, and each account's last notice, for its status tool.
// 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.
@@ -107,8 +140,19 @@ type AccountStatus struct {
Summary string `json:"summary"`
}
// Notifier buffers qualifying mail per account and decides, once no more of that account's mail arrives for a
// moment, how to show what it buffered.
// accountBuffer is one account's sliding window: the first FoldOver qualifying mails are shown one by one as
// soon as Flush runs; the rest, from the fourth onward, are folded and shown as one notice once the window has
// run its Minute (novox/hq ADR 0378 §9, the lead's reading).
type accountBuffer struct {
windowStart time.Time
count int // qualifying mails counted in this window so far
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
@@ -120,19 +164,24 @@ type Notifier struct {
EmitShown func(account, messageID string) error
Now func() time.Time
Logf func(string, ...any)
Debounce time.Duration
mu sync.Mutex
pending map[string][]EventBody
last map[string]time.Time
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][]EventBody{}
n.last = map[string]time.Time{}
n.pending = map[string]*accountBuffer{}
n.buffered = map[string]bool{}
n.cooldown = map[string]time.Time{}
}
if n.Now == nil {
n.Now = time.Now
@@ -140,16 +189,14 @@ func (n *Notifier) init() {
if n.Logf == nil {
n.Logf = func(string, ...any) {}
}
if n.Debounce == 0 {
n.Debounce = BurstWindow
}
}
// Add takes one added event. It returns an error only to have the event offered again (the handler
// Subscribe wraps this in follows that rule).
func (n *Notifier) Add(e EventBody) error {
n.init()
seen, err := n.Store.HandleSeen(e.Handle())
handle := e.Handle()
seen, err := n.Store.HandleSeen(handle)
if err != nil {
return err
}
@@ -163,10 +210,25 @@ func (n *Notifier) Add(e EventBody) error {
if !n.qualifies(e, own, known) {
return nil
}
n.mu.Lock()
n.pending[e.Account] = append(n.pending[e.Account], e)
n.last[e.Account] = n.Now()
n.mu.Unlock()
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 || now.Sub(b.windowStart) >= Window {
b = &accountBuffer{windowStart: now}
n.pending[e.Account] = b
}
b.count++
n.buffered[handle] = true
if b.count <= FoldOver {
b.immediate = append(b.immediate, e)
} else {
b.folded = append(b.folded, e)
}
return nil
}
@@ -185,7 +247,7 @@ func (n *Notifier) qualifies(e EventBody, ownAddress string, ownKnown bool) bool
}
func (n *Notifier) ownAddress(account string) (string, bool, error) {
accounts, err := n.Accounts()
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.
@@ -196,83 +258,198 @@ func (n *Notifier) ownAddress(account string) (string, bool, error) {
return addr, ok, nil
}
// Flush decides every account whose buffer has been quiet for Debounce: one notice each mail it holds, or
// one notice folding them all when there are more than FoldOver.
// 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()
var ready []string
for account, at := range n.last {
if now.Sub(at) >= n.Debounce {
ready = append(ready, account)
}
}
batches := map[string][]EventBody{}
for _, account := range ready {
batches[account] = n.pending[account]
delete(n.pending, account)
delete(n.last, account)
accounts := make([]string, 0, len(n.pending))
for account := range n.pending {
accounts = append(accounts, account)
}
n.mu.Unlock()
for account, events := range batches {
if err := n.decide(account, events); err != nil {
return err
}
sort.Strings(accounts)
for _, account := range accounts {
n.flushAccount(account, now)
}
return nil
}
// decide shows what one account buffered, and emits `shown` for the notice.
func (n *Notifier) decide(account string, events []EventBody) error {
if len(events) == 0 {
return nil
// flushAccount ships one account's ready notices (its immediate mail, and its folded mail once the window is
// over), and keeps whatever fails to send pending, under cooldown, for the next Flush.
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...)
windowDone := now.Sub(b.windowStart) >= Window
var fold []EventBody
if windowDone {
fold = append([]EventBody(nil), b.folded...)
}
n.mu.Unlock()
if len(immediate) == 0 && len(fold) == 0 {
if windowDone {
n.closeIfEmpty(account, b)
}
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)); 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 = nil
}
// The window itself — its start and count — is only ever dropped once it has run its course: while it
// is still open, mail arriving later in it must still count against the same window, even if nothing
// is pending to send right this moment (the bug review round 1 of issue 800 found: emptying the
// buffer early reset the window and let a fourth mail be shown on its own instead of folded).
if windowDone && len(cur.immediate) == 0 && len(cur.folded) == 0 {
delete(n.pending, account)
}
}
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()
}
func (n *Notifier) closeIfEmpty(account string, b *accountBuffer) {
n.mu.Lock()
defer n.mu.Unlock()
if cur := n.pending[account]; cur == b && len(cur.immediate) == 0 && len(cur.folded) == 0 {
delete(n.pending, account)
}
}
// 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 an account's window folded — its fourth qualifying mail onward —
// naming how many and the latest sender (novox/hq ADR 0378 §9: "more than three new mails from one account
// within a minute become one notice that says how many and names the latest sender").
func (n *Notifier) foldNotice(account string, events []EventBody) Notice {
latest := events[len(events)-1]
var notice Notice
if len(events) > FoldOver {
notice = Notice{
Account: account,
Args: map[string]any{
"summary": strconv.Itoa(len(events)) + " new mails",
"body": "latest from " + latest.sender() + " — " + account,
"app_name": "mesh-mail",
},
Latest: latest,
}
for _, e := range events {
notice.Handles = append(notice.Handles, e.Handle())
}
if err := n.show(notice); err != nil {
return err
}
return nil
notice := Notice{
Account: account,
Args: map[string]any{
"summary": strconv.Itoa(len(events)) + " new mails in " + account,
"body": "latest from " + latest.sender(),
"app_name": "mesh-mail",
},
Latest: latest,
}
for _, e := range events {
notice := Notice{
Account: account,
Args: map[string]any{
"summary": e.Subject,
"body": e.sender() + " — " + account,
"app_name": "mesh-mail",
},
Handles: []string{e.Handle()},
Latest: e,
}
if err := n.show(notice); err != nil {
return err
}
notice.Handles = append(notice.Handles, e.Handle())
}
return nil
return notice
}
// show sends one notice, marks every message it accounts for seen, keeps the account's status, and emits
// `shown`.
// `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)
@@ -286,22 +463,46 @@ func (n *Notifier) show(notice Notice) error {
}
// 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").
// 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}
}
return map[string]any{
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",
"more than three new mails from one account arriving close together become one notice naming the count and the latest sender",
"of one account's qualifying mail within a sliding minute, the first three are shown one by one; the rest of that minute is folded into one notice naming the count and the latest sender",
},
}, nil
}
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
}
@@ -1,6 +1,8 @@
package main
import (
"errors"
"fmt"
"strings"
"sync"
"testing"
@@ -12,10 +14,15 @@ 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{}}
return &fakeStore{
handles: map[string]bool{},
statuses: map[string]AccountStatus{},
failures: map[string]FailureStatus{},
}
}
func (s *fakeStore) HandleSeen(handle string) (bool, error) {
@@ -48,7 +55,31 @@ func (s *fakeStore) Statuses() (map[string]AccountStatus, error) {
return out, nil
}
// fakeClock is a clock the test moves by hand, so Flush's debounce needs no real sleep.
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
@@ -66,104 +97,300 @@ func (c *fakeClock) Advance(d time.Duration) {
c.now = c.now.Add(d)
}
// TestFirstMailOfAnAccountIsShownOnceEachAndABurstIsFolded is the issue's first test (novox/hq ADR 0378 §9,
// issue 800 part B): given a new inbox mail from another sender, one already read, one from the account's own
// address, one in another folder, and five within a minute from one account, it sends exactly one notice,
// nothing, nothing, nothing, and one notice folding the five and naming the latest sender; it fails today
// because the module does not exist.
func TestFirstMailOfAnAccountIsShownOnceEachAndABurstIsFolded(t *testing.T) {
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 mu sync.Mutex
var sent []map[string]any
var shown []struct{ account, id string }
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 {
mu.Lock()
defer mu.Unlock()
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{"work": "me@work.example", "home": "me@home.example"}, nil
},
Store: store,
EmitShown: func(account, messageID string) error {
mu.Lock()
defer mu.Unlock()
shown = append(shown, struct{ account, id string }{account, messageID})
return nil
},
Logf: t.Logf,
Accounts: func() (map[string]string, error) { return map[string]string{}, nil },
Store: store,
EmitShown: func(string, string) error { return nil },
Logf: t.Logf,
}
// 1. A new inbox mail from another sender: shown.
one := EventBody{Account: "work", Folder: "INBOX", UID: 1, MessageID: "m1", Subject: "Hi",
From: []Person{{Name: "Alice", Address: "alice@example.com"}}}
// 2. Already read.
two := EventBody{Account: "work", Folder: "INBOX", UID: 2, MessageID: "m2", Subject: "Old",
From: []Person{{Address: "bob@example.com"}}, Flags: []string{"\\Seen"}}
// 3. From the account's own address.
three := EventBody{Account: "work", Folder: "INBOX", UID: 3, MessageID: "m3", Subject: "Sent",
From: []Person{{Address: "me@work.example"}}}
// 4. In another folder.
four := EventBody{Account: "work", Folder: "Archive", UID: 4, MessageID: "m4", Subject: "Filed",
From: []Person{{Address: "carol@example.com"}}}
for _, e := range []EventBody{one, two, three, four} {
if err := n.Add(e); err != nil {
t.Fatalf("Add(%+v): %v", e, err)
}
if err := n.Add(mail("broke", 1, "b1", "Broken", "Someone")); err != nil {
t.Fatalf("Add(broke): %v", err)
}
// 5. Five new inbox mails from another account within a minute.
var burst []EventBody
for i := 1; i <= 5; i++ {
e := EventBody{Account: "home", Folder: "INBOX", UID: uint32(i), MessageID: "burst" + string(rune('0'+i)),
Subject: "Mail", From: []Person{{Name: "Sender " + string(rune('0'+i)), Address: "s@example.com"}}}
burst = append(burst, e)
clock.Advance(time.Second)
if err := n.Add(e); err != nil {
t.Fatalf("Add(%+v): %v", e, err)
}
if err := n.Add(mail("ok", 1, "o1", "Fine", "Someone")); err != nil {
t.Fatalf("Add(ok): %v", err)
}
latest := burst[len(burst)-1]
clock.Advance(BurstWindow + time.Second)
if err := n.Flush(); err != nil {
t.Fatalf("Flush: %v", err)
}
if len(sent) != 2 {
t.Fatalf("sent %d notice(s): %+v", len(sent), sent)
if 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 sent[0]["summary"] != "Hi" || !strings.Contains(sent[0]["body"].(string), "Alice") {
t.Errorf("the first notice is %+v, want Alice's mail Hi", sent[0])
}
if sent[1]["summary"] != "5 new mails" || !strings.Contains(sent[1]["body"].(string), latest.sender()) {
t.Errorf("the folded notice is %+v, want 5 new mails naming %s", sent[1], latest.sender())
if failures, err := store.Failures(); err != nil || failures["broke"].Count != 1 {
t.Fatalf("Store.Failures() = %+v, %v; want broke recorded once", failures, err)
}
if len(shown) != 2 {
t.Fatalf("emitted shown %d time(s): %+v", len(shown), shown)
}
if shown[0].account != "work" || shown[0].id != "m1" {
t.Errorf("the first shown is %+v, want work/m1", shown[0])
}
if shown[1].account != "home" || shown[1].id != latest.MessageID {
t.Errorf("the folded shown is %+v, want home/%s (the latest)", shown[1], latest.MessageID)
}
// Redelivering the first event again shows nothing more: it was marked seen.
if err := n.Add(one); err != nil {
t.Fatalf("Add(one) again: %v", err)
}
clock.Advance(BurstWindow + time.Second)
// 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) != 2 {
t.Fatalf("a redelivered event was shown again: sent now %+v", sent)
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 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 new mails in home" || !strings.Contains(sent[3]["body"].(string), "Sender 4") {
t.Errorf("the fold notice is %+v, want it to say 1 new mail in home, 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 new mails in home" {
t.Errorf("the fold notice is %+v, want it to count the redelivered fourth mail once, not twice", 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)
}
}
+8 -2
View File
@@ -26,7 +26,8 @@
}
],
"tools": [
"mail-notifier.status"
"mail-notifier.status",
"mail_notifier_health"
],
"own-secrets": {
"broker": "${dir:state}/broker"
@@ -57,7 +58,12 @@
"notify"
],
"health": {
"kind": "unit"
"kind": "tool",
"tool": "mail_notifier_health",
"interval": "60s",
"timeout": "10s",
"looks": 2,
"grace": "120s"
}
}
],