From ed66a2e8735f5c9eb74220b356e963735347ed23 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 15 Oct 2026 17:54:27 +0200 Subject: [PATCH] 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. --- modules/mesh-mail-notifier/README.md | 15 +- .../cmd/mesh-mail-notifier/health.go | 53 +++ .../cmd/mesh-mail-notifier/health_test.go | 39 ++ .../cmd/mesh-mail-notifier/main.go | 62 ++- .../cmd/mesh-mail-notifier/notifier.go | 385 +++++++++++++----- .../cmd/mesh-mail-notifier/notifier_test.go | 383 +++++++++++++---- modules/mesh-mail-notifier/module.json | 10 +- 7 files changed, 762 insertions(+), 185 deletions(-) create mode 100644 modules/mesh-mail-notifier/cmd/mesh-mail-notifier/health.go create mode 100644 modules/mesh-mail-notifier/cmd/mesh-mail-notifier/health_test.go diff --git a/modules/mesh-mail-notifier/README.md b/modules/mesh-mail-notifier/README.md index 1f82055c..2ce347ac 100644 --- a/modules/mesh-mail-notifier/README.md +++ b/modules/mesh-mail-notifier/README.md @@ -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 | diff --git a/modules/mesh-mail-notifier/cmd/mesh-mail-notifier/health.go b/modules/mesh-mail-notifier/cmd/mesh-mail-notifier/health.go new file mode 100644 index 00000000..324b6af2 --- /dev/null +++ b/modules/mesh-mail-notifier/cmd/mesh-mail-notifier/health.go @@ -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, "; ")) +} diff --git a/modules/mesh-mail-notifier/cmd/mesh-mail-notifier/health_test.go b/modules/mesh-mail-notifier/cmd/mesh-mail-notifier/health_test.go new file mode 100644 index 00000000..750beae2 --- /dev/null +++ b/modules/mesh-mail-notifier/cmd/mesh-mail-notifier/health_test.go @@ -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) + } +} diff --git a/modules/mesh-mail-notifier/cmd/mesh-mail-notifier/main.go b/modules/mesh-mail-notifier/cmd/mesh-mail-notifier/main.go index bf74727d..36bc9180 100644 --- a/modules/mesh-mail-notifier/cmd/mesh-mail-notifier/main.go +++ b/modules/mesh-mail-notifier/cmd/mesh-mail-notifier/main.go @@ -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 +} diff --git a/modules/mesh-mail-notifier/cmd/mesh-mail-notifier/notifier.go b/modules/mesh-mail-notifier/cmd/mesh-mail-notifier/notifier.go index 5e2801b9..27e0d88d 100644 --- a/modules/mesh-mail-notifier/cmd/mesh-mail-notifier/notifier.go +++ b/modules/mesh-mail-notifier/cmd/mesh-mail-notifier/notifier.go @@ -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 } diff --git a/modules/mesh-mail-notifier/cmd/mesh-mail-notifier/notifier_test.go b/modules/mesh-mail-notifier/cmd/mesh-mail-notifier/notifier_test.go index 99582247..5a191ad6 100644 --- a/modules/mesh-mail-notifier/cmd/mesh-mail-notifier/notifier_test.go +++ b/modules/mesh-mail-notifier/cmd/mesh-mail-notifier/notifier_test.go @@ -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) } } diff --git a/modules/mesh-mail-notifier/module.json b/modules/mesh-mail-notifier/module.json index 1d1cb773..15dbd40a 100644 --- a/modules/mesh-mail-notifier/module.json +++ b/modules/mesh-mail-notifier/module.json @@ -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" } } ],