Files
meshcore-analyzer/cmd/server/notify_service.go
T
efitenandClaude Opus 5.5 b65a75c88d feat(observer-logins): offline mails for an owner's own observers
New event observer.offline.own, open to every role and off by default.
An owner gets the observer offline/online mails for observers bound to
their own login; an admin with observer.offline too gets only the
all-observers mails. Creating a login adds the event to the user's
preferences.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 16:10:00 +01:00

430 lines
14 KiB
Go

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
// externalErr is the last tick's reason the externalAlerts feed was not
// used ("" when it was), so each new reason is logged once.
externalErr string
// adaptive caches the learned offline thresholds (offlineWindows).
adaptive map[string]adaptiveEntry
}
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
}
wantExternal := false
if n.a.set.notify.external.url != "" {
for _, p := range prefs {
wantExternal = wantExternal || p.Has(users.NotifyNodeExternal)
}
}
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 owners map[string]int64
if n.a.obsIdx != nil {
for _, p := range prefs {
if p.Has(users.NotifyObserverOfflineOwn) {
wantObservers = true
owners = n.a.obsIdx.ownersSnapshot()
break
}
}
}
var observers []notifyObserver
if wantObservers {
if observers, err = n.src.observers(); err != nil {
log.Printf("[notify] read observers: %v", err)
return
}
}
var external *externalFeed
if wantExternal {
external = n.readExternal(now)
}
heard, lock := n.src.lastHeard(pubkeys)
var infra []string
for _, pk := range pubkeys {
if r := strings.ToLower(nodes[pk].Role); r == "repeater" || r == "room" {
infra = append(infra, pk)
}
}
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, SharedSeen: n.src.sharedPrefixSeen(infra), OfflineAfter: n.offlineWindows(pubkeys, now), NewestPacket: n.src.newestPacket(), Observers: observers, ObserverOwners: owners, IngestStale: stale, IngestResumedAt: n.ingestResumedAt, External: external})
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))
}
// readExternal fetches and validates the externalAlerts feed. It returns
// nil when the feed cannot be used (fetch failed, malformed, too old), so
// no node.external state changes; a dead feed never mails "resolved". The
// reason is logged when it changes, and once when the feed is usable again.
func (n *notifier) readExternal(now time.Time) *externalFeed {
body, err := n.src.externalFeed()
var f *externalFeed
if err == nil {
f, err = parseExternalFeed(body, now, n.a.set.notify.external.maxAge)
}
why := ""
if err != nil {
why = err.Error()
}
switch {
case why != "" && why != n.externalErr:
log.Printf("[notify] external alerts feed not used, node.external unchanged: %s", why)
case why == "" && n.externalErr != "":
log.Printf("[notify] external alerts feed usable again")
}
n.externalErr = why
if f != nil && f.Skipped > 0 {
log.Printf("[notify] external alerts feed: %d malformed alert(s) skipped", f.Skipped)
}
return f
}
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, a.set.notify.external.label), 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).
// label names the externalAlerts source.
func notifyChangeText(c notifyChange, label string) string {
bad := c.To == users.NotifyBad
var what string
switch c.Event {
case users.NotifyNodeOffline:
what = "back online"
if bad {
what = "offline"
if !c.SharedSeen.IsZero() {
// #2260: the UI shows such a node as "uncertain".
what += " (nothing that can only be this node; its hop prefix, shared with other repeaters, was last in a path " +
c.SharedSeen.UTC().Format("2006-01-02 15:04 UTC") + ", possibly another node)"
}
}
case users.NotifyNodeBattery:
what = "battery recovered"
if bad {
what = "battery low"
}
if c.BatteryMv != nil {
what += fmt.Sprintf(" (%d mV)", *c.BatteryMv)
}
case users.NotifyNodeExternal:
_, key, _ := strings.Cut(c.Subject, "/")
what = label + " resolved (" + key + ")"
if bad {
what = label + ": " + c.Text
}
case users.NotifyForeignNew:
what = "new foreign node"
case users.NotifyObserverOffline, users.NotifyObserverOfflineOwn:
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, or an
// external alert to the link the feed gave it.
func notifySubjectURL(base string, c notifyChange) string {
switch c.Event {
case users.NotifyObserverOffline, users.NotifyObserverOfflineOwn:
return base + "/#/observers/" + url.PathEscape(c.Subject)
case users.NotifyNodeExternal:
if c.URL != "" {
return c.URL
}
pk, _, _ := strings.Cut(c.Subject, "/")
return base + "/#/nodes/" + url.PathEscape(pk)
}
return base + "/#/nodes/" + url.PathEscape(c.Subject)
}