Repository navigation
feat: mail notifications for watched nodes (user management part E) - #2140
Merged
Merged
Conversation
…t C) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FGYZDCxPrcM9FC6VHksY71
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FGYZDCxPrcM9FC6VHksY71
… schema v3 AuditList filters by action (exact or a ".*" group), user as actor or target, and an inclusive period, newest first, keyset-paginated on id (default 100, at most 500). PruneAudit deletes rows of the listed actions older than an age and keeps every other action. UsersByID resolves the users of a page in one query. Schema v3 adds an index on audit_log(at). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FGYZDCxPrcM9FC6VHksY71
Account counts by status, active admins, stuck pending accounts (older than 24 hours), bouncing addresses, registrations over 7 and 30 days and per UTC day for 30 days (from user.register audit rows), active users, logins and failed logins in 24 hours, mail of the last 7 days by latest delivery event, and accounts with 5 or more failed logins in 24 hours. Thresholds are constants for now. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FGYZDCxPrcM9FC6VHksY71
A successful login writes user.login; a refused login for an existing account writes user.login.failed with reason wrong_password, pending or disabled. An unknown address writes nothing and no IP is stored. The audit write follows the decided answer and a failure is only logged, so every failure answer stays identical. The hourly janitor deletes login rows older than 90 days and keeps all other actions. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FGYZDCxPrcM9FC6VHksY71
Admin-only and registered only with user management on. Filters by
action or a ".*" group, user as actor or target, and an RFC 3339
period; keyset-paginated with next/before, default 100, capped at 500.
Actor and target are resolved in one query, a removed account becomes
{id, deleted: true}. Invalid parameters answer 400.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FGYZDCxPrcM9FC6VHksY71
Admin-only and registered only with user management on. Serves the users.Stats figures as a typed struct; newPerDay always holds 30 days and guessing is always an array. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FGYZDCxPrcM9FC6VHksY71
The login audit INSERT ran before the buffered answer reached the client, so a refusal for an existing account took one extra SQLite commit (up to the 5 s busy timeout on a locked users.db) that an unknown address never paid: a timing signal for account existence. user.login and user.login.failed are now written by auditAsync after the answer is decided, best-effort, tracked by a WaitGroup that closeUserManagement drains after stopping the janitor. Response bytes, session creation, rate limiting and the dummy hash are unchanged. Spec amendment 7 now says so, including that a row can be lost if the process stops right after a login. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FGYZDCxPrcM9FC6VHksY71
Filters (action or group, period, numeric user id) live in the hash and are validated on read; filter changes use replaceState. Rows are rendered newest first, Load more appends the next keyset page and hides at the end. Account names link to the Users tab. Not reachable yet: the admin shell mounts it in a later commit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FGYZDCxPrcM9FC6VHksY71
load() clears auditMsg first. A filter change drops the paging cursor, hides Load more and empties the table before the new request, so a click cannot mix pages and a failed request leaves no rows of the old filter. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FGYZDCxPrcM9FC6VHksY71
Needs attention (stuck activations, bouncing mail, password guessing, MQTT sources down for 10 minutes or never heard), the Users card from /api/admin/stats with a 30-day registrations chart, and the System card composed from /api/health, /api/healthz, /api/mqtt/status and /api/observers. A failing source shows Could not load in its own block; healthz 503 while warming up is shown as warming up. Refreshes every 60 s while the browser tab is visible; the timer skips /api/healthz, which is read on open and on Refresh only. Not reachable yet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FGYZDCxPrcM9FC6VHksY71
Page admin is now a tab shell at #/admin?tab=overview|users|audit; the user table becomes the Users tab and gains a Bouncing mail only filter. Old #/admin/users links are rewritten to the Users tab with replaceState, filters and detail id kept. The account menu entry and the account page button read Admin. Non-admins see Admins only; with the feature off the page stays Not found. The shell unmounts the active tab on every init and on destroy, and an init whose auth check resolves after the page was left mounts nothing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FGYZDCxPrcM9FC6VHksY71
An admin sees the user figures and a five-attempt failed-login burst under Needs attention, follows it to the audit tab and finds the row. The old #/admin/users link lands on the Users tab with its filter. Axe runs on all three admin tabs. Menu assertions now look for Admin. The axe steps park the mouse at 0,0 first: a hovered row tints its cells to the row-hover colour, where the active status text is 4.48:1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FGYZDCxPrcM9FC6VHksY71
Green status words in the user and admin tables used --status-green-text (green-700 #15803d), which is 4.48:1 on the row-hover tint #eef2ff. A scoped token --status-green-tint-text (light green-800 #166534, dark keeps #22c55e in both dark blocks) now colours them: measured 6.38:1 on #eef2ff, 6.54:1 on --surface-0 in light, 5.75:1 on #2d2d50 in dark. The global --status-green-text is unchanged. The token is mapped in the customizer THEME_CSS_MAP. The contrast unit test gains the hovered-row pairs for green, yellow and red, and the E2E mouse-park comment now says parking only avoids hover noise. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FGYZDCxPrcM9FC6VHksY71
The audit tab accepted /^\d+$/ for the user id, so user=0 in the hash reached the server and came back as a 400 message instead of being ignored like other invalid URL filters. Both the hash reader and the user id input now accept /^[1-9]\d*$/: user=0 and leading zeros such as 007 are ignored in the hash and rejected in the input (the previous value is restored, no reload). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FGYZDCxPrcM9FC6VHksY71
The 250 ms debounced search read #umQ without a guard, so typing and then switching to another admin tab within 250 ms threw a TypeError when the timer fired. The callback now carries a mount token: it returns when the tab was unmounted or remounted since, or when #umQ is gone. Two unit tests cover type then unmount (no throw, no request, hash untouched) and a stale timer from a previous mount. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FGYZDCxPrcM9FC6VHksY71
Every Refresh or Retry click started a full refresh including /api/healthz, which walks the packet store under its read lock, so repeated clicks queued repeated walks. Open, Refresh and Retry now go through one guard: while a full refresh is in flight further clicks are ignored and the Refresh button is disabled until it settles. The 60-second timer is unchanged (it never reads /api/healthz). Unit test: clicks during the mount refresh start nothing, the button is disabled then re-enabled, and two rapid clicks make one /api/healthz request. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FGYZDCxPrcM9FC6VHksY71
The amendment said neither the audit write nor a locked users.db changes response timing. That holds for the login response itself, but users.db uses one connection (internal/users/store.go SetMaxOpenConns(1)), so the background INSERT can delay the next users.db request by one commit. The text now says so, and that the login rate limits (10 per 15 minutes per address, and per IP when IPs can be told apart) keep it from being a useful signal. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FGYZDCxPrcM9FC6VHksY71
The Users tab applied bouncing=1 in the browser to the list the server returns, which is capped at 1000 rows, so on a large instance it could show fewer users than the overview's bouncing count. users.ListFilter gains Bouncing (email_bouncing != 0, combined with the other filters), GET /api/admin/users accepts bouncing=1 (any other value is 400, like the status and role filters), and the Users tab sends it instead of filtering client-side. OpenAPI and docs/api-spec.md describe the parameter. Tests: internal/users List with Bouncing alone and with Role, cmd/server bouncing=1 rows and 400 for 0/yes/true, UI request carries bouncing=1 and the browser no longer drops rows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FGYZDCxPrcM9FC6VHksY71
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FGYZDCxPrcM9FC6VHksY71
ValidateHashtagName: 31-byte firmware limit, no invisible or control characters except ZWJ, case preserved, Public refused. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FGYZDCxPrcM9FC6VHksY71
One generic proposals table, kind hashtag_channel. Propose, Decide, ListProposals, ProposalsByUser, ApprovedSubjects, PruneProposals; limits are checked inside the state-change transaction. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FGYZDCxPrcM9FC6VHksY71
…oposals POST /api/proposals, GET /api/account/proposals, GET /api/admin/proposals and approve/reject/revoke, audited as proposal.*; the janitor prunes decisions after 90 days. Off by default: no routes registered. A name config.json already decrypts (hashChannels, channelKeys) is refused with 409; the server reads channelKeys for its names only and drops the key values. Every decision reloads the approved-name snapshot from users.db under a mutex. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FGYZDCxPrcM9FC6VHksY71
…g load LoadConfig falls back to pure defaults on any parse error, so the names-only channelKeys type made a string or array value start the server without apiKey or userManagement. It now logs and keeps no names. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FGYZDCxPrcM9FC6VHksY71
approvedChannels from an in-memory snapshot refreshed on approve and revoke, capped at maxApproved like the ingestor; client config gains userManagement.channelProposals. Absent when the feature is off. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FGYZDCxPrcM9FC6VHksY71
Reads users.db read-only once a minute, re-validates every name, merges below the configured keys and swaps the key map atomically; a failed read keeps the last good set. Off by default. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FGYZDCxPrcM9FC6VHksY71
window.CSProposals mirrors the hashtag name rules, posts proposals, merges approved channels into the channel list, and renders the account page's My proposals. Inert unless the server advertises the feature. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FGYZDCxPrcM9FC6VHksY71
The Add Channel dialog gains Propose for everyone; approved hashtag channels are listed with an approved marker, also without traffic. Channel names are now escaped in row data attributes and in the timestamp timer's selectors, since approved names may contain quotes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FGYZDCxPrcM9FC6VHksY71
Approve, reject or revoke proposed hashtag channels with an optional note; approving asks first because the channel becomes readable for every visitor. Shown only with channel proposals on; deep link #/admin?tab=proposals&status=. The audit filter gains proposal.*. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FGYZDCxPrcM9FC6VHksY71
…rows A decision in flight ignores a second click on the same row and disables its buttons until it settles. A refused decision (for example another admin decided first) reloads the list and keeps the error message. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FGYZDCxPrcM9FC6VHksY71
evaluateNotifications turns watches, preferences, stored states and the analyzer snapshot into new states and per-user changes, without a clock or a database. Offline follows the node page (last_seen, packet-store last heard, relay hop for repeaters and rooms); battery and observer states have hysteresis; foreign.new keeps a per-admin baseline so a new foreign node is mailed once. A subject's first evaluation never mails. A watcher without a preferences row gets the defaults (enabled, node events). A disabled user's states stay current without changes, so re-enabling never mails what happened while off. A non-admin's admin-event rows are returned in Drop, like opting out, so a re-promoted admin starts with a silent baseline. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FGYZDCxPrcM9FC6VHksY71
NotifyNodes reads the watched nodes (primary-key lookups in chunks) and, when an admin wants them, the foreign nodes from the read-only handle. LastHeardMap is the node page's Last Heard in bulk under one read lock; relay times come from the cached relay metrics. ready() follows /api/healthz, so no evaluation runs while the store is still loading. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FGYZDCxPrcM9FC6VHksY71
userManagement.notifications (off by default) starts a notifier next to the janitor, on the same stop channel and wait group. Each tick waits for the analyzer, evaluates, deletes the admin-event states of demoted admins, writes all states in one transaction, then sends at most one mail per user within the per-user and per-instance 24-hour limits counted from mail_log. Users without a preferences row get the defaults and the row is created before their first mail. Watch pubkeys are lowercased once, matching the analyzer snapshot keys. Skipped and failed changes stay recorded and are never mailed later; a panic is logged and the next tick runs. Mails carry List-Unsubscribe and List-Unsubscribe-Post headers and a one-click unsubscribe link. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FGYZDCxPrcM9FC6VHksY71
With notifications on: read and change the own settings (admin events only for admins), watch and unwatch a node (64 hex characters, present in the analyzer database, within the per-user limit), copy the synced My nodes list. The unsubscribe GET only redirects to the confirm view, so link scanners change nothing; the POST works without a session for the provider's one-click. /api/config/client advertises the feature and the admin overview gets mail and watch counts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FGYZDCxPrcM9FC6VHksY71
notifications.js (window.CSNotify) shows a Notify me / Notifying toggle in the node side pane and on the full node page for logged-in users when the server advertises notifications. One GET per user, cached; each change answers the full state. mount waits for CSAuth.ready(). At the watch limit the button is disabled with a hint. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FGYZDCxPrcM9FC6VHksY71
The account page gains a Notifications section (on/off, events with the admin events for admins, the watch list with Remove, Watch my nodes, the daily limit), deep-linked as #/account?section=notifications. The unsubscribe link opens #/account/unsubscribe with one confirm button. The admin overview shows notification mails in the last 24 hours against the limit and watch counts; the audit filter gains notify.*. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FGYZDCxPrcM9FC6VHksY71
A user turns on Notify me on a node page, the account page lists the node, and the unsubscribe link (from an e2etest-only hook) turns the mails off. The E2E also checks that the toggle is absent while logged out and with the feature off, and that the #/account?section=notifications deep link scrolls to the section. CI's user-management server runs with notifications on. Operator and user docs, config example, and the spec's amendments from the plan and the implementation rulings. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FGYZDCxPrcM9FC6VHksY71
readiness is set before the background fill of the packet store ends, so a first check could see a partial store and mail "offline" and then "back online". ready() now also requires StartupLoadDone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FGYZDCxPrcM9FC6VHksY71
Node names are chosen by node owners, observer names come from MQTT status payloads, and a line break in one forged a separate line in the plain-text part. One sanitiser (mailSafeText) turns control characters and bidi overrides/isolates into a space, collapses runs and trims; the evaluator applies it to node and observer names and render to every mail line, so both parts get the same text. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FGYZDCxPrcM9FC6VHksY71
The limit counts notification mail only, and 300 was Brevo's whole free daily quota: on a day notifications reached it, activation, reset and address-change mail would fail at the provider. 100 leaves room for them; the docs and config example say why. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FGYZDCxPrcM9FC6VHksY71
The evaluator, the notifier and NotifyPrefsFor each spelled out the default preferences (on, node events); they must agree, so they now share users.DefaultNotifyPrefs. It copies NodeNotifyEvents, so a caller cannot change the package default. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FGYZDCxPrcM9FC6VHksY71
After the loop context was cancelled, deliver kept going and logged one failed send per remaining user. It now stops at the next user and logs how many were not mailed; their changes are already stored. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FGYZDCxPrcM9FC6VHksY71
An MQTT or ingestor outage longer than a role's silent window made every watched node look offline, so every watcher got an "offline" mail and later a "back online" one. When the newest packet in the store is older than 30 minutes (read from the tail of the sorted packet slice, O(1)), the evaluator now skips node.offline and observer.offline: states stay, nothing is mailed. Battery and foreign nodes go on. The pause and the resume are each logged once. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FGYZDCxPrcM9FC6VHksY71
"[notify] tick: users=N changes=M mails=K took=Xms lock=Yms". lock is how long LastHeardMap held the packet store's read lock, timed inside the locked section and returned with the map, so staging and live report the number that was only estimated before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FGYZDCxPrcM9FC6VHksY71
… account form The toggle was a plain btn-primary, larger than the node page buttons next to it. It now takes their sizes (side pane 11px / 2px 8px, full page 12px / 4px 10px), carries a 16px icon like they do so the heights match, never breaks its label, and drops the extra margin on the full page row, which spaces its buttons with gap. The "Watched nodes" heading gets space above it instead of touching the Save button. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FGYZDCxPrcM9FC6VHksY71
The two "no toggle" steps slept 500 ms before counting toggles. They now wait until the node page has rendered its paths section, a round trip that starts after the toggle mount decided, and for the auth round trip. axeClean waits for finite animations (the .page-enter fade) to finish: a fade in flight blended the Save button colour and failed color-contrast in 2 of 9 local runs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FGYZDCxPrcM9FC6VHksY71
Pausing offline checks while ingest was stale only moved the false mails: the first check after recovery still mailed every watcher "offline". The notifier now remembers when the stale period ended (also a store that was stale at startup) and the evaluator treats older evidence of a node or observer whose stored state is good as heard at that moment, so nothing goes offline until a full silent window has passed after recovery. A bad state is not lifted. A node that died during the outage is reported up to one window late; a restart inside the window loses the grace (in memory). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FGYZDCxPrcM9FC6VHksY71
…ge ended Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FGYZDCxPrcM9FC6VHksY71
…into feat/notifications
efiten
added a commit
to efiten/CoreScope
that referenced
this pull request
Oct 8, 2026
…into feat/account-export-backup
efiten
added a commit
that referenced
this pull request
Oct 8, 2026
A small follow-up to parts A to E of #2128: users can download all data the instance holds about them, and the server backs up `users.db`. Stacked on #2140. Review the commits after that branch. ## The situation - An instance with accounts holds personal data, but a user has no way to get a copy (GDPR articles 15 and 20). Self-delete already exists. - `users.db` is the only file the server writes, and nothing backs it up: `GET /api/backup` snapshots the analyzer database only. ## What this PR adds **Data export.** `GET /api/account/export` and a "Download my data" button on the account page give one JSON file with: - the profile, including activation and a pending email change; - sessions (times and user agent); - synced settings, own proposals, notification prefs and watches; - audit rows where the user is actor or target, with other accounts as an id only; - the mail log. Password hash, session and link tokens and the unsubscribe token are left out: they are credentials. Each export is audited as `user.export`. **users.db backup** - Daily `VACUUM INTO` snapshots through `internal/users`, on by default when user management is on. They go to `backups/` next to `users.db`, the 7 newest are kept, the directory is 0700 and the files 0600. Rotation only touches `users-<timestamp>.db` files and never the snapshot it just wrote. Config: `userManagement.backup {enabled, dir, keep}`. - `GET /api/admin/users-backup`: an admin downloads a fresh snapshot to keep off the server; audited as `user.backup`. - `docs/user-guide/accounts.md` gains a restore procedure. It covers what a restore brings back (accounts deleted after the snapshot, old passwords, revoked sessions) and how to clean that up afterwards. Spec: [`docs/specs/2026-10-08-account-export-and-users-backup-design.md`](https://github.com/efiten/CoreScope/blob/feat/account-export-backup/docs/specs/2026-10-08-account-export-and-users-backup-design.md). ## Verification - `internal/users` with `-race` and the `cmd/server` suite pass locally, 30 new Go tests. The file-mode tests skip on Windows. - `sh test-all.sh` exits 0; the XSS gate in diff mode passes. - User-management E2E: 27 of 27 steps locally. - On our production instance since 8 October 2026; the first snapshot there was 143 kB. ## Not in this PR - Importing an export into another instance. - Off-site upload of the snapshots. The admin download covers that by hand. --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This was referenced Oct 8, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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'shealthThresholdswindow. Last heard comes from the packet store, and repeaters and rooms count relayed traffic (Active nodes are disappearing from the map #1598).node.battery: advert telemetry belowbatteryThresholds.lowMv, recovered atlowMv + 100.foreign.new(once per node) andobserver.offline.Store. Schema v5 with
notification_prefs,notification_watchesandnotification_state.Server (only with
userManagement.notifications.enabled)intervalMinutes(5). The evaluator is a pure function of watches, prefs, stored states and node snapshots.List-UnsubscribeandList-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.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.MessagegainsHeaders.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.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.No work is added to ingest, broadcast or any request path.
Verification
internal/usersandinternal/mailerwith-race; thecmd/serversuite plus-raceon the notifier tests; 74 new Go tests. The same Windows-only failure as noted in feat: admin dashboard with overview and audit log (user management part C) #2138 applies.sh test-all.shexits 0; the XSS gate in diff mode passes.Not in this PR