feat: mail notifications for watched nodes (user management part E) (#2140)

Part E of #2128: a logged-in user watches nodes and gets one mail when a
watched node goes offline, comes back, or reports a low battery. Admins
can add instance events: a new foreign node, and an observer going
offline or back. This covers the per-user mail part of #775.

Stacked on #2139 (part D, which builds on #2138). Review the commits
after that branch.

## The situation

A sysop learns that a repeater went silent, or that its battery is
running down, only by opening CoreScope. #775 asks for notifications.
Accounts (part A) now give a verified address to mail and a mailer with
delivery status.

## What this PR adds

**Events.** They use the instance thresholds, so a mail does not
disagree with the node page:
- `node.offline`: silent for the role's `healthThresholds` window. Last
heard comes from the packet store, and repeaters and rooms count relayed
traffic (#1598).
- `node.battery`: advert telemetry below `batteryThresholds.lowMv`,
recovered at `lowMv + 100`.
- Admins only: `foreign.new` (once per node) and `observer.offline`.

**Store.** Schema v5 with `notification_prefs`, `notification_watches`
and `notification_state`.

**Server** (only with `userManagement.notifications.enabled`)
- A notifier next to the janitor, every `intervalMinutes` (5). The
evaluator is a pure function of watches, prefs, stored states and node
snapshots.
- No burst after a restart or a new watch: a subject's first evaluation
stores its state without mailing. The loop waits for the startup load.
While ingest is stale (newest packet older than 30 minutes) the offline
checks pause, and after recovery they wait one silent window.
- One mail per user per check. Limits: 20 per user and 100 per instance
per rolling 24 hours, 50 watches per user. A change over a limit is
recorded and never mailed later.
- Every mail has a one-click unsubscribe (`List-Unsubscribe` and
`List-Unsubscribe-Post`). The GET only redirects to a confirm page, so
link scanners cannot unsubscribe anyone. Node names are cleaned of
control and bidi characters before they go into a mail.
- Routes: `GET`/`PUT /api/account/notifications`, `PUT`/`DELETE
/api/account/notifications/watches/{pubkey}`, `POST
/api/account/notifications/watch-my-nodes` (copies the synced "my nodes"
list), `GET`/`POST /api/notifications/unsubscribe`. `mailer.Message`
gains `Headers`.

**Frontend.** A "Notify me" toggle on the node pages, a Notifications
section on the account page (`#/account?section=notifications`), an
unsubscribe page, and the notification mail count on the admin overview.

Spec:
[`docs/specs/2026-10-07-node-notifications-design.md`](https://github.com/efiten/CoreScope/blob/feat/notifications/docs/specs/2026-10-07-node-notifications-design.md).

## Performance

Each check reads watches, prefs and states from `users.db` (one query
each), the watched nodes from the analyzer DB in chunks of 500, and
last-heard times from the packet store.

| Measurement | Result |
|---|---|
| One check on our staging instance, 130,000 packets in memory, 1
watcher | 6.9 ms, of which 0.93 ms holding the store read lock |
| Evaluator benchmark, 100 users with 50 watches each, 2,000 nodes |
1.75 ms |

No work is added to ingest, broadcast or any request path.

## Verification

- `internal/users` and `internal/mailer` with `-race`; the `cmd/server`
suite plus `-race` on the notifier tests; 74 new Go tests. The same
Windows-only failure as noted in #2138 applies.
- `sh test-all.sh` exits 0; the XSS gate in diff mode passes.
- User-management E2E: 26 of 26 steps locally. With notifications off
the new steps fail, so they do test the flag.
- On our staging and production instance since 7 October 2026.

## Not in this PR

- Other channels (Discord, Telegram, webhooks). Detection is separate
from delivery, so one can be added.
- The topology, RF and anomaly alerts of #775.
- If the whole server starts after a feed outage that already ended, the
grace window is unknown and a watcher can get one wrong offline mail.
The user guide says so.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
efiten
2026-10-08 22:02:39 +02:00
committed by GitHub
co-authored by Claude Opus 5.5
parent abbe6846ff
commit de364a8bb6
46 changed files with 4992 additions and 26 deletions
+2 -1
View File
@@ -529,7 +529,8 @@ jobs:
"adminEmails": ["admin@e2e.test"],
"publicBaseUrl": "http://localhost:13582",
"mail": { "provider": "fake", "fromEmail": "noreply@e2e.test" },
"channelProposals": { "enabled": true }
"channelProposals": { "enabled": true },
"notifications": { "enabled": true }
}
}
JSON
+27 -1
View File
@@ -30,6 +30,15 @@ type guessedAccountJSON struct {
Failed int `json:"failed"`
}
// adminNotifyJSON: notification figures, counts only (watch lists are
// private). Present only with notifications on.
type adminNotifyJSON struct {
MailsLast24h int `json:"mailsLast24h"`
MaxMailsPerDay int `json:"maxMailsPerDay"`
Watches int `json:"watches"`
WatchingUsers int `json:"watchingUsers"`
}
type adminStatsJSON struct {
Total int `json:"total"`
Active int `json:"active"`
@@ -47,6 +56,7 @@ type adminStatsJSON struct {
FailedLogins24h int `json:"failedLogins24h"`
Mail7d mailCountsJSON `json:"mail7d"`
Guessing []guessedAccountJSON `json:"guessing"`
Notify *adminNotifyJSON `json:"notify,omitempty"`
}
func adminStatsFrom(st users.Stats) adminStatsJSON {
@@ -72,5 +82,21 @@ func (s *Server) handleAdminStats(w http.ResponseWriter, _ *http.Request, _ *use
writeError(w, http.StatusInternalServerError, "internal error")
return
}
writeJSON(w, adminStatsFrom(st))
out := adminStatsFrom(st)
if s.auth.notify != nil {
total, _, err := s.auth.st.NotifyMailCounts(s.auth.notify.now().Add(-24 * time.Hour))
if err != nil {
log.Printf("[users] admin stats notification mails: %v", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
watches, watching, err := s.auth.st.NotifyWatchStats()
if err != nil {
log.Printf("[users] admin stats watches: %v", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
out.Notify = &adminNotifyJSON{MailsLast24h: total, MaxMailsPerDay: s.auth.set.notify.maxMailsPerDay, Watches: watches, WatchingUsers: watching}
}
writeJSON(w, out)
}
+22 -1
View File
@@ -4,7 +4,9 @@ package main
// E2E-only hooks, compiled only with -tags e2etest (CI's corescope-server-e2e):
// the "fake" mail provider and GET /__e2e/last-mail, which returns the newest
// fake mail so Playwright can follow its link. Never part of a release build.
// fake mail so Playwright can follow its link, and GET /__e2e/unsubscribe-link,
// which returns a user's notification unsubscribe link. Never part of a
// release build.
import (
"net/http"
@@ -19,6 +21,10 @@ type e2eMail struct {
Text string `json:"text"`
}
type e2eLink struct {
Link string `json:"link"`
}
func init() {
fakeMailerAllowed = true
e2eRoutes = func(s *Server, r *mux.Router) {
@@ -35,5 +41,20 @@ func init() {
}
writeJSON(w, e2eMail{To: m.To, Subject: m.Subject, Text: m.Text})
}).Methods("GET")
// The unsubscribe link a notification mail would carry, so the E2E
// can follow it without waiting for a real state change.
r.HandleFunc("/__e2e/unsubscribe-link", func(w http.ResponseWriter, req *http.Request) {
u, err := s.auth.st.GetByEmail(req.URL.Query().Get("email"))
if err != nil {
writeError(w, http.StatusNotFound, "no such user")
return
}
p, err := s.auth.st.NotifyPrefsFor(u.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal error")
return
}
writeJSON(w, e2eLink{Link: s.auth.link("unsubscribe", p.UnsubToken)})
}).Methods("GET")
}
}
+15
View File
@@ -15,3 +15,18 @@ func TestE2ELastMailReturnsNewestFakeMail(t *testing.T) {
t.Fatalf("unexpected mail: %+v", m)
}
}
func TestE2EUnsubscribeLink(t *testing.T) {
f := newAuthFixture(t)
expectStatus(t, f.do("GET", "/__e2e/unsubscribe-link?email=nobody@example.test", nil), 404)
c := f.registerAndActivate(t, "e2e@example.test", "E2E", "correct horse battery")
w := f.do("GET", "/__e2e/unsubscribe-link?email=e2e@example.test", nil)
expectStatus(t, w, 200)
p, err := f.st.NotifyPrefsFor(c.me.ID)
if err != nil {
t.Fatal(err)
}
if l := decode[e2eLink](t, w); l.Link != testBase+"/#/account/unsubscribe?token="+p.UnsubToken {
t.Fatalf("link = %q", l.Link)
}
}
+41 -4
View File
@@ -8,17 +8,24 @@ import (
"regexp"
"strings"
"time"
"unicode"
"github.com/meshcore-analyzer/mailer"
"github.com/meshcore-analyzer/users"
)
// mailLine is one linked line of a mail: a list item in HTML, the text and
// the URL on their own lines in plain text.
type mailLine struct{ text, url string }
type mailContent struct {
subject string
greeting string
paragraphs []string
lines []mailLine // after the paragraphs
actionLabel string
actionURL string
footer *mailLine // replaces the default footer when set
}
// link builds {publicBaseUrl}/#/account/{page}?token=… (never from the
@@ -27,9 +34,21 @@ func (a *authService) link(page, token string) string {
return a.set.baseURL.String() + "/#/account/" + page + "?token=" + url.QueryEscape(token)
}
// mailSafeText makes a name chosen by someone else safe for one line of a
// mail: control characters (line breaks and tabs included) and bidi
// overrides and isolates (U+202A-U+202E, U+2066-U+2069) become a space,
// runs of spaces collapse and the ends are trimmed. The HTML part escapes
// the result as usual.
func mailSafeText(s string) string {
return strings.Join(strings.Fields(strings.Map(func(r rune) rune {
if unicode.IsControl(r) || (r >= 0x202A && r <= 0x202E) || (r >= 0x2066 && r <= 0x2069) {
return ' '
}
return r
}, s)), " ")
}
func (a *authService) render(to, toName, tag string, c mailContent) mailer.Message {
footer := "You received this because this address was used on " + a.set.baseURL.Host +
". If that was not you, you can ignore this mail."
var text, h strings.Builder
text.WriteString(c.greeting + "\n\n")
h.WriteString("<p>" + html.EscapeString(c.greeting) + "</p>")
@@ -37,12 +56,30 @@ func (a *authService) render(to, toName, tag string, c mailContent) mailer.Messa
text.WriteString(p + "\n\n")
h.WriteString("<p>" + html.EscapeString(p) + "</p>")
}
if len(c.lines) > 0 {
h.WriteString("<ul>")
for _, l := range c.lines {
l.text = mailSafeText(l.text)
text.WriteString("- " + l.text + "\n " + l.url + "\n")
h.WriteString(`<li><a href="` + html.EscapeString(l.url) + `">` + html.EscapeString(l.text) + `</a></li>`)
}
h.WriteString("</ul>")
text.WriteString("\n")
}
if c.actionURL != "" {
text.WriteString(c.actionLabel + ":\n" + c.actionURL + "\n\n")
h.WriteString(`<p><a href="` + html.EscapeString(c.actionURL) + `">` + html.EscapeString(c.actionLabel) + `</a></p>`)
}
text.WriteString(footer + "\n")
h.WriteString(`<p style="color:#666;font-size:12px">` + html.EscapeString(footer) + `</p>`)
if c.footer != nil {
text.WriteString(c.footer.text + "\n" + c.footer.url + "\n")
h.WriteString(`<p style="color:#666;font-size:12px">` + html.EscapeString(c.footer.text) + ` <a href="` +
html.EscapeString(c.footer.url) + `">` + html.EscapeString(c.footer.url) + `</a></p>`)
} else {
footer := "You received this because this address was used on " + a.set.baseURL.Host +
". If that was not you, you can ignore this mail."
text.WriteString(footer + "\n")
h.WriteString(`<p style="color:#666;font-size:12px">` + html.EscapeString(footer) + `</p>`)
}
return mailer.Message{To: to, ToName: toName, Subject: "[" + a.set.fromName + "] " + c.subject,
HTML: h.String(), Text: text.String(), Tag: tag}
}
+14
View File
@@ -78,3 +78,17 @@ func TestLogStartupShowsAbsoluteUsersDBPath(t *testing.T) {
t.Fatalf("log line = %q, want the absolute path %q", buf.String(), want)
}
}
func TestRenderLinesAndFooter(t *testing.T) {
a, _ := newTestAuthService(t)
m := a.render("x@example.org", "X", "notify", mailContent{subject: "S", greeting: "Hi",
lines: []mailLine{{text: "<b>n</b>: offline", url: "https://e.org/#/nodes/ab"}},
footer: &mailLine{text: "Stop these mails:", url: "https://e.org/#/account/unsubscribe?token=t"}})
if !strings.Contains(m.HTML, `<ul><li><a href="https://e.org/#/nodes/ab">&lt;b&gt;n&lt;/b&gt;: offline</a></li></ul>`) ||
!strings.Contains(m.Text, "- <b>n</b>: offline\n https://e.org/#/nodes/ab\n") ||
!strings.Contains(m.HTML, `Stop these mails: <a href="https://e.org/#/account/unsubscribe?token=t">`) ||
!strings.Contains(m.Text, "Stop these mails:\nhttps://e.org/#/account/unsubscribe?token=t\n") ||
strings.Contains(m.Text, "If that was not you") {
t.Fatalf("rendered = %q / %q", m.HTML, m.Text)
}
}
+3
View File
@@ -41,6 +41,9 @@ func (s *Server) registerAuthRoutes(r *mux.Router) {
if s.auth.set.proposals.enabled {
s.registerProposalRoutes(r)
}
if s.auth.notify != nil {
s.registerNotifyRoutes(r)
}
// The webhook exists only when a secret is configured.
if s.auth.set.webhookSecret != "" {
r.HandleFunc("/api/mail/brevo/webhook", s.handleBrevoWebhook).Methods("POST")
+15 -1
View File
@@ -32,10 +32,14 @@ type authService struct {
approvedMu sync.Mutex
approved atomic.Pointer[[]string]
// notify evaluates node notifications; nil unless
// userManagement.notifications.enabled.
notify *notifier
warnIndistinct sync.Once
stop chan struct{}
stopOnce sync.Once
wg sync.WaitGroup // the janitor
wg sync.WaitGroup // the janitor and the notifier
auditWG sync.WaitGroup // in-flight auditAsync writes
}
@@ -81,6 +85,16 @@ func (s *Server) initUserManagement(measurementDBPath string) error {
defer s.auth.wg.Done()
s.auth.janitor(time.Hour)
}()
if set.notify.enabled {
s.auth.notify = newNotifier(s.auth, serverNotifySource{s: s}, time.Now)
s.auth.wg.Add(1)
go func() {
defer s.auth.wg.Done()
s.auth.notify.loop(s.auth.stop, set.notify.interval)
}()
log.Printf("[notify] node notifications enabled: every %s, at most %d mails per user and %d in total per 24 hours",
set.notify.interval, set.notify.perUserPerDay, set.notify.maxMailsPerDay)
}
return nil
}
+302
View File
@@ -0,0 +1,302 @@
package main
import (
"sort"
"strings"
"time"
"github.com/meshcore-analyzer/users"
)
// Node notification evaluator (docs/specs/2026-10-07-node-notifications-design.md).
// evaluateNotifications is a pure function: everything it reads is in
// notifyInput, the time included, so it is tested without a clock or a
// database. The notifier (notify_service.go) gathers the input and acts on
// the result.
// notifyNode is one analyzer node row the evaluator needs.
type notifyNode struct {
Pubkey string // lowercase hex
Name string
Role string // as stored; compared lowercase
LastSeen string // nodes.last_seen, "" when NULL
BatteryMv *int // latest advert telemetry; nil when the node reports none
Foreign bool // nodes.foreign_advert = 1
}
// notifyObserver is one active (not soft-deleted) observer.
type notifyObserver struct {
ID string
Name string
LastSeen string // observers.last_seen, "" when NULL
}
// notifyInput is everything one evaluation reads.
type notifyInput struct {
Now time.Time
Health HealthThresholds // resolved, as Config.GetHealthThresholds returns it
LowMv int // Config.LowBatteryMv
Accounts map[int64]users.User
Prefs []users.NotifyPrefs
Watches []users.NotifyWatch
States []users.NotifyState
Nodes map[string]notifyNode // watched nodes, plus every foreign node when an admin chose foreign.new
Heard map[string]time.Time // newest packet involving the node in the packet store (node page "Last Heard")
Relayed map[string]time.Time // last relay hop, used for repeaters and rooms only (#1598)
Observers []notifyObserver // nil unless an admin chose observer.offline
// IngestStale: the newest packet in the store is old, so a silent node
// and a silent feed look the same. node.offline and observer.offline
// are then not compared (states unchanged, no changes).
IngestStale bool
// IngestResumedAt: when the last stale period ended (zero: none since
// startup). For a node or observer whose stored state is good, evidence
// older than this counts as heard at this time, so nothing goes offline
// until a full silent window has passed after the feed came back.
IngestResumedAt time.Time
}
// notifyChange is one transition for one user.
type notifyChange struct {
Event string
Subject string // node pubkey or observer id
Name string // display name at evaluation time
To string // users.NotifyGood, users.NotifyBad or users.NotifyTold
BatteryMv *int // node.battery only
At time.Time
}
// notifyResult: Changes per user, ordered by event then name; States are
// the rows to write (new or changed), disabled users included; Drop are the
// rows to delete (admin-event rows of users who are no longer admin).
type notifyResult struct {
Changes map[int64][]notifyChange
States []users.NotifyState
Drop []users.NotifyKey
}
const (
// batteryHysteresisMv: a low battery is good again only at lowMv + 100.
batteryHysteresisMv = 100
// foreignBaselineSubject marks that an admin's foreign.new baseline
// exists: the first evaluation after opting in stores every current
// foreign node as told without mailing; afterwards a foreign node
// without a row is new.
foreignBaselineSubject = "*"
)
var notifyEventRank = map[string]int{
users.NotifyNodeOffline: 0, users.NotifyNodeBattery: 1, users.NotifyForeignNew: 2, users.NotifyObserverOffline: 3,
}
// notifyInfra reports the roles whose relay activity counts as heard
// (GetHealthMs and roles.js use the same two).
func notifyInfra(role string) bool {
r := strings.ToLower(role)
return r == "repeater" || r == "room"
}
// nodeLabel is the node's name made safe for a mail line (mailSafeText),
// or the first 8 hex characters of its key.
func nodeLabel(n notifyNode, known bool, pk string) string {
if name := mailSafeText(n.Name); known && name != "" {
return name
}
if len(pk) > 8 {
return pk[:8]
}
return pk
}
// nodeOnlineState is good when the node was heard within its role's silent
// window, like roles.js getNodeStatus: the latest of last_seen, the packet
// store's last heard and, for repeaters and rooms, the last relay hop. A
// node missing from the analyzer database is bad. While prev is good, the
// latest evidence is never older than in.IngestResumedAt (the outage grace).
func nodeOnlineState(pk string, n notifyNode, known bool, prev string, in *notifyInput) string {
if !known {
return users.NotifyBad
}
last, _ := parseRelayTS(n.LastSeen)
if t, ok := in.Heard[pk]; ok && t.After(last) {
last = t
}
if notifyInfra(n.Role) {
if t, ok := in.Relayed[pk]; ok && t.After(last) {
last = t
}
}
if prev == users.NotifyGood && last.Before(in.IngestResumedAt) {
last = in.IngestResumedAt
}
_, silentMs := in.Health.GetHealthMs(strings.ToLower(n.Role))
if last.IsZero() || in.Now.Sub(last) >= time.Duration(silentMs)*time.Millisecond {
return users.NotifyBad
}
return users.NotifyGood
}
// batteryState applies the low-battery threshold with hysteresis.
func batteryState(mv, lowMv int, prev string) string {
if prev == users.NotifyBad {
if mv >= lowMv+batteryHysteresisMv {
return users.NotifyGood
}
return users.NotifyBad
}
if mv < lowMv {
return users.NotifyBad
}
return users.NotifyGood
}
// observerState: bad when last_seen is older than observerStaleMinutes,
// good again within observerOnlineMinutes. While prev is good, last_seen
// is never older than resumed (the outage grace, zero for none).
func observerState(o notifyObserver, h HealthThresholds, now, resumed time.Time, prev string) string {
t, ok := parseRelayTS(o.LastSeen)
if prev == users.NotifyGood && !resumed.IsZero() && (!ok || t.Before(resumed)) {
t, ok = resumed, true
}
if !ok {
return users.NotifyBad
}
age := now.Sub(t)
if prev == users.NotifyBad {
if age <= time.Duration(h.ObserverOnlineMinutes)*time.Minute {
return users.NotifyGood
}
return users.NotifyBad
}
if age > time.Duration(h.ObserverStaleMinutes)*time.Minute {
return users.NotifyBad
}
return users.NotifyGood
}
// evaluateNotifications computes every chosen (user, event, subject) state
// and compares it with the stored one. A subject without a stored state is
// stored without a change (a restart or a new watch never mails); a stored
// state that differs is a change. Users missing from Accounts are skipped.
// A user with watches but no preferences row gets the defaults (enabled,
// node events). A disabled user's states are kept current without changes,
// so turning notifications on again never mails what happened while off.
// Admin events are evaluated only for admins; a non-admin's admin-event
// rows are dropped, like opting out, so a re-promotion starts silently.
func evaluateNotifications(in notifyInput) notifyResult {
prev := make(map[users.NotifyKey]string, len(in.States))
for _, s := range in.States {
prev[s.NotifyKey] = s.State
}
watched := map[int64][]string{}
for _, w := range in.Watches {
watched[w.UserID] = append(watched[w.UserID], w.Pubkey)
}
var foreign []string
for pk, n := range in.Nodes {
if n.Foreign {
foreign = append(foreign, pk)
}
}
sort.Strings(foreign)
res := notifyResult{Changes: map[int64][]notifyChange{}}
for _, s := range in.States {
if u, ok := in.Accounts[s.UserID]; ok && u.Role != users.RoleAdmin && users.IsAdminNotifyEvent(s.Event) {
res.Drop = append(res.Drop, s.NotifyKey)
}
}
prefs := make([]users.NotifyPrefs, 0, len(in.Prefs)+len(watched))
hasPrefs := make(map[int64]bool, len(in.Prefs))
for _, p := range in.Prefs {
prefs = append(prefs, p)
hasPrefs[p.UserID] = true
}
var defaulted []int64
for uid := range watched {
if !hasPrefs[uid] {
defaulted = append(defaulted, uid)
}
}
sort.Slice(defaulted, func(i, j int) bool { return defaulted[i] < defaulted[j] })
for _, uid := range defaulted {
prefs = append(prefs, users.DefaultNotifyPrefs(uid))
}
mailing := false // Enabled of the user being evaluated
store := func(k users.NotifyKey, state string) {
res.States = append(res.States, users.NotifyState{NotifyKey: k, State: state, ChangedAt: in.Now})
}
compare := func(k users.NotifyKey, name, state string, mv *int) {
old, had := prev[k]
if had && old == state {
return
}
store(k, state)
if had && mailing {
res.Changes[k.UserID] = append(res.Changes[k.UserID],
notifyChange{Event: k.Event, Subject: k.Subject, Name: name, To: state, BatteryMv: mv, At: in.Now})
}
}
for _, p := range prefs {
u, ok := in.Accounts[p.UserID]
if !ok {
continue
}
mailing = p.Enabled
for _, pk := range watched[p.UserID] {
n, known := in.Nodes[pk]
name := nodeLabel(n, known, pk)
if p.Has(users.NotifyNodeOffline) && !in.IngestStale {
k := users.NotifyKey{UserID: p.UserID, Event: users.NotifyNodeOffline, Subject: pk}
compare(k, name, nodeOnlineState(pk, n, known, prev[k], &in), nil)
}
if p.Has(users.NotifyNodeBattery) && known && n.BatteryMv != nil {
k := users.NotifyKey{UserID: p.UserID, Event: users.NotifyNodeBattery, Subject: pk}
compare(k, name, batteryState(*n.BatteryMv, in.LowMv, prev[k]), n.BatteryMv)
}
}
if u.Role != users.RoleAdmin {
continue
}
if p.Has(users.NotifyForeignNew) {
base := users.NotifyKey{UserID: p.UserID, Event: users.NotifyForeignNew, Subject: foreignBaselineSubject}
_, baselined := prev[base]
if !baselined {
store(base, users.NotifyGood)
}
for _, pk := range foreign {
k := users.NotifyKey{UserID: p.UserID, Event: users.NotifyForeignNew, Subject: pk}
if _, told := prev[k]; told {
continue
}
store(k, users.NotifyTold)
if baselined && mailing {
res.Changes[p.UserID] = append(res.Changes[p.UserID], notifyChange{Event: users.NotifyForeignNew, Subject: pk,
Name: nodeLabel(in.Nodes[pk], true, pk), To: users.NotifyTold, At: in.Now})
}
}
}
if p.Has(users.NotifyObserverOffline) && !in.IngestStale {
for _, o := range in.Observers {
k := users.NotifyKey{UserID: p.UserID, Event: users.NotifyObserverOffline, Subject: o.ID}
name := mailSafeText(o.Name)
if name == "" {
name = o.ID
}
compare(k, name, observerState(o, in.Health, in.Now, in.IngestResumedAt, prev[k]), nil)
}
}
}
for _, list := range res.Changes {
sort.SliceStable(list, func(i, j int) bool {
if a, b := notifyEventRank[list[i].Event], notifyEventRank[list[j].Event]; a != b {
return a < b
}
if list[i].Name != list[j].Name {
return list[i].Name < list[j].Name
}
return list[i].Subject < list[j].Subject
})
}
return res
}
+552
View File
@@ -0,0 +1,552 @@
package main
import (
"fmt"
"strings"
"testing"
"time"
"github.com/meshcore-analyzer/users"
)
var evNow = time.Date(2026, 10, 7, 12, 0, 0, 0, time.UTC)
const (
evUser = int64(1)
evAdmin = int64(2)
evAdmin2 = int64(3)
)
var (
evPkA = strings.Repeat("a", 64)
evPkB = strings.Repeat("b", 64)
evPkF = strings.Repeat("f", 64)
evPkG = strings.Repeat("e", 64)
)
func evAgo(d time.Duration) string { return evNow.Add(-d).Format(time.RFC3339) }
func evMv(v int) *int { return &v }
// evInput: one user watching evPkA with the default node events, two
// admins without prefs, no stored states.
func evInput() notifyInput {
return notifyInput{
Now: evNow, Health: (&Config{}).GetHealthThresholds(), LowMv: 3300,
Accounts: map[int64]users.User{
evUser: {ID: evUser, Role: users.RoleUser, Status: users.StatusActive},
evAdmin: {ID: evAdmin, Role: users.RoleAdmin, Status: users.StatusActive},
evAdmin2: {ID: evAdmin2, Role: users.RoleAdmin, Status: users.StatusActive},
},
Prefs: []users.NotifyPrefs{{UserID: evUser, Enabled: true, Events: []string{users.NotifyNodeOffline, users.NotifyNodeBattery}}},
Watches: []users.NotifyWatch{{UserID: evUser, Pubkey: evPkA}},
Nodes: map[string]notifyNode{},
Heard: map[string]time.Time{},
Relayed: map[string]time.Time{},
}
}
func evStateRow(uid int64, event, subject, state string) users.NotifyState {
return users.NotifyState{NotifyKey: users.NotifyKey{UserID: uid, Event: event, Subject: subject}, State: state, ChangedAt: evNow.Add(-time.Hour)}
}
func evState(res notifyResult, uid int64, event, subject string) (string, bool) {
for _, s := range res.States {
if s.UserID == uid && s.Event == event && s.Subject == subject {
return s.State, true
}
}
return "", false
}
func TestEvaluateFirstEvaluationStoresWithoutChanges(t *testing.T) {
in := evInput()
in.Watches = append(in.Watches, users.NotifyWatch{UserID: evUser, Pubkey: evPkB})
in.Nodes[evPkA] = notifyNode{Pubkey: evPkA, Name: "Alpha", Role: "companion", LastSeen: evAgo(48 * time.Hour)}
in.Nodes[evPkB] = notifyNode{Pubkey: evPkB, Name: "Bravo", Role: "companion", LastSeen: evAgo(time.Hour), BatteryMv: evMv(3100)}
res := evaluateNotifications(in)
if len(res.Changes) != 0 {
t.Fatalf("changes on the first evaluation: %+v", res.Changes)
}
for _, c := range []struct{ event, subject, want string }{
{users.NotifyNodeOffline, evPkA, users.NotifyBad},
{users.NotifyNodeOffline, evPkB, users.NotifyGood},
{users.NotifyNodeBattery, evPkB, users.NotifyBad},
} {
if got, ok := evState(res, evUser, c.event, c.subject); !ok || got != c.want {
t.Errorf("%s %s = %q (stored %v); want %q", c.event, c.subject[:4], got, ok, c.want)
}
}
if _, ok := evState(res, evUser, users.NotifyNodeBattery, evPkA); ok {
t.Error("battery state stored for a node without telemetry")
}
for _, s := range res.States {
if !s.ChangedAt.Equal(evNow) {
t.Errorf("ChangedAt = %v; want the input's Now", s.ChangedAt)
}
}
}
func TestEvaluateOfflineAndBack(t *testing.T) {
in := evInput()
in.Nodes[evPkA] = notifyNode{Pubkey: evPkA, Name: "Alpha", Role: "companion", LastSeen: evAgo(24 * time.Hour)}
in.States = []users.NotifyState{evStateRow(evUser, users.NotifyNodeOffline, evPkA, users.NotifyGood)}
res := evaluateNotifications(in)
ch := res.Changes[evUser]
if len(ch) != 1 || ch[0].Event != users.NotifyNodeOffline || ch[0].Subject != evPkA || ch[0].To != users.NotifyBad ||
ch[0].Name != "Alpha" || !ch[0].At.Equal(evNow) {
t.Fatalf("changes = %+v; want one offline change for Alpha", ch)
}
// One minute inside the 24 h companion window: still online, nothing to write.
in.Nodes[evPkA] = notifyNode{Pubkey: evPkA, Name: "Alpha", Role: "companion", LastSeen: evAgo(24*time.Hour - time.Minute)}
if res := evaluateNotifications(in); len(res.Changes) != 0 || len(res.States) != 0 {
t.Fatalf("unchanged online node produced %+v", res)
}
// Stored bad, advert 30 h old, but the packet store heard it an hour ago.
in.States = []users.NotifyState{evStateRow(evUser, users.NotifyNodeOffline, evPkA, users.NotifyBad)}
in.Nodes[evPkA] = notifyNode{Pubkey: evPkA, Name: "Alpha", Role: "companion", LastSeen: evAgo(30 * time.Hour)}
in.Heard[evPkA] = evNow.Add(-time.Hour)
if ch := evaluateNotifications(in).Changes[evUser]; len(ch) != 1 || ch[0].To != users.NotifyGood {
t.Fatalf("changes = %+v; want one back-online change", ch)
}
}
func TestEvaluateRelayAwareInfra(t *testing.T) {
cases := []struct {
name, role, lastSeen string
relayed time.Duration
want string
}{
{"repeater relaying", "repeater", evAgo(80 * time.Hour), time.Hour, users.NotifyGood},
{"room relaying, role case", "Room", evAgo(80 * time.Hour), time.Hour, users.NotifyGood},
{"repeater silent exactly 72 h", "repeater", evAgo(72 * time.Hour), 0, users.NotifyBad},
{"repeater 71 h", "repeater", evAgo(71 * time.Hour), 0, users.NotifyGood},
{"companion: relay time ignored", "companion", evAgo(30 * time.Hour), time.Hour, users.NotifyBad},
{"no timestamp at all", "repeater", "", 0, users.NotifyBad},
}
for _, c := range cases {
in := evInput()
in.Nodes[evPkA] = notifyNode{Pubkey: evPkA, Role: c.role, LastSeen: c.lastSeen}
if c.relayed > 0 {
in.Relayed[evPkA] = evNow.Add(-c.relayed)
}
if got, _ := evState(evaluateNotifications(in), evUser, users.NotifyNodeOffline, evPkA); got != c.want {
t.Errorf("%s: %q; want %q", c.name, got, c.want)
}
}
}
func TestEvaluateBatteryHysteresis(t *testing.T) {
cases := []struct {
prev string
mv int
want string
change bool
}{
{"", 3299, users.NotifyBad, false},
{"", 3300, users.NotifyGood, false},
{"", 3350, users.NotifyGood, false},
{users.NotifyGood, 3299, users.NotifyBad, true},
{users.NotifyGood, 3300, users.NotifyGood, false},
{users.NotifyBad, 3399, users.NotifyBad, false},
{users.NotifyBad, 3400, users.NotifyGood, true},
}
for _, c := range cases {
in := evInput()
in.Nodes[evPkA] = notifyNode{Pubkey: evPkA, Name: "Alpha", Role: "companion", LastSeen: evAgo(time.Hour), BatteryMv: evMv(c.mv)}
in.States = []users.NotifyState{evStateRow(evUser, users.NotifyNodeOffline, evPkA, users.NotifyGood)}
if c.prev != "" {
in.States = append(in.States, evStateRow(evUser, users.NotifyNodeBattery, evPkA, c.prev))
}
res := evaluateNotifications(in)
got, stored := evState(res, evUser, users.NotifyNodeBattery, evPkA)
if !stored {
got = c.prev
}
ch := res.Changes[evUser]
if got != c.want || (len(ch) == 1) != c.change {
t.Errorf("prev %q, %d mV: state %q, changes %+v; want %q, change %v", c.prev, c.mv, got, ch, c.want, c.change)
continue
}
if c.change && (ch[0].Event != users.NotifyNodeBattery || ch[0].BatteryMv == nil || *ch[0].BatteryMv != c.mv) {
t.Errorf("prev %q, %d mV: change = %+v", c.prev, c.mv, ch[0])
}
}
}
func TestEvaluateAbsentNodeIsOfflineOnce(t *testing.T) {
in := evInput() // evPkA is watched but not in the analyzer database
in.States = []users.NotifyState{
evStateRow(evUser, users.NotifyNodeOffline, evPkA, users.NotifyGood),
evStateRow(evUser, users.NotifyNodeBattery, evPkA, users.NotifyBad),
}
ch := evaluateNotifications(in).Changes[evUser]
if len(ch) != 1 || ch[0].Event != users.NotifyNodeOffline || ch[0].To != users.NotifyBad || ch[0].Name != evPkA[:8] {
t.Fatalf("changes = %+v; want one offline change named by the key prefix", ch)
}
in.States[0] = evStateRow(evUser, users.NotifyNodeOffline, evPkA, users.NotifyBad)
if res := evaluateNotifications(in); len(res.Changes) != 0 || len(res.States) != 0 {
t.Fatalf("absent node evaluated again: %+v", res)
}
}
func TestEvaluateForeignOncePerAdmin(t *testing.T) {
in := evInput()
in.Prefs[0].Events = append(in.Prefs[0].Events, users.NotifyForeignNew) // a non-admin with an admin event
in.Prefs = append(in.Prefs, users.NotifyPrefs{UserID: evAdmin, Enabled: true, Events: []string{users.NotifyForeignNew}})
in.Nodes[evPkF] = notifyNode{Pubkey: evPkF, Name: "Foxtrot", Foreign: true, LastSeen: evAgo(time.Hour)}
res := evaluateNotifications(in)
if len(res.Changes[evAdmin]) != 0 {
t.Fatalf("first evaluation mailed: %+v", res.Changes[evAdmin])
}
if s, ok := evState(res, evAdmin, users.NotifyForeignNew, foreignBaselineSubject); !ok || s != users.NotifyGood {
t.Fatalf("baseline row = %q, %v", s, ok)
}
if s, ok := evState(res, evAdmin, users.NotifyForeignNew, evPkF); !ok || s != users.NotifyTold {
t.Fatalf("existing foreign node = %q, %v; want told", s, ok)
}
if _, ok := evState(res, evUser, users.NotifyForeignNew, evPkF); ok {
t.Fatal("non-admin evaluated for foreign.new")
}
// Next tick: a second foreign node appears; a second admin opts in now.
in.States = res.States
in.Prefs = append(in.Prefs, users.NotifyPrefs{UserID: evAdmin2, Enabled: true, Events: []string{users.NotifyForeignNew}})
in.Nodes[evPkG] = notifyNode{Pubkey: evPkG, Name: "Golf", Foreign: true}
res = evaluateNotifications(in)
ch := res.Changes[evAdmin]
if len(ch) != 1 || ch[0].Subject != evPkG || ch[0].To != users.NotifyTold || ch[0].Name != "Golf" {
t.Fatalf("admin changes = %+v; want Golf once", ch)
}
if len(res.Changes[evAdmin2]) != 0 {
t.Fatalf("second admin's first evaluation mailed: %+v", res.Changes[evAdmin2])
}
in.States = append(in.States, res.States...)
if res := evaluateNotifications(in); len(res.Changes) != 0 {
t.Fatalf("foreign nodes mailed twice: %+v", res.Changes)
}
}
func TestEvaluateObserverOfflineAndBack(t *testing.T) {
cases := []struct {
prev string
age time.Duration
want string
change bool
}{
{"", 2 * time.Hour, users.NotifyGood, false},
{"", 25 * time.Hour, users.NotifyBad, false},
{users.NotifyGood, 24 * time.Hour, users.NotifyGood, false},
{users.NotifyGood, 24*time.Hour + time.Minute, users.NotifyBad, true},
{users.NotifyBad, 2 * time.Hour, users.NotifyBad, false},
{users.NotifyBad, 60 * time.Minute, users.NotifyGood, true},
}
for _, c := range cases {
in := evInput()
in.Prefs = []users.NotifyPrefs{{UserID: evAdmin, Enabled: true, Events: []string{users.NotifyObserverOffline}}}
in.Watches = nil
in.Observers = []notifyObserver{{ID: "OBS1", Name: "Roof", LastSeen: evAgo(c.age)}}
if c.prev != "" {
in.States = []users.NotifyState{evStateRow(evAdmin, users.NotifyObserverOffline, "OBS1", c.prev)}
}
res := evaluateNotifications(in)
got, stored := evState(res, evAdmin, users.NotifyObserverOffline, "OBS1")
if !stored {
got = c.prev
}
ch := res.Changes[evAdmin]
if got != c.want || (len(ch) == 1) != c.change || (c.change && ch[0].Name != "Roof") {
t.Errorf("prev %q, age %v: state %q, changes %+v; want %q, change %v", c.prev, c.age, got, ch, c.want, c.change)
}
}
in := evInput()
in.Prefs = []users.NotifyPrefs{{UserID: evAdmin, Enabled: true, Events: []string{users.NotifyObserverOffline}}}
in.Observers = []notifyObserver{{ID: "OBS2"}}
if s, _ := evState(evaluateNotifications(in), evAdmin, users.NotifyObserverOffline, "OBS2"); s != users.NotifyBad {
t.Fatalf("observer without last_seen = %q; want bad", s)
}
}
func TestEvaluateOnlyChosenEventsAndKnownAccounts(t *testing.T) {
in := evInput()
in.Prefs[0].Events = []string{users.NotifyNodeBattery, users.NotifyObserverOffline} // observer.offline: the user is no admin
in.Nodes[evPkA] = notifyNode{Pubkey: evPkA, Role: "companion", LastSeen: evAgo(48 * time.Hour), BatteryMv: evMv(4000)}
in.Observers = []notifyObserver{{ID: "OBS1", LastSeen: evAgo(time.Hour)}}
res := evaluateNotifications(in)
if len(res.States) != 1 || res.States[0].Event != users.NotifyNodeBattery {
t.Fatalf("states = %+v; want only node.battery", res.States)
}
in.Accounts = map[int64]users.User{} // the user was deleted after the read
if res := evaluateNotifications(in); len(res.States) != 0 {
t.Fatalf("deleted user evaluated: %+v", res.States)
}
}
func TestEvaluateChangesAreOrderedPerUser(t *testing.T) {
in := evInput()
in.Watches = []users.NotifyWatch{{UserID: evUser, Pubkey: evPkB}, {UserID: evUser, Pubkey: evPkA}}
in.Nodes[evPkA] = notifyNode{Pubkey: evPkA, Name: "Alpha", Role: "companion", LastSeen: evAgo(48 * time.Hour), BatteryMv: evMv(3000)}
in.Nodes[evPkB] = notifyNode{Pubkey: evPkB, Name: "Bravo", Role: "companion", LastSeen: evAgo(48 * time.Hour)}
in.States = []users.NotifyState{
evStateRow(evUser, users.NotifyNodeOffline, evPkA, users.NotifyGood),
evStateRow(evUser, users.NotifyNodeOffline, evPkB, users.NotifyGood),
evStateRow(evUser, users.NotifyNodeBattery, evPkA, users.NotifyGood),
}
var got []string
for _, c := range evaluateNotifications(in).Changes[evUser] {
got = append(got, c.Event+" "+c.Name)
}
if want := "node.offline Alpha|node.offline Bravo|node.battery Alpha"; strings.Join(got, "|") != want {
t.Fatalf("order = %q; want %q", strings.Join(got, "|"), want)
}
}
// 100 users x 50 watches over 2,000 nodes: the spec's Performance figure
// (5,000 state rows per event type). Run with -bench and quote the result
// in the PR.
func BenchmarkEvaluateNotifications(b *testing.B) {
in := notifyInput{Now: evNow, Health: (&Config{}).GetHealthThresholds(), LowMv: 3300,
Accounts: map[int64]users.User{}, Nodes: map[string]notifyNode{}, Heard: map[string]time.Time{}, Relayed: map[string]time.Time{}}
for i := 0; i < 2000; i++ {
pk := fmt.Sprintf("%064x", i)
in.Nodes[pk] = notifyNode{Pubkey: pk, Role: "repeater", LastSeen: evAgo(time.Duration(i) * time.Minute), BatteryMv: evMv(3200 + i%300)}
}
for u := int64(1); u <= 100; u++ {
in.Accounts[u] = users.User{ID: u, Role: users.RoleUser, Status: users.StatusActive}
in.Prefs = append(in.Prefs, users.NotifyPrefs{UserID: u, Enabled: true, Events: users.NodeNotifyEvents})
for w := 0; w < 50; w++ {
in.Watches = append(in.Watches, users.NotifyWatch{UserID: u, Pubkey: fmt.Sprintf("%064x", (int(u)*37+w*11)%2000)})
}
}
in.States = evaluateNotifications(in).States
b.ResetTimer()
for i := 0; i < b.N; i++ {
evaluateNotifications(in)
}
}
// P7: an admin who loses the admin role loses their admin-event states, like
// opting out of those events, so a re-promotion starts a fresh baseline.
func TestEvaluateDemotedAdminDropsAdminEventStates(t *testing.T) {
in := evInput()
in.Prefs = append(in.Prefs, users.NotifyPrefs{UserID: evAdmin, Enabled: true,
Events: []string{users.NotifyNodeOffline, users.NotifyForeignNew, users.NotifyObserverOffline}})
in.Watches = append(in.Watches, users.NotifyWatch{UserID: evAdmin, Pubkey: evPkA})
in.Nodes[evPkA] = notifyNode{Pubkey: evPkA, Name: "Alpha", Role: "companion", LastSeen: evAgo(time.Hour)}
in.Nodes[evPkF] = notifyNode{Pubkey: evPkF, Name: "Foxtrot", Foreign: true}
in.Observers = []notifyObserver{{ID: "OBS1", Name: "Roof", LastSeen: evAgo(time.Hour)}}
in.States = evaluateNotifications(in).States
if s, ok := evState(notifyResult{States: in.States}, evAdmin, users.NotifyForeignNew, foreignBaselineSubject); !ok || s != users.NotifyGood {
t.Fatalf("baseline row = %q, %v", s, ok)
}
// Demoted: the admin-event rows are dropped, the node rows stay, nothing mailed.
admin := in.Accounts[evAdmin]
admin.Role = users.RoleUser
in.Accounts[evAdmin] = admin
in.Nodes[evPkG] = notifyNode{Pubkey: evPkG, Name: "Golf", Foreign: true}
in.Observers[0].LastSeen = evAgo(48 * time.Hour)
res := evaluateNotifications(in)
if len(res.Changes) != 0 || len(res.States) != 0 {
t.Fatalf("demoted admin evaluated: %+v", res)
}
want := map[users.NotifyKey]bool{
{UserID: evAdmin, Event: users.NotifyForeignNew, Subject: foreignBaselineSubject}: true,
{UserID: evAdmin, Event: users.NotifyForeignNew, Subject: evPkF}: true,
{UserID: evAdmin, Event: users.NotifyObserverOffline, Subject: "OBS1"}: true,
}
if len(res.Drop) != len(want) {
t.Fatalf("drop = %+v; want %d admin-event keys", res.Drop, len(want))
}
for _, k := range res.Drop {
if !want[k] {
t.Errorf("dropped %+v", k)
}
}
dropped := map[users.NotifyKey]bool{}
for _, k := range res.Drop {
dropped[k] = true
}
var kept []users.NotifyState
for _, s := range in.States {
if !dropped[s.NotifyKey] {
kept = append(kept, s)
}
}
in.States = kept
if res := evaluateNotifications(in); len(res.Drop) != 0 {
t.Fatalf("dropped again: %+v", res.Drop)
}
// Re-promoted: Golf appeared and the observer went offline meanwhile; the
// first evaluation stores a new baseline and mails nothing.
admin.Role = users.RoleAdmin
in.Accounts[evAdmin] = admin
res = evaluateNotifications(in)
if len(res.Changes) != 0 || len(res.Drop) != 0 {
t.Fatalf("re-promoted admin: changes %+v, drop %+v", res.Changes, res.Drop)
}
for _, c := range []struct{ event, subject, want string }{
{users.NotifyForeignNew, foreignBaselineSubject, users.NotifyGood},
{users.NotifyForeignNew, evPkG, users.NotifyTold},
{users.NotifyObserverOffline, "OBS1", users.NotifyBad},
} {
if got, ok := evState(res, evAdmin, c.event, c.subject); !ok || got != c.want {
t.Errorf("%s %s = %q (stored %v); want %q", c.event, c.subject, got, ok, c.want)
}
}
}
// A user who watches nodes but has no preferences row (never opened the
// settings, or created before the notification schema) gets the defaults:
// enabled, node events on, admin events off.
func TestEvaluateWatcherWithoutPrefsGetsDefaults(t *testing.T) {
in := evInput()
in.Prefs = nil
in.Watches = []users.NotifyWatch{{UserID: evAdmin, Pubkey: evPkA}}
in.Nodes[evPkA] = notifyNode{Pubkey: evPkA, Name: "Alpha", Role: "companion", LastSeen: evAgo(48 * time.Hour), BatteryMv: evMv(3000)}
in.Nodes[evPkF] = notifyNode{Pubkey: evPkF, Name: "Foxtrot", Foreign: true}
in.Observers = []notifyObserver{{ID: "OBS1", LastSeen: evAgo(time.Hour)}}
in.States = []users.NotifyState{
evStateRow(evAdmin, users.NotifyNodeOffline, evPkA, users.NotifyGood),
evStateRow(evAdmin, users.NotifyNodeBattery, evPkA, users.NotifyGood),
}
res := evaluateNotifications(in)
var got []string
for _, c := range res.Changes[evAdmin] {
got = append(got, c.Event+" "+c.To)
}
if want := "node.offline bad|node.battery bad"; strings.Join(got, "|") != want {
t.Fatalf("changes = %q; want %q", strings.Join(got, "|"), want)
}
for _, s := range res.States {
if users.IsAdminNotifyEvent(s.Event) {
t.Errorf("admin event evaluated without opting in: %+v", s)
}
}
in.Accounts = map[int64]users.User{}
if res := evaluateNotifications(in); len(res.States) != 0 {
t.Fatalf("watcher without an account evaluated: %+v", res.States)
}
}
// A disabled user keeps being evaluated: states follow the node, nothing is
// mailed, and re-enabling does not mail what changed while off.
func TestEvaluateDisabledUserUpdatesStatesSilently(t *testing.T) {
in := evInput()
in.Prefs[0].Enabled = false
in.Prefs = append(in.Prefs, users.NotifyPrefs{UserID: evAdmin, Enabled: false, Events: []string{users.NotifyForeignNew}})
in.Nodes[evPkA] = notifyNode{Pubkey: evPkA, Name: "Alpha", Role: "companion", LastSeen: evAgo(time.Hour)}
in.Nodes[evPkF] = notifyNode{Pubkey: evPkF, Name: "Foxtrot", Foreign: true}
in.States = evaluateNotifications(in).States
// While off: Alpha goes offline and Golf appears.
in.Nodes[evPkA] = notifyNode{Pubkey: evPkA, Name: "Alpha", Role: "companion", LastSeen: evAgo(48 * time.Hour)}
in.Nodes[evPkG] = notifyNode{Pubkey: evPkG, Name: "Golf", Foreign: true}
res := evaluateNotifications(in)
if len(res.Changes) != 0 {
t.Fatalf("disabled users mailed: %+v", res.Changes)
}
if s, ok := evState(res, evUser, users.NotifyNodeOffline, evPkA); !ok || s != users.NotifyBad {
t.Fatalf("disabled user's offline state = %q, %v; want bad stored", s, ok)
}
if s, ok := evState(res, evAdmin, users.NotifyForeignNew, evPkG); !ok || s != users.NotifyTold {
t.Fatalf("disabled admin's foreign state = %q, %v; want told stored", s, ok)
}
in.States = append(in.States, res.States...)
// Re-enabled: the next evaluation has nothing to send.
in.Prefs[0].Enabled = true
in.Prefs[1].Enabled = true
if res := evaluateNotifications(in); len(res.Changes) != 0 || len(res.States) != 0 {
t.Fatalf("re-enabling mailed old changes: %+v", res)
}
}
// While ingest is stale nobody can tell a silent node from a silent
// MQTT feed: offline comparisons pause with their states unchanged;
// battery and foreign nodes go on.
func TestEvaluateIngestStalePausesOfflineChecks(t *testing.T) {
in := evInput()
in.IngestStale = true
in.Watches = append(in.Watches, users.NotifyWatch{UserID: evUser, Pubkey: evPkB})
in.Prefs = append(in.Prefs, users.NotifyPrefs{UserID: evAdmin, Enabled: true, Events: []string{users.NotifyForeignNew, users.NotifyObserverOffline}})
in.Nodes[evPkA] = notifyNode{Pubkey: evPkA, Name: "Alpha", Role: "companion", LastSeen: evAgo(48 * time.Hour), BatteryMv: evMv(3100)}
in.Nodes[evPkF] = notifyNode{Pubkey: evPkF, Name: "Foxtrot", Foreign: true}
in.Observers = []notifyObserver{{ID: "OBS1", Name: "Roof", LastSeen: evAgo(48 * time.Hour)}}
in.States = []users.NotifyState{
evStateRow(evUser, users.NotifyNodeOffline, evPkA, users.NotifyGood),
evStateRow(evUser, users.NotifyNodeBattery, evPkA, users.NotifyGood),
evStateRow(evAdmin, users.NotifyObserverOffline, "OBS1", users.NotifyGood),
evStateRow(evAdmin, users.NotifyForeignNew, foreignBaselineSubject, users.NotifyGood),
}
res := evaluateNotifications(in)
for _, k := range []struct {
uid int64
event, subject string
}{{evUser, users.NotifyNodeOffline, evPkA}, {evUser, users.NotifyNodeOffline, evPkB}, {evAdmin, users.NotifyObserverOffline, "OBS1"}} {
if s, ok := evState(res, k.uid, k.event, k.subject); ok {
t.Errorf("%s %s written as %q while ingest is stale", k.event, k.subject, s)
}
}
if ch := res.Changes[evUser]; len(ch) != 1 || ch[0].Event != users.NotifyNodeBattery || ch[0].To != users.NotifyBad {
t.Fatalf("user changes = %+v; want only the battery", ch)
}
if ch := res.Changes[evAdmin]; len(ch) != 1 || ch[0].Event != users.NotifyForeignNew || ch[0].Subject != evPkF {
t.Fatalf("admin changes = %+v; want only the foreign node", ch)
}
in.IngestStale = false
res = evaluateNotifications(in)
if s, _ := evState(res, evUser, users.NotifyNodeOffline, evPkA); s != users.NotifyBad {
t.Fatalf("offline check after ingest resumed = %q; want bad", s)
}
if s, _ := evState(res, evAdmin, users.NotifyObserverOffline, "OBS1"); s != users.NotifyBad {
t.Fatalf("observer check after ingest resumed = %q; want bad", s)
}
}
// After a feed outage, evidence older than the recovery counts as heard at
// the recovery: no node or observer goes offline until a full silent window
// has passed since then. A state that is already bad is not lifted by it.
func TestEvaluateGraceAfterIngestResumes(t *testing.T) {
in := evInput()
in.Prefs = append(in.Prefs, users.NotifyPrefs{UserID: evAdmin, Enabled: true, Events: []string{users.NotifyObserverOffline}})
in.Watches = append(in.Watches, users.NotifyWatch{UserID: evUser, Pubkey: evPkB})
in.Nodes[evPkA] = notifyNode{Pubkey: evPkA, Name: "Alpha", Role: "companion", LastSeen: evAgo(25 * time.Hour)}
in.Nodes[evPkB] = notifyNode{Pubkey: evPkB, Name: "Bravo", Role: "companion", LastSeen: evAgo(48 * time.Hour)}
in.Observers = []notifyObserver{{ID: "OBS1", Name: "Roof", LastSeen: evAgo(25 * time.Hour)}, {ID: "OBS2", Name: "Mast", LastSeen: evAgo(48 * time.Hour)}}
in.States = []users.NotifyState{
evStateRow(evUser, users.NotifyNodeOffline, evPkA, users.NotifyGood),
evStateRow(evUser, users.NotifyNodeOffline, evPkB, users.NotifyBad),
evStateRow(evAdmin, users.NotifyObserverOffline, "OBS1", users.NotifyGood),
evStateRow(evAdmin, users.NotifyObserverOffline, "OBS2", users.NotifyBad),
}
in.IngestResumedAt = evNow.Add(-10 * time.Minute)
if res := evaluateNotifications(in); len(res.Changes) != 0 || len(res.States) != 0 {
t.Fatalf("10 minutes after recovery: changes %+v, states %+v; want none", res.Changes, res.States)
}
// One silent window (24 h companions, observerStaleMinutes 1440) plus a
// minute after recovery, still no new evidence: offline.
in.Now = in.IngestResumedAt.Add(24*time.Hour + time.Minute)
res := evaluateNotifications(in)
if ch := res.Changes[evUser]; len(ch) != 1 || ch[0].Subject != evPkA || ch[0].To != users.NotifyBad {
t.Fatalf("node changes after the window = %+v; want Alpha offline", ch)
}
if ch := res.Changes[evAdmin]; len(ch) != 1 || ch[0].Subject != "OBS1" || ch[0].To != users.NotifyBad {
t.Fatalf("observer changes after the window = %+v; want Roof offline", ch)
}
// A node heard after the recovery behaves as without the grace.
in.Now = evNow
in.Nodes[evPkB] = notifyNode{Pubkey: evPkB, Name: "Bravo", Role: "companion", LastSeen: evAgo(5 * time.Minute)}
in.Observers[1].LastSeen = evAgo(5 * time.Minute)
res = evaluateNotifications(in)
if ch := res.Changes[evUser]; len(ch) != 1 || ch[0].Subject != evPkB || ch[0].To != users.NotifyGood {
t.Fatalf("node heard after recovery = %+v; want Bravo back online", ch)
}
if ch := res.Changes[evAdmin]; len(ch) != 1 || ch[0].Subject != "OBS2" || ch[0].To != users.NotifyGood {
t.Fatalf("observer heard after recovery = %+v; want Mast back online", ch)
}
}
+312
View File
@@ -0,0 +1,312 @@
package main
import (
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"regexp"
"strconv"
"strings"
"time"
"github.com/gorilla/mux"
"github.com/meshcore-analyzer/users"
)
// Node notification routes (docs/specs/2026-10-07-node-notifications-design.md).
// Registered only when userManagement.notifications.enabled (see
// registerAuthRoutes).
type notifyPrefsRequest struct {
Enabled bool `json:"enabled"`
Events []string `json:"events"`
}
type notifyWatchJSON struct {
Pubkey string `json:"pubkey"`
Name string `json:"name"` // "" when unnamed or no longer in the analyzer database
Known bool `json:"known"` // false once the node left the analyzer database
CreatedAt string `json:"createdAt"`
}
type notifyLimitsJSON struct {
MaxWatches int `json:"maxWatches"`
PerUserPerDay int `json:"perUserPerDay"`
MailsLast24h int `json:"mailsLast24h"`
}
// notifyAccountJSON is the caller's notification state: GET answers it, and
// so does every change, so the client can replace its copy.
type notifyAccountJSON struct {
Enabled bool `json:"enabled"`
Events []string `json:"events"`
AvailableEvents []string `json:"availableEvents"`
Watches []notifyWatchJSON `json:"watches"`
Limits notifyLimitsJSON `json:"limits"`
}
type watchMyNodesJSON struct {
Added int `json:"added"`
Already int `json:"already"`
Skipped int `json:"skipped"` // malformed, not in the analyzer database, or over the limit
Account notifyAccountJSON `json:"account"`
}
func (s *Server) registerNotifyRoutes(r *mux.Router) {
r.HandleFunc("/api/account/notifications", s.withUser(s.handleNotifyGet)).Methods("GET")
r.HandleFunc("/api/account/notifications", s.withUser(s.handleNotifyPut)).Methods("PUT")
r.HandleFunc("/api/account/notifications/watches/{pubkey}", s.withUser(s.handleWatchPut)).Methods("PUT")
r.HandleFunc("/api/account/notifications/watches/{pubkey}", s.withUser(s.handleWatchDelete)).Methods("DELETE")
r.HandleFunc("/api/account/notifications/watch-my-nodes", s.withUser(s.handleWatchMyNodes)).Methods("POST")
// No session: the link comes from a mail, and the provider's one-click
// POST (List-Unsubscribe-Post) carries neither a cookie nor our Origin.
r.HandleFunc("/api/notifications/unsubscribe", s.handleUnsubscribeGet).Methods("GET")
r.HandleFunc("/api/notifications/unsubscribe", s.handleUnsubscribePost).Methods("POST")
}
var notifyPubkeyRE = regexp.MustCompile(`^[0-9a-f]{64}$`)
// notifyPubkey lowercases raw and reports whether it is a 64-hex-character key.
func notifyPubkey(raw string) (string, bool) {
pk := strings.ToLower(strings.TrimSpace(raw))
return pk, notifyPubkeyRE.MatchString(pk)
}
func availableNotifyEvents(u *users.User) []string {
out := append([]string{}, users.NodeNotifyEvents...)
if u.Role == users.RoleAdmin {
out = append(out, users.AdminNotifyEvents...)
}
return out
}
func notifyInternal(w http.ResponseWriter, op string, uid int64, err error) {
log.Printf("[notify] %s for user #%d: %v", op, uid, err)
writeError(w, http.StatusInternalServerError, "internal error")
}
// notifyAccount builds the caller's state; it creates the default
// preferences row on first use. A demoted admin's stored admin events are
// not shown (and are not evaluated).
func (s *Server) notifyAccount(u *users.User) (notifyAccountJSON, error) {
n, st, ns := s.auth.notify, s.auth.st, s.auth.set.notify
p, err := st.NotifyPrefsFor(u.ID)
if err != nil {
return notifyAccountJSON{}, err
}
ws, err := st.WatchesFor(u.ID)
if err != nil {
return notifyAccountJSON{}, err
}
pks := make([]string, len(ws))
for i, w := range ws {
pks[i] = w.Pubkey
}
nodes, err := n.src.nodes(pks, false)
if err != nil {
return notifyAccountJSON{}, err
}
_, perUser, err := st.NotifyMailCounts(n.now().Add(-24 * time.Hour))
if err != nil {
return notifyAccountJSON{}, err
}
out := notifyAccountJSON{Enabled: p.Enabled, Events: []string{}, AvailableEvents: availableNotifyEvents(u),
Watches: make([]notifyWatchJSON, 0, len(ws)),
Limits: notifyLimitsJSON{MaxWatches: ns.maxWatchesPerUser, PerUserPerDay: ns.perUserPerDay, MailsLast24h: perUser[u.ID]}}
for _, e := range p.Events {
if u.Role == users.RoleAdmin || !users.IsAdminNotifyEvent(e) {
out.Events = append(out.Events, e)
}
}
for _, w := range ws {
nd, known := nodes[w.Pubkey]
out.Watches = append(out.Watches, notifyWatchJSON{Pubkey: w.Pubkey, Name: nd.Name, Known: known, CreatedAt: rfc3339(w.CreatedAt)})
}
return out, nil
}
func (s *Server) writeNotifyAccount(w http.ResponseWriter, u *users.User) {
out, err := s.notifyAccount(u)
if err != nil {
notifyInternal(w, "account state", u.ID, err)
return
}
writeJSON(w, out)
}
func (s *Server) handleNotifyGet(w http.ResponseWriter, _ *http.Request, u *users.User, _ *users.Session) {
s.writeNotifyAccount(w, u)
}
func (s *Server) handleNotifyPut(w http.ResponseWriter, r *http.Request, u *users.User, _ *users.Session) {
var req notifyPrefsRequest
if !decodeJSON(w, r, &req) {
return
}
for _, e := range req.Events {
if !users.ValidNotifyEvent(e) {
writeError(w, http.StatusBadRequest, "unknown event type")
return
}
if users.IsAdminNotifyEvent(e) && u.Role != users.RoleAdmin {
writeError(w, http.StatusForbidden, "only admins can choose this event")
return
}
}
p, err := s.auth.st.SetNotifyPrefs(u.ID, req.Enabled, req.Events)
if err != nil {
notifyInternal(w, "save preferences", u.ID, err)
return
}
s.auth.audit(idPtr(u.ID), "notify.prefs", idPtr(u.ID),
map[string]string{"enabled": strconv.FormatBool(p.Enabled), "events": strings.Join(p.Events, ",")})
s.writeNotifyAccount(w, u)
}
func (s *Server) handleWatchPut(w http.ResponseWriter, r *http.Request, u *users.User, _ *users.Session) {
pk, ok := notifyPubkey(mux.Vars(r)["pubkey"])
if !ok {
writeError(w, http.StatusBadRequest, "invalid node key: expected 64 hex characters")
return
}
nodes, err := s.auth.notify.src.nodes([]string{pk}, false)
if err != nil {
notifyInternal(w, "look up node", u.ID, err)
return
}
if _, found := nodes[pk]; !found {
writeError(w, http.StatusNotFound, "node not found")
return
}
if _, err := s.auth.st.NotifyPrefsFor(u.ID); err != nil {
notifyInternal(w, "preferences", u.ID, err)
return
}
limit := s.auth.set.notify.maxWatchesPerUser
if err := s.auth.st.AddWatch(u.ID, pk, limit); err != nil {
if errors.Is(err, users.ErrWatchLimit) {
writeError(w, http.StatusConflict, fmt.Sprintf("you watch the maximum of %d nodes; remove one first", limit))
return
}
notifyInternal(w, "add watch", u.ID, err)
return
}
s.writeNotifyAccount(w, u)
}
func (s *Server) handleWatchDelete(w http.ResponseWriter, r *http.Request, u *users.User, _ *users.Session) {
pk, ok := notifyPubkey(mux.Vars(r)["pubkey"])
if !ok {
writeError(w, http.StatusBadRequest, "invalid node key: expected 64 hex characters")
return
}
if err := s.auth.st.RemoveWatch(u.ID, pk); err != nil {
notifyInternal(w, "remove watch", u.ID, err)
return
}
s.writeNotifyAccount(w, u)
}
// myNodesPubkeys returns the pubkeys of the synced "meshcore-my-nodes" list
// (settings sync; [{pubkey, name, addedAt}]) in list order; nil when the
// document or the key is absent or does not decode.
func myNodesPubkeys(doc string) []string {
if doc == "" {
return nil
}
var d settingsDoc
if json.Unmarshal([]byte(doc), &d) != nil {
return nil
}
raw, ok := d.Keys["meshcore-my-nodes"]
if !ok {
return nil
}
var items []struct {
Pubkey string `json:"pubkey"`
}
if json.Unmarshal([]byte(raw), &items) != nil {
return nil
}
out := make([]string, 0, len(items))
for _, it := range items {
out = append(out, it.Pubkey)
}
return out
}
func (s *Server) handleWatchMyNodes(w http.ResponseWriter, _ *http.Request, u *users.User, _ *users.Session) {
st := s.auth.st
doc, _, err := st.GetSettings(u.ID)
if err != nil {
notifyInternal(w, "read synced settings", u.ID, err)
return
}
var res watchMyNodesJSON
seen := map[string]bool{}
var valid []string
for _, raw := range myNodesPubkeys(doc) {
pk, ok := notifyPubkey(raw)
if !ok {
res.Skipped++
continue
}
if !seen[pk] {
seen[pk] = true
valid = append(valid, pk)
}
}
nodes, err := s.auth.notify.src.nodes(valid, false)
if err != nil {
notifyInternal(w, "look up nodes", u.ID, err)
return
}
known := make([]string, 0, len(valid))
for _, pk := range valid {
if _, ok := nodes[pk]; ok {
known = append(known, pk)
} else {
res.Skipped++
}
}
if _, err := st.NotifyPrefsFor(u.ID); err != nil {
notifyInternal(w, "preferences", u.ID, err)
return
}
added, already, over, err := st.AddWatches(u.ID, known, s.auth.set.notify.maxWatchesPerUser)
if err != nil {
notifyInternal(w, "add watches", u.ID, err)
return
}
res.Added, res.Already, res.Skipped = added, already, res.Skipped+over
if res.Account, err = s.notifyAccount(u); err != nil {
notifyInternal(w, "account state", u.ID, err)
return
}
writeJSON(w, res)
}
// handleUnsubscribeGet sends a mail client or a scanner that follows the
// List-Unsubscribe URL to the confirm view. A GET never changes anything.
func (s *Server) handleUnsubscribeGet(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, s.auth.link("unsubscribe", r.URL.Query().Get("token")), http.StatusSeeOther)
}
// handleUnsubscribePost turns notifications off for the token's owner: the
// confirm button and the provider's one-click POST. The token only does this.
func (s *Server) handleUnsubscribePost(w http.ResponseWriter, r *http.Request) {
uid, was, err := s.auth.st.DisableNotifyByToken(r.URL.Query().Get("token"))
if err != nil {
if !errors.Is(err, users.ErrTokenInvalid) {
log.Printf("[notify] unsubscribe: %v", err)
}
writeTokenError(w, err)
return
}
if was {
s.auth.audit(idPtr(uid), "notify.unsubscribe", idPtr(uid), map[string]string{"via": "link"})
}
writeJSON(w, okResponse{OK: true, Message: "Notifications are off. Turn them back on from your account page."})
}
+273
View File
@@ -0,0 +1,273 @@
package main
import (
"encoding/json"
"net/http/httptest"
"reflect"
"strings"
"testing"
"time"
"github.com/meshcore-analyzer/users"
)
func notifyPath(pk string) string { return "/api/account/notifications/watches/" + pk }
func TestNotifyRoutesAbsentWhenOff(t *testing.T) {
f := newNotifyFixture(t, notifySettings{})
for _, rt := range []struct{ method, path string }{
{"GET", "/api/account/notifications"}, {"PUT", "/api/account/notifications"},
{"PUT", notifyPath(evPkA)}, {"DELETE", notifyPath(evPkA)},
{"POST", "/api/account/notifications/watch-my-nodes"},
{"GET", "/api/notifications/unsubscribe?token=x"}, {"POST", "/api/notifications/unsubscribe?token=x"},
} {
if w := f.do(rt.method, rt.path, nil); w.Code != 404 {
t.Errorf("%s %s = %d with notifications off; want 404", rt.method, rt.path, w.Code)
}
}
}
func TestNotifyGetDefaultsAndAdminEvents(t *testing.T) {
f := newNotifyFixture(t, defaultNotifySettings())
boss := f.registerAndActivate(t, "admin@example.org", "Boss", pw)
pat := f.registerAndActivate(t, "pat@example.org", "Pat", pw)
expectStatus(t, f.do("GET", "/api/account/notifications", nil), 401)
w := f.do("GET", "/api/account/notifications", nil, as(pat))
expectStatus(t, w, 200)
want := notifyAccountJSON{Enabled: true, Events: []string{"node.offline", "node.battery"}, AvailableEvents: []string{"node.offline", "node.battery"},
Watches: []notifyWatchJSON{}, Limits: notifyLimitsJSON{MaxWatches: 50, PerUserPerDay: 20}}
if got := decode[notifyAccountJSON](t, w); !reflect.DeepEqual(got, want) {
t.Fatalf("user defaults = %+v; want %+v", got, want)
}
if !strings.Contains(w.Body.String(), `"watches":[]`) {
t.Fatalf("empty watch list is not an array: %s", w.Body.String())
}
adm := decode[notifyAccountJSON](t, f.do("GET", "/api/account/notifications", nil, as(boss)))
if !reflect.DeepEqual(adm.AvailableEvents, []string{"node.offline", "node.battery", "foreign.new", "observer.offline"}) ||
!reflect.DeepEqual(adm.Events, []string{"node.offline", "node.battery"}) {
t.Fatalf("admin = %+v", adm)
}
}
func TestNotifyPutPrefs(t *testing.T) {
f := newNotifyFixture(t, defaultNotifySettings())
boss := f.registerAndActivate(t, "admin@example.org", "Boss", pw)
pat := f.registerAndActivate(t, "pat@example.org", "Pat", pw)
put := func(c *client, body any) *httptest.ResponseRecorder {
return f.do("PUT", "/api/account/notifications", body, as(c))
}
expectStatus(t, put(&client{cookie: pat.cookie}, notifyPrefsRequest{Enabled: true}), 403)
w := put(pat, notifyPrefsRequest{Enabled: false, Events: []string{"node.battery"}})
expectStatus(t, w, 200)
if got := decode[notifyAccountJSON](t, w); got.Enabled || !reflect.DeepEqual(got.Events, []string{"node.battery"}) {
t.Fatalf("after PUT = %+v", got)
}
entries, err := f.st.AuditList(users.AuditFilter{Actions: []string{"notify.prefs"}})
if err != nil || len(entries) != 1 || *entries[0].ActorUserID != pat.me.ID || entries[0].Detail["enabled"] != "false" || entries[0].Detail["events"] != "node.battery" {
t.Fatalf("audit = %+v, %v", entries, err)
}
if w := put(pat, notifyPrefsRequest{Enabled: true, Events: []string{"node.reboot"}}); w.Code != 400 || !strings.Contains(w.Body.String(), "unknown event type") {
t.Errorf("unknown event: %d %s", w.Code, w.Body.String())
}
if w := put(pat, notifyPrefsRequest{Enabled: true, Events: []string{"foreign.new"}}); w.Code != 403 || !strings.Contains(w.Body.String(), "only admins") {
t.Errorf("admin event by a user: %d %s", w.Code, w.Body.String())
}
if w := put(pat, map[string]any{"enabled": true, "x": 1}); w.Code != 400 {
t.Errorf("unknown field: %d", w.Code)
}
w = put(boss, notifyPrefsRequest{Enabled: true, Events: []string{"observer.offline", "foreign.new"}})
if got := decode[notifyAccountJSON](t, w); w.Code != 200 || !reflect.DeepEqual(got.Events, []string{"foreign.new", "observer.offline"}) {
t.Fatalf("admin PUT: %d %+v", w.Code, got)
}
}
func TestWatchPutAndDelete(t *testing.T) {
ns := defaultNotifySettings()
ns.maxWatchesPerUser = 1
f := newNotifyFixture(t, ns)
f.setNode(evPkA, "Alpha", "repeater", time.Hour, nil)
f.setNode(evPkB, "Bravo", "companion", time.Hour, nil)
pat := f.registerAndActivate(t, "pat@example.org", "Pat", pw)
expectStatus(t, f.do("PUT", notifyPath(evPkA), nil), 401)
expectStatus(t, f.do("PUT", notifyPath(evPkA), nil, as(&client{cookie: pat.cookie})), 403)
for _, bad := range []string{"xyz", evPkA[:63], evPkA + "0", strings.Repeat("g", 64)} {
if w := f.do("PUT", notifyPath(bad), nil, as(pat)); w.Code != 400 {
t.Errorf("PUT %q = %d; want 400", bad, w.Code)
}
}
if w := f.do("PUT", notifyPath(evPkG), nil, as(pat)); w.Code != 404 || !strings.Contains(w.Body.String(), "node not found") {
t.Fatalf("unknown node: %d %s", w.Code, w.Body.String())
}
w := f.do("PUT", notifyPath(strings.ToUpper(evPkA)), nil, as(pat))
expectStatus(t, w, 200)
got := decode[notifyAccountJSON](t, w)
if len(got.Watches) != 1 || got.Watches[0].Pubkey != evPkA || got.Watches[0].Name != "Alpha" || !got.Watches[0].Known {
t.Fatalf("after watching = %+v", got.Watches)
}
expectStatus(t, f.do("PUT", notifyPath(evPkA), nil, as(pat)), 200)
if w := f.do("PUT", notifyPath(evPkB), nil, as(pat)); w.Code != 409 || !strings.Contains(w.Body.String(), "you watch the maximum of 1 nodes") {
t.Fatalf("over the limit: %d %s", w.Code, w.Body.String())
}
delete(f.src.nodeMap, evPkA) // the node left the analyzer database
if got := decode[notifyAccountJSON](t, f.do("GET", "/api/account/notifications", nil, as(pat))); got.Watches[0].Known || got.Watches[0].Name != "" {
t.Fatalf("vanished node = %+v", got.Watches[0])
}
w = f.do("DELETE", notifyPath(evPkA), nil, as(pat))
expectStatus(t, w, 200)
if got := decode[notifyAccountJSON](t, w); len(got.Watches) != 0 {
t.Fatalf("after DELETE = %+v", got.Watches)
}
expectStatus(t, f.do("DELETE", notifyPath(evPkA), nil, as(pat)), 200)
expectStatus(t, f.do("DELETE", notifyPath("xyz"), nil, as(pat)), 400)
if entries, _ := f.st.AuditList(users.AuditFilter{Actions: []string{"notify.*"}}); len(entries) != 0 {
t.Fatalf("watch changes were audited: %+v", entries)
}
}
func TestMyNodesPubkeys(t *testing.T) {
for _, doc := range []string{"", "not json", `{"v":1,"keys":{}}`, `{"v":1,"keys":{"meshcore-my-nodes":"not json"}}`} {
if got := myNodesPubkeys(doc); got != nil {
t.Errorf("%q = %v; want nil", doc, got)
}
}
got := myNodesPubkeys(`{"v":1,"keys":{"meshcore-my-nodes":"[{\"pubkey\":\"ab\",\"name\":\"x\",\"addedAt\":1},{\"name\":\"no key\"}]"}}`)
if !reflect.DeepEqual(got, []string{"ab", ""}) {
t.Fatalf("myNodesPubkeys = %v", got)
}
}
func TestWatchMyNodes(t *testing.T) {
ns := defaultNotifySettings()
ns.maxWatchesPerUser = 1
f := newNotifyFixture(t, ns)
f.setNode(evPkA, "Alpha", "repeater", time.Hour, nil)
f.setNode(evPkB, "Bravo", "companion", time.Hour, nil)
pat := f.registerAndActivate(t, "pat@example.org", "Pat", pw)
post := func() watchMyNodesJSON {
t.Helper()
w := f.do("POST", "/api/account/notifications/watch-my-nodes", nil, as(pat))
expectStatus(t, w, 200)
return decode[watchMyNodesJSON](t, w)
}
if r := post(); r.Added != 0 || r.Already != 0 || r.Skipped != 0 {
t.Fatalf("without synced settings = %+v", r)
}
list, _ := json.Marshal([]map[string]string{{"pubkey": strings.ToUpper(evPkA)}, {"pubkey": evPkB}, {"pubkey": evPkG}, {"pubkey": "bad"}, {"pubkey": evPkA}})
doc, _ := json.Marshal(settingsDoc{V: 1, Keys: map[string]string{"meshcore-my-nodes": string(list)}})
if _, err := f.st.PutSettings(pat.me.ID, users.SettingsVersion{}, string(doc)); err != nil {
t.Fatal(err)
}
// A watched, B over the limit, G not in the analyzer, "bad" malformed, the second A a duplicate.
if r := post(); r.Added != 1 || r.Already != 0 || r.Skipped != 3 || len(r.Account.Watches) != 1 || r.Account.Watches[0].Pubkey != evPkA {
t.Fatalf("first copy = %+v", r)
}
if r := post(); r.Added != 0 || r.Already != 1 || r.Skipped != 3 {
t.Fatalf("second copy = %+v", r)
}
expectStatus(t, f.do("POST", "/api/account/notifications/watch-my-nodes", nil, as(&client{cookie: pat.cookie})), 403)
}
func TestUnsubscribe(t *testing.T) {
f := newNotifyFixture(t, defaultNotifySettings())
pat := f.registerAndActivate(t, "pat@example.org", "Pat", pw)
p, err := f.st.NotifyPrefsFor(pat.me.ID)
if err != nil {
t.Fatal(err)
}
q := "/api/notifications/unsubscribe?token=" + p.UnsubToken
w := f.do("GET", q, nil)
if w.Code != 303 || w.Header().Get("Location") != testBase+"/#/account/unsubscribe?token="+p.UnsubToken {
t.Fatalf("GET = %d, Location %q", w.Code, w.Header().Get("Location"))
}
if again, _ := f.st.NotifyPrefsFor(pat.me.ID); !again.Enabled {
t.Fatal("a GET (a link scanner) turned notifications off")
}
// The provider's one-click POST: form body, a foreign Origin, no session.
w = f.do("POST", q, nil, header("Origin", "https://mail.example.net"), header("Content-Type", "application/x-www-form-urlencoded"))
expectStatus(t, w, 200)
if r := decode[okResponse](t, w); !r.OK || !strings.Contains(r.Message, "Notifications are off") {
t.Fatalf("POST = %+v", r)
}
if again, _ := f.st.NotifyPrefsFor(pat.me.ID); again.Enabled {
t.Fatal("still enabled after the POST")
}
expectStatus(t, f.do("POST", q, nil), 200)
entries, err := f.st.AuditList(users.AuditFilter{Actions: []string{"notify.unsubscribe"}})
if err != nil || len(entries) != 1 || *entries[0].TargetUserID != pat.me.ID || entries[0].Detail["via"] != "link" {
t.Fatalf("audit = %+v, %v; want one row for the change", entries, err)
}
for _, bad := range []string{"/api/notifications/unsubscribe?token=nope", "/api/notifications/unsubscribe"} {
if w := f.do("POST", bad, nil); w.Code != 410 {
t.Errorf("POST %s = %d; want 410", bad, w.Code)
}
}
}
func TestClientConfigAdvertisesNotifications(t *testing.T) {
srv, router := setupTestServer(t)
a, _ := newTestAuthService(t)
a.notify = newNotifier(a, &fakeNotifySource{}, time.Now)
srv.auth = a
w := httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest("GET", "/api/config/client", nil))
if !strings.Contains(w.Body.String(), `"userManagement":{"enabled":true,"notifications":true}`) {
t.Fatalf("client config = %s", w.Body.String())
}
}
func TestAdminStatsNotifyBlock(t *testing.T) {
f := newNotifyFixture(t, defaultNotifySettings())
boss := f.registerAndActivate(t, "admin@example.org", "Boss", pw)
f.watcher(t, "pat@example.org", "Pat", evPkA, evPkB)
if _, err := f.st.LogMail(nil, "gone@example.org", users.NotifyMailPurpose, ""); err != nil {
t.Fatal(err)
}
w := f.do("GET", "/api/admin/stats", nil, as(boss))
expectStatus(t, w, 200)
st := decode[adminStatsJSON](t, w)
if st.Notify == nil || *st.Notify != (adminNotifyJSON{MailsLast24h: 1, MaxMailsPerDay: 300, Watches: 2, WatchingUsers: 1}) {
t.Fatalf("notify block = %+v", st.Notify)
}
off, offBoss, _ := adminFixture(t)
if w := off.do("GET", "/api/admin/stats", nil, as(offBoss)); strings.Contains(w.Body.String(), `"notify"`) {
t.Fatalf("notify block with notifications off: %s", w.Body.String())
}
}
// users.db never holds case variants: every route lowercases the key first.
func TestWatchRoutesStoreLowercaseKeys(t *testing.T) {
f := newNotifyFixture(t, defaultNotifySettings())
f.setNode(evPkA, "Alpha", "repeater", time.Hour, nil)
f.setNode(evPkB, "Bravo", "companion", time.Hour, nil)
pat := f.registerAndActivate(t, "pat@example.org", "Pat", pw)
stored := func() []string {
t.Helper()
ws, err := f.st.WatchesFor(pat.me.ID)
if err != nil {
t.Fatal(err)
}
out := []string{}
for _, w := range ws {
out = append(out, w.Pubkey)
}
return out
}
expectStatus(t, f.do("PUT", notifyPath(strings.ToUpper(evPkA)), nil, as(pat)), 200)
expectStatus(t, f.do("PUT", notifyPath(evPkA), nil, as(pat)), 200)
if got := stored(); !reflect.DeepEqual(got, []string{evPkA}) {
t.Fatalf("after PUT upper then lower = %v", got)
}
expectStatus(t, f.do("DELETE", notifyPath(strings.ToUpper(evPkA)), nil, as(pat)), 200)
if got := stored(); len(got) != 0 {
t.Fatalf("DELETE with an uppercase key left %v", got)
}
list, _ := json.Marshal([]map[string]string{{"pubkey": strings.ToUpper(evPkB)}})
doc, _ := json.Marshal(settingsDoc{V: 1, Keys: map[string]string{"meshcore-my-nodes": string(list)}})
if _, err := f.st.PutSettings(pat.me.ID, users.SettingsVersion{}, string(doc)); err != nil {
t.Fatal(err)
}
expectStatus(t, f.do("POST", "/api/account/notifications/watch-my-nodes", nil, as(pat)), 200)
if got := stored(); !reflect.DeepEqual(got, []string{evPkB}) {
t.Fatalf("watch-my-nodes stored %v", got)
}
}
+357
View File
@@ -0,0 +1,357 @@
package main
import (
"context"
"fmt"
"log"
"net/url"
"sort"
"strings"
"time"
"github.com/meshcore-analyzer/mailer"
"github.com/meshcore-analyzer/users"
)
// notifier evaluates node notifications on a timer and mails the changes
// (docs/specs/2026-10-07-node-notifications-design.md). authService.notify
// is nil unless userManagement.notifications.enabled.
type notifier struct {
a *authService
src notifySource
now func() time.Time
// ingestStale is the last tick's verdict, so the pause and the resume
// are each logged once; ingestResumedAt is when the last stale period
// ended (the offline grace, see notifyInput). In memory only: a restart
// inside the grace loses it. Only the loop goroutine touches them.
ingestStale bool
ingestResumedAt time.Time
}
func newNotifier(a *authService, src notifySource, now func() time.Time) *notifier {
return &notifier{a: a, src: src, now: now}
}
// notifySendTimeout bounds one mail send inside a tick.
const notifySendTimeout = 30 * time.Second
// notifyIngestStaleAfter: when the newest packet in the store is older,
// offline checks pause (an MQTT or ingestor outage would otherwise mail
// every watcher "offline" and later "back online").
const notifyIngestStaleAfter = 30 * time.Minute
// checkIngest reports whether ingest is stale at now and logs when that
// verdict changes.
func (n *notifier) checkIngest(now time.Time) bool {
newest := n.src.newestPacket()
stale := newest.IsZero() || now.Sub(newest) > notifyIngestStaleAfter
switch {
case stale && !n.ingestStale:
since := "(no packets)"
if !newest.IsZero() {
since = newest.UTC().Format(time.RFC3339)
}
log.Printf("[notify] ingest stale since %s; offline checks paused", since)
case !stale && n.ingestStale:
n.ingestResumedAt = now
log.Printf("[notify] ingest fresh again; offline checks resumed")
}
n.ingestStale = stale
return stale
}
// loop evaluates every interval until stop closes. The first evaluation
// runs one interval after startup. A tick in flight sees its context
// cancelled when stop closes, so a slow mail provider does not hold up
// the shutdown.
func (n *notifier) loop(stop <-chan struct{}, every time.Duration) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go func() {
select {
case <-stop:
cancel()
case <-ctx.Done():
}
}()
t := time.NewTicker(every)
defer t.Stop()
for {
select {
case <-stop:
return
case <-t.C:
n.safeTick(ctx)
}
}
}
// safeTick runs one evaluation; a panic is logged and the next tick runs.
func (n *notifier) safeTick(ctx context.Context) {
defer func() {
if r := recover(); r != nil {
log.Printf("[notify] evaluation panicked, the next one runs as planned: %v", r)
}
}()
n.tick(ctx)
}
// tick reads all preferences, watches and states (one query each), the
// accounts and the analyzer snapshot they need, evaluates, deletes the
// dropped states and writes the new ones, and then mails each user's
// changes. Users without a preferences row and users with notifications
// off are evaluated too (the evaluator gives the first the defaults and
// keeps the second's states current without changes). A failed read or
// state write ends the tick without mail; the next tick starts over. A
// tick that evaluates logs one line with its counts, its duration and how
// long LastHeardMap held the packet store's read lock.
func (n *notifier) tick(ctx context.Context) {
if !n.src.ready() {
return
}
started := time.Now()
now := n.now()
stale := n.checkIngest(now)
st := n.a.st
prefs, err := st.AllNotifyPrefs()
if err != nil {
log.Printf("[notify] read preferences: %v", err)
return
}
stored, err := st.AllWatches()
if err != nil {
log.Printf("[notify] read watches: %v", err)
return
}
if len(prefs) == 0 && len(stored) == 0 {
return
}
states, err := st.AllNotifyStates()
if err != nil {
log.Printf("[notify] read states: %v", err)
return
}
// The analyzer snapshot is keyed by lowercase pubkey; normalise the
// watches once so every lookup and state subject uses that form.
watches := make([]users.NotifyWatch, 0, len(stored))
watchSeen := make(map[users.NotifyKey]bool, len(stored))
seen := map[string]bool{}
var pubkeys []string
for _, w := range stored {
w.Pubkey = strings.ToLower(w.Pubkey)
k := users.NotifyKey{UserID: w.UserID, Subject: w.Pubkey}
if watchSeen[k] {
continue
}
watchSeen[k] = true
watches = append(watches, w)
if !seen[w.Pubkey] {
seen[w.Pubkey] = true
pubkeys = append(pubkeys, w.Pubkey)
}
}
byUser := make(map[int64]users.NotifyPrefs, len(prefs))
var ids []int64
for _, p := range prefs {
byUser[p.UserID] = p
ids = append(ids, p.UserID)
}
noPrefs := map[int64]bool{}
for _, w := range watches {
if _, ok := byUser[w.UserID]; !ok && !noPrefs[w.UserID] {
noPrefs[w.UserID] = true
ids = append(ids, w.UserID)
}
}
accounts, err := st.UsersByID(ids)
if err != nil {
log.Printf("[notify] read accounts: %v", err)
return
}
wantForeign, wantObservers := false, false
for _, p := range prefs {
if accounts[p.UserID].Role != users.RoleAdmin {
continue
}
wantForeign = wantForeign || p.Has(users.NotifyForeignNew)
wantObservers = wantObservers || p.Has(users.NotifyObserverOffline)
}
nodes, err := n.src.nodes(pubkeys, wantForeign)
if err != nil {
log.Printf("[notify] read nodes: %v", err)
return
}
var infra []string
for _, pk := range pubkeys {
if nd, ok := nodes[pk]; ok && notifyInfra(nd.Role) {
infra = append(infra, pk)
}
}
var observers []notifyObserver
if wantObservers {
if observers, err = n.src.observers(); err != nil {
log.Printf("[notify] read observers: %v", err)
return
}
}
heard, lock := n.src.lastHeard(pubkeys)
res := evaluateNotifications(notifyInput{Now: now, Health: n.src.health(), LowMv: n.src.lowBatteryMv(),
Accounts: accounts, Prefs: prefs, Watches: watches, States: states, Nodes: nodes,
Heard: heard, Relayed: n.src.lastRelayed(infra), Observers: observers, IngestStale: stale, IngestResumedAt: n.ingestResumedAt})
if err := st.DeleteNotifyStates(res.Drop); err != nil {
log.Printf("[notify] delete dropped states, nothing mailed this time: %v", err)
return
}
if len(res.States) > 0 {
if err := st.WriteNotifyStates(res.States); err != nil {
log.Printf("[notify] write states, nothing mailed this time: %v", err)
return
}
}
mails := n.deliver(ctx, now, res.Changes, accounts, byUser)
changes := 0
for _, list := range res.Changes {
changes += len(list)
}
log.Printf("[notify] tick: users=%d changes=%d mails=%d took=%.2fms lock=%.2fms",
len(accounts), changes, mails, msFloat(time.Since(started)), msFloat(lock))
}
func msFloat(d time.Duration) float64 { return float64(d.Microseconds()) / 1000 }
// notifySkipReason says why a user's changes are not mailed; "" means send.
// Skipped changes are already stored and are never mailed later.
func notifySkipReason(u users.User, p users.NotifyPrefs, userMails, totalMails int, ns notifySettings) string {
switch {
case u.Status != users.StatusActive:
return "account not active"
case u.EmailBouncing:
return "address bounces"
case !p.Enabled:
return "notifications off"
case userMails >= ns.perUserPerDay:
return "daily limit for this user reached"
case totalMails >= ns.maxMailsPerDay:
return "daily limit for this instance reached"
}
return ""
}
// deliver sends one mail per user with changes, within the limits counted
// from mail_log over the last 24 hours (this tick's mails included). A user
// with changes but no preferences row was evaluated with the defaults; the
// row (and so the unsubscribe token) is created before the first mail.
// Once ctx is cancelled (shutdown) the remaining users are not mailed;
// their changes are already stored, like any other skipped change. It
// returns the number of mails sent.
func (n *notifier) deliver(ctx context.Context, now time.Time, changes map[int64][]notifyChange,
accounts map[int64]users.User, prefs map[int64]users.NotifyPrefs) int {
if len(changes) == 0 {
return 0
}
total, perUser, err := n.a.st.NotifyMailCounts(now.Add(-24 * time.Hour))
if err != nil {
log.Printf("[notify] count mails, nothing mailed this time: %v", err)
return 0
}
sent := 0
ids := make([]int64, 0, len(changes))
for uid := range changes {
ids = append(ids, uid)
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
for i, uid := range ids {
if ctx.Err() != nil {
log.Printf("[notify] shutting down: %d user(s) with changes not mailed", len(ids)-i)
return sent
}
u := accounts[uid]
p, ok := prefs[uid]
if !ok {
p = users.DefaultNotifyPrefs(uid)
}
if why := notifySkipReason(u, p, perUser[uid], total, n.a.set.notify); why != "" {
log.Printf("[notify] user #%d: %d change(s) not mailed: %s", uid, len(changes[uid]), why)
continue
}
if !ok {
if p, err = n.a.st.NotifyPrefsFor(uid); err != nil {
log.Printf("[notify] user #%d: create preferences, %d change(s) not mailed: %v", uid, len(changes[uid]), err)
continue
}
}
sendCtx, cancel := context.WithTimeout(ctx, notifySendTimeout)
err := n.a.sendMail(sendCtx, &u, users.NotifyMailPurpose, n.a.notifyMail(u, p, changes[uid]))
cancel()
if err != nil {
continue // logged by sendMail; the changes stay recorded
}
total++
perUser[uid]++
sent++
}
return sent
}
// notifyMail is the one mail for all of a user's changes in one evaluation.
func (a *authService) notifyMail(u users.User, p users.NotifyPrefs, changes []notifyChange) mailer.Message {
base := a.set.baseURL.String()
lines := make([]mailLine, 0, len(changes))
for _, c := range changes {
lines = append(lines, mailLine{text: notifyChangeText(c), url: notifySubjectURL(base, c)})
}
subject := fmt.Sprintf("%d changes on your watched nodes", len(changes))
if len(changes) == 1 {
subject = "1 change on your watched nodes"
}
msg := a.render(u.Email, u.DisplayName, "notify", mailContent{
subject: subject, greeting: "Hello " + u.DisplayName + ",",
paragraphs: []string{"Changes since the last check:"},
lines: lines,
actionLabel: "Manage notifications", actionURL: base + "/#/account?section=notifications",
footer: &mailLine{text: "You get this mail because you asked " + a.set.baseURL.Host +
" to watch these nodes. Turn all notification mails off with one click:", url: a.link("unsubscribe", p.UnsubToken)},
})
msg.Headers = map[string]string{
"List-Unsubscribe": "<" + base + "/api/notifications/unsubscribe?token=" + url.QueryEscape(p.UnsubToken) + ">",
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
}
return msg
}
// notifyChangeText is one line of the mail: name, what happened, when (UTC).
func notifyChangeText(c notifyChange) string {
bad := c.To == users.NotifyBad
var what string
switch c.Event {
case users.NotifyNodeOffline:
what = "back online"
if bad {
what = "offline"
}
case users.NotifyNodeBattery:
what = "battery recovered"
if bad {
what = "battery low"
}
if c.BatteryMv != nil {
what += fmt.Sprintf(" (%d mV)", *c.BatteryMv)
}
case users.NotifyForeignNew:
what = "new foreign node"
case users.NotifyObserverOffline:
what = "observer back online"
if bad {
what = "observer offline"
}
}
return c.Name + ": " + what + ", " + c.At.UTC().Format("2006-01-02 15:04 UTC")
}
// notifySubjectURL links a change to its node or observer page.
func notifySubjectURL(base string, c notifyChange) string {
if c.Event == users.NotifyObserverOffline {
return base + "/#/observers/" + url.PathEscape(c.Subject)
}
return base + "/#/nodes/" + url.PathEscape(c.Subject)
}
+727
View File
@@ -0,0 +1,727 @@
package main
import (
"context"
"errors"
"path/filepath"
"regexp"
"strings"
"testing"
"time"
"github.com/gorilla/mux"
"github.com/meshcore-analyzer/mailer"
"github.com/meshcore-analyzer/users"
)
type fakeNotifySource struct {
isReady bool
hs HealthThresholds
low int
nodeMap map[string]notifyNode
heard map[string]time.Time
relayed map[string]time.Time
obs []notifyObserver
newestAt func() time.Time // newest packet in the store; nil means none
lock time.Duration // what lastHeard reports as its lock time
panicNext bool
heardAsked []string
foreignAsked int
observersAsked int
}
func (f *fakeNotifySource) ready() bool { return f.isReady }
func (f *fakeNotifySource) health() HealthThresholds { return f.hs }
func (f *fakeNotifySource) lowBatteryMv() int { return f.low }
func (f *fakeNotifySource) nodes(pubkeys []string, withForeign bool) (map[string]notifyNode, error) {
if f.panicNext {
f.panicNext = false
panic("fake source panic")
}
out := map[string]notifyNode{}
for _, pk := range pubkeys {
if n, ok := f.nodeMap[pk]; ok {
out[pk] = n
}
}
if withForeign {
f.foreignAsked++
for pk, n := range f.nodeMap {
if n.Foreign {
out[pk] = n
}
}
}
return out, nil
}
func (f *fakeNotifySource) lastHeard(pks []string) (map[string]time.Time, time.Duration) {
f.heardAsked = append([]string(nil), pks...)
return f.heard, f.lock
}
func (f *fakeNotifySource) lastRelayed([]string) map[string]time.Time { return f.relayed }
func (f *fakeNotifySource) newestPacket() time.Time {
if f.newestAt == nil {
return time.Time{}
}
return f.newestAt()
}
func (f *fakeNotifySource) observers() ([]notifyObserver, error) {
f.observersAsked++
return f.obs, nil
}
type notifyClock struct{ t time.Time }
func (c *notifyClock) Now() time.Time { return c.t }
func (c *notifyClock) Advance(d time.Duration) { c.t = c.t.Add(d) }
func defaultNotifySettings() notifySettings {
return notifySettings{enabled: true, interval: 5 * time.Minute, perUserPerDay: 20, maxMailsPerDay: 300, maxWatchesPerUser: 50}
}
type notifyFixture struct {
*authFixture
n *notifier
src *fakeNotifySource
clk *notifyClock
}
// newNotifyFixture is newAuthFixture with notifications set to ns, a fake
// analyzer source and one fixed clock for the notifier and users.db.
// admin@example.org is a config admin.
func newNotifyFixture(t *testing.T, ns notifySettings) *notifyFixture {
t.Helper()
a, fake := newTestAuthService(t, "admin@example.org")
a.set.notify = ns
clk := &notifyClock{t: time.Date(2026, 10, 7, 12, 0, 0, 0, time.UTC)}
a.st.SetClock(clk.Now)
src := &fakeNotifySource{isReady: true, hs: (&Config{}).GetHealthThresholds(), low: 3300,
nodeMap: map[string]notifyNode{}, heard: map[string]time.Time{}, relayed: map[string]time.Time{}, newestAt: clk.Now}
if ns.enabled {
a.notify = newNotifier(a, src, clk.Now)
}
srv := &Server{cfg: &Config{APIKey: testAPIKey}, perfStats: NewPerfStats(), auth: a}
r := mux.NewRouter()
srv.registerAuthRoutes(r)
return &notifyFixture{authFixture: &authFixture{srv: srv, router: r, fake: fake, st: a.st}, n: a.notify, src: src, clk: clk}
}
// watcher registers and activates a user with default prefs watching pubkeys.
func (f *notifyFixture) watcher(t *testing.T, email, name string, pubkeys ...string) *client {
t.Helper()
c := f.registerAndActivate(t, email, name, pw)
if _, err := f.st.NotifyPrefsFor(c.me.ID); err != nil {
t.Fatal(err)
}
if _, _, _, err := f.st.AddWatches(c.me.ID, pubkeys, 0); err != nil {
t.Fatal(err)
}
return c
}
// setNode puts a node in the fake analyzer, last seen lastSeen before now.
func (f *notifyFixture) setNode(pk, name, role string, lastSeen time.Duration, battery *int) {
f.src.nodeMap[pk] = notifyNode{Pubkey: pk, Name: name, Role: role, LastSeen: f.clk.t.Add(-lastSeen).Format(time.RFC3339), BatteryMv: battery}
}
func (f *notifyFixture) tick() { f.n.tick(context.Background()) }
func (f *notifyFixture) notifyMails() []mailer.Message {
var out []mailer.Message
for _, m := range f.fake.Sent() {
if m.Tag == "notify" {
out = append(out, m)
}
}
return out
}
func TestNotifierMailsAChangeAfterTheSilentFirstTick(t *testing.T) {
f := newNotifyFixture(t, defaultNotifySettings())
pat := f.watcher(t, "pat@example.org", "Pat", evPkA)
f.setNode(evPkA, "Alpha", "companion", time.Hour, nil)
f.tick()
if m := f.notifyMails(); len(m) != 0 {
t.Fatalf("first tick mailed: %+v", m)
}
f.clk.Advance(25 * time.Hour)
f.tick()
m := f.notifyMails()
if len(m) != 1 {
t.Fatalf("mails = %d; want 1", len(m))
}
p, _ := f.st.NotifyPrefsFor(pat.me.ID)
if m[0].To != "pat@example.org" || m[0].Subject != "[CoreScope] 1 change on your watched nodes" ||
!strings.Contains(m[0].Text, "Alpha: offline, 2026-10-08 13:00 UTC") ||
!strings.Contains(m[0].Text, testBase+"/#/nodes/"+evPkA) ||
!strings.Contains(m[0].Text, testBase+"/#/account?section=notifications") ||
!strings.Contains(m[0].Text, testBase+"/#/account/unsubscribe?token="+p.UnsubToken) {
t.Fatalf("mail = %+v", m[0])
}
if m[0].Headers["List-Unsubscribe"] != "<"+testBase+"/api/notifications/unsubscribe?token="+p.UnsubToken+">" ||
m[0].Headers["List-Unsubscribe-Post"] != "List-Unsubscribe=One-Click" {
t.Fatalf("headers = %v", m[0].Headers)
}
if total, per, err := f.st.NotifyMailCounts(f.clk.t.Add(-time.Hour)); err != nil || total != 1 || per[pat.me.ID] != 1 {
t.Fatalf("mail_log notify rows = %d, %v, %v", total, per, err)
}
f.clk.Advance(5 * time.Minute)
f.tick()
if len(f.notifyMails()) != 1 {
t.Fatal("an unchanged state was mailed again")
}
f.src.heard[evPkA] = f.clk.t.Add(-time.Minute)
f.tick()
if m := f.notifyMails(); len(m) != 2 || !strings.Contains(m[1].Text, "Alpha: back online") {
t.Fatalf("back-online mail = %+v", m)
}
}
func TestNotifierOneMailPerUserPerTick(t *testing.T) {
f := newNotifyFixture(t, defaultNotifySettings())
f.watcher(t, "pat@example.org", "Pat", evPkA, evPkB)
f.watcher(t, "quinn@example.org", "Quinn", evPkA)
f.setNode(evPkA, "Alpha", "companion", time.Hour, nil)
f.setNode(evPkB, "Bravo", "companion", time.Hour, nil)
f.tick()
f.clk.Advance(25 * time.Hour)
f.tick()
m := f.notifyMails()
if len(m) != 2 {
t.Fatalf("mails = %d; want one per user", len(m))
}
byTo := map[string]mailer.Message{m[0].To: m[0], m[1].To: m[1]}
pat, quinn := byTo["pat@example.org"], byTo["quinn@example.org"]
if pat.Subject != "[CoreScope] 2 changes on your watched nodes" || !strings.Contains(pat.Text, "Alpha: offline") || !strings.Contains(pat.Text, "Bravo: offline") {
t.Fatalf("Pat's mail = %+v", pat)
}
if quinn.Subject != "[CoreScope] 1 change on your watched nodes" || strings.Contains(quinn.Text, "Bravo") {
t.Fatalf("Quinn's mail = %+v", quinn)
}
}
func TestNotifierSkipsAreNeverMailedLater(t *testing.T) {
cases := []struct {
name string
tune func(*notifySettings)
block func(*notifyFixture, int64) error
lift func(*notifyFixture, int64) error
}{
{"notifications off", nil,
func(f *notifyFixture, uid int64) error {
_, err := f.st.SetNotifyPrefs(uid, false, users.NodeNotifyEvents)
return err
},
func(f *notifyFixture, uid int64) error {
_, err := f.st.SetNotifyPrefs(uid, true, users.NodeNotifyEvents)
return err
}},
{"address bounces", nil,
func(f *notifyFixture, uid int64) error { return f.st.SetEmailBouncing(uid, true) },
func(f *notifyFixture, uid int64) error { return f.st.SetEmailBouncing(uid, false) }},
{"account disabled", nil,
func(f *notifyFixture, uid int64) error { return f.st.SetStatus(uid, users.StatusDisabled) },
func(f *notifyFixture, uid int64) error { return f.st.SetStatus(uid, users.StatusActive) }},
{"per-user limit", func(ns *notifySettings) { ns.perUserPerDay = 1 },
func(f *notifyFixture, uid int64) error {
_, err := f.st.LogMail(&uid, "pat@example.org", users.NotifyMailPurpose, "")
return err
},
func(f *notifyFixture, uid int64) error { f.clk.Advance(25 * time.Hour); return nil }},
{"instance limit", func(ns *notifySettings) { ns.maxMailsPerDay = 1 },
func(f *notifyFixture, uid int64) error {
_, err := f.st.LogMail(nil, "gone@example.org", users.NotifyMailPurpose, "")
return err
},
func(f *notifyFixture, uid int64) error { f.clk.Advance(25 * time.Hour); return nil }},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
ns := defaultNotifySettings()
if c.tune != nil {
c.tune(&ns)
}
f := newNotifyFixture(t, ns)
pat := f.watcher(t, "pat@example.org", "Pat", evPkA)
f.setNode(evPkA, "Alpha", "companion", time.Hour, nil)
f.tick()
// Advance first, so a blocking mail_log row lies inside the
// rolling 24 hours of the next tick.
f.clk.Advance(25 * time.Hour)
if err := c.block(f, pat.me.ID); err != nil {
t.Fatal(err)
}
f.tick()
if m := f.notifyMails(); len(m) != 0 {
t.Fatalf("mailed while blocked: %+v", m)
}
if err := c.lift(f, pat.me.ID); err != nil {
t.Fatal(err)
}
f.tick()
if m := f.notifyMails(); len(m) != 0 {
t.Fatalf("a skipped change was mailed later: %+v", m)
}
})
}
}
func TestNotifierRestartAndNewWatchStaySilent(t *testing.T) {
f := newNotifyFixture(t, defaultNotifySettings())
pat := f.watcher(t, "pat@example.org", "Pat", evPkA)
f.setNode(evPkA, "Alpha", "companion", time.Hour, nil)
f.tick()
f.clk.Advance(25 * time.Hour)
f.tick()
if len(f.notifyMails()) != 1 {
t.Fatal("no mail for the change")
}
restarted := newNotifier(f.srv.auth, f.src, f.clk.Now) // the states live in users.db
f.setNode(evPkB, "Bravo", "companion", 48*time.Hour, nil)
if _, _, _, err := f.st.AddWatches(pat.me.ID, []string{evPkB}, 0); err != nil {
t.Fatal(err)
}
f.clk.Advance(5 * time.Minute)
restarted.tick(context.Background())
if m := f.notifyMails(); len(m) != 1 {
t.Fatalf("the restart or the new watch mailed: %+v", m[1:])
}
}
func TestNotifierSendFailureIsNotRetried(t *testing.T) {
f := newNotifyFixture(t, defaultNotifySettings())
f.watcher(t, "pat@example.org", "Pat", evPkA)
f.setNode(evPkA, "Alpha", "companion", time.Hour, nil)
f.tick()
f.fake.SetSendErr(errors.New("provider down"))
f.clk.Advance(25 * time.Hour)
f.tick()
f.fake.SetSendErr(nil)
f.clk.Advance(5 * time.Minute)
f.tick()
if m := f.notifyMails(); len(m) != 0 {
t.Fatalf("a failed send was retried: %+v", m)
}
if total, _, _ := f.st.NotifyMailCounts(f.clk.t.Add(-48 * time.Hour)); total != 0 {
t.Fatalf("mail_log has %d notify rows for a mail that never left", total)
}
}
func TestNotifierStateWriteFailureSendsNothing(t *testing.T) {
f := newNotifyFixture(t, defaultNotifySettings())
f.watcher(t, "pat@example.org", "Pat", evPkA)
f.setNode(evPkA, "Alpha", "companion", time.Hour, nil)
f.tick()
f.execDB(t, `CREATE TRIGGER notify_state_fail BEFORE UPDATE ON notification_state BEGIN SELECT RAISE(ABORT, 'boom'); END`)
f.clk.Advance(25 * time.Hour)
f.tick()
if m := f.notifyMails(); len(m) != 0 {
t.Fatalf("mailed although the states were not written: %+v", m)
}
f.execDB(t, `DROP TRIGGER notify_state_fail`)
f.tick()
if m := f.notifyMails(); len(m) != 1 {
t.Fatalf("mails after the write works again = %d; want 1", len(m))
}
}
func TestNotifierWaitsForTheAnalyzer(t *testing.T) {
f := newNotifyFixture(t, defaultNotifySettings())
f.watcher(t, "pat@example.org", "Pat", evPkA)
f.setNode(evPkA, "Alpha", "companion", time.Hour, nil)
f.src.isReady = false
f.tick()
if states, _ := f.st.AllNotifyStates(); len(states) != 0 {
t.Fatalf("evaluated while the store was loading: %+v", states)
}
f.src.isReady = true
f.tick()
if states, _ := f.st.AllNotifyStates(); len(states) != 1 {
t.Fatalf("states once ready = %+v", states)
}
}
func TestNotifierPanicIsRecovered(t *testing.T) {
f := newNotifyFixture(t, defaultNotifySettings())
f.watcher(t, "pat@example.org", "Pat", evPkA)
f.setNode(evPkA, "Alpha", "companion", time.Hour, nil)
f.src.panicNext = true
f.n.safeTick(context.Background())
f.tick()
if states, _ := f.st.AllNotifyStates(); len(states) != 1 {
t.Fatalf("the tick after the panic did not run: %+v", states)
}
}
func TestNotifierReadsForeignAndObserversOnlyForAdmins(t *testing.T) {
f := newNotifyFixture(t, defaultNotifySettings())
f.watcher(t, "pat@example.org", "Pat", evPkA)
f.setNode(evPkA, "Alpha", "companion", time.Hour, nil)
f.tick()
if f.src.foreignAsked != 0 || f.src.observersAsked != 0 {
t.Fatalf("admin data read without an admin asking: foreign %d, observers %d", f.src.foreignAsked, f.src.observersAsked)
}
boss := f.registerAndActivate(t, "admin@example.org", "Boss", pw)
if _, err := f.st.SetNotifyPrefs(boss.me.ID, true, []string{users.NotifyForeignNew}); err != nil {
t.Fatal(err)
}
f.tick()
if f.src.foreignAsked != 1 || f.src.observersAsked != 0 {
t.Fatalf("foreign %d, observers %d; want 1, 0", f.src.foreignAsked, f.src.observersAsked)
}
if err := f.st.SetRole(boss.me.ID, users.RoleUser); err != nil {
t.Fatal(err)
}
f.tick()
if f.src.foreignAsked != 1 {
t.Fatal("foreign nodes read for a demoted admin")
}
}
func TestNotifyMailEscapesNodeNames(t *testing.T) {
f := newNotifyFixture(t, defaultNotifySettings())
f.watcher(t, "pat@example.org", "Pat", evPkA)
f.setNode(evPkA, `<b>Bad</b> & "co"`, "companion", time.Hour, nil)
f.tick()
f.clk.Advance(25 * time.Hour)
f.tick()
m := f.notifyMails()
if len(m) != 1 || strings.Contains(m[0].HTML, "<b>Bad") || !strings.Contains(m[0].HTML, "&lt;b&gt;Bad&lt;/b&gt; &amp; &#34;co&#34;") {
t.Fatalf("HTML = %q", m[0].HTML)
}
}
// Names come from whoever owns a node or runs an observer: a line break
// must not forge a line in the mail, a bidi override must not reorder it.
func TestNotifyMailSanitisesNames(t *testing.T) {
f := newNotifyFixture(t, defaultNotifySettings())
f.watcher(t, "pat@example.org", "Pat", evPkA)
admin := f.registerAndActivate(t, "admin@example.org", "Ada", pw)
if _, err := f.st.SetNotifyPrefs(admin.me.ID, true, []string{users.NotifyObserverOffline}); err != nil {
t.Fatal(err)
}
f.setNode(evPkA, "x\r\nAccount suspended, sign in: https://evil.example\t\u202egnp.exe", "companion", time.Hour, nil)
seen := f.clk.t.Add(-time.Minute).Format(time.RFC3339)
f.src.obs = []notifyObserver{{ID: "OBS1", Name: "Roof\n\u2066Fake line\u2069", LastSeen: seen}, {ID: "OBS2", Name: "\n\u202e", LastSeen: seen}}
f.tick()
f.clk.Advance(25 * time.Hour)
f.tick()
m := f.notifyMails()
if len(m) != 2 {
t.Fatalf("mails = %d, want 2", len(m))
}
for _, msg := range m {
for _, part := range []string{msg.Text, msg.HTML} {
for _, bad := range []string{"\r", "\t", "\u202e", "\u2066", "\u2069", "\nAccount", "\nFake"} {
if strings.Contains(part, bad) {
t.Fatalf("mail part contains %q:\n%s", bad, part)
}
}
}
}
body := m[0].Text + m[1].Text
for _, want := range []string{"- x Account suspended, sign in: https://evil.example gnp.exe: offline,",
"- Roof Fake line: observer offline,", "- OBS2: observer offline,"} {
if !strings.Contains(body, want) {
t.Fatalf("text lacks %q:\n%s", want, body)
}
}
}
func TestMailSafeText(t *testing.T) {
cases := map[string]string{
"plain": "plain",
" a \n\n b\r\tc ": "a b c",
"a\u202eb\u2066c\u2069d": "a b c d",
"a\u202ab\u202dc": "a b c",
"a\u0085b\x7fc": "a b c",
"\n\u202a": "",
"Zo\u00eb \u00e9": "Zo\u00eb \u00e9",
}
for in, want := range cases {
if got := mailSafeText(in); got != want {
t.Errorf("mailSafeText(%q) = %q; want %q", in, got, want)
}
}
}
func TestNotifyChangeText(t *testing.T) {
at := time.Date(2026, 10, 7, 9, 5, 0, 0, time.UTC)
cases := []struct {
c notifyChange
want string
}{
{notifyChange{Event: users.NotifyNodeOffline, Name: "A", To: users.NotifyBad, At: at}, "A: offline, 2026-10-07 09:05 UTC"},
{notifyChange{Event: users.NotifyNodeOffline, Name: "A", To: users.NotifyGood, At: at}, "A: back online, 2026-10-07 09:05 UTC"},
{notifyChange{Event: users.NotifyNodeBattery, Name: "A", To: users.NotifyBad, BatteryMv: evMv(3250), At: at}, "A: battery low (3250 mV), 2026-10-07 09:05 UTC"},
{notifyChange{Event: users.NotifyNodeBattery, Name: "A", To: users.NotifyGood, BatteryMv: evMv(3410), At: at}, "A: battery recovered (3410 mV), 2026-10-07 09:05 UTC"},
{notifyChange{Event: users.NotifyForeignNew, Name: "F", To: users.NotifyTold, At: at}, "F: new foreign node, 2026-10-07 09:05 UTC"},
{notifyChange{Event: users.NotifyObserverOffline, Name: "O", To: users.NotifyBad, At: at}, "O: observer offline, 2026-10-07 09:05 UTC"},
{notifyChange{Event: users.NotifyObserverOffline, Name: "O", To: users.NotifyGood, At: at}, "O: observer back online, 2026-10-07 09:05 UTC"},
}
for _, c := range cases {
if got := notifyChangeText(c.c); got != c.want {
t.Errorf("%q; want %q", got, c.want)
}
}
if got := notifySubjectURL("https://e.org", notifyChange{Event: users.NotifyObserverOffline, Subject: "AB CD"}); got != "https://e.org/#/observers/AB%20CD" {
t.Errorf("observer URL = %q", got)
}
if got := notifySubjectURL("https://e.org", notifyChange{Event: users.NotifyNodeOffline, Subject: evPkA}); got != "https://e.org/#/nodes/"+evPkA {
t.Errorf("node URL = %q", got)
}
}
func TestNotifySkipReason(t *testing.T) {
ns := defaultNotifySettings()
ok := users.User{Status: users.StatusActive}
on := users.NotifyPrefs{Enabled: true}
cases := []struct {
u users.User
p users.NotifyPrefs
mine, total int
want string
}{
{ok, on, 0, 0, ""},
{users.User{Status: users.StatusDisabled}, on, 0, 0, "account not active"},
{users.User{Status: users.StatusActive, EmailBouncing: true}, on, 0, 0, "address bounces"},
{ok, users.NotifyPrefs{}, 0, 0, "notifications off"},
{ok, on, 20, 0, "daily limit for this user reached"},
{ok, on, 19, 300, "daily limit for this instance reached"},
{ok, on, 19, 299, ""},
}
for _, c := range cases {
if got := notifySkipReason(c.u, c.p, c.mine, c.total, ns); got != c.want {
t.Errorf("%+v %+v %d %d = %q; want %q", c.u, c.p, c.mine, c.total, got, c.want)
}
}
}
func TestInitUserManagementStartsTheNotifierOnlyWhenEnabled(t *testing.T) {
for _, on := range []bool{false, true} {
um := &UserManagementConfig{Enabled: true, PublicBaseURL: testBase,
Mail: UserMailConfig{BrevoAPIKey: "k", FromEmail: "noreply@example.org"}}
if on {
um.Notifications = &NotificationsConfig{Enabled: true}
}
srv := &Server{cfg: &Config{UserManagement: um}}
if err := srv.initUserManagement(filepath.Join(t.TempDir(), "meshcore.db")); err != nil {
t.Fatal(err)
}
if (srv.auth.notify != nil) != on {
t.Fatalf("notifications %v: notifier = %v", on, srv.auth.notify)
}
done := make(chan struct{})
go func() { srv.closeUserManagement(); close(done) }()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("closeUserManagement did not stop the notifier")
}
}
}
func TestNotifierDeletesAdminStatesOfADemotedAdmin(t *testing.T) {
f := newNotifyFixture(t, defaultNotifySettings())
boss := f.registerAndActivate(t, "admin@example.org", "Boss", pw)
if _, err := f.st.SetNotifyPrefs(boss.me.ID, true, []string{users.NotifyForeignNew}); err != nil {
t.Fatal(err)
}
f.src.nodeMap[evPkB] = notifyNode{Pubkey: evPkB, Name: "Far", Foreign: true}
f.tick()
if states, _ := f.st.AllNotifyStates(); len(states) != 2 {
t.Fatalf("baseline states = %+v; want the sentinel and Far", states)
}
if err := f.st.SetRole(boss.me.ID, users.RoleUser); err != nil {
t.Fatal(err)
}
f.tick()
if states, _ := f.st.AllNotifyStates(); len(states) != 0 {
t.Fatalf("states of a demoted admin = %+v; want none", states)
}
if err := f.st.SetRole(boss.me.ID, users.RoleAdmin); err != nil {
t.Fatal(err)
}
f.tick()
if m := f.notifyMails(); len(m) != 0 {
t.Fatalf("re-promotion mailed: %+v", m)
}
}
func TestNotifierMailsAUserWithoutAPrefsRow(t *testing.T) {
f := newNotifyFixture(t, defaultNotifySettings())
pat := f.registerAndActivate(t, "pat@example.org", "Pat", pw)
if _, _, _, err := f.st.AddWatches(pat.me.ID, []string{evPkA}, 0); err != nil {
t.Fatal(err)
}
f.setNode(evPkA, "Alpha", "companion", time.Hour, nil)
f.tick()
if states, _ := f.st.AllNotifyStates(); len(states) != 1 {
t.Fatalf("states = %+v; want the default node.offline baseline", states)
}
if prefs, _ := f.st.AllNotifyPrefs(); len(prefs) != 0 {
t.Fatalf("a silent tick created prefs: %+v", prefs)
}
f.clk.Advance(25 * time.Hour)
f.tick()
m := f.notifyMails()
p, err := f.st.NotifyPrefsFor(pat.me.ID)
if err != nil {
t.Fatal(err)
}
if len(m) != 1 || p.UnsubToken == "" || !strings.Contains(m[0].Text, "unsubscribe?token="+p.UnsubToken) ||
m[0].Headers["List-Unsubscribe"] != "<"+testBase+"/api/notifications/unsubscribe?token="+p.UnsubToken+">" {
t.Fatalf("mail = %+v, prefs = %+v", m, p)
}
}
func TestNotifierNormalisesMixedCaseWatches(t *testing.T) {
f := newNotifyFixture(t, defaultNotifySettings())
pat := f.watcher(t, "pat@example.org", "Pat", strings.ToUpper(evPkA))
if _, _, _, err := f.st.AddWatches(pat.me.ID, []string{evPkA}, 0); err != nil {
t.Fatal(err)
}
f.setNode(evPkA, "Alpha", "companion", time.Hour, nil) // keyed lowercase, like NotifyNodes
f.src.heard[evPkA] = f.clk.t.Add(-time.Minute)
f.tick()
if len(f.src.heardAsked) != 1 || f.src.heardAsked[0] != evPkA {
t.Fatalf("last heard asked for %v; want [%s]", f.src.heardAsked, evPkA)
}
states, _ := f.st.AllNotifyStates()
if len(states) != 1 || states[0].Subject != evPkA || states[0].State != users.NotifyGood {
t.Fatalf("states = %+v; want one good row under the lowercase key", states)
}
delete(f.src.heard, evPkA)
f.clk.Advance(25 * time.Hour)
f.tick()
if m := f.notifyMails(); len(m) != 1 || !strings.Contains(m[0].Text, "Alpha: offline") ||
strings.Count(m[0].Text, "Alpha:") != 1 {
t.Fatalf("mail = %+v; want one Alpha offline line", m)
}
}
// cancelAfterSend cancels the tick's context once a mail went out, like a
// shutdown arriving mid-delivery.
type cancelAfterSend struct {
mailer.Mailer
cancel context.CancelFunc
}
func (c cancelAfterSend) Send(ctx context.Context, m mailer.Message) (string, error) {
id, err := c.Mailer.Send(ctx, m)
c.cancel()
return id, err
}
func TestNotifierStopsDeliveringAfterShutdown(t *testing.T) {
f := newNotifyFixture(t, defaultNotifySettings())
f.watcher(t, "pat@example.org", "Pat", evPkA)
f.watcher(t, "quinn@example.org", "Quinn", evPkA)
f.setNode(evPkA, "Alpha", "companion", time.Hour, nil)
f.tick()
f.clk.Advance(25 * time.Hour)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
f.n.a.mail = cancelAfterSend{Mailer: f.n.a.mail, cancel: cancel}
f.n.tick(ctx)
if m := f.notifyMails(); len(m) != 1 {
t.Fatalf("mails after a shutdown mid-delivery = %d; want 1", len(m))
}
}
func TestNotifierPausesOfflineChecksWhileIngestIsStale(t *testing.T) {
f := newNotifyFixture(t, defaultNotifySettings())
f.watcher(t, "pat@example.org", "Pat", evPkA)
f.setNode(evPkA, "Alpha", "companion", time.Hour, nil)
f.tick() // baseline: online
lastPacket := f.clk.t
f.src.newestAt = func() time.Time { return lastPacket }
logs := captureLog(func() {
f.clk.Advance(25 * time.Hour) // feed down for a day: Alpha looks offline
f.tick()
f.clk.Advance(5 * time.Minute)
f.tick()
})
if m := f.notifyMails(); len(m) != 0 {
t.Fatalf("mailed %d while ingest is stale", len(m))
}
paused := "[notify] ingest stale since " + lastPacket.UTC().Format(time.RFC3339) + "; offline checks paused"
if n := strings.Count(logs, paused); n != 1 {
t.Fatalf("pause logged %d times; want once:\n%s", n, logs)
}
f.src.newestAt = f.clk.Now // feed back, Alpha still silent
logs = captureLog(f.tick)
if m := f.notifyMails(); len(m) != 0 {
t.Fatalf("mailed %d on the first tick after recovery; offline waits one silent window", len(m))
}
if n := strings.Count(logs, "[notify] ingest fresh again; offline checks resumed"); n != 1 {
t.Fatalf("resume logged %d times; want once:\n%s", n, logs)
}
f.clk.Advance(23 * time.Hour)
f.tick()
if m := f.notifyMails(); len(m) != 0 {
t.Fatalf("mailed %d inside the window after recovery", len(m))
}
f.clk.Advance(time.Hour + time.Minute) // a full companion window after recovery
f.tick()
if m := f.notifyMails(); len(m) != 1 || !strings.Contains(m[0].Text, "Alpha: offline") {
t.Fatalf("mails one window after recovery = %+v; want Alpha offline", m)
}
}
// A store that is already stale at startup gets the same grace once fresh.
func TestNotifierGraceWhenStaleAtStartup(t *testing.T) {
f := newNotifyFixture(t, defaultNotifySettings())
f.watcher(t, "pat@example.org", "Pat", evPkA)
f.setNode(evPkA, "Alpha", "companion", time.Hour, nil)
f.tick() // baseline: online (states persist in users.db)
f.n = newNotifier(f.n.a, f.src, f.clk.Now) // restart
f.clk.Advance(25 * time.Hour)
old := f.clk.t.Add(-25 * time.Hour)
f.src.newestAt = func() time.Time { return old }
f.tick() // stale at startup
f.src.newestAt = f.clk.Now
f.tick()
if m := f.notifyMails(); len(m) != 0 {
t.Fatalf("mailed %d right after a stale startup recovered", len(m))
}
f.clk.Advance(24*time.Hour + time.Minute)
f.tick()
if m := f.notifyMails(); len(m) != 1 {
t.Fatalf("mails one window after recovery = %d; want 1", len(m))
}
}
func TestNotifierTreatsAnEmptyStoreAsStale(t *testing.T) {
f := newNotifyFixture(t, defaultNotifySettings())
f.watcher(t, "pat@example.org", "Pat", evPkA)
f.setNode(evPkA, "Alpha", "companion", time.Hour, nil)
f.src.newestAt = nil
if logs := captureLog(f.tick); !strings.Contains(logs, "[notify] ingest stale since (no packets); offline checks paused") {
t.Fatalf("log = %q", logs)
}
if s, err := f.st.AllNotifyStates(); err != nil || len(s) != 0 {
t.Fatalf("states with no packets in the store = %+v, %v", s, err)
}
}
func TestNotifierLogsOneLinePerTick(t *testing.T) {
f := newNotifyFixture(t, defaultNotifySettings())
f.watcher(t, "pat@example.org", "Pat", evPkA, evPkB)
f.watcher(t, "quinn@example.org", "Quinn", evPkB)
f.setNode(evPkA, "Alpha", "companion", time.Hour, nil)
f.setNode(evPkB, "Bravo", "companion", time.Hour, nil)
f.src.lock = 1500 * time.Microsecond
logs := captureLog(f.tick)
if !regexp.MustCompile(`\[notify\] tick: users=2 changes=0 mails=0 took=\d+\.\d\dms lock=1\.50ms`).MatchString(logs) {
t.Fatalf("first tick log = %q", logs)
}
f.clk.Advance(25 * time.Hour)
f.src.newestAt = f.clk.Now
logs = captureLog(f.tick)
if n := strings.Count(logs, "[notify] tick:"); n != 1 || !strings.Contains(logs, "users=2 changes=3 mails=2 ") {
t.Fatalf("second tick log (%d lines) = %q", n, logs)
}
}
+205
View File
@@ -0,0 +1,205 @@
package main
import (
"database/sql"
"strings"
"time"
)
// notifySource is the analyzer side the notifier reads. serverNotifySource
// is the production one; tests substitute a fake.
type notifySource interface {
ready() bool // false while the packet store is still loading after startup
health() HealthThresholds
lowBatteryMv() int
nodes(pubkeys []string, withForeign bool) (map[string]notifyNode, error)
lastHeard(pubkeys []string) (map[string]time.Time, time.Duration) // and how long the store lock was held
lastRelayed(pubkeys []string) map[string]time.Time
observers() ([]notifyObserver, error)
newestPacket() time.Time // zero when the store holds no packet
}
type serverNotifySource struct{ s *Server }
// ready needs /api/healthz readiness (best-observation pick and neighbor
// graph done) and the finished startup load: hot window and background
// fill. Readiness alone can come while the newest packets are still
// loading.
func (src serverNotifySource) ready() bool {
if readiness.Load() == 0 || src.s.store == nil {
return false
}
select {
case <-src.s.store.StartupLoadDone():
return true
default:
return false
}
}
func (src serverNotifySource) health() HealthThresholds { return src.s.cfg.GetHealthThresholds() }
func (src serverNotifySource) lowBatteryMv() int { return src.s.cfg.LowBatteryMv() }
func (src serverNotifySource) nodes(pubkeys []string, withForeign bool) (map[string]notifyNode, error) {
return src.s.db.NotifyNodes(pubkeys, withForeign)
}
func (src serverNotifySource) lastHeard(pubkeys []string) (map[string]time.Time, time.Duration) {
if src.s.store == nil {
return map[string]time.Time{}, 0
}
return src.s.store.LastHeardMap(pubkeys)
}
// lastRelayed reads the cached relay metrics (the map /api/nodes uses; the
// background recomputer refreshes it). Pass repeaters and rooms only.
func (src serverNotifySource) lastRelayed(pubkeys []string) map[string]time.Time {
if src.s.store == nil || len(pubkeys) == 0 {
return map[string]time.Time{}
}
return relayTimes(src.s.store.GetRepeaterRelayInfoMap(src.s.cfg.GetHealthThresholds().RelayActiveHours), pubkeys)
}
func (src serverNotifySource) newestPacket() time.Time {
if src.s.store == nil {
return time.Time{}
}
return src.s.store.NewestFirstSeen()
}
func (src serverNotifySource) observers() ([]notifyObserver, error) {
list, err := src.s.db.GetObservers()
if err != nil {
return nil, err
}
return notifyObserversFrom(list), nil
}
// relayTimes parses LastRelayed for the given keys; keys without a relay
// time are absent.
func relayTimes(m map[string]RepeaterRelayInfo, pubkeys []string) map[string]time.Time {
out := make(map[string]time.Time, len(pubkeys))
for _, pk := range pubkeys {
info, ok := lookupRelayInfo(m, pk)
if !ok || info.LastRelayed == "" {
continue
}
if t, ok := parseRelayTS(info.LastRelayed); ok {
out[pk] = t
}
}
return out
}
func notifyObserversFrom(list []Observer) []notifyObserver {
out := make([]notifyObserver, 0, len(list))
for _, o := range list {
n := notifyObserver{ID: o.ID}
if o.Name != nil {
n.Name = *o.Name
}
if o.LastSeen != nil {
n.LastSeen = *o.LastSeen
}
out = append(out, n)
}
return out
}
// notifyNodeChunk bounds the IN list of one query, far below SQLite's
// variable limit.
const notifyNodeChunk = 500
const notifyNodeCols = `SELECT public_key, COALESCE(name, ''), COALESCE(role, ''), COALESCE(last_seen, ''), battery_mv, COALESCE(foreign_advert, 0) FROM nodes`
// NotifyNodes reads the node rows notifications need: the given pubkeys
// (primary-key lookups, in chunks) and, with withForeign, every node with
// foreign_advert = 1 (partial index idx_nodes_foreign_advert). Keyed by
// lowercase pubkey. Read-only, on the server's mode=ro handle.
func (db *DB) NotifyNodes(pubkeys []string, withForeign bool) (map[string]notifyNode, error) {
out := make(map[string]notifyNode, len(pubkeys))
scan := func(rows *sql.Rows) error {
defer rows.Close()
for rows.Next() {
var n notifyNode
var mv sql.NullInt64
var foreign int
if err := rows.Scan(&n.Pubkey, &n.Name, &n.Role, &n.LastSeen, &mv, &foreign); err != nil {
return err
}
n.Pubkey = strings.ToLower(n.Pubkey)
if mv.Valid {
v := int(mv.Int64)
n.BatteryMv = &v
}
n.Foreign = foreign != 0
out[n.Pubkey] = n
}
return rows.Err()
}
for i := 0; i < len(pubkeys); i += notifyNodeChunk {
chunk := pubkeys[i:min(i+notifyNodeChunk, len(pubkeys))]
args := make([]any, len(chunk))
for j, pk := range chunk {
args[j] = pk
}
rows, err := db.conn.Query(notifyNodeCols+` WHERE public_key IN (`+sqlPlaceholders(len(chunk))+`)`, args...)
if err != nil {
return nil, err
}
if err := scan(rows); err != nil {
return nil, err
}
}
if withForeign {
rows, err := db.conn.Query(notifyNodeCols + ` WHERE foreign_advert = 1`)
if err != nil {
return nil, err
}
if err := scan(rows); err != nil {
return nil, err
}
}
return out, nil
}
// LastHeardMap returns, per pubkey with packets in the store, the newest
// FirstSeen among them: the node page's "Last Heard" (GetNodeHealth) in
// bulk, and how long the read lock was held (logged once per tick). One
// read lock, O(packets of the given nodes), string compares only; parsing
// happens after the lock is released.
func (s *PacketStore) LastHeardMap(pubkeys []string) (map[string]time.Time, time.Duration) {
latest := make(map[string]string, len(pubkeys))
s.mu.RLock()
locked := time.Now()
for _, pk := range pubkeys {
for _, tx := range s.byNode[pk] {
if tx != nil && tx.FirstSeen > latest[pk] {
latest[pk] = tx.FirstSeen
}
}
}
held := time.Since(locked)
s.mu.RUnlock()
out := make(map[string]time.Time, len(latest))
for pk, ts := range latest {
if t, ok := parseRelayTS(ts); ok {
out[pk] = t
}
}
return out, held
}
// NewestFirstSeen is the FirstSeen of the newest transmission in the
// store, zero when there is none or it does not parse. O(1): s.packets is
// kept sorted by FirstSeen with the newest at the tail.
func (s *PacketStore) NewestFirstSeen() time.Time {
s.mu.RLock()
var ts string
if n := len(s.packets); n > 0 && s.packets[n-1] != nil {
ts = s.packets[n-1].FirstSeen
}
s.mu.RUnlock()
t, _ := parseRelayTS(ts)
return t
}
+151
View File
@@ -0,0 +1,151 @@
package main
import (
"fmt"
"testing"
"time"
)
func TestDBNotifyNodes(t *testing.T) {
db := setupTestDB(t)
for _, q := range []string{
fmt.Sprintf(`INSERT INTO nodes (public_key, name, role, last_seen, battery_mv, foreign_advert) VALUES ('%s', 'Alpha', 'repeater', '2026-10-07T11:00:00Z', 3712, 0)`, evPkA),
fmt.Sprintf(`INSERT INTO nodes (public_key, name, role, last_seen, foreign_advert) VALUES ('%s', NULL, 'companion', NULL, 0)`, evPkB),
fmt.Sprintf(`INSERT INTO nodes (public_key, name, role, last_seen, foreign_advert) VALUES ('%s', 'Foxtrot', 'repeater', '2026-10-07T10:00:00Z', 1)`, evPkF),
} {
if _, err := db.conn.Exec(q); err != nil {
t.Fatal(err)
}
}
got, err := db.NotifyNodes([]string{evPkA, evPkB, evPkG}, false)
if err != nil {
t.Fatal(err)
}
a, b := got[evPkA], got[evPkB]
if len(got) != 2 || a.Pubkey != evPkA || a.Name != "Alpha" || a.Role != "repeater" || a.LastSeen != "2026-10-07T11:00:00Z" ||
a.BatteryMv == nil || *a.BatteryMv != 3712 || a.Foreign {
t.Fatalf("NotifyNodes = %+v", got)
}
if b.Name != "" || b.LastSeen != "" || b.BatteryMv != nil {
t.Fatalf("NULL columns = %+v", b)
}
got, err = db.NotifyNodes([]string{evPkA}, true)
if err != nil || len(got) != 2 || !got[evPkF].Foreign || got[evPkF].Name != "Foxtrot" {
t.Fatalf("with foreign = %+v, %v", got, err)
}
if got, err := db.NotifyNodes(nil, false); err != nil || len(got) != 0 {
t.Fatalf("no keys = %+v, %v", got, err)
}
}
func TestDBNotifyNodesChunksLongLists(t *testing.T) {
db := setupTestDB(t)
keys := make([]string, 0, 1200)
for i := 0; i < 1200; i++ {
keys = append(keys, fmt.Sprintf("%064x", i))
}
for _, i := range []int{3, 600, 1199} {
if _, err := db.conn.Exec(`INSERT INTO nodes (public_key, role) VALUES (?, 'companion')`, keys[i]); err != nil {
t.Fatal(err)
}
}
got, err := db.NotifyNodes(keys, false)
if err != nil || len(got) != 3 {
t.Fatalf("1200 keys over %d-key chunks = %d nodes, %v; want 3", notifyNodeChunk, len(got), err)
}
}
func TestPacketStoreLastHeardMap(t *testing.T) {
s := &PacketStore{byNode: map[string][]*StoreTx{
evPkA: {{FirstSeen: "2026-10-07T10:00:00Z"}, {FirstSeen: "2026-10-07T11:30:00.000Z"}, nil, {FirstSeen: "2026-10-07T09:00:00Z"}},
evPkB: {{FirstSeen: "not a time"}},
}}
got, lock := s.LastHeardMap([]string{evPkA, evPkB, evPkF})
if len(got) != 1 || !got[evPkA].Equal(time.Date(2026, 10, 7, 11, 30, 0, 0, time.UTC)) {
t.Fatalf("LastHeardMap = %v", got)
}
if lock < 0 || lock > time.Second {
t.Fatalf("lock time = %v", lock)
}
}
// 5,000 watched nodes with 20 packets each, the spec's upper figure. Quote
// the result in the PR: this runs under the store's read lock.
func BenchmarkLastHeardMap(b *testing.B) {
s := &PacketStore{byNode: map[string][]*StoreTx{}}
keys := make([]string, 0, 5000)
for i := 0; i < 5000; i++ {
pk := fmt.Sprintf("%064x", i)
keys = append(keys, pk)
for j := 0; j < 20; j++ {
s.byNode[pk] = append(s.byNode[pk], &StoreTx{FirstSeen: evNow.Add(-time.Duration(j) * time.Minute).Format(time.RFC3339)})
}
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
s.LastHeardMap(keys)
}
}
func TestRelayTimes(t *testing.T) {
m := map[string]RepeaterRelayInfo{
evPkA: {LastRelayed: "2026-10-07T11:00:00Z"},
evPkB: {},
}
got := relayTimes(m, []string{evPkA, evPkB, evPkF})
if len(got) != 1 || !got[evPkA].Equal(time.Date(2026, 10, 7, 11, 0, 0, 0, time.UTC)) {
t.Fatalf("relayTimes = %v", got)
}
}
func TestNotifyObserversFrom(t *testing.T) {
name, seen := "Roof", "2026-10-07T11:00:00Z"
got := notifyObserversFrom([]Observer{{ID: "OBS1", Name: &name, LastSeen: &seen}, {ID: "OBS2"}})
if len(got) != 2 || got[0] != (notifyObserver{ID: "OBS1", Name: "Roof", LastSeen: seen}) || got[1] != (notifyObserver{ID: "OBS2"}) {
t.Fatalf("notifyObserversFrom = %+v", got)
}
}
func TestServerNotifySourceWithoutStore(t *testing.T) {
src := serverNotifySource{s: &Server{cfg: &Config{}}}
if heard, lock := src.lastHeard([]string{evPkA}); len(heard) != 0 || lock != 0 || len(src.lastRelayed([]string{evPkA})) != 0 {
t.Fatal("a server without a packet store reported times")
}
if src.lowBatteryMv() != 3300 || src.health().InfraSilentHours != 72 {
t.Fatal("defaults not taken from the config")
}
}
// The startup load (hot window plus background fill) must be finished, not
// only /api/healthz readiness: readiness can be reached while the newest
// packets are still loading, which would mail "offline" then "back online".
func TestServerNotifySourceReadyWaitsForTheStartupLoad(t *testing.T) {
readiness.Store(1)
defer readiness.Store(0)
store := &PacketStore{clockSkew: &ClockSkewEngine{}}
src := serverNotifySource{s: &Server{cfg: &Config{}, store: store}}
if src.ready() {
t.Fatal("ready before the startup load finished")
}
store.signalStartupLoadDone()
if !src.ready() {
t.Fatal("not ready after readiness and the startup load")
}
readiness.Store(0)
if src.ready() {
t.Fatal("ready without readiness")
}
if (serverNotifySource{s: &Server{cfg: &Config{}}}).ready() {
t.Fatal("ready without a packet store")
}
}
func TestPacketStoreNewestFirstSeen(t *testing.T) {
if got := (&PacketStore{}).NewestFirstSeen(); !got.IsZero() {
t.Fatalf("empty store = %v", got)
}
s := &PacketStore{packets: []*StoreTx{{FirstSeen: "2026-10-07T10:00:00Z"}, {FirstSeen: "2026-10-07T11:30:00.000Z"}}}
if got := s.NewestFirstSeen(); !got.Equal(time.Date(2026, 10, 7, 11, 30, 0, 0, time.UTC)) {
t.Fatalf("NewestFirstSeen = %v", got)
}
}
+9
View File
@@ -95,6 +95,15 @@ func routeDescriptions() map[string]routeMeta {
"POST /api/admin/proposals/{id}/revoke": {Summary: "Revoke an approved proposal (admin)", Description: "Body {note}. Approved only (409 otherwise). The ingestor stops decrypting the channel from its next refresh unless the channel is configured; stored messages stay. The name can be proposed again.", Tag: "users", Session: true},
"POST /api/mail/brevo/webhook": {Summary: "Brevo delivery-event webhook", Description: "Authenticated with Authorization: Bearer <userManagement.mail.webhookSecret>. Registered only when the secret is set.", Tag: "users"},
// Node notifications (user management, part E)
"GET /api/account/notifications": {Summary: "Get own notification settings", Description: "Returns {enabled, events, availableEvents, watches: [{pubkey, name, known, createdAt}], limits: {maxWatches, perUserPerDay, mailsLast24h}}. Creates the default settings (on, node.offline and node.battery) on first use. known is false once the node left the analyzer database. Registered only when userManagement.notifications.enabled.", Tag: "users", Session: true},
"PUT /api/account/notifications": {Summary: "Change own notification settings", Description: "Body {enabled, events}. events from node.offline, node.battery and, for admins, foreign.new and observer.offline. Answers the GET body. 400 unknown event type, 403 an admin event chosen by a non-admin. Events left out lose their stored state, so choosing them again starts without a mail.", Tag: "users", Session: true},
"PUT /api/account/notifications/watches/{pubkey}": {Summary: "Watch a node", Description: "Answers the GET body. 400 when pubkey is not 64 hex characters, 404 when the node is not in the analyzer database, 409 when userManagement.notifications.maxWatchesPerUser nodes are watched. Watching a watched node is 200.", Tag: "users", Session: true},
"DELETE /api/account/notifications/watches/{pubkey}": {Summary: "Stop watching a node", Description: "Answers the GET body; removing a node that is not watched is 200. 400 when pubkey is not 64 hex characters.", Tag: "users", Session: true},
"POST /api/account/notifications/watch-my-nodes": {Summary: "Watch the synced My nodes", Description: "Copies the pubkeys of the caller's synced meshcore-my-nodes setting into the watch list, up to the limit. Answers {added, already, skipped, account}; skipped counts malformed keys, nodes not in the analyzer database and nodes over the limit; account is the GET body.", Tag: "users", Session: true},
"GET /api/notifications/unsubscribe": {Summary: "Unsubscribe link (redirect)", Description: "303 to <publicBaseUrl>/#/account/unsubscribe?token=, the confirm view. Changes nothing, so link scanners cannot unsubscribe anyone.", Tag: "users", QueryParams: []paramMeta{{Name: "token", Description: "The unsubscribe token from the mail", Type: "string"}}},
"POST /api/notifications/unsubscribe": {Summary: "Turn notification mails off", Description: "No session and no Origin check: the confirm button and the mail provider's List-Unsubscribe-Post one-click both land here. 200 {ok, message}, also when already off; 410 for an unknown token. The token can do nothing else.", Tag: "users", QueryParams: []paramMeta{{Name: "token", Description: "The unsubscribe token from the mail", Type: "string"}}},
// Packets
"GET /api/packets": {Summary: "List packets", Description: "Returns decoded packets with filtering, sorting, and pagination.", Tag: "packets",
QueryParams: []paramMeta{
+1 -1
View File
@@ -534,7 +534,7 @@ func (s *Server) clientUserManagement() *ClientUserManagement {
if s.auth == nil {
return nil
}
return &ClientUserManagement{Enabled: true, ChannelProposals: s.auth.set.proposals.enabled}
return &ClientUserManagement{Enabled: true, ChannelProposals: s.auth.set.proposals.enabled, Notifications: s.auth.notify != nil}
}
func (s *Server) handleConfigAreas(w http.ResponseWriter, r *http.Request) {
+2 -1
View File
@@ -1093,10 +1093,11 @@ type ClientConfigResponse struct {
}
// ClientUserManagement tells the frontend that accounts exist, and whether
// channel proposals are on.
// channel proposals and node notifications are on.
type ClientUserManagement struct {
Enabled bool `json:"enabled"`
ChannelProposals bool `json:"channelProposals,omitempty"`
Notifications bool `json:"notifications,omitempty"`
}
// CustomizerClientConfig is the operator-side customizer-modal knobs that
+47
View File
@@ -22,6 +22,7 @@ type UserManagementConfig struct {
TrustedProxies []string `json:"trustedProxies,omitempty"`
Mail UserMailConfig `json:"mail"`
ChannelProposals *ChannelProposalsConfig `json:"channelProposals,omitempty"`
Notifications *NotificationsConfig `json:"notifications,omitempty"`
}
// UserMailConfig is userManagement.mail.
@@ -76,6 +77,50 @@ func resolveProposals(c *ChannelProposalsConfig) proposalSettings {
}
}
// NotificationsConfig is userManagement.notifications
// (docs/specs/2026-10-07-node-notifications-design.md). Off by default.
type NotificationsConfig struct {
Enabled bool `json:"enabled"`
IntervalMinutes int `json:"intervalMinutes,omitempty"`
PerUserPerDay int `json:"perUserPerDay,omitempty"`
MaxMailsPerDay int `json:"maxMailsPerDay,omitempty"`
MaxWatchesPerUser int `json:"maxWatchesPerUser,omitempty"`
}
// notifySettings is the resolved form; the zero value means off.
type notifySettings struct {
enabled bool
interval time.Duration // between evaluations, at least a minute
perUserPerDay int // notification mails per user per 24 hours
maxMailsPerDay int // notification mails per instance per 24 hours
maxWatchesPerUser int
}
const (
defaultNotifyIntervalMinutes = 5
defaultNotifyPerUserPerDay = 20
// defaultNotifyMaxMailsPerDay counts notification mail only. A third of
// Brevo's free 300 a day, so activation, reset and address-change mail
// (and other senders on the same account) still fit in the quota.
defaultNotifyMaxMailsPerDay = 100
defaultNotifyMaxWatches = 50
)
// resolveNotifications fills the defaults for absent, zero or negative
// values. The interval is in whole minutes, so its floor is one minute.
func resolveNotifications(c *NotificationsConfig) notifySettings {
if c == nil || !c.Enabled {
return notifySettings{}
}
return notifySettings{
enabled: true,
interval: time.Duration(positiveOr(c.IntervalMinutes, defaultNotifyIntervalMinutes)) * time.Minute,
perUserPerDay: positiveOr(c.PerUserPerDay, defaultNotifyPerUserPerDay),
maxMailsPerDay: positiveOr(c.MaxMailsPerDay, defaultNotifyMaxMailsPerDay),
maxWatchesPerUser: positiveOr(c.MaxWatchesPerUser, defaultNotifyMaxWatches),
}
}
// UserManagementEnabled reports whether optional accounts are on. Nil config
// or absent section means off (the default).
func (c *Config) UserManagementEnabled() bool {
@@ -96,6 +141,7 @@ type userMgmtSettings struct {
fromName string
webhookSecret string
proposals proposalSettings
notify notifySettings
}
const defaultSessionDays = 30
@@ -172,6 +218,7 @@ func resolveUserManagement(u *UserManagementConfig, measurementDBPath string, ge
return nil, errors.New("userManagement.mail.webhookSecret must be at least 16 characters")
}
set.proposals = resolveProposals(u.ChannelProposals)
set.notify = resolveNotifications(u.Notifications)
return set, nil
}
+28
View File
@@ -112,3 +112,31 @@ func TestResolveChannelProposals(t *testing.T) {
t.Fatal("enabled false resolved as on")
}
}
func TestResolveNotifications(t *testing.T) {
u := validUM()
set, err := resolveUserManagement(u, "meshcore.db", noEnv)
if err != nil {
t.Fatal(err)
}
if set.notify != (notifySettings{}) {
t.Fatalf("absent block = %+v; want off", set.notify)
}
defaults := notifySettings{enabled: true, interval: 5 * time.Minute, perUserPerDay: 20, maxMailsPerDay: 100, maxWatchesPerUser: 50}
u.Notifications = &NotificationsConfig{Enabled: true}
if set, _ = resolveUserManagement(u, "meshcore.db", noEnv); set.notify != defaults {
t.Fatalf("defaults = %+v", set.notify)
}
u.Notifications = &NotificationsConfig{Enabled: true, IntervalMinutes: 1, PerUserPerDay: 3, MaxMailsPerDay: 9, MaxWatchesPerUser: 2}
if set, _ = resolveUserManagement(u, "meshcore.db", noEnv); set.notify != (notifySettings{enabled: true, interval: time.Minute, perUserPerDay: 3, maxMailsPerDay: 9, maxWatchesPerUser: 2}) {
t.Fatalf("explicit = %+v", set.notify)
}
u.Notifications = &NotificationsConfig{Enabled: true, IntervalMinutes: -5, PerUserPerDay: -1, MaxWatchesPerUser: -2}
if set, _ = resolveUserManagement(u, "meshcore.db", noEnv); set.notify != defaults {
t.Fatalf("zero and negative values = %+v; want defaults", set.notify)
}
u.Notifications = &NotificationsConfig{Enabled: false, PerUserPerDay: 3}
if set, _ = resolveUserManagement(u, "meshcore.db", noEnv); set.notify.enabled {
t.Fatal("enabled false resolved as on")
}
}
+8
View File
@@ -448,10 +448,18 @@
"maxPending": 100,
"maxApproved": 128,
"perUserPerDay": 5
},
"notifications": {
"enabled": false,
"intervalMinutes": 5,
"perUserPerDay": 20,
"maxMailsPerDay": 100,
"maxWatchesPerUser": 50
}
},
"_comment_userManagement": "Optional accounts (off by default; see docs/user-guide/accounts.md). When enabled: visitors can register with email + password and activate through a mailed link; addresses in adminEmails become admins; admins manage users at #/admin/users and can use the operator endpoints without the apiKey. The dashboard stays public. dbPath defaults to users.db next to the analyzer database: a separate file, the server never writes measurement data. publicBaseUrl is required and is used for every link in every mail. Mail goes through Brevo's transactional API (a free account is enough); brevoApiKey and webhookSecret may instead come from CORESCOPE_BREVO_API_KEY and CORESCOPE_BREVO_WEBHOOK_SECRET. Set webhookSecret (16+ chars) and point a Brevo transactional webhook at <publicBaseUrl>/api/mail/brevo/webhook with bearer auth to see delivery status per user. trustedProxies (CIDRs) lets login rate limits use X-Forwarded-For behind a reverse proxy. Startup fails if enabled with an incomplete mail setup.",
"_comment_channelProposals": "Optional, needs userManagement.enabled (docs/user-guide/accounts.md, Channel proposals). Logged-in users propose hashtag channels; admins approve, reject or revoke them on the Proposals tab. An approved channel is decrypted by the ingestor from its next refresh (once a minute, no restart) and listed for every visitor on the Channels page. maxApproved bounds the decryption work (every approved key is tried on every group message); perUserPerDay and maxPending limit proposals. The ingestor reads users.db read-only from userManagement.dbPath, or next to the analyzer database; set dbPath explicitly when the server and the ingestor are not given the same analyzer database path.",
"_comment_notifications": "Optional, needs userManagement.enabled (docs/user-guide/accounts.md, Node notifications). Logged-in users watch nodes and get one mail per check when a watched node goes offline, comes back or reports a low battery; admins can also watch for new foreign nodes and observers going offline. Thresholds are healthThresholds and batteryThresholds. A check runs every intervalMinutes; at most perUserPerDay mails per user and maxMailsPerDay in total go out per 24 hours (over a limit the change is recorded and dropped, never queued). maxMailsPerDay counts notification mail only: keep it below the mail provider's daily quota so activation, password-reset and address-change mail still go out (the default 100 leaves 200 of Brevo's free 300 a day); maxWatchesPerUser bounds each watch list. Every mail has a one-click unsubscribe.",
"customizer": {
"disabledTabs": [],
"_comment_disabledTabs": "Issue #1508. List of customizer-modal tab ids to hide from end users. Useful when operators want to expose only daily-use viewer controls and keep one-shot admin chrome out of the way. Recognized ids: branding, theme, nodes, home, display, geofilter, export. Example: [\"branding\", \"geofilter\", \"export\"] hides the admin tabs and leaves theme/colors/home/display visible. Default [] keeps the legacy behavior (all tabs visible). Operators edit this in config.json directly — there is no in-app UI for this list (by design)."
@@ -0,0 +1,310 @@
# Optional User Management, Sub-project E: Node Notifications, Design Spec
**Status:** written 2026-10-07 while the operator was away. The decisions in the table
below were taken by Claude with its recommended option, as the operator asked; each one
is open for review and can be reversed.
**Builds on:** A (`docs/specs/2026-10-06-user-management-design.md`), B
(`docs/specs/2026-10-06-user-settings-sync-design.md`), C
(`docs/specs/2026-10-07-admin-dashboard-design.md`) and D
(`docs/specs/2026-10-07-channel-proposals-design.md`). Roadmap #2128, part E.
**Related issues:** #775 (alerting epic), #730 (foreign advert detection, implemented in
the ingestor as `foreignAdverts.mode`), #663 (battery thresholds).
## Problem
A sysop learns that their repeater went silent, or that its battery is running down, only
by opening CoreScope and looking. #775 asks for notifications on network events. With
accounts (A) there is now a person and a verified mail address to notify, and a mailer
with delivery status.
## Goals
1. A logged-in user picks nodes to watch and gets one mail when a watched node goes
offline, comes back, or reports a low battery.
2. Admins can additionally watch the instance: a new foreign node appears, or an observer
goes offline.
3. Mail volume stays bounded per user and per instance, and every mail carries a
one-click unsubscribe.
4. Off by default. With the feature off, nothing changes.
## Non-goals
- Other channels (Discord, Telegram, push, outbound webhooks). The design keeps the event
detection separate from delivery so a second channel can be added later.
- The topology, RF and anomaly alerts of #775 (mesh split, SNR degradation, noise
spikes, clock drift). They need their own thresholds and validation.
- Notifications for visitors without an account.
- Per-user thresholds. The instance's `healthThresholds` and `batteryThresholds` apply.
## Decisions (taken by Claude, open for review)
| # | Topic | Decision | Alternative not taken |
|---|---|---|---|
| 1 | Who | Logged-in active users watch nodes; admins also get two instance-wide event types. | Admin-only notifications. |
| 2 | What to watch | An explicit watch list in `users.db`, filled from a "Notify me" toggle on the node page, with a one-click "Watch my nodes" that copies the synced `meshcore-my-nodes` list (B). | Watch every node in the synced "my nodes" list automatically. |
| 3 | Events | Node offline, node back online, node battery low (and recovered, in the same mail as other changes); admins: new foreign node, observer offline and back. | All of #775. |
| 4 | Detection | A server loop every 5 minutes compares current state with the last state stored per (user, subject, event) in `users.db`. | Event hooks inside packet ingest. |
| 5 | Restart behaviour | A subject's first evaluation stores the state without mailing, so a restart or a new watch never sends a burst. | Mail on the first evaluation. |
| 6 | Delivery | Mail only; all changes for one user in one evaluation go into one mail. | One mail per event. |
| 7 | Limits | Per user at most 20 notification mails per day; per instance at most 300 per day (Brevo's free tier); at most 50 watched nodes per user. Over a limit, the change is recorded and skipped, never queued. | A queue that sends later. |
| 8 | Unsubscribe | Each mail has a one-click unsubscribe link (and `List-Unsubscribe` + `List-Unsubscribe-Post` headers) that turns notifications off for that user; the account page turns them back on. | Link to the account page only. |
## Events
All thresholds are the instance's existing configuration, so a notification agrees with
what the node page shows.
| Event | Subject | Goes to "bad" when | Goes back to "good" when |
|---|---|---|---|
| `node.offline` | node pubkey | the node is silent: no traffic for `healthThresholds` silent hours for its role (infra 72 h, others 24 h); for repeaters and rooms the later of last heard and last relayed counts (#1598), as on the node page | the node is heard again within the silent window |
| `node.battery` | node pubkey | the latest advert telemetry `battery_mv` is below `batteryThresholds.lowMv` (3300) | `battery_mv` is at or above `lowMv + 100` (hysteresis against flapping around the threshold) |
| `foreign.new` (admin) | node pubkey | a node with `foreign_advert = 1` that this admin has not been told about | no "good" transition; one mail per node, ever |
| `observer.offline` (admin) | observer id | `last_seen` older than `healthThresholds.observerStaleMinutes` (1440) | `last_seen` within `observerOnlineMinutes` (60) |
A node without battery telemetry never triggers `node.battery`. A node that disappears
from the analyzer database (retention) is treated as offline once and then dropped from
evaluation; its watch stays until the user removes it.
## Storage (`internal/users`, schema v5)
```
CREATE TABLE notification_prefs (
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
enabled INTEGER NOT NULL DEFAULT 1,
events TEXT NOT NULL DEFAULT '', -- comma list of opted-in event types
unsub_token TEXT NOT NULL UNIQUE, -- random, 32 bytes base64url
updated_at INTEGER NOT NULL
);
CREATE TABLE notification_watches (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
pubkey TEXT NOT NULL, -- lowercase hex
created_at INTEGER NOT NULL,
PRIMARY KEY (user_id, pubkey)
);
CREATE TABLE notification_state (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
event TEXT NOT NULL,
subject TEXT NOT NULL,
state TEXT NOT NULL, -- 'good' | 'bad' | 'told' (foreign.new)
changed_at INTEGER NOT NULL,
PRIMARY KEY (user_id, event, subject)
);
```
Defaults on first use: node events opted in, admin events opted out (an admin turns them
on). State rows for subjects no longer watched are deleted when the watch is removed.
Mail counts for the limits come from `mail_log` (purpose `notify`).
## Config
```json
"userManagement": {
"notifications": {
"enabled": false,
"intervalMinutes": 5,
"perUserPerDay": 20,
"maxMailsPerDay": 300,
"maxWatchesPerUser": 50
}
}
```
Values at or below 0 fall back to the defaults. `intervalMinutes` has a floor of 1.
## Server (`cmd/server`, only when user management and notifications are on)
- **Evaluator.** Started in `initUserManagement` next to the janitor, stopped through the
same `stop` channel and wait group, with `recover()` so a panic logs and the loop
continues on the next tick. Each tick:
1. Read all watches, prefs and states from `users.db` (one query each).
2. Read the analyzer data it needs on the read-only connection: one
`SELECT public_key, role, last_seen, battery_mv, foreign_advert, name FROM nodes`
limited to watched pubkeys and foreign nodes, the relay times from the existing
in-memory relay metrics, and the active observers.
3. Compute each (user, event, subject) state, compare, collect transitions per user.
4. For users with transitions: skip when the user is not active, has
`email_bouncing`, has notifications off, or is over a limit; otherwise send one mail.
5. Write the new states in one transaction, also for skipped users (so a skipped change
is not mailed later).
- **Routes** (withUser unless noted, all in OpenAPI under the `users` tag):
- `GET /api/account/notifications`: prefs, watches (with node names), limits.
- `PUT /api/account/notifications`: `{enabled, events}`; admin events refused for
non-admins.
- `PUT /api/account/notifications/watches/{pubkey}` and `DELETE ...`: add or remove a
watch; 409 over `maxWatchesPerUser`, 400 for a malformed pubkey, 404 for a node not in
the analyzer database.
- `POST /api/account/notifications/watch-my-nodes`: copies the pubkeys from the
user's synced `meshcore-my-nodes` (B) into the watch list, up to the limit; returns
added and skipped counts.
- `GET /api/notifications/unsubscribe?token=` (no session): shows a confirm page;
`POST` with the token turns notifications off. The `List-Unsubscribe-Post` one-click
POST goes to the same handler.
- **Mailer.** `mailer.Message` gains a `Headers map[string]string` field, passed to
Brevo's `headers`. Only `List-Unsubscribe` and `List-Unsubscribe-Post` are set.
- **Mail content.** Subject `[<fromName>] <n> change(s) on your watched nodes`; one line
per transition with node name, event, time, and a link to the node page; footer with
the unsubscribe link and a link to the account page. Plain text and HTML through the
existing `render` helper.
- **Audit.** `notify.unsubscribe` (via link) and `notify.prefs` (changes on the account
page). Watch add/remove is not audited (noise).
- **Admin dashboard (C).** The Users card's mail buckets already count `notify` mail
through `mail_log`; the Overview gains "notification mails today: n of maxMailsPerDay".
## Frontend
- **Node page** (side pane and full page): a "Notify me" toggle for logged-in users when
the feature is on, showing the watch state; disabled with a hint at the limit.
- **Account page:** a "Notifications" section: on/off, event checkboxes (admin events
only for admins), the watch list with remove buttons, "Watch my nodes", and the
per-day limit.
- **Unsubscribe page:** `#/account/unsubscribe?token=` with one confirm button.
- Every rendered value goes through `escapeHtml`; colors through CSS variables; the
account section deep-links as `#/account?section=notifications`.
## Security and privacy
- Watches are private: only the user sees their own list; admins see counts, not lists.
- The unsubscribe token is random, per user, unrelated to the session, and only turns
notifications off (it cannot read or change anything else).
- No new personal data leaves `users.db`; mail goes only to verified, active addresses.
## Performance
One tick every 5 minutes: three small `users.db` queries, one analyzer query bounded by
the number of distinct watched pubkeys plus foreign nodes (indexed on `public_key`, and a
partial index exists for `foreign_advert`), and an in-memory comparison. No work is added
to packet ingest, WebSocket broadcast or any request path. With 100 users x 50 watches
the state table holds at most 5,000 rows per event type.
## Testing
- `internal/users`: migration v4 to v5; prefs defaults and token uniqueness; watches add,
remove, limit; state upsert and cleanup on watch removal; cascade on user delete.
- `cmd/server` evaluator (pure function from inputs to transitions, plus the loop with a
fake mailer and a fixed clock): first evaluation sends nothing; offline and back;
relay-aware infra; battery hysteresis at `lowMv` and `lowMv + 100`; foreign node once
per admin; observer offline and back; one mail per user per tick; skipped for inactive,
bouncing, disabled, over per-user limit, over instance limit, and those skips not
mailed later; restart (states survive).
- Routes: auth, CSRF, limits, 400/404/409, admin events refused for users, unsubscribe
with a valid and an invalid token, feature off: routes absent and no loop started.
- Mailer: `Headers` reach the Brevo request body.
- Frontend unit (vm): toggle states, account section, escaping.
- Playwright (e2etest build): a user watches a node, the account page lists it, the
unsubscribe link turns notifications off.
## Amendments from the implementation plan
The plan (`docs/plans/2026-10-07-node-notifications.md`, untracked) decided these points
where the spec was silent or did not match the code; all 18 were accepted as written,
except where a ruling below changes one.
1. "Per day" is a rolling 24 hours counted from `mail_log`, like the proposal limit.
2. Offline uses the node page's three timestamps: `nodes.last_seen`, the packet store's
newest packet involving the node, and for repeaters and rooms `last_relayed`.
3. No evaluation runs while the packet store is still loading after a restart; the first
one runs one interval after startup.
4. `foreign.new` keeps a per-admin baseline row (subject `*`): the first evaluation after
opting in stores the current foreign nodes without mailing.
5. Without a previous state, a battery between `lowMv` and `lowMv + 100` and an observer
between `observerOnlineMinutes` and `observerStaleMinutes` start as good.
6. A watched node missing from the analyzer database is offline (one change) and not
evaluated for battery; the watch stays.
7. States are written before mails are sent; a failed state write sends nothing and the
next check tries again; a failed send is not retried.
8. States exist only for chosen events; leaving an event out deletes its states. Admin
events are evaluated only while the account is an admin.
9. Preferences rows are created lazily (GET or PUT of the notification state, a watch
add, `watch-my-nodes`) with notifications on and the two node events. Changed by
ruling R1: users without a row are evaluated too.
10. The unsubscribe GET only redirects to `#/account/unsubscribe?token=`; the POST takes
the token from the query string without a session or Origin check, answers 200 also
when notifications were already off, and 410 for an unknown token. The token is
stored raw, never rotates and only turns notifications off.
11. `watch-my-nodes` answers `{added, already, skipped, account}`.
12. The watch routes answer the full notification state; watching an already watched
node and unwatching an unwatched one answer 200.
13. An admin event chosen by a non-admin answers 403; an unknown event answers 400.
14. Pubkeys are 64 hex characters, lowercased on input; anything else answers 400.
15. Mail: one subject line with the number of changes, one linked line per change, a
"Manage notifications" button and a footer with the one-click unsubscribe link;
every existing mail renders byte-identical.
16. The admin overview's Users card shows mails in the last 24 hours against
`maxMailsPerDay`, watched nodes and watching users; the audit filter gains
`notify.*`.
17. The e2etest build gains `GET /__e2e/unsubscribe-link?email=` so Playwright can follow
an unsubscribe link without waiting for a real state change.
18. Foreign `told` rows are never pruned; they are bounded by the foreign nodes the
instance has seen.
### Rulings during the implementation
- R1. Users without a preferences row are evaluated with the default preferences
(enabled, node events). Users with notifications off keep being evaluated: their
states are written and nothing is mailed, so turning notifications on again never
mails old changes.
- R2. Inactive and bouncing accounts are skipped at send time, not in the evaluator;
their states are still written, so the skipped changes are never mailed later.
- R3. Offline takes the later of `last_seen` and the packet store's last heard (more
lenient than `roles.js` `last_heard || last_seen`); in a rare case the mail says
online while the node page says stale.
- R4. Admin events of a user who is no longer an admin: their states are deleted, like
opting out, so a re-promotion starts with a fresh baseline and no burst of mails. A
demoted and re-promoted admin misses the foreign nodes seen in between.
- R5. The server lowercases pubkeys at the route boundary before storing a watch (PUT
and DELETE of a watch, `watch-my-nodes`); `nodes.public_key` is lowercase.
- R6. The Notify-me toggle waits for the first `/api/auth/me` round trip before it
decides whether a user is logged in; a failed state GET hides the toggle silently.
- R7. `DB.NotifyNodes` reuses the existing SQL placeholder helper; `internal/users`
gets one placeholder helper shared by the new and the two existing inline copies.
- R8. Test and tooling details without behaviour change: the limit test advances the
clock before writing the blocking `mail_log` row; the frontend unit test reads
elements through the test document; the toggle computes its state on its own line
before the HTML sink (XSS gate); gofmt runs only on touched files.
### Amendments from the final review
- F1 (I1). "Packet store loaded" (amendment 3) means the whole startup load: the hot
window and the background fill (`PacketStore.StartupLoadDone`), on top of the
`/api/healthz` readiness. Readiness alone can come while the newest packets are still
loading, which would mail "offline" and then "back online" for a node whose recent
packets were not loaded yet.
- F2 (I2). Node and observer names in a notification mail pass one sanitiser
(`mailSafeText`): control characters (CR, LF and tab included) and bidi overrides and
isolates (U+202A-U+202E, U+2066-U+2069) become a space, runs of spaces collapse and
the ends are trimmed. It applies to the plain-text and the HTML part; the HTML part is
still escaped. A name that is empty afterwards falls back to the key prefix or the
observer id.
- F3 (I3). The default `maxMailsPerDay` is 100, not 300 (decision 7 and the Config
example). It counts notification mail only (`mail_log` purpose `notify`); activation,
reset and address-change mail share the provider's daily quota (Brevo free tier: 300
for the whole account) and would fail at the provider on a day notifications used all
of it. 100 leaves room for that mail and for other senders on the same account.
- F4 (I4). While ingest is stale (the newest transmission in the packet store, read in
O(1) from the tail of `PacketStore.packets`, is older than 30 minutes, or the store is
empty), `node.offline` and `observer.offline` are not compared: no state is written or
changed and nothing is mailed for them. `node.battery` and `foreign.new` go on. The
evaluator stays pure (`notifyInput.IngestStale`); the notifier logs
`[notify] ingest stale since <time>; offline checks paused` once per stale period and
`[notify] ingest fresh again; offline checks resumed` when it ends. Without this, an
MQTT or ingestor outage longer than a role's silent window mailed every watcher
"offline" and later "back online".
- F5 (I5). Every tick that evaluates logs one line:
`[notify] tick: users=N changes=M mails=K took=Xms lock=Yms`, where `lock` is how long
`PacketStore.LastHeardMap` held the store's read lock (timed inside the locked section
and returned with the map). The lock time at live scale was an estimate; staging and
live now report the measured value every interval. Code changes follow only if it is
high.
- F6 (I4, second round). Pausing alone only moved the false mails to the first check
after recovery. The notifier keeps `IngestResumedAt` in memory, set when a stale
period ends (also when the store was stale at startup and becomes fresh), and passes
it to the evaluator. For `node.offline` and `observer.offline` with a stored good
state, evidence older than `IngestResumedAt` counts as heard at that time, so no
offline transition fires until a full silent window (per role; `observerStaleMinutes`
for observers) has passed after recovery. A stored bad state is not lifted by it. A
node that died during the outage is reported up to one window late; the grace is lost
when the server restarts after the recovery and inside that window, and when the
server starts after an outage that ended before its first check (no stale check is
ever seen; seeding the grace at every start would delay real offline reports by a
silent window after each deploy). A watched node
missing from the analyzer database is still offline at once (no evidence to defer).
+54
View File
@@ -87,6 +87,9 @@ tracking pixels automatically, and others block them.
(accounts, registrations, active users, logins, mail of the last 7 days) and the system
status (version, uptime, MQTT sources, observers). It refreshes every minute while the
tab is visible. Each item links to the matching user list or audit entries.
With node notifications on, the Users card also shows the notification mails of the
last 24 hours against `maxMailsPerDay`, and how many nodes are watched by how many
users (counts only; watch lists are private).
- **Users**: the user list. *Bouncing mail only* shows addresses whose mail bounces.
- **Audit**: every recorded action, newest first, filtered by action, period or user.
Successful and failed logins are recorded without IP address and deleted after 90
@@ -139,6 +142,53 @@ on the *Proposals* tab.
Names that need the zero-width non-joiner (U+200C), as some Persian and Urdu
spellings do, are refused as well.
### Node notifications (optional)
With node notifications on, logged-in users choose nodes to watch and get a mail when a
watched node changes state. Admins can also watch the instance.
```json
"userManagement": {
"notifications": { "enabled": true, "intervalMinutes": 5, "perUserPerDay": 20, "maxMailsPerDay": 100, "maxWatchesPerUser": 50 }
}
```
- Events: a watched node goes offline or comes back, using the node page's thresholds:
offline once it has not been heard for the `healthThresholds` silent hours of its role
(heard is the latest of its last advert, the packet store's newest packet involving
it, and for repeaters and rooms its last relay hop). A watched node also reports a
battery below `batteryThresholds.lowMv`, and recovers at `lowMv + 100` mV. Admins can
add a new foreign node (once per node) and an observer going offline
(`observerStaleMinutes`) or back (`observerOnlineMinutes`).
- A check runs every `intervalMinutes` (the first one an interval after startup, and only
after the startup load). All changes for one user in one check go into one
mail. The first check of a newly watched node stores its state without a mail; states
are kept in `users.db`, so a restart does not mail again.
- While ingest is stale (the newest packet in the packet store is older than 30 minutes,
for example when the MQTT broker or the ingestor is down), the offline checks for
nodes and observers pause: their states stay as they were and nothing is mailed for
them. Battery and foreign-node checks go on. The server log says when the pause
starts and ends. After the feed comes back, offline reports wait one silent window
(the role's silent hours for nodes, `observerStaleMinutes` for observers): until then
older evidence counts as heard at the moment the feed came back, so the outage itself
mails nobody. A node that died during the outage is reported up to one window late.
The server keeps this grace in memory: a restart after the recovery and inside that
window loses it, and so does a server that starts after an outage that already ended
before its first check (for example a reboot of the whole host after a long outage).
- Limits: `perUserPerDay` mails per user and `maxMailsPerDay` in total, both over a
rolling 24 hours. A change over a limit, for an account that is not active, for a
bouncing address or while the user has notifications off is recorded and never mailed
later. `maxWatchesPerUser` bounds each watch list.
- `maxMailsPerDay` counts notification mail only; activation, password-reset and
address-change mail are not counted and are not limited by it. They share the mail
provider's daily quota, though (Brevo's free tier allows 300 a day for the whole
account), so keep `maxMailsPerDay` well below that quota. The default 100 leaves 200
a day for account mail and for any other sender on the same provider account.
- Every mail carries a one-click unsubscribe link and `List-Unsubscribe` headers; the
link turns notification mails off for that account and nothing else. The account page
turns them back on.
- Watch lists are private: admins see counts on the overview, not lists.
### Backups
`users.db` holds password hashes and addresses. Back it up together with the analyzer
@@ -166,6 +216,10 @@ database, and protect it the same way. Deleting it removes all accounts and noth
channel*, type the hashtag name, *Propose for everyone*. *My account, My proposals*
shows the status and the admin's note. Approved channels are readable for every
visitor of the instance.
- **Node notifications** (when the operator turned them on): *Notify me* on a node's
page watches it; *My account, Notifications* lists your watched nodes, turns mails on
or off, chooses the events, and *Watch my nodes* copies your synced My nodes. You get
at most one mail per check, and every mail has a link that turns the mails off.
- **Never synced:** channel keys and decrypted messages, the API key, panel and column
sizes, collapsed panels and map positions. They stay in the browser where you set them.
- **Logging out** asks whether to keep your settings on this device (the default) or
+8 -6
View File
@@ -46,12 +46,13 @@ type brevoAddress struct {
}
type brevoSendRequest struct {
Sender brevoAddress `json:"sender"`
To []brevoAddress `json:"to"`
Subject string `json:"subject"`
HTMLContent string `json:"htmlContent,omitempty"`
TextContent string `json:"textContent,omitempty"`
Tags []string `json:"tags,omitempty"`
Sender brevoAddress `json:"sender"`
To []brevoAddress `json:"to"`
Subject string `json:"subject"`
HTMLContent string `json:"htmlContent,omitempty"`
TextContent string `json:"textContent,omitempty"`
Tags []string `json:"tags,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
}
type brevoError struct {
@@ -69,6 +70,7 @@ func (b *Brevo) Send(ctx context.Context, m Message) (string, error) {
Subject: m.Subject,
HTMLContent: m.HTML,
TextContent: m.Text,
Headers: m.Headers,
}
if m.Tag != "" {
req.Tags = []string{m.Tag}
+38
View File
@@ -134,3 +134,41 @@ func TestBrevoSendRejectsEmptyContentAndNilClient(t *testing.T) {
t.Fatal("expected connection error")
}
}
func TestBrevoSendPassesHeaders(t *testing.T) {
var body map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
json.Unmarshal(b, &body)
w.WriteHeader(http.StatusCreated)
w.Write([]byte(`{"messageId":"<m1>"}`))
}))
defer srv.Close()
b := NewBrevo("k", "noreply@example.org", "CoreScope")
b.BaseURL = srv.URL
hdr := map[string]string{
"List-Unsubscribe": "<https://scope.example.org/api/notifications/unsubscribe?token=t>",
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
}
if _, err := b.Send(context.Background(), Message{To: "a@example.org", Text: "t", Headers: hdr}); err != nil {
t.Fatal(err)
}
got, ok := body["headers"].(map[string]any)
if !ok || got["List-Unsubscribe"] != hdr["List-Unsubscribe"] || got["List-Unsubscribe-Post"] != "List-Unsubscribe=One-Click" || len(got) != 2 {
t.Fatalf("headers in the request = %#v", body["headers"])
}
body = nil
if _, err := b.Send(context.Background(), Message{To: "a@example.org", Text: "t"}); err != nil {
t.Fatal(err)
}
if _, present := body["headers"]; present {
t.Fatalf("headers key sent without headers: %#v", body)
}
}
func TestFakeKeepsHeaders(t *testing.T) {
f := &Fake{}
f.Send(context.Background(), Message{To: "a@example.org", Text: "t", Headers: map[string]string{"List-Unsubscribe": "<x>"}})
if m, _, _ := f.Last(); m.Headers["List-Unsubscribe"] != "<x>" {
t.Fatalf("fake lost the headers: %+v", m)
}
}
+2 -1
View File
@@ -14,7 +14,8 @@ type Message struct {
Subject string
HTML string
Text string
Tag string // provider tag, e.g. "activate"
Tag string // provider tag, e.g. "activate"
Headers map[string]string // extra mail headers, e.g. List-Unsubscribe; nil for none
}
// Canonical delivery event names stored by CoreScope, independent of provider.
+1 -1
View File
@@ -122,7 +122,7 @@ func (s *Store) PruneAudit(actions []string, maxAge time.Duration) (int64, error
args = append(args, a)
}
args = append(args, unix(s.now())-int64(maxAge/time.Second))
ph := strings.TrimSuffix(strings.Repeat("?,", len(actions)), ",")
ph := placeholders(len(actions))
res, err := s.db.Exec(`DELETE FROM audit_log WHERE action IN (`+ph+`) AND at < ?`, args...)
if err != nil {
return 0, err
+449
View File
@@ -0,0 +1,449 @@
package users
import (
"database/sql"
"errors"
"strings"
"time"
)
// Node notifications (docs/specs/2026-10-07-node-notifications-design.md):
// per-user preferences, the watch list, and the last state per (user, event,
// subject) that the server's evaluator compares against.
// Event types.
const (
NotifyNodeOffline = "node.offline"
NotifyNodeBattery = "node.battery"
NotifyForeignNew = "foreign.new" // admins only
NotifyObserverOffline = "observer.offline" // admins only
)
// Subject states.
const (
NotifyGood = "good"
NotifyBad = "bad"
NotifyTold = "told" // foreign.new: the admin was told about this node
)
// NotifyMailPurpose labels notification mails in mail_log; the daily limits
// count it.
const NotifyMailPurpose = "notify"
var (
// NodeNotifyEvents are open to every user and chosen by default.
NodeNotifyEvents = []string{NotifyNodeOffline, NotifyNodeBattery}
// AdminNotifyEvents are open to admins only and off by default.
AdminNotifyEvents = []string{NotifyForeignNew, NotifyObserverOffline}
)
// ErrWatchLimit: the user already watches the maximum number of nodes.
var ErrWatchLimit = errors.New("users: watch limit reached")
// IsAdminNotifyEvent reports whether only admins may choose e.
func IsAdminNotifyEvent(e string) bool { return e == NotifyForeignNew || e == NotifyObserverOffline }
// ValidNotifyEvent reports whether e is a known event type.
func ValidNotifyEvent(e string) bool {
return e == NotifyNodeOffline || e == NotifyNodeBattery || IsAdminNotifyEvent(e)
}
// NotifyPrefs is one user's notification preferences.
type NotifyPrefs struct {
UserID int64
Enabled bool
Events []string // chosen events, canonical order, no duplicates
UnsubToken string // random, only turns notifications off
UpdatedAt time.Time
}
// DefaultNotifyPrefs is what a user without a preferences row gets:
// notifications on, the node events chosen, no unsubscribe token yet.
// NotifyPrefsFor stores these values when it creates the row.
func DefaultNotifyPrefs(userID int64) NotifyPrefs {
return NotifyPrefs{UserID: userID, Enabled: true, Events: append([]string(nil), NodeNotifyEvents...)}
}
// Has reports whether the user chose event.
func (p NotifyPrefs) Has(event string) bool {
for _, e := range p.Events {
if e == event {
return true
}
}
return false
}
// NotifyWatch is one watched node of one user.
type NotifyWatch struct {
UserID int64
Pubkey string // lowercase hex
CreatedAt time.Time
}
// NotifyKey identifies one evaluated subject of one user.
type NotifyKey struct {
UserID int64
Event string
Subject string // node pubkey, observer id, or "*" (foreign.new baseline)
}
// NotifyState is the last evaluated state of a NotifyKey.
type NotifyState struct {
NotifyKey
State string // NotifyGood, NotifyBad or NotifyTold
ChangedAt time.Time
}
// canonicalEvents keeps the known events of list in canonical order, once each.
func canonicalEvents(list []string) []string {
want := make(map[string]bool, len(list))
for _, e := range list {
want[e] = true
}
out := []string{}
for _, group := range [][]string{NodeNotifyEvents, AdminNotifyEvents} {
for _, e := range group {
if want[e] {
out = append(out, e)
}
}
}
return out
}
func placeholders(n int) string { return strings.TrimSuffix(strings.Repeat("?,", n), ",") }
const prefsCols = `user_id, enabled, events, unsub_token, updated_at`
func scanPrefs(row rowScanner) (*NotifyPrefs, error) {
var p NotifyPrefs
var enabled int
var events string
var updated int64
if err := row.Scan(&p.UserID, &enabled, &events, &p.UnsubToken, &updated); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
p.Enabled = enabled != 0
p.Events = canonicalEvents(strings.Split(events, ","))
p.UpdatedAt = fromUnix(updated)
return &p, nil
}
func (s *Store) getPrefs(userID int64) (*NotifyPrefs, error) {
return scanPrefs(s.db.QueryRow(`SELECT `+prefsCols+` FROM notification_prefs WHERE user_id = ?`, userID))
}
// NotifyPrefsFor returns the user's preferences, creating the default row
// (on, node events, a new unsubscribe token) on first use.
func (s *Store) NotifyPrefsFor(userID int64) (NotifyPrefs, error) {
if p, err := s.getPrefs(userID); !errors.Is(err, ErrNotFound) {
if err != nil {
return NotifyPrefs{}, err
}
return *p, nil
}
tok, _, err := NewToken()
if err != nil {
return NotifyPrefs{}, err
}
d := DefaultNotifyPrefs(userID)
if _, err := s.db.Exec(`INSERT OR IGNORE INTO notification_prefs (user_id, enabled, events, unsub_token, updated_at)
VALUES (?, ?, ?, ?, ?)`, userID, d.Enabled, strings.Join(d.Events, ","), tok, unix(s.now())); err != nil {
return NotifyPrefs{}, err
}
p, err := s.getPrefs(userID)
if err != nil {
return NotifyPrefs{}, err
}
return *p, nil
}
// SetNotifyPrefs stores enabled and events (unknown events dropped,
// canonical order) and deletes the state rows of events no longer chosen,
// so choosing an event again starts with a silent first evaluation. The
// caller checks which events the user may choose.
func (s *Store) SetNotifyPrefs(userID int64, enabled bool, events []string) (NotifyPrefs, error) {
if _, err := s.NotifyPrefsFor(userID); err != nil {
return NotifyPrefs{}, err
}
ev := canonicalEvents(events)
en := 0
if enabled {
en = 1
}
tx, err := s.db.Begin()
if err != nil {
return NotifyPrefs{}, err
}
defer tx.Rollback()
if _, err := tx.Exec(`UPDATE notification_prefs SET enabled = ?, events = ?, updated_at = ? WHERE user_id = ?`,
en, strings.Join(ev, ","), unix(s.now()), userID); err != nil {
return NotifyPrefs{}, err
}
q := `DELETE FROM notification_state WHERE user_id = ?`
args := []any{userID}
if len(ev) > 0 {
q += ` AND event NOT IN (` + placeholders(len(ev)) + `)`
for _, e := range ev {
args = append(args, e)
}
}
if _, err := tx.Exec(q, args...); err != nil {
return NotifyPrefs{}, err
}
if err := tx.Commit(); err != nil {
return NotifyPrefs{}, err
}
return s.NotifyPrefsFor(userID)
}
// DisableNotifyByToken turns notifications off for the user holding the
// unsubscribe token. wasEnabled reports whether they were on.
// ErrTokenInvalid for an empty or unknown token.
func (s *Store) DisableNotifyByToken(token string) (userID int64, wasEnabled bool, err error) {
if token == "" {
return 0, false, ErrTokenInvalid
}
var en int
err = s.db.QueryRow(`SELECT user_id, enabled FROM notification_prefs WHERE unsub_token = ?`, token).Scan(&userID, &en)
if errors.Is(err, sql.ErrNoRows) {
return 0, false, ErrTokenInvalid
}
if err != nil {
return 0, false, err
}
if en != 0 {
if _, err := s.db.Exec(`UPDATE notification_prefs SET enabled = 0, updated_at = ? WHERE user_id = ?`, unix(s.now()), userID); err != nil {
return 0, false, err
}
}
return userID, en != 0, nil
}
// AllNotifyPrefs returns every preferences row, by user id.
func (s *Store) AllNotifyPrefs() ([]NotifyPrefs, error) {
rows, err := s.db.Query(`SELECT ` + prefsCols + ` FROM notification_prefs ORDER BY user_id`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []NotifyPrefs
for rows.Next() {
p, err := scanPrefs(rows)
if err != nil {
return nil, err
}
out = append(out, *p)
}
return out, rows.Err()
}
// AddWatches adds pubkeys (lowercase hex, checked by the caller) to the
// user's watch list in one transaction, in order, while the list holds
// fewer than max entries (max <= 0: no limit). It reports how many were
// added, how many were already watched and how many the limit left out.
func (s *Store) AddWatches(userID int64, pubkeys []string, max int) (added, already, overLimit int, err error) {
tx, err := s.db.Begin()
if err != nil {
return 0, 0, 0, err
}
defer tx.Rollback()
have := map[string]bool{}
rows, err := tx.Query(`SELECT pubkey FROM notification_watches WHERE user_id = ?`, userID)
if err != nil {
return 0, 0, 0, err
}
for rows.Next() {
var pk string
if err := rows.Scan(&pk); err != nil {
rows.Close()
return 0, 0, 0, err
}
have[pk] = true
}
rows.Close()
if err := rows.Err(); err != nil {
return 0, 0, 0, err
}
now := unix(s.now())
for _, pk := range pubkeys {
switch {
case have[pk]:
already++
case max > 0 && len(have) >= max:
overLimit++
default:
if _, err := tx.Exec(`INSERT INTO notification_watches (user_id, pubkey, created_at) VALUES (?, ?, ?)`, userID, pk, now); err != nil {
return 0, 0, 0, err
}
have[pk] = true
added++
}
}
if err := tx.Commit(); err != nil {
return 0, 0, 0, err
}
return added, already, overLimit, nil
}
// AddWatch watches one node. Watching an already watched node is a no-op;
// ErrWatchLimit when the list is full.
func (s *Store) AddWatch(userID int64, pubkey string, max int) error {
_, _, over, err := s.AddWatches(userID, []string{pubkey}, max)
if err != nil {
return err
}
if over > 0 {
return ErrWatchLimit
}
return nil
}
// RemoveWatch stops watching a node and deletes its node.* state rows.
// Removing a node that is not watched is a no-op.
func (s *Store) RemoveWatch(userID int64, pubkey string) error {
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(`DELETE FROM notification_watches WHERE user_id = ? AND pubkey = ?`, userID, pubkey); err != nil {
return err
}
if _, err := tx.Exec(`DELETE FROM notification_state WHERE user_id = ? AND subject = ? AND event IN (?, ?)`,
userID, pubkey, NotifyNodeOffline, NotifyNodeBattery); err != nil {
return err
}
return tx.Commit()
}
func (s *Store) queryWatches(q string, args ...any) ([]NotifyWatch, error) {
rows, err := s.db.Query(q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []NotifyWatch
for rows.Next() {
var w NotifyWatch
var created int64
if err := rows.Scan(&w.UserID, &w.Pubkey, &created); err != nil {
return nil, err
}
w.CreatedAt = fromUnix(created)
out = append(out, w)
}
return out, rows.Err()
}
// WatchesFor returns the user's watches, oldest first.
func (s *Store) WatchesFor(userID int64) ([]NotifyWatch, error) {
return s.queryWatches(`SELECT user_id, pubkey, created_at FROM notification_watches WHERE user_id = ? ORDER BY created_at, pubkey`, userID)
}
// AllWatches returns every watch, by user and pubkey.
func (s *Store) AllWatches() ([]NotifyWatch, error) {
return s.queryWatches(`SELECT user_id, pubkey, created_at FROM notification_watches ORDER BY user_id, pubkey`)
}
// AllNotifyStates returns every stored state.
func (s *Store) AllNotifyStates() ([]NotifyState, error) {
rows, err := s.db.Query(`SELECT user_id, event, subject, state, changed_at FROM notification_state`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []NotifyState
for rows.Next() {
var st NotifyState
var changed int64
if err := rows.Scan(&st.UserID, &st.Event, &st.Subject, &st.State, &changed); err != nil {
return nil, err
}
st.ChangedAt = fromUnix(changed)
out = append(out, st)
}
return out, rows.Err()
}
// WriteNotifyStates upserts the rows in one transaction. Rows of users
// deleted since the caller read them are skipped instead of failing the
// whole write on the foreign key.
func (s *Store) WriteNotifyStates(list []NotifyState) error {
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
stmt, err := tx.Prepare(`INSERT INTO notification_state (user_id, event, subject, state, changed_at)
SELECT ?, ?, ?, ?, ? WHERE EXISTS (SELECT 1 FROM users WHERE id = ?)
ON CONFLICT (user_id, event, subject) DO UPDATE SET state = excluded.state, changed_at = excluded.changed_at`)
if err != nil {
return err
}
defer stmt.Close()
for _, st := range list {
if _, err := stmt.Exec(st.UserID, st.Event, st.Subject, st.State, unix(st.ChangedAt), st.UserID); err != nil {
return err
}
}
return tx.Commit()
}
// DeleteNotifyStates deletes the rows of keys in one transaction; keys
// without a row are ignored.
func (s *Store) DeleteNotifyStates(keys []NotifyKey) error {
if len(keys) == 0 {
return nil
}
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
stmt, err := tx.Prepare(`DELETE FROM notification_state WHERE user_id = ? AND event = ? AND subject = ?`)
if err != nil {
return err
}
defer stmt.Close()
for _, k := range keys {
if _, err := stmt.Exec(k.UserID, k.Event, k.Subject); err != nil {
return err
}
}
return tx.Commit()
}
// NotifyMailCounts counts notification mails sent at or after since: in
// total (deleted accounts included) and per existing user.
func (s *Store) NotifyMailCounts(since time.Time) (total int, perUser map[int64]int, err error) {
perUser = map[int64]int{}
rows, err := s.db.Query(`SELECT user_id, COUNT(*) FROM mail_log WHERE purpose = ? AND sent_at >= ? GROUP BY user_id`,
NotifyMailPurpose, unix(since))
if err != nil {
return 0, nil, err
}
defer rows.Close()
for rows.Next() {
var uid sql.NullInt64
var n int
if err := rows.Scan(&uid, &n); err != nil {
return 0, nil, err
}
total += n
if uid.Valid {
perUser[uid.Int64] = n
}
}
return total, perUser, rows.Err()
}
// NotifyWatchStats counts watches and the users who have at least one.
func (s *Store) NotifyWatchStats() (watches, watchingUsers int, err error) {
err = s.db.QueryRow(`SELECT COUNT(*), COUNT(DISTINCT user_id) FROM notification_watches`).Scan(&watches, &watchingUsers)
return watches, watchingUsers, err
}
+348
View File
@@ -0,0 +1,348 @@
package users
import (
"database/sql"
"errors"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
)
var (
nPkA = strings.Repeat("a", 64)
nPkB = strings.Repeat("b", 64)
nPkC = strings.Repeat("c", 64)
)
func mustPrefs(t *testing.T, st *Store, uid int64) NotifyPrefs {
t.Helper()
p, err := st.NotifyPrefsFor(uid)
if err != nil {
t.Fatalf("NotifyPrefsFor(%d): %v", uid, err)
}
return p
}
func nState(uid int64, event, subject, state string, at time.Time) NotifyState {
return NotifyState{NotifyKey: NotifyKey{UserID: uid, Event: event, Subject: subject}, State: state, ChangedAt: at}
}
func statesByKey(t *testing.T, st *Store) map[NotifyKey]NotifyState {
t.Helper()
list, err := st.AllNotifyStates()
if err != nil {
t.Fatal(err)
}
out := map[NotifyKey]NotifyState{}
for _, s := range list {
out[s.NotifyKey] = s
}
return out
}
// A users.db written by a v4 binary gains the notification tables and keeps its rows.
func TestMigrateV4DatabaseToV5(t *testing.T) {
path := filepath.Join(t.TempDir(), "users.db")
db, err := sql.Open("sqlite", path)
if err != nil {
t.Fatal(err)
}
stmts := []string{`CREATE TABLE schema_version (version INTEGER NOT NULL)`, `INSERT INTO schema_version (version) VALUES (4)`}
for _, m := range migrations[:4] {
stmts = append(stmts, m...)
}
stmts = append(stmts, `INSERT INTO users (email, display_name, password_hash, created_at) VALUES ('old@example.org', 'Old', 'x', 1)`)
for _, s := range stmts {
if _, err := db.Exec(s); err != nil {
t.Fatalf("build v4 db: %v", err)
}
}
db.Close()
st, err := Open(path)
if err != nil {
t.Fatalf("Open v4 db: %v", err)
}
defer st.Close()
if v, err := st.SchemaVersion(); err != nil || v != len(migrations) {
t.Fatalf("SchemaVersion = %d, %v; want %d", v, err, len(migrations))
}
if len(migrations) < 5 {
t.Fatal("migration v5 missing")
}
u, err := st.GetByEmail("old@example.org")
if err != nil {
t.Fatalf("v4 user lost: %v", err)
}
if p := mustPrefs(t, st, u.ID); !p.Enabled {
t.Fatalf("prefs after migration = %+v", p)
}
}
func TestNotifyPrefsDefaultsAndToken(t *testing.T) {
st, clk := newTestStore(t)
a := mustCreate(t, st, "a@example.org", "A")
b := mustCreate(t, st, "b@example.org", "B")
p := mustPrefs(t, st, a.ID)
if !p.Enabled || !reflect.DeepEqual(p.Events, []string{NotifyNodeOffline, NotifyNodeBattery}) ||
len(p.UnsubToken) != 43 || !p.UpdatedAt.Equal(clk.Now()) {
t.Fatalf("defaults = %+v", p)
}
if again := mustPrefs(t, st, a.ID); again.UnsubToken != p.UnsubToken {
t.Fatal("a second read issued a new token")
}
if other := mustPrefs(t, st, b.ID); other.UnsubToken == p.UnsubToken {
t.Fatal("two users share an unsubscribe token")
}
if _, err := st.NotifyPrefsFor(9999); err == nil {
t.Fatal("prefs created for a user that does not exist")
}
all, err := st.AllNotifyPrefs()
if err != nil || len(all) != 2 || all[0].UserID != a.ID || all[1].UserID != b.ID {
t.Fatalf("AllNotifyPrefs = %+v, %v", all, err)
}
}
func TestNotifyEventHelpers(t *testing.T) {
for _, e := range []string{NotifyNodeOffline, NotifyNodeBattery, NotifyForeignNew, NotifyObserverOffline} {
if !ValidNotifyEvent(e) {
t.Errorf("%s not valid", e)
}
}
if ValidNotifyEvent("node.reboot") || ValidNotifyEvent("") {
t.Error("unknown event accepted")
}
if IsAdminNotifyEvent(NotifyNodeOffline) || !IsAdminNotifyEvent(NotifyForeignNew) || !IsAdminNotifyEvent(NotifyObserverOffline) {
t.Error("admin event classification wrong")
}
p := NotifyPrefs{Events: []string{NotifyNodeBattery}}
if !p.Has(NotifyNodeBattery) || p.Has(NotifyNodeOffline) {
t.Error("Has wrong")
}
}
func TestSetNotifyPrefsCanonicalAndDropsStates(t *testing.T) {
st, clk := newTestStore(t)
u := mustCreate(t, st, "a@example.org", "A")
tok := mustPrefs(t, st, u.ID).UnsubToken
if err := st.WriteNotifyStates([]NotifyState{
nState(u.ID, NotifyNodeOffline, nPkA, NotifyGood, clk.Now()),
nState(u.ID, NotifyNodeBattery, nPkA, NotifyBad, clk.Now()),
}); err != nil {
t.Fatal(err)
}
clk.Advance(time.Minute)
p, err := st.SetNotifyPrefs(u.ID, false, []string{NotifyObserverOffline, NotifyNodeOffline, "bogus", NotifyNodeOffline})
if err != nil {
t.Fatal(err)
}
if p.Enabled || !reflect.DeepEqual(p.Events, []string{NotifyNodeOffline, NotifyObserverOffline}) ||
p.UnsubToken != tok || !p.UpdatedAt.Equal(clk.Now()) {
t.Fatalf("after set = %+v", p)
}
got := statesByKey(t, st)
if _, ok := got[NotifyKey{u.ID, NotifyNodeOffline, nPkA}]; !ok || len(got) != 1 {
t.Fatalf("states after dropping node.battery = %+v", got)
}
if _, err := st.SetNotifyPrefs(u.ID, true, nil); err != nil {
t.Fatal(err)
}
if got := statesByKey(t, st); len(got) != 0 {
t.Fatalf("states with no events chosen = %+v", got)
}
}
func TestDisableNotifyByToken(t *testing.T) {
st, _ := newTestStore(t)
u := mustCreate(t, st, "a@example.org", "A")
tok := mustPrefs(t, st, u.ID).UnsubToken
uid, was, err := st.DisableNotifyByToken(tok)
if err != nil || uid != u.ID || !was {
t.Fatalf("first = %d, %v, %v", uid, was, err)
}
if p := mustPrefs(t, st, u.ID); p.Enabled {
t.Fatal("still enabled")
}
if uid, was, err = st.DisableNotifyByToken(tok); err != nil || uid != u.ID || was {
t.Fatalf("second = %d, %v, %v; want the user, wasEnabled false", uid, was, err)
}
for _, bad := range []string{"", "nope"} {
if _, _, err := st.DisableNotifyByToken(bad); !errors.Is(err, ErrTokenInvalid) {
t.Errorf("token %q: err = %v; want ErrTokenInvalid", bad, err)
}
}
}
func TestAddWatchesLimitAndDuplicates(t *testing.T) {
st, clk := newTestStore(t)
u := mustCreate(t, st, "a@example.org", "A")
added, already, over, err := st.AddWatches(u.ID, []string{nPkA, nPkB, nPkC, nPkA}, 2)
if err != nil || added != 2 || already != 1 || over != 1 {
t.Fatalf("AddWatches = %d added, %d already, %d over, %v; want 2, 1, 1", added, already, over, err)
}
if err := st.AddWatch(u.ID, nPkC, 2); !errors.Is(err, ErrWatchLimit) {
t.Fatalf("AddWatch over the limit = %v", err)
}
if err := st.AddWatch(u.ID, nPkA, 2); err != nil {
t.Fatalf("AddWatch of a watched node at the limit = %v", err)
}
if err := st.AddWatch(u.ID, nPkC, 0); err != nil {
t.Fatalf("AddWatch without a limit = %v", err)
}
ws, err := st.WatchesFor(u.ID)
if err != nil || len(ws) != 3 || ws[0].Pubkey != nPkA || ws[0].UserID != u.ID || !ws[0].CreatedAt.Equal(clk.Now()) {
t.Fatalf("WatchesFor = %+v, %v", ws, err)
}
watches, watching, err := st.NotifyWatchStats()
if err != nil || watches != 3 || watching != 1 {
t.Fatalf("NotifyWatchStats = %d, %d, %v", watches, watching, err)
}
}
func TestRemoveWatchDeletesItsNodeStates(t *testing.T) {
st, clk := newTestStore(t)
u := mustCreate(t, st, "a@example.org", "A")
if _, _, _, err := st.AddWatches(u.ID, []string{nPkA, nPkB}, 0); err != nil {
t.Fatal(err)
}
now := clk.Now()
if err := st.WriteNotifyStates([]NotifyState{
nState(u.ID, NotifyNodeOffline, nPkA, NotifyGood, now), nState(u.ID, NotifyNodeBattery, nPkA, NotifyBad, now),
nState(u.ID, NotifyNodeOffline, nPkB, NotifyGood, now), nState(u.ID, NotifyObserverOffline, nPkA, NotifyGood, now),
}); err != nil {
t.Fatal(err)
}
if err := st.RemoveWatch(u.ID, nPkA); err != nil {
t.Fatal(err)
}
if err := st.RemoveWatch(u.ID, nPkA); err != nil {
t.Fatalf("second remove = %v; want idempotent", err)
}
got := statesByKey(t, st)
_, keepB := got[NotifyKey{u.ID, NotifyNodeOffline, nPkB}]
_, keepObs := got[NotifyKey{u.ID, NotifyObserverOffline, nPkA}]
if len(got) != 2 || !keepB || !keepObs {
t.Fatalf("states after removing the watch = %+v", got)
}
all, err := st.AllWatches()
if err != nil || len(all) != 1 || all[0].Pubkey != nPkB {
t.Fatalf("AllWatches = %+v, %v", all, err)
}
}
func TestWriteNotifyStatesUpsertsAndSkipsDeletedUsers(t *testing.T) {
st, clk := newTestStore(t)
u := mustCreate(t, st, "a@example.org", "A")
gone := mustCreate(t, st, "b@example.org", "B")
if err := st.Delete(gone.ID); err != nil {
t.Fatal(err)
}
t0 := clk.Now()
if err := st.WriteNotifyStates([]NotifyState{
nState(u.ID, NotifyNodeOffline, nPkA, NotifyGood, t0),
nState(gone.ID, NotifyNodeOffline, nPkA, NotifyGood, t0),
}); err != nil {
t.Fatalf("write with a deleted user = %v; want the row skipped", err)
}
t1 := t0.Add(time.Hour)
if err := st.WriteNotifyStates([]NotifyState{nState(u.ID, NotifyNodeOffline, nPkA, NotifyBad, t1)}); err != nil {
t.Fatal(err)
}
got := statesByKey(t, st)
s, ok := got[NotifyKey{u.ID, NotifyNodeOffline, nPkA}]
if len(got) != 1 || !ok || s.State != NotifyBad || !s.ChangedAt.Equal(t1) {
t.Fatalf("states = %+v", got)
}
}
func TestDeleteNotifyStatesRemovesOnlyTheKeys(t *testing.T) {
st, clk := newTestStore(t)
u := mustCreate(t, st, "a@example.org", "A")
now := clk.Now()
if err := st.WriteNotifyStates([]NotifyState{
nState(u.ID, NotifyNodeOffline, nPkA, NotifyGood, now),
nState(u.ID, NotifyForeignNew, "*", NotifyGood, now),
nState(u.ID, NotifyForeignNew, nPkB, NotifyTold, now),
}); err != nil {
t.Fatal(err)
}
if err := st.DeleteNotifyStates(nil); err != nil {
t.Fatalf("empty delete: %v", err)
}
if err := st.DeleteNotifyStates([]NotifyKey{
{u.ID, NotifyForeignNew, "*"},
{u.ID, NotifyForeignNew, nPkB},
{u.ID, NotifyObserverOffline, "OBS1"}, // no such row
}); err != nil {
t.Fatal(err)
}
got := statesByKey(t, st)
if _, ok := got[NotifyKey{u.ID, NotifyNodeOffline, nPkA}]; len(got) != 1 || !ok {
t.Fatalf("states after delete = %+v", got)
}
}
func TestNotifyRowsCascadeOnUserDelete(t *testing.T) {
st, clk := newTestStore(t)
u := mustCreate(t, st, "a@example.org", "A")
mustPrefs(t, st, u.ID)
if _, _, _, err := st.AddWatches(u.ID, []string{nPkA}, 0); err != nil {
t.Fatal(err)
}
if err := st.WriteNotifyStates([]NotifyState{nState(u.ID, NotifyNodeOffline, nPkA, NotifyGood, clk.Now())}); err != nil {
t.Fatal(err)
}
if err := st.Delete(u.ID); err != nil {
t.Fatal(err)
}
for _, table := range []string{"notification_prefs", "notification_watches", "notification_state"} {
var n int
if err := st.db.QueryRow(`SELECT COUNT(*) FROM ` + table).Scan(&n); err != nil || n != 0 {
t.Errorf("%s rows after delete = %d, %v", table, n, err)
}
}
}
func TestNotifyMailCounts(t *testing.T) {
st, clk := newTestStore(t)
a := mustCreate(t, st, "a@example.org", "A")
b := mustCreate(t, st, "b@example.org", "B")
log := func(uid *int64, purpose string) {
t.Helper()
if _, err := st.LogMail(uid, "x@example.org", purpose, ""); err != nil {
t.Fatal(err)
}
}
log(&a.ID, NotifyMailPurpose) // 25 hours before the count: outside the window
clk.Advance(2 * time.Hour)
log(&a.ID, NotifyMailPurpose)
log(&a.ID, NotifyMailPurpose)
log(&b.ID, NotifyMailPurpose)
log(nil, NotifyMailPurpose) // account deleted since; still counts for the instance
log(&a.ID, "activate")
clk.Advance(23 * time.Hour)
total, per, err := st.NotifyMailCounts(clk.Now().Add(-24 * time.Hour))
if err != nil || total != 4 || per[a.ID] != 2 || per[b.ID] != 1 || len(per) != 2 {
t.Fatalf("NotifyMailCounts = %d, %v, %v; want 4, {a: 2, b: 1}", total, per, err)
}
}
func TestDefaultNotifyPrefs(t *testing.T) {
p := DefaultNotifyPrefs(7)
if p.UserID != 7 || !p.Enabled || !reflect.DeepEqual(p.Events, NodeNotifyEvents) || p.UnsubToken != "" {
t.Fatalf("DefaultNotifyPrefs = %+v", p)
}
p.Events[0] = "changed"
if NodeNotifyEvents[0] != NotifyNodeOffline {
t.Fatal("DefaultNotifyPrefs shares NodeNotifyEvents")
}
st, _ := newTestStore(t)
u := mustCreate(t, st, "d@example.org", "D")
got := mustPrefs(t, st, u.ID)
want := DefaultNotifyPrefs(u.ID)
if got.Enabled != want.Enabled || !reflect.DeepEqual(got.Events, want.Events) {
t.Fatalf("NotifyPrefsFor created %+v; want the defaults %+v", got, want)
}
}
+2 -2
View File
@@ -360,8 +360,8 @@ func TestMigrateV3DatabaseToV4(t *testing.T) {
t.Fatalf("Open v3 db: %v", err)
}
defer st.Close()
if v, err := st.SchemaVersion(); err != nil || v != 4 {
t.Fatalf("SchemaVersion = %d, %v; want 4", v, err)
if v, err := st.SchemaVersion(); err != nil || v != len(migrations) {
t.Fatalf("SchemaVersion = %d, %v; want %d", v, err, len(migrations))
}
if !hasIndex(t, st, "proposals_kind_subject") || !hasIndex(t, st, "proposals_status") {
t.Fatal("proposals indexes missing after migration")
+23
View File
@@ -101,6 +101,29 @@ var migrations = [][]string{
`CREATE UNIQUE INDEX proposals_kind_subject ON proposals(kind, subject)`,
`CREATE INDEX proposals_status ON proposals(status)`,
},
{ // v5: sub-project E, node notifications
`CREATE TABLE notification_prefs (
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
enabled INTEGER NOT NULL DEFAULT 1,
events TEXT NOT NULL DEFAULT '',
unsub_token TEXT NOT NULL UNIQUE,
updated_at INTEGER NOT NULL
)`,
`CREATE TABLE notification_watches (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
pubkey TEXT NOT NULL,
created_at INTEGER NOT NULL,
PRIMARY KEY (user_id, pubkey)
)`,
`CREATE TABLE notification_state (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
event TEXT NOT NULL,
subject TEXT NOT NULL,
state TEXT NOT NULL,
changed_at INTEGER NOT NULL,
PRIMARY KEY (user_id, event, subject)
)`,
},
}
func (s *Store) migrate() error {
+1 -1
View File
@@ -229,7 +229,7 @@ func (s *Store) UsersByID(ids []int64) (map[int64]User, error) {
for i, id := range ids {
args[i] = id
}
ph := strings.TrimSuffix(strings.Repeat("?,", len(ids)), ",")
ph := placeholders(len(ids))
rows, err := s.db.Query(`SELECT `+userCols+` FROM users WHERE id IN (`+ph+`)`, args...)
if err != nil {
return nil, err
+20
View File
@@ -86,3 +86,23 @@ a.account-btn { display: inline-flex; align-items: center; text-decoration: none
.cs-dialog { display: flex; flex-direction: column; gap: 8px; color: var(--text); }
.cs-dialog h3 { margin: 0; }
.cs-dialog-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
/* Node notifications toggle (notifications.js) on the node side pane and full page. */
.node-notify-slot { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-left: 4px; }
.node-notify-slot:empty { display: none; }
.node-notify-btn { white-space: nowrap; }
/* Sized like the neighbouring buttons, which carry inline sizes in nodes.js
(side pane: Details/Analytics/Reach; full page: Copy URL/Reach). */
#nodeNotifySlot .node-notify-btn { font-size: 11px; padding: 2px 8px; }
#nodeNotifySlotFull { margin-left: 0; } /* the full page row spaces its buttons with gap */
#nodeNotifySlotFull .node-notify-btn { font-size: 12px; padding: 4px 10px; }
.node-notify-btn[aria-pressed="true"] { box-shadow: inset 0 0 0 2px var(--status-green-text); }
.node-notify-hint { color: var(--text-muted); font-size: var(--fs-sm); }
.node-notify-hint:empty { display: none; }
/* Notifications section (notifications.js) on the account page. */
.account-check { display: flex; align-items: center; gap: 8px; font-size: var(--fs-sm); }
.account-fieldset { display: flex; flex-direction: column; gap: 6px; margin: 0; padding: 8px 12px; border: 1px solid var(--border); border-radius: 6px; }
#notifyForm + h4 { margin-top: var(--space-lg); }
.account-watches { list-style: none; padding: 0; margin: 0 0 8px; }
.account-watches li { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px solid var(--border); font-size: var(--fs-sm); }
+30 -1
View File
@@ -1,6 +1,7 @@
/* Account pages for optional user management.
* #/account/login | register | activate?token= | forgot | reset?token= | confirm-email?token= | check-mail
* #/account : profile, password, address, sessions, my proposals, delete
* #/account : profile, password, address, sessions, my proposals, notifications, delete
* #/account/unsubscribe?token= : confirm turning notification mails off (link from a notification mail)
* Every dynamic string goes through escapeHtml; messages use textContent. */
(function () {
'use strict';
@@ -111,6 +112,7 @@
submitBtn('Change address') + msgBox('emailMsg') + '</form>' +
'<h3>Devices</h3><ul class="account-sessions" id="sessList"></ul>' + msgBox('sessMsg') +
(window.CSProposals && window.CSProposals.enabled() ? '<h3>My proposals</h3><div id="propList"></div>' + msgBox('propMsg') : '') +
(window.CSNotify && window.CSNotify.enabled() ? '<h3 id="notifications">Notifications</h3><div id="notifySection"></div>' + msgBox('notifyMsg') : '') +
(window.CSSettingsSync ? '<h3>Settings sync</h3><div id="syncSection"></div>' : '') +
'<h3>Delete account</h3><form id="delForm" class="account-form" novalidate>' +
'<p class="account-hint">This removes your account permanently.</p>' +
@@ -254,6 +256,27 @@
if (p) p.then(function (r) { if (r && r.ok && CSAuth.user()) CSAuth.refreshMe(); });
},
unsubscribe: function (app) {
var token = takeToken('unsubscribe');
app.innerHTML = shell('Stop notification mails',
'<p class="account-hint">This turns off all node notification mails for the account the link was sent to. You can turn them back on from your account page.</p>' +
'<button type="button" id="unsubBtn" class="account-btn account-btn-primary">Turn notifications off</button>' + msgBox() +
'<p class="account-links"><a href="#/account?section=notifications">Notification settings</a></p>');
var btn = document.getElementById('unsubBtn');
if (!token) {
btn.disabled = true;
CSAuth.say('accountMsg', 'This link is incomplete. Open the link from the mail again.', false);
return;
}
btn.addEventListener('click', function () {
btn.disabled = true;
return CSAuth.request('POST', '/api/notifications/unsubscribe?token=' + encodeURIComponent(token)).then(function (r) {
CSAuth.say('accountMsg', r.ok ? r.data.message : CSAuth.errText(r), r.ok);
if (!r.ok) btn.disabled = false;
}, function () { btn.disabled = false; CSAuth.say('accountMsg', 'Network error, try again.', false); });
});
},
profile: function (app) {
var u = CSAuth.user();
if (!u) { location.hash = '#/account/login'; return; }
@@ -263,6 +286,12 @@
document.getElementById('profName').value = u.displayName;
if (window.CSSettingsSync) window.CSSettingsSync.mountSection(document.getElementById('syncSection'));
if (window.CSProposals && window.CSProposals.enabled()) window.CSProposals.loadMine(document.getElementById('propList'), 'propMsg');
if (window.CSNotify && window.CSNotify.enabled()) {
window.CSNotify.mountSection(document.getElementById('notifySection'), 'notifyMsg').then(function () {
var h = query().get('section') === 'notifications' && document.getElementById('notifications');
if (h && h.scrollIntoView) h.scrollIntoView();
});
}
document.getElementById('accountPageLogout').addEventListener('click', function () {
return CSAuth.logout('#/account/login').then(function (r) {
+2 -1
View File
@@ -21,7 +21,8 @@
['user.password.*', 'Password changes and resets'],
['user.email.*', 'Address changes'],
['user.mail.refresh', 'Mail status refreshed'],
['proposal.*', 'Channel proposals']
['proposal.*', 'Channel proposals'],
['notify.*', 'Notification settings']
];
var DAY_MS = 24 * 60 * 60 * 1000;
var PERIODS = [['', 'Any time', 0], ['24h', 'Last 24 hours', DAY_MS], ['7d', 'Last 7 days', 7 * DAY_MS], ['30d', 'Last 30 days', 30 * DAY_MS]];
+9 -1
View File
@@ -79,6 +79,14 @@
return html + '</div>';
}
// Notification figures; the server sends them only with notifications on.
function notifyStatsHtml(n) {
if (!n) return '';
return '<h4>Notifications</h4><dl class="admin-stats">' +
stat('notifyMails', 'Mails, 24 hours', n.mailsLast24h + ' of ' + n.maxMailsPerDay) +
stat('notifyWatches', 'Watched nodes', n.watches) + stat('notifyUsers', 'Users watching', n.watchingUsers) + '</dl>';
}
function usersCardHtml(r) {
var head = '<section class="admin-card" id="aoUsers" aria-labelledby="aoUsersH"><h3 id="aoUsersH">Users</h3>';
if (!r || r.error) return head + failedHtml('user figures') + '</section>';
@@ -94,7 +102,7 @@
stat('mailDelivered', 'Delivered', m.delivered) + stat('mailBounced', 'Bounced', m.bounced) +
stat('mailBlocked', 'Blocked', m.blocked) + stat('mailSpam', 'Spam', m.spam) +
stat('mailPending', 'Pending', m.pending) + stat('mailOther', 'Other', m.other) +
'</dl></section>';
'</dl>' + notifyStatsHtml(s.notify) + '</section>';
}
function systemCardHtml(res, nowMs) {
+1
View File
@@ -212,6 +212,7 @@
<script src="channel-colors.js?v=__BUST__"></script>
<script src="channel-color-picker.js?v=__BUST__"></script>
<script src="channel-proposals.js?v=__BUST__"></script>
<script src="notifications.js?v=__BUST__"></script>
<script src="packets.js?v=__BUST__"></script>
<script src="geo-filter-overlay.js?v=__BUST__"></script>
<script src="map.js?v=__BUST__" onerror="console.error('Failed to load:', this.src)"></script>
+4
View File
@@ -680,6 +680,7 @@
<a href="#/nodes/${encodeURIComponent(n.public_key)}/analytics" class="btn-primary" style="flex:0 0 auto;text-decoration:none;font-size:12px;padding:4px 10px"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-chart-bar"/></svg> Analytics</a>
<a href="#/nodes/${encodeURIComponent(n.public_key)}/reach" class="btn-primary" style="flex:0 0 auto;text-decoration:none;font-size:12px;padding:4px 10px"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-broadcast"/></svg> Reach</a>
<a href="#/observers/${encodeURIComponent(n.public_key.toUpperCase())}" class="btn-primary" title="View this pubkey as an observer" style="flex:0 0 auto;text-decoration:none;font-size:12px;padding:4px 10px"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-eye"/></svg> Observer →</a>
<span class="node-notify-slot" id="nodeNotifySlotFull"></span>
</div>
</div>
@@ -822,6 +823,7 @@
</div>
<div class="node-full-card skew-detail-section" id="node-clock-skew" style="display:none"></div>`;
if (window.CSNotify) window.CSNotify.mount(document.getElementById('nodeNotifySlotFull'), n.public_key);
// Map
if (hasLoc) {
@@ -1712,6 +1714,7 @@
<button class="btn-primary node-detail-btn" data-pubkey="${encodeURIComponent(n.public_key)}" aria-label="View details for ${escapeHtml(n.name || n.public_key)}" style="font-size:11px;padding:2px 8px;margin-left:8px;cursor:pointer"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-magnifying-glass"/></svg> Details</button>
<a href="#/nodes/${encodeURIComponent(n.public_key)}/analytics" class="btn-primary" style="display:inline-block;margin-left:4px;text-decoration:none;font-size:11px;padding:2px 8px"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-chart-bar"/></svg> Analytics</a>
<a href="#/nodes/${encodeURIComponent(n.public_key)}/reach" class="btn-primary" style="display:inline-block;margin-left:4px;text-decoration:none;font-size:11px;padding:2px 8px"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-broadcast"/></svg> Reach</a>
<span class="node-notify-slot" id="nodeNotifySlot"></span>
</div>
${renderStatusExplanation(n)}
@@ -1794,6 +1797,7 @@
<div class="node-detail-section skew-detail-section" id="node-clock-skew" style="display:none"></div>
</div>`;
if (window.CSNotify) window.CSNotify.mount(document.getElementById('nodeNotifySlot'), n.public_key);
// Init map
if (hasLoc) {
+201
View File
@@ -0,0 +1,201 @@
/* Node notifications client (docs/specs/2026-10-07-node-notifications-design.md).
* window.CSNotify: the "Notify me" toggle on the node side pane and full
* page, and the account page's Notifications section. Inert unless
* /api/config/client advertises userManagement.notifications (roles.js sets
* window.MC_USER_MGMT). One GET /api/account/notifications per user, cached;
* every change answers the full state, which replaces the cache. Every
* dynamic value in HTML goes through escapeHtml; messages use textContent. */
(function () {
'use strict';
var cache = { userId: null, promise: null };
function enabled() { return !!(window.MC_USER_MGMT && window.MC_USER_MGMT.notifications); }
function currentUser() { return window.CSAuth ? window.CSAuth.user() : null; }
// load resolves to the caller's state (the GET body) or null.
function load() {
var u = currentUser();
if (!enabled() || !u) return Promise.resolve(null);
if (!cache.promise || cache.userId !== u.id) {
cache.userId = u.id;
cache.promise = window.CSAuth.request('GET', '/api/account/notifications').then(function (r) {
if (!r.ok) { cache.promise = null; return null; }
return r.data;
}, function () { cache.promise = null; return null; });
}
return cache.promise;
}
// store makes data (the answer of a change) the cached state.
function store(data) {
var u = currentUser();
cache.userId = u ? u.id : null;
cache.promise = Promise.resolve(data);
return data;
}
function isWatched(data, pubkey) {
var pk = String(pubkey || '').toLowerCase();
return !!(data && data.watches && data.watches.some(function (w) { return w.pubkey === pk; }));
}
// toggleState: 'hidden' (no state), 'on', 'off' or 'full' (limit reached).
function toggleState(data, pubkey) {
if (!data) return 'hidden';
if (isWatched(data, pubkey)) return 'on';
return (data.watches || []).length >= Number(data.limits && data.limits.maxWatches) ? 'full' : 'off';
}
function toggleHtml(state, data) {
if (state === 'hidden') return '';
var on = state === 'on';
var title = on ? 'You get a mail when this node goes offline, comes back or reports a low battery. Click to stop.'
: 'Get a mail when this node goes offline, comes back or reports a low battery.';
var html = '<button type="button" class="btn-primary node-notify-btn" data-notify-toggle aria-pressed="' + (on ? 'true' : 'false') + '"' +
(state === 'full' ? ' disabled' : '') + ' title="' + escapeHtml(title) + '"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor-sprite.svg#ph-envelope-simple"/></svg> ' +
escapeHtml(on ? 'Notifying' : 'Notify me') + '</button>';
if (state === 'full') {
html += ' <small class="node-notify-hint">' +
escapeHtml('You watch the maximum of ' + data.limits.maxWatches + ' nodes; remove one on your account page.') + '</small>';
}
return html + ' <small class="node-notify-hint" data-notify-msg role="status" aria-live="polite"></small>';
}
function say(slot, text) {
var m = slot.querySelector('[data-notify-msg]');
if (m) m.textContent = text;
}
function render(slot, pubkey, data) {
var state = toggleState(data, pubkey);
slot.innerHTML = toggleHtml(state, data);
var btn = slot.querySelector('[data-notify-toggle]');
if (!btn) return;
btn.addEventListener('click', function () {
var on = btn.getAttribute('aria-pressed') === 'true';
btn.disabled = true;
var path = '/api/account/notifications/watches/' + encodeURIComponent(String(pubkey).toLowerCase());
return window.CSAuth.request(on ? 'DELETE' : 'PUT', path).then(function (r) {
if (!r.ok) { btn.disabled = false; say(slot, window.CSAuth.errText(r)); return; }
render(slot, pubkey, store(r.data));
}, function () { btn.disabled = false; say(slot, 'Network error, try again.'); });
});
}
// mount fills slot with the toggle for pubkey; empty when the feature is
// off or nobody is logged in.
function mount(slot, pubkey) {
if (!slot) return Promise.resolve();
slot.innerHTML = '';
var ready = window.CSAuth && window.CSAuth.ready ? window.CSAuth.ready() : Promise.resolve();
return Promise.resolve(ready).then(function () {
if (!enabled() || !currentUser()) return;
return load().then(function (data) { render(slot, pubkey, data); });
});
}
var EVENT_LABELS = {
'node.offline': 'A watched node goes offline or comes back',
'node.battery': 'A watched node reports a low battery or recovers',
'foreign.new': 'A new foreign node appears (admin)',
'observer.offline': 'An observer goes offline or comes back (admin)'
};
function eventsHtml(data) {
return (data.availableEvents || []).map(function (e) {
var id = 'notifyEv-' + e.replace(/\./g, '-');
return '<label class="account-check" for="' + escapeHtml(id) + '"><input type="checkbox" id="' + escapeHtml(id) +
'" data-notify-event="' + escapeHtml(e) + '"' + ((data.events || []).indexOf(e) !== -1 ? ' checked' : '') + '> ' +
escapeHtml(EVENT_LABELS[e] || e) + '</label>';
}).join('');
}
function watchesHtml(data) {
if (!data.watches.length) return '<p class="account-hint">You watch no nodes yet. Use Notify me on a node page, or Watch my nodes below.</p>';
return '<ul class="account-watches">' + data.watches.map(function (w) {
return '<li data-pubkey="' + escapeHtml(w.pubkey) + '"><span><a href="#/nodes/' + encodeURIComponent(w.pubkey) + '">' +
escapeHtml(w.name || w.pubkey.slice(0, 12)) + '</a>' + (w.known ? '' : ' <small>(no longer in the database)</small>') + '</span>' +
'<button type="button" class="account-btn account-btn-secondary" data-unwatch="' + escapeHtml(w.pubkey) + '">Remove</button></li>';
}).join('') + '</ul>';
}
function sectionHtml(data) {
var lim = data.limits || {};
return '<form id="notifyForm" class="account-form" novalidate>' +
'<label class="account-check" for="notifyEnabled"><input type="checkbox" id="notifyEnabled"' + (data.enabled ? ' checked' : '') + '> Send me notification mails</label>' +
'<fieldset class="account-fieldset"><legend>Events</legend>' + eventsHtml(data) + '</fieldset>' +
'<p class="account-hint">' + escapeHtml('At most ' + lim.perUserPerDay + ' mails per 24 hours; sent in the last 24 hours: ' + lim.mailsLast24h + '.') + '</p>' +
'<button type="submit" class="account-btn account-btn-primary">Save</button></form>' +
'<h4>' + escapeHtml('Watched nodes (' + data.watches.length + ' of ' + lim.maxWatches + ')') + '</h4>' +
'<div id="notifyWatches">' + watchesHtml(data) + '</div>' +
'<button type="button" class="account-btn account-btn-secondary" id="notifyMyNodes">Watch my nodes</button>';
}
function sayIn(msgId, text, ok) { window.CSAuth.say(msgId, text, ok); }
function failed(msgId) { return function () { sayIn(msgId, 'Network error, try again.', false); }; }
// draw renders the section from data and binds its controls. Controls are
// found by id: they are unique on the account page. busy guards against a
// second request while one is in flight.
function draw(el, msgId, data) {
var busy = false;
function guarded(fn) {
if (busy) return Promise.resolve();
busy = true;
return fn().then(function () { busy = false; }, function () { busy = false; });
}
el.innerHTML = sectionHtml(data);
document.getElementById('notifyForm').addEventListener('submit', function (e) {
e.preventDefault();
var events = (data.availableEvents || []).filter(function (ev) {
var box = document.getElementById('notifyEv-' + ev.replace(/\./g, '-'));
return !!(box && box.checked);
});
var en = document.getElementById('notifyEnabled');
return guarded(function () {
return window.CSAuth.request('PUT', '/api/account/notifications', { enabled: !!(en && en.checked), events: events }).then(function (r) {
if (!r.ok) { sayIn(msgId, window.CSAuth.errText(r), false); return; }
draw(el, msgId, store(r.data));
sayIn(msgId, 'Saved.', true);
}, failed(msgId));
});
});
document.getElementById('notifyWatches').addEventListener('click', function (e) {
var pk = e.target && e.target.getAttribute && e.target.getAttribute('data-unwatch');
if (!pk) return;
return guarded(function () {
return window.CSAuth.request('DELETE', '/api/account/notifications/watches/' + encodeURIComponent(pk)).then(function (r) {
if (!r.ok) { sayIn(msgId, window.CSAuth.errText(r), false); return; }
draw(el, msgId, store(r.data));
sayIn(msgId, 'Removed.', true);
}, failed(msgId));
});
});
document.getElementById('notifyMyNodes').addEventListener('click', function () {
return guarded(function () {
return window.CSAuth.request('POST', '/api/account/notifications/watch-my-nodes').then(function (r) {
if (!r.ok) { sayIn(msgId, window.CSAuth.errText(r), false); return; }
var d = r.data;
draw(el, msgId, store(d.account));
sayIn(msgId, (d.added + d.already + d.skipped) === 0
? 'Your synced My nodes list is empty. Add nodes to My nodes on the Nodes page and turn on settings sync.'
: 'Added ' + d.added + ', already watched ' + d.already + ', skipped ' + d.skipped + '.', true);
}, failed(msgId));
});
});
}
// mountSection loads the caller's state (always fresh) into el.
function mountSection(el, msgId) {
if (!el || !enabled() || !currentUser()) return Promise.resolve();
return window.CSAuth.request('GET', '/api/account/notifications').then(function (r) {
if (!r.ok) { sayIn(msgId, window.CSAuth.errText(r), false); return; }
draw(el, msgId, store(r.data));
}, failed(msgId));
}
window.addEventListener('cs-auth-changed', function () { cache.userId = null; cache.promise = null; });
window.CSNotify = { enabled: enabled, load: load, store: store, toggleState: toggleState, toggleHtml: toggleHtml, mount: mount,
sectionHtml: sectionHtml, mountSection: mountSection };
})();
+1 -1
View File
@@ -656,7 +656,7 @@
? { disabledTabs: Array.isArray(cfg.customizer.disabledTabs) ? cfg.customizer.disabledTabs : [] }
: { disabledTabs: [] };
// Optional user management: present only when the server enables it.
window.MC_USER_MGMT = (cfg.userManagement && cfg.userManagement.enabled) ? { enabled: true, channelProposals: !!cfg.userManagement.channelProposals } : null;
window.MC_USER_MGMT = (cfg.userManagement && cfg.userManagement.enabled) ? { enabled: true, channelProposals: !!cfg.userManagement.channelProposals, notifications: !!cfg.userManagement.notifications } : null;
// #1574 — operator-configurable cap on /live map node count.
if (cfg.liveMapMaxNodes != null) window.LIVE_MAP_MAX_NODES = cfg.liveMapMaxNodes;
// #1784 — path trust threshold: minimum hash bytes for mapping evidence.
+1
View File
@@ -162,6 +162,7 @@ node tests/unit/test-node-reach-coverage.js
node tests/unit/test-nodes-export-wiring.js
node tests/unit/test-nav-drawer-version-footer.js
node tests/unit/test-nodes-export.js
node tests/unit/test-notifications-ui.js
node tests/unit/test-observer-iata-1188.js
node tests/unit/test-observer-menu-interactions.js
node tests/unit/test-observer-naive-clock-1478.js
+75 -1
View File
@@ -9,7 +9,7 @@
* (cd cmd/server && go build -o ../../corescope-server . && go build -tags e2etest -o ../../corescope-server-e2e .)
* # config.json for the on-server (in $CFGDIR): port 13582, userManagement {enabled: true,
* # dbPath "users.db", adminEmails ["admin@e2e.test"], publicBaseUrl "http://localhost:13582",
* # mail {provider "fake", fromEmail "noreply@e2e.test"}, channelProposals {enabled: true}}
* # mail {provider "fake", fromEmail "noreply@e2e.test"}, channelProposals {enabled: true}, notifications {enabled: true}}
* corescope-server -port 13581 -db "$TMP/off.db" -public public &
* (cd "$CFGDIR" && corescope-server-e2e -config-dir . -port 13582 -db "$TMP/on.db" -public <repo>/public) &
* BASE_URL=http://localhost:13582 BASE_URL_OFF=http://localhost:13581 node tests/e2e/test-user-management-e2e.js
@@ -35,6 +35,16 @@ async function authReady(page) {
await page.evaluate(() => window.CSAuth.ready());
}
// Resolves once the full node page has rendered its paths section, which
// needs a network round trip started after CSNotify.mount ran: a positive
// signal that the page settled before asserting that something is absent.
async function nodePageRendered(page) {
await page.waitForFunction(() => {
const el = document.getElementById('fullPathsContent');
return !!el && !el.querySelector('.spinner');
});
}
async function lastMailToken(page) {
const r = await page.request.get(BASE + '/__e2e/last-mail');
assert(r.ok(), 'last-mail HTTP ' + r.status());
@@ -63,6 +73,10 @@ async function registerAndActivate(page, email, name) {
// axeClean fails on serious or critical WCAG 2 A/AA violations inside sel.
async function axeClean(pg, sel) {
// A finite animation in flight (the 150 ms .page-enter fade) blends colours
// and fails color-contrast at random; wait until those have finished.
await pg.waitForFunction(() => document.getAnimations().every((a) =>
a.playState !== 'running' || !a.effect || a.effect.getComputedTiming().endTime === Infinity));
const res = await new AxeBuilder({ page: pg }).include(sel).withTags(['wcag2a', 'wcag2aa']).analyze();
const bad = res.violations.filter((v) => v.impact === 'serious' || v.impact === 'critical');
assert(bad.length === 0, sel + ': ' + bad.map((v) => v.id + ' ' + v.nodes.map((n) => n.target.join(' ') + ' ' + ((n.any[0] || {}).message || '')).join(' | ')).join(', '));
@@ -352,6 +366,66 @@ async function until(fn, label) {
assert(!('approvedChannels' in body), 'approvedChannels present while off');
});
let watchKey = null;
await step('notifications: no Notify me toggle on a node page while logged out', async () => {
const body = await (await proposer.request.get(BASE + '/api/nodes?limit=1')).json();
watchKey = body.nodes[0].public_key;
const anon = await (await browser.newContext()).newPage();
anon.setDefaultTimeout(8000);
await anon.goto(BASE + '/#/nodes/' + encodeURIComponent(watchKey));
await anon.waitForSelector('#nodeNotifySlotFull', { state: 'attached' });
await authReady(anon);
await nodePageRendered(anon);
assert((await anon.locator('[data-notify-toggle]').count()) === 0, 'toggle shown while logged out');
await anon.context().close();
});
await step('notifications: a user turns on Notify me on a node page', async () => {
await proposer.goto(BASE + '/#/nodes/' + encodeURIComponent(watchKey));
await proposer.waitForSelector('#nodeNotifySlotFull [data-notify-toggle][aria-pressed="false"]');
await proposer.click('#nodeNotifySlotFull [data-notify-toggle]');
await proposer.waitForSelector('#nodeNotifySlotFull [data-notify-toggle][aria-pressed="true"]');
});
await step('notifications: the #/account?section=notifications deep link shows the watched node', async () => {
await proposer.goto(BASE + '/#/account?section=notifications');
await proposer.waitForSelector('#notifySection li[data-pubkey="' + watchKey + '"]');
assert(await proposer.isChecked('#notifyEnabled'), 'notifications are not on by default');
await until(() => proposer.evaluate(() => {
const r = document.getElementById('notifications').getBoundingClientRect();
return r.top >= -1 && r.top < window.innerHeight;
}), 'deep link scrolls to the Notifications heading');
await axeClean(proposer, '#notifySection');
});
await step('notifications: the unsubscribe link turns notifications off', async () => {
const r = await proposer.request.get(BASE + '/__e2e/unsubscribe-link?email=' + encodeURIComponent('proposer@e2e.test'));
assert(r.ok(), 'unsubscribe-link HTTP ' + r.status());
const { link } = await r.json();
const anon = await (await browser.newContext()).newPage();
anon.setDefaultTimeout(8000);
await anon.goto(link);
await anon.waitForSelector('#unsubBtn:not([disabled])');
await axeClean(anon, '#app');
await anon.click('#unsubBtn');
await until(async () => (await anon.textContent('#accountMsg')).indexOf('Notifications are off') !== -1, 'unsubscribe result');
await anon.context().close();
await proposer.goto(BASE + '/#/account?section=notifications');
await proposer.reload();
await proposer.waitForSelector('#notifyEnabled');
assert(!(await proposer.isChecked('#notifyEnabled')), 'still on after the unsubscribe link');
});
await step('feature off: no userManagement block, so no notifications flag, and no toggle on the node page', async () => {
const body = await (await off.request.get(BASE_OFF + '/api/config/client')).json();
assert(!body.userManagement, 'userManagement present while off');
await off.goto(BASE_OFF + '/#/nodes/' + encodeURIComponent(watchKey));
await off.waitForSelector('#nodeNotifySlotFull', { state: 'attached' });
await authReady(off);
await nodePageRendered(off);
assert((await off.locator('[data-notify-toggle]').count()) === 0, 'toggle shown while the feature is off');
});
await browser.close();
console.log('\n' + passed + '/' + (passed + failed) + ' tests passed');
process.exit(failed > 0 ? 1 : 0);
+13
View File
@@ -670,6 +670,19 @@ test('an unknown id does nothing; unmount drops a late answer', async () => {
assert.strictEqual(env.calls.length, 1);
});
test('Users card shows notification figures only when the server sends them', () => {
const t = ovT();
assert.strictEqual(t.usersCardHtml({ data: STATS }).indexOf('Notifications'), -1);
const h = t.usersCardHtml({ data: Object.assign({}, STATS, { notify: { mailsLast24h: 7, maxMailsPerDay: 300, watches: 12, watchingUsers: 3 } }) });
assert(h.indexOf('<h4>Notifications</h4>') !== -1, h);
assert(h.indexOf('data-stat="notifyMails">7 of 300<') !== -1 && h.indexOf('data-stat="notifyWatches">12<') !== -1 &&
h.indexOf('data-stat="notifyUsers">3<') !== -1, h);
});
test('the audit action filter offers notification settings', () => {
assert.strictEqual(auditT().readHash('#/admin?tab=audit&action=notify.*').action, 'notify.*');
});
Promise.all(pending).then(() => {
console.log('\n' + passed + ' passed, ' + failed + ' failed');
process.exit(failed ? 1 : 0);
+255
View File
@@ -0,0 +1,255 @@
/* Unit tests for public/notifications.js
* (docs/specs/2026-10-07-node-notifications-design.md), loaded from disk into
* a vm sandbox (pattern of test-channel-proposals-ui.js). */
'use strict';
const vm = require('vm');
const fs = require('fs');
const path = require('path');
const assert = require('assert');
const ROOT = path.resolve(__dirname, '..', '..');
let passed = 0, failed = 0;
const pending = [];
function test(name, fn) {
const p = Promise.resolve().then(fn).then(
() => { passed++; console.log(' ok ' + name); },
(e) => { failed++; console.log(' FAIL ' + name + ': ' + e.message); });
pending.push(p);
return p;
}
const src = (f) => fs.readFileSync(path.join(ROOT, f), 'utf8');
const plain = (v) => JSON.parse(JSON.stringify(v));
const XSS = '<img src=x onerror=alert(1)>';
const PK = 'ab'.repeat(32);
function loadEscapeHtml() {
const m = src('public/app.js').match(/function escapeHtml\(s\) \{[\s\S]*?\n\}/);
assert(m, 'escapeHtml not found in app.js');
return vm.runInNewContext('(' + m[0].replace(/^function escapeHtml/, 'function') + ')');
}
// makeEl is a stand-in DOM node: innerHTML is a string, querySelector finds
// the [data-...] markers notifications.js uses (one object per marker and
// innerHTML, so a re-render yields a new element).
function makeEl(id) {
const el = { id, innerHTML: '', textContent: '', checked: false, disabled: false, handlers: {}, attrs: {}, kids: {} };
el.addEventListener = (t, fn) => { el.handlers[t] = fn; };
el.getAttribute = (n) => (n in el.attrs ? el.attrs[n] : null);
el.querySelector = (sel) => {
const m = /^\[([a-z-]+)\]$/.exec(sel);
if (!m || el.innerHTML.indexOf(m[1]) === -1) return null;
const key = sel + '|' + el.innerHTML;
if (!el.kids[key]) {
const k = makeEl(sel);
const pressed = /aria-pressed="(true|false)"/.exec(el.innerHTML);
if (pressed) k.attrs['aria-pressed'] = pressed[1];
k.disabled = m[1] === 'data-notify-toggle' && / disabled[ >]/.test(el.innerHTML);
el.kids[key] = k;
}
return el.kids[key];
};
return el;
}
const STATE = (o) => Object.assign({ enabled: true, events: ['node.offline', 'node.battery'],
availableEvents: ['node.offline', 'node.battery'], watches: [],
limits: { maxWatches: 50, perUserPerDay: 20, mailsLast24h: 0 } }, o);
function load(opts) {
opts = opts || {};
const els = {};
const document = { getElementById(id) { return els[id] || (els[id] = makeEl(id)); } };
const calls = [];
const listeners = {};
const user = { current: opts.user === undefined ? { id: 2, role: 'user' } : opts.user };
const routes = opts.routes || (() => ({ ok: true, status: 200, data: STATE() }));
const CSAuth = {
ready() { return opts.ready || Promise.resolve(); },
user() { return user.current; },
request(method, p, body) { calls.push({ method, p, body }); return Promise.resolve().then(() => routes(method, p, body)); },
say(id, text, ok) { const el = document.getElementById(id); el.textContent = text; el.ok = !!ok; },
errText(r) { return (r.data && r.data.error) || ('Request failed (HTTP ' + r.status + ')'); },
};
const ctx = { document, CSAuth, console, escapeHtml: loadEscapeHtml(),
addEventListener(t, fn) { listeners[t] = fn; },
MC_USER_MGMT: opts.flag === false ? null : { enabled: true, notifications: opts.notifications !== false } };
ctx.window = ctx;
vm.createContext(ctx);
vm.runInContext(src('public/notifications.js'), ctx);
return { N: ctx.CSNotify, els, calls, user, document, fire(t) { if (listeners[t]) listeners[t]({}); } };
}
console.log('notifications.js: toggle');
test('no toggle and no request when the feature is off or nobody is logged in', async () => {
for (const opts of [{ notifications: false }, { flag: false }, { user: null }]) {
const env = load(opts);
const slot = makeEl('s');
slot.innerHTML = 'old';
await env.N.mount(slot, PK);
assert.strictEqual(slot.innerHTML, '', JSON.stringify(opts));
assert.strictEqual(env.calls.length, 0, JSON.stringify(opts));
}
});
test('node data arriving before auth is ready still gets the toggle', async () => {
let release;
const ready = new Promise((res) => { release = res; });
const env = load({ ready, user: null });
const slot = makeEl('s');
const p = env.N.mount(slot, PK);
await Promise.resolve();
assert.strictEqual(env.calls.length, 0, 'no request before ready');
env.user.current = { id: 2, role: 'user' };
release();
await p;
assert.strictEqual(env.calls.length, 1);
assert(slot.innerHTML.indexOf('aria-pressed="false"') !== -1, slot.innerHTML);
});
test('toggle states and markup: off, on, full, hidden', () => {
const N = load().N;
assert.strictEqual(N.toggleState(null, PK), 'hidden');
assert.strictEqual(N.toggleState(STATE(), PK), 'off');
assert.strictEqual(N.toggleState(STATE({ watches: [{ pubkey: PK }] }), PK.toUpperCase()), 'on');
assert.strictEqual(N.toggleState(STATE({ watches: [{ pubkey: 'cd'.repeat(32) }], limits: { maxWatches: 1 } }), PK), 'full');
const off = N.toggleHtml('off', STATE());
assert(off.indexOf('aria-pressed="false"') !== -1 && off.indexOf('Notify me') !== -1 && off.indexOf(' disabled') === -1, off);
const on = N.toggleHtml('on', STATE());
assert(on.indexOf('aria-pressed="true"') !== -1 && on.indexOf('Notifying') !== -1, on);
// An icon like the neighbouring node-page buttons, so it gets their height.
assert(off.indexOf('<svg class="ph-icon" aria-hidden="true">') !== -1 && on.indexOf('#ph-envelope-simple') !== -1, off);
const full = N.toggleHtml('full', STATE({ limits: { maxWatches: XSS } }));
assert(full.indexOf(' disabled') !== -1 && full.indexOf('<img') === -1 && full.indexOf('&lt;img') !== -1, full);
assert.strictEqual(N.toggleHtml('hidden', null), '');
});
test('two mounts share one GET; a click watches with a PUT and the answer replaces the cache', async () => {
const watched = STATE({ watches: [{ pubkey: PK, name: 'N', known: true }] });
const env = load({ routes: (m) => ({ ok: true, status: 200, data: m === 'GET' ? STATE() : watched }) });
const a = makeEl('a'), b = makeEl('b');
await Promise.all([env.N.mount(a, PK.toUpperCase()), env.N.mount(b, PK)]);
assert.strictEqual(env.calls.length, 1);
assert(a.innerHTML.indexOf('aria-pressed="false"') !== -1, a.innerHTML);
await a.querySelector('[data-notify-toggle]').handlers.click();
assert.deepStrictEqual(plain(env.calls[1]), { method: 'PUT', p: '/api/account/notifications/watches/' + PK });
assert(a.innerHTML.indexOf('aria-pressed="true"') !== -1, a.innerHTML);
await env.N.mount(b, PK);
assert.strictEqual(env.calls.length, 2, 'mounted again from the cache');
assert(b.innerHTML.indexOf('aria-pressed="true"') !== -1, b.innerHTML);
});
test('a click on Notifying unwatches with a DELETE', async () => {
const env = load({ routes: (m) => ({ ok: true, status: 200, data: m === 'GET' ? STATE({ watches: [{ pubkey: PK }] }) : STATE() }) });
const slot = makeEl('s');
await env.N.mount(slot, PK);
await slot.querySelector('[data-notify-toggle]').handlers.click();
assert.strictEqual(env.calls[1].method, 'DELETE');
assert(slot.innerHTML.indexOf('aria-pressed="false"') !== -1, slot.innerHTML);
});
test('a refused change shows the server message and re-enables the button', async () => {
const msg = 'you watch the maximum of 50 nodes; remove one first';
const env = load({ routes: (m) => (m === 'GET' ? { ok: true, status: 200, data: STATE() } : { ok: false, status: 409, data: { error: msg } }) });
const slot = makeEl('s');
await env.N.mount(slot, PK);
const btn = slot.querySelector('[data-notify-toggle]');
await btn.handlers.click();
assert.strictEqual(btn.disabled, false);
assert.strictEqual(slot.querySelector('[data-notify-msg]').textContent, msg);
});
test('another user or an auth change drops the cached state', async () => {
const env = load();
await env.N.mount(makeEl('a'), PK);
env.user.current = { id: 3, role: 'user' };
await env.N.mount(makeEl('b'), PK);
assert.strictEqual(env.calls.length, 2);
env.fire('cs-auth-changed');
await env.N.mount(makeEl('c'), PK);
assert.strictEqual(env.calls.length, 3);
});
test('nodes.js mounts the toggle on both views; roles.js and index.html wire the module', () => {
const nodes = src('public/nodes.js');
assert(nodes.indexOf('id="nodeNotifySlot"') !== -1 && nodes.indexOf("CSNotify.mount(document.getElementById('nodeNotifySlot'), n.public_key)") !== -1);
assert(nodes.indexOf('id="nodeNotifySlotFull"') !== -1 && nodes.indexOf("CSNotify.mount(document.getElementById('nodeNotifySlotFull'), n.public_key)") !== -1);
assert(/notifications: !!cfg\.userManagement\.notifications/.test(src('public/roles.js')));
assert(src('public/index.html').indexOf('<script src="notifications.js?v=__BUST__"></script>') !== -1);
});
console.log('notifications.js: account section');
test('section escapes names and shows events, watches and limits', () => {
const N = load().N;
const html = N.sectionHtml(STATE({ availableEvents: ['node.offline', 'node.battery', 'foreign.new', 'observer.offline'],
events: ['node.offline', 'foreign.new'], limits: { maxWatches: 50, perUserPerDay: 20, mailsLast24h: 3 },
watches: [{ pubkey: PK, name: XSS, known: true }, { pubkey: 'cd'.repeat(32), name: '', known: false }] }));
assert(html.indexOf('<img') === -1 && html.indexOf('&lt;img') !== -1, 'name not escaped');
assert(html.indexOf('id="notifyEv-foreign-new" data-notify-event="foreign.new" checked') !== -1, html);
assert(html.indexOf('id="notifyEv-node-battery" data-notify-event="node.battery">') !== -1, html);
assert(html.indexOf('id="notifyEnabled" checked') !== -1);
assert(html.indexOf('no longer in the database') !== -1);
assert(html.indexOf('Watched nodes (2 of 50)') !== -1);
assert(html.indexOf('At most 20 mails per 24 hours; sent in the last 24 hours: 3.') !== -1);
assert(html.indexOf('data-unwatch="' + PK + '"') !== -1 && html.indexOf('href="#/nodes/' + PK + '"') !== -1);
assert(N.sectionHtml(STATE()).indexOf('You watch no nodes yet') !== -1);
});
test('Save sends enabled and the checked events; the answer redraws', async () => {
const env = load({ routes: (m) => ({ ok: true, status: 200, data: m === 'GET' ? STATE() : STATE({ enabled: false, events: ['node.battery'] }) }) });
await env.N.mountSection(env.document.getElementById('notifySection'), 'notifyMsg');
assert(env.els.notifySection.innerHTML.indexOf('notifyForm') !== -1);
env.document.getElementById('notifyEnabled').checked = false;
env.document.getElementById('notifyEv-node-offline').checked = false;
env.document.getElementById('notifyEv-node-battery').checked = true;
await env.els.notifyForm.handlers.submit({ preventDefault() {} });
assert.deepStrictEqual(plain(env.calls[1]), { method: 'PUT', p: '/api/account/notifications', body: { enabled: false, events: ['node.battery'] } });
assert.strictEqual(env.els.notifyMsg.textContent, 'Saved.');
assert(env.els.notifySection.innerHTML.indexOf('id="notifyEnabled">') !== -1, 'not redrawn from the answer');
});
test('Remove unwatches; Watch my nodes reports the counts', async () => {
const env = load({ routes: (m, p) => {
if (m === 'GET') return { ok: true, status: 200, data: STATE({ watches: [{ pubkey: PK, name: 'N', known: true }] }) };
if (m === 'DELETE') return { ok: true, status: 200, data: STATE() };
return { ok: true, status: 200, data: { added: 2, already: 1, skipped: 3, account: STATE() } };
} });
await env.N.mountSection(env.document.getElementById('notifySection'), 'notifyMsg');
await env.els.notifyWatches.handlers.click({ target: { getAttribute: (n) => (n === 'data-unwatch' ? PK : null) } });
assert.deepStrictEqual(plain(env.calls[1]), { method: 'DELETE', p: '/api/account/notifications/watches/' + PK });
assert.strictEqual(env.els.notifyMsg.textContent, 'Removed.');
await env.els.notifyMyNodes.handlers.click();
assert.deepStrictEqual(plain(env.calls[2]), { method: 'POST', p: '/api/account/notifications/watch-my-nodes' });
assert.strictEqual(env.els.notifyMsg.textContent, 'Added 2, already watched 1, skipped 3.');
});
test('Watch my nodes with an empty synced list says how to fill it', async () => {
const env = load({ routes: (m) => ({ ok: true, status: 200, data: m === 'GET' ? STATE() : { added: 0, already: 0, skipped: 0, account: STATE() } }) });
await env.N.mountSection(env.document.getElementById('notifySection'), 'notifyMsg');
await env.els.notifyMyNodes.handlers.click();
assert(env.els.notifyMsg.textContent.indexOf('My nodes list is empty') !== -1, env.els.notifyMsg.textContent);
});
test('a failed load shows the server message', async () => {
const env = load({ routes: () => ({ ok: false, status: 500, data: { error: 'internal error' } }) });
await env.N.mountSection(env.document.getElementById('notifySection'), 'notifyMsg');
assert.strictEqual(env.els.notifyMsg.textContent, 'internal error');
});
test('a second click while a request is in flight sends nothing', async () => {
let release;
const gate = new Promise((res) => { release = res; });
const env = load({ routes: (m) => (m === 'GET' ? { ok: true, status: 200, data: STATE() } : gate.then(() => ({ ok: true, status: 200, data: STATE() }))) });
await env.N.mountSection(env.document.getElementById('notifySection'), 'notifyMsg');
const a = env.els.notifyForm.handlers.submit({ preventDefault() {} });
const b = env.els.notifyForm.handlers.submit({ preventDefault() {} });
release();
await Promise.all([a, b]);
assert.strictEqual(env.calls.filter((c) => c.method === 'PUT').length, 1);
});
Promise.all(pending).then(() => {
console.log('\n' + passed + ' passed, ' + failed + ' failed');
process.exit(failed > 0 ? 1 : 0);
});
+31
View File
@@ -946,6 +946,37 @@ test('feature on: a refused reset alerts and keeps the stats', async () => {
assert.deepStrictEqual(alerts, ['Reset failed: HTTP 403']);
});
test('profile has the Notifications section only with notifications on', () => {
const u = { email: 'a@example.org', role: 'user', displayName: 'A' };
assert.strictEqual(loadAccount('#/account', () => ({})).t.profileHtml(u).indexOf('notifySection'), -1);
const on = loadAccount('#/account', () => ({}), { CSNotify: { enabled: () => true } });
assert(on.t.profileHtml(u).indexOf('<h3 id="notifications">Notifications</h3><div id="notifySection"></div>') !== -1);
});
test('unsubscribe view posts the token only after the confirm click', async () => {
const msg = 'Notifications are off. Turn them back on from your account page.';
const env = loadAccount('#/account/unsubscribe?token=T%2B1', () => ({ ok: true, status: 200, data: { ok: true, message: msg } }));
env.t.views.unsubscribe({});
assert.strictEqual(env.loc.hash, '#/account/unsubscribe', 'token left in the address bar');
assert.strictEqual(env.calls.length, 0, 'posted before the click');
await env.els.unsubBtn.handlers.click();
assert.strictEqual(env.calls[0].method, 'POST');
assert.strictEqual(env.calls[0].p, '/api/notifications/unsubscribe?token=T%2B1');
assert.strictEqual(env.els.accountMsg.textContent, msg);
});
test('unsubscribe view: no token, and a dead token', async () => {
const none = loadAccount('#/account/unsubscribe', () => ({}));
none.t.views.unsubscribe({});
assert.strictEqual(none.els.unsubBtn.disabled, true);
assert(none.els.accountMsg.textContent.indexOf('incomplete') !== -1);
const dead = loadAccount('#/account/unsubscribe?token=x', () => ({ ok: false, status: 410, data: { error: 'this link is invalid or was already used' } }));
dead.t.views.unsubscribe({});
await dead.els.unsubBtn.handlers.click();
assert.strictEqual(dead.els.accountMsg.textContent, 'this link is invalid or was already used');
assert.strictEqual(dead.els.unsubBtn.disabled, false);
});
Promise.all(pending).then(() => {
console.log('\n' + passed + ' passed, ' + failed + ' failed');
process.exit(failed ? 1 : 0);