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 e4fa191a..6f8f997d 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,17 @@ 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. Of one account's qualifying mail, a mail is shown at once when fewer -// than FoldOver of that account's mails were shown in the Window before it; otherwise it is folded and shown, -// with the other mail its session folds, once Window has passed since the first of them — the window slides -// on the gap between mails, not on time elapsed since the session's first one, so a session already past its -// quota stays folding for as long as mail keeps arriving within Window of the last (novox/hq ADR 0378 §9, the -// lead's reading, review rounds 1 and 2 of issue 800). +// and for anything outside the inbox. For each new qualifying mail of an account, arriving at t, N counts that +// account's qualifying mail — shown or folded alike, each counted once — that arrived in the 60 seconds up to +// and including t: N <= FoldOver is shown at once; N > FoldOver is folded. The first folded mail opens the +// account's fold; every folded mail joins it; the fold is shown, as one notice naming how many it holds and +// the latest sender, once Window has passed since its own first mail — after which a new fold may open. The +// count is a true sliding window of arrivals, not a per-session quota that only resets after a quiet Window: +// review round 2's reading kept a session's count alive, and so folding, for as long as any gap stayed under +// Window, which folds a steady trickle (one mail every 50s, say) forever even though no burst ever happened; +// review round 3 of issue 800 replaces that reading with this one. Nothing pending — a folded mail awaiting +// its fold's deadline, or a failed send awaiting its retry — is ever dropped (novox/hq ADR 0378 §9, review +// round 3 of issue 800, which replaces the round 1 and round 2 readings). // // Nothing here names Betterbird (ADR 0379 §3). @@ -31,14 +36,11 @@ const SeenFlag = "\\Seen" // that window is folded into a single notice (novox/hq ADR 0378 §9: "more than three"). const FoldOver = 3 -// Window is the sliding minute ADR 0378 §9 judges a burst over. It slides on the gap between one qualifying -// mail and the next, not on time elapsed since a session's first mail (review round 2 of issue 800): a mail -// is shown at once when fewer than FoldOver of the account's mails were shown in the Window before it; past -// that, it is folded and shown, with whatever else its session folds, once Window has passed since the first -// of them. A session's count never resets while mail keeps arriving within Window of the last one, however -// long the session has run; it is only ever replaced once idle for a full Window with nothing left pending -// (review round 2 of issue 800, BLOCKING: a buffer is never replaced while it holds an unsent mail). There is -// no separate quiet-gap timer beside this (review round 1 of issue 800: BurstWindow goes). +// Window is the trailing 60 seconds ADR 0378 §9 counts a mail's N over (every qualifying mail of the account, +// shown or folded alike, that arrived in the 60 seconds up to and including this one), and the time a fold +// waits, from its own first mail, before it is shown (review round 3 of issue 800, which replaces the round 1 +// and round 2 readings: the count is a true sliding window of arrivals, not a per-session quota that stays +// alive, and so keeps folding, for as long as any gap between mails stays under Window). const Window = time.Minute // RetryBackoff is how long the notifier waits, after an account's notice fails to send, before it tries that @@ -147,16 +149,14 @@ type AccountStatus struct { Summary string `json:"summary"` } -// accountBuffer is one account's sliding session: it stays open, and its count never resets, as long as a -// qualifying mail keeps arriving within Window of the previous one (review round 2 of issue 800). The first -// FoldOver mails of a session are shown one by one as soon as Flush runs; the rest, from the fourth onward, -// are folded and shown as one notice once Window has passed since the first of them, whether or not the -// session itself is still open. +// accountBuffer is one account's state: history is every qualifying mail's arrival time, kept only for the +// trailing Window, that N is counted against (review round 3 of issue 800); immediate holds mail waiting to be +// shown one by one at the next Flush; folded holds the account's current fold (its first FoldOver mails within +// Window are never folded, so a fold only ever holds the overflow from the fourth qualifying mail of its own +// window onward), due as one notice once Window has passed since foldStart, its first mail's arrival. type accountBuffer struct { - lastArrival time.Time // the latest qualifying mail's arrival: a gap of Window since this lets a fresh session open - foldStart time.Time // when this session's first folded mail arrived; zero while nothing is folded - count int // every qualifying mail this session has counted, shown or folded alike (bullet 3: the - // fold notice names this total, not just the overflow) + history []time.Time + foldStart time.Time // when the current fold's first mail arrived; zero while nothing is folded immediate []EventBody folded []EventBody } @@ -229,23 +229,28 @@ func (n *Notifier) Add(e EventBody) error { } now := n.Now() b := n.pending[e.Account] - // A session stays open, and its count never resets, as long as a qualifying mail keeps arriving within - // Window of the last one — even once more than Window has passed since the session's own first mail - // (the sliding minute, review round 2 of issue 800). It is replaced only once it is both idle for a full - // Window and fully flushed: nothing pending is ever discarded to make room for a fresh session (BLOCKING: - // a buffer is never replaced while it holds an unsent mail — a failed send waiting for its retry, or a - // folded mail waiting for its minute's end). - if b == nil || (now.Sub(b.lastArrival) >= Window && len(b.immediate) == 0 && len(b.folded) == 0) { + if b == nil { b = &accountBuffer{} n.pending[e.Account] = b } - b.lastArrival = now - b.count++ + // N is every qualifying mail of this account — shown or folded alike, each counted once — that arrived in + // the 60 seconds up to and including now: a true sliding window over arrivals, not a per-session quota that + // stays alive, and so keeps folding, for as long as any gap stays under Window (review round 3 of issue + // 800, which replaces the round 1 and round 2 readings; nothing in this count depends on whether an earlier + // mail has been shown yet). + cutoff := now.Add(-Window) + history := make([]time.Time, 0, len(b.history)+1) + for _, at := range b.history { + if !at.Before(cutoff) { + history = append(history, at) + } + } + b.history = append(history, now) n.buffered[handle] = true - if b.count <= FoldOver { + if len(b.history) <= FoldOver { b.immediate = append(b.immediate, e) } else { - if b.foldStart.IsZero() { + if len(b.folded) == 0 { b.foldStart = now } b.folded = append(b.folded, e) @@ -320,10 +325,10 @@ func (n *Notifier) Flush() error { return nil } -// flushAccount ships one account's ready notices (its immediate mail, and its folded mail once Window has -// passed since the first of them), and keeps whatever fails to send pending, under cooldown, for the next -// Flush. Nothing is deleted before it is shown (review round 1 BLOCKING 2), and nothing is replaced, or lost, -// while it is still pending (review round 2 BLOCKING; see Add). +// flushAccount ships one account's ready notices (its immediate mail, and its fold once Window has passed +// since the fold's own first mail), and keeps whatever fails to send pending, under cooldown, for the next +// Flush. Nothing is deleted before it is shown (review round 1 BLOCKING 2), and nothing pending is ever lost +// (review round 2 BLOCKING, kept under review round 3's rule; see Add). func (n *Notifier) flushAccount(account string, now time.Time) { n.mu.Lock() if cd, ok := n.cooldown[account]; ok && now.Before(cd) { @@ -336,19 +341,15 @@ func (n *Notifier) flushAccount(account string, now time.Time) { return } immediate := append([]EventBody(nil), b.immediate...) - // The fold group is due Window after its own first mail — independent of whether the session itself is - // still open (review round 2 of issue 800: the window slides on gaps, so a session can still be taking in - // new mail well past its first fold's deadline). - foldDue := !b.foldStart.IsZero() && now.Sub(b.foldStart) >= Window + // The fold is due Window after its own first mail (review round 3 of issue 800). + foldDue := len(b.folded) > 0 && now.Sub(b.foldStart) >= Window var fold []EventBody - foldCount := b.count // every mail of the account this session has counted (bullet 3), not just the fold's own if foldDue { fold = append([]EventBody(nil), b.folded...) } n.mu.Unlock() if len(immediate) == 0 && len(fold) == 0 { - n.closeIfIdle(account, b, now) return } @@ -363,7 +364,7 @@ func (n *Notifier) flushAccount(account string, now time.Time) { } foldSent := false if failErr == nil && len(fold) > 0 { - if err := n.show(n.foldNotice(account, fold, foldCount)); err != nil { + if err := n.show(n.foldNotice(account, fold, len(fold))); err != nil { failErr = err } else { foldSent = true @@ -376,19 +377,13 @@ func (n *Notifier) flushAccount(account string, now time.Time) { if foldSent { cur.folded = cur.folded[len(fold):] if len(cur.folded) == 0 { - cur.foldStart = time.Time{} + cur.foldStart = time.Time{} // nothing left folded: a new fold may open (review round 3 of issue 800) } else { // More mail folded in while this batch was being sent: its own Window starts now, counted from // when it was first noticed. Nothing of it is lost either way. cur.foldStart = now } } - // The session itself — its lastArrival and count — is only ever dropped once it has gone idle for a - // full Window with nothing left pending (review round 2 of issue 800, BLOCKING: a buffer is never - // replaced, or its unsent mail lost, while it still holds one). - if now.Sub(cur.lastArrival) >= Window && len(cur.immediate) == 0 && len(cur.folded) == 0 { - delete(n.pending, account) - } } if failErr != nil { n.cooldown[account] = now.Add(RetryBackoff) @@ -400,17 +395,6 @@ func (n *Notifier) flushAccount(account string, now time.Time) { n.mu.Unlock() } -// closeIfIdle drops an account's session once it has gone idle for a full Window with nothing left pending: -// the next qualifying mail then opens a fresh session with a fresh quota (see Add). -func (n *Notifier) closeIfIdle(account string, b *accountBuffer, now time.Time) { - n.mu.Lock() - defer n.mu.Unlock() - if cur := n.pending[account]; cur == b && now.Sub(cur.lastArrival) >= Window && - 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. @@ -452,17 +436,17 @@ func (n *Notifier) singleNotice(account string, e EventBody) Notice { } } -// foldNotice is one notice for everything an account's session folded — its fourth qualifying mail onward — -// naming the latest sender and how many mail the account has had in total this session, not only the -// overflow (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"; review round 2 of issue 800, bullet 3: the count is -// every mail of the account in that minute, "mail" for one and "mails" for more). +// foldNotice is one notice for everything the account's current fold holds — its qualifying mail from the +// fourth within some trailing 60 seconds onward — naming how many mail that fold holds (not the account's +// whole history of arrivals) and the latest sender (novox/hq ADR 0378 §9, review round 3 of issue 800, which +// replaces the round 1 and round 2 readings: " more new mail(s) in , latest from ", "mail" for one and "mails" for more). func (n *Notifier) foldNotice(account string, events []EventBody, count int) Notice { latest := events[len(events)-1] notice := Notice{ Account: account, Args: map[string]any{ - "summary": strconv.Itoa(count) + " new " + mailWord(count) + " in " + account, + "summary": strconv.Itoa(count) + " more new " + mailWord(count) + " in " + account, "body": "latest from " + latest.sender(), "app_name": "mesh-mail", }, @@ -474,7 +458,8 @@ func (n *Notifier) foldNotice(account string, events []EventBody, count int) Not return notice } -// mailWord says "mail" for a count of one, "mails" for anything else (review round 2 of issue 800, bullet 3). +// mailWord says "mail" for a count of one, "mails" for anything else (review round 2 of issue 800, bullet 3; +// kept by review round 3). func mailWord(count int) string { if count == 1 { return "mail" @@ -531,7 +516,7 @@ func (n *Notifier) Status() (map[string]any, error) { "rules": []string{ "it shows each new inbox mail at most once, through node-notifier.send on this machine (novox/hq ADR 0378 §7)", "quiet during a first sync, for mail from the account's own address, for mail already marked read, and for anything outside the inbox", - "of one account's qualifying mail 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", + "of one account's qualifying mail, one arriving when fewer than three of the account's mails fell in the 60 seconds before it is shown at once; the rest is folded and shown, as one notice naming how many it holds and the latest sender, 60 seconds after the fold's own first mail", }, } if knownErr != nil { 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 841dd86a..3592c95b 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 @@ -210,9 +210,9 @@ func TestAFailedSendIsRetriedWithoutLosingItOrHoldingUpOtherAccounts(t *testing. } // 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. +// ADR 0378 §9, the lead's reading, the fold notice's wording updated by review round 3 of issue 800): the +// first three of an account's qualifying mail within a minute are shown one by one as Flush runs; the fourth +// onward is folded and shown as one notice once the window — a minute from the first of them — is over. func TestABurstShowsTheFirstThreeAtOnceAndFoldsTheRestAtTheWindowsEnd(t *testing.T) { clock := &fakeClock{now: time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC)} store := newFakeStore() @@ -246,8 +246,8 @@ func TestABurstShowsTheFirstThreeAtOnceAndFoldsTheRestAtTheWindowsEnd(t *testing 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"] != "4 new mails in home" || !strings.Contains(sent[3]["body"].(string), "Sender 4") { - t.Errorf("the fold notice is %+v, want it to count every mail of the session (4), naming Sender 4", sent[3]) + if sent[3]["summary"] != "1 more new mail in home" || !strings.Contains(sent[3]["body"].(string), "Sender 4") { + t.Errorf("the fold notice is %+v, want it to count only what the fold holds (1), naming Sender 4", sent[3]) } } @@ -298,8 +298,8 @@ func TestARedeliveredEventIsNeverShownOrCountedTwice(t *testing.T) { 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"] != "4 new mails in home" { - t.Errorf("the fold notice is %+v, want it to count the redelivered fourth mail once, not twice, among the session's 4", sent[3]) + if sent[3]["summary"] != "1 more new mail in home" { + t.Errorf("the fold notice is %+v, want it to count the redelivered fourth mail once, not twice, among the fold's 1", sent[3]) } } @@ -450,53 +450,15 @@ func TestABufferIsNeverReplacedWhileItHoldsAnUnsentMail(t *testing.T) { } } -// TestAFoldedMailIsNeverLostBetweenTheWindowsEndAndTheNextFlush is review round 2 of issue 800's second test -// for the same BLOCKING finding: no failure here, but the fourth mail is folded, its minute ends, and a new -// mail arrives before the next Flush. The folded mail is still shown, never lost. -func TestAFoldedMailIsNeverLostBetweenTheWindowsEndAndTheNextFlush(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) - } - if len(sent) != 3 { - t.Fatalf("sent %d notice(s), want 3 shown one by one, the fourth folded, not yet due: %+v", len(sent), sent) - } - - // The fold's minute is over (a Window since mail 4, its first folded mail), but Flush has not run again - // yet: a new mail arrives in that gap. - clock.Advance(Window + time.Second) - if err := n.Add(mail(account, 5, "m5", "Mail 5", "Sender 5")); err != nil { - t.Fatalf("Add mail 5: %v", err) - } - - if err := n.Flush(); err != nil { - t.Fatalf("Flush: %v", err) - } - if len(sent) != 4 { - t.Fatalf("sent %d notice(s), want 4 (one fold notice carrying both mail 4 and mail 5): %+v", len(sent), sent) - } - if sent[3]["summary"] != "5 new mails in home" || !strings.Contains(sent[3]["body"].(string), "Sender 5") { - t.Errorf("the fold notice is %+v, want it to count all 5 of the session, naming Sender 5 (the latest)", sent[3]) - } -} - -// TestTheWindowSlidesOnGapsNotOnElapsedTimeSinceItsFirstMail is review round 2 of issue 800's test for the -// sliding minute: a mail is shown at once when fewer than three of the account's mails were shown in the 60 -// seconds before it; otherwise it is folded and shown with the rest 60 seconds after the first of them. Mails -// at 0, 50, 55, 70, 75 and 80 seconds: 0, 50 and 55 are each within 60s of fewer than 3 prior mails, so each -// is shown; 70 is not (50 and 55 both fall in its preceding 60s, and the session is already past its quota), -// so it and everything after it, within the same ongoing session, fold into one notice. -func TestTheWindowSlidesOnGapsNotOnElapsedTimeSinceItsFirstMail(t *testing.T) { +// TestTheFoldFollowsATrueSlidingWindowOfArrivalsNotASessionQuota is review round 3 of issue 800's first test, +// replacing round 2's (which contradicted round 3's rule): N, for a mail arriving at t, is every qualifying +// mail of the account — shown or folded alike — that arrived in the 60 seconds up to and including t. Mails at +// 0, 50, 55, 70, 75 and 80 seconds: at 70s the preceding 60 seconds (from 10s) hold 50, 55 and 70 themselves +// (0s has aged out), N=3, so 70 is still shown at once, not folded; at 75s the preceding 60 seconds (from 15s) +// still hold 50 too, so it, 55, 70 and 75 themselves make N=4, and 75 folds; 80 folds with it (its own +// preceding 60 seconds hold 50, 55, 70, 75 and 80, N=5). The fold is due 60 seconds after its own first mail +// (75s), at 135s, as one notice naming only what it holds (2) and the latest sender (80s's). +func TestTheFoldFollowsATrueSlidingWindowOfArrivalsNotASessionQuota(t *testing.T) { clock := &fakeClock{now: time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC)} store := newFakeStore() var sent []map[string]any @@ -513,24 +475,95 @@ func TestTheWindowSlidesOnGapsNotOnElapsedTimeSinceItsFirstMail(t *testing.T) { t.Fatalf("Flush at mail %d: %v", i+1, err) } } - if len(sent) != 3 { - t.Fatalf("sent %d notice(s) before the fold's window ends, want 3 shown one by one (0s, 50s, 55s): %+v", len(sent), sent) + if len(sent) != 4 { + t.Fatalf("sent %d notice(s), want 4 shown one by one (0s, 50s, 55s, 70s): %+v", len(sent), sent) } - for i, want := range []string{"Mail 1", "Mail 2", "Mail 3"} { + for i, want := range []string{"Mail 1", "Mail 2", "Mail 3", "Mail 4"} { if sent[i]["summary"] != want { t.Errorf("sent[%d] = %+v, want summary %q", i, sent[i], want) } } - // The fold group's own window — a minute from its first mail, at 70s — ends at 130s. - clock.Advance(50 * time.Second) + // The fold's own window — a minute from its first mail, 75s — ends at 135s. + clock.Advance(55 * time.Second) // now at 80s + 55s = 135s + if err := n.Flush(); err != nil { + t.Fatalf("Flush: %v", err) + } + if len(sent) != 5 { + t.Fatalf("sent %d notice(s), want 5 (75s and 80s folded into one, at 135s): %+v", len(sent), sent) + } + if sent[4]["summary"] != "2 more new mails in home" || !strings.Contains(sent[4]["body"].(string), "Sender 6") { + t.Errorf("the fold notice is %+v, want it to count only what it holds (2), naming Sender 6 (80s, the latest)", sent[4]) + } +} + +// TestASteadyTrickleNeverFoldsForever is review round 3 of issue 800's second test: the bug the round found +// (a session that only resets after a quiet minute folds a steady stream forever, even without a burst). One +// mail every 50 seconds, 20 of them: each one's preceding 60 seconds holds only the one before it, so N never +// exceeds 2, and all 20 are shown at once — none ever fold. +func TestASteadyTrickleNeverFoldsForever(t *testing.T) { + clock := &fakeClock{now: time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC)} + store := newFakeStore() + var sent []map[string]any + n := newTestNotifier(t, clock, store, &sent) + + account := "home" + for i := 1; i <= 20; i++ { + if i > 1 { + clock.Advance(50 * time.Second) + } + if err := n.Add(mail(account, i, fmt.Sprintf("m%d", i), fmt.Sprintf("Mail %d", i), fmt.Sprintf("Sender %d", i))); err != nil { + t.Fatalf("Add mail %d: %v", i, err) + } + if err := n.Flush(); err != nil { + t.Fatalf("Flush at mail %d: %v", i, err) + } + if len(sent) != i { + t.Fatalf("after mail %d, sent %d notice(s), want %d: a steady trickle must never fold: %+v", i, len(sent), i, sent) + } + } + for i, got := range sent { + want := fmt.Sprintf("Mail %d", i+1) + if got["summary"] != want { + t.Errorf("sent[%d] = %+v, want summary %q", i, got, want) + } + } +} + +// TestFiveMailsWithinTenSecondsFoldAfterThree is review round 3 of issue 800's third test: five mails within +// ten seconds — the first three shown at once, the fourth opens a fold, the fifth joins it, and the fold is +// shown 60 seconds after the fourth (the fold's own first mail) as one notice. +func TestFiveMailsWithinTenSecondsFoldAfterThree(t *testing.T) { + clock := &fakeClock{now: time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC)} + store := newFakeStore() + var sent []map[string]any + n := newTestNotifier(t, clock, store, &sent) + + account := "home" + for i := 1; i <= 5; i++ { + if i > 1 { + clock.Advance(2 * time.Second) + } + if err := n.Add(mail(account, i, fmt.Sprintf("m%d", i), fmt.Sprintf("Mail %d", i), fmt.Sprintf("Sender %d", i))); err != nil { + t.Fatalf("Add mail %d: %v", i, err) + } + if err := n.Flush(); err != nil { + t.Fatalf("Flush at mail %d: %v", i, err) + } + } + if len(sent) != 3 { + t.Fatalf("sent %d notice(s), want 3 shown one by one, the fourth and fifth folded, not yet due: %+v", len(sent), sent) + } + + // The fold is due 60 seconds after its own first mail (the fourth, at 6s). + clock.Advance(Window) if err := n.Flush(); err != nil { t.Fatalf("Flush: %v", err) } if len(sent) != 4 { - t.Fatalf("sent %d notice(s), want 4 (70s, 75s and 80s folded into one, at 130s): %+v", len(sent), sent) + t.Fatalf("sent %d notice(s), want 4 (one fold notice carrying the fourth and fifth): %+v", len(sent), sent) } - if sent[3]["summary"] != "6 new mails in home" || !strings.Contains(sent[3]["body"].(string), "Sender 6") { - t.Errorf("the fold notice is %+v, want it to count all 6 of the session, naming Sender 6 (80s, the latest)", sent[3]) + if sent[3]["summary"] != "2 more new mails in home" || !strings.Contains(sent[3]["body"].(string), "Sender 5") { + t.Errorf("the fold notice is %+v, want it to count only what it holds (2), naming Sender 5 (the latest)", sent[3]) } }