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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user