Files
meshcore-analyzer/internal/users/schema.go
T
efitenandClaude Opus 5.5 de364a8bb6 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>
2026-10-08 22:02:39 +02:00

174 lines
5.6 KiB
Go

package users
import (
"database/sql"
"errors"
"fmt"
)
// migrations[i] upgrades the schema from version i to i+1. Forward-only:
// never edit a shipped entry, append a new one.
var migrations = [][]string{
{ // v1: sub-project A
`CREATE TABLE users (
id INTEGER PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('user','admin')),
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','active','disabled')),
created_at INTEGER NOT NULL,
activated_at INTEGER,
activated_by INTEGER,
last_login_at INTEGER,
email_bouncing INTEGER NOT NULL DEFAULT 0
)`,
`CREATE TABLE sessions (
id INTEGER PRIMARY KEY,
token_hash TEXT NOT NULL UNIQUE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
csrf_token TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
last_seen_at INTEGER NOT NULL,
user_agent TEXT NOT NULL DEFAULT ''
)`,
`CREATE INDEX sessions_user ON sessions(user_id)`,
`CREATE TABLE tokens (
token_hash TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
purpose TEXT NOT NULL CHECK (purpose IN ('activate','reset','email_change')),
new_email TEXT,
expires_at INTEGER NOT NULL,
used_at INTEGER
)`,
`CREATE INDEX tokens_user ON tokens(user_id, purpose)`,
`CREATE TABLE audit_log (
id INTEGER PRIMARY KEY,
at INTEGER NOT NULL,
actor_user_id INTEGER,
action TEXT NOT NULL,
target_user_id INTEGER,
detail TEXT NOT NULL DEFAULT '{}'
)`,
`CREATE INDEX audit_target ON audit_log(target_user_id)`,
`CREATE INDEX audit_actor ON audit_log(actor_user_id)`,
`CREATE TABLE mail_log (
id INTEGER PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
to_email TEXT NOT NULL,
purpose TEXT NOT NULL,
provider_message_id TEXT,
sent_at INTEGER NOT NULL,
last_event TEXT NOT NULL DEFAULT 'sent',
last_event_at INTEGER NOT NULL,
last_reason TEXT NOT NULL DEFAULT ''
)`,
`CREATE INDEX mail_user ON mail_log(user_id)`,
`CREATE UNIQUE INDEX mail_msgid ON mail_log(provider_message_id) WHERE provider_message_id IS NOT NULL`,
`CREATE TABLE mail_events (
mail_id INTEGER NOT NULL REFERENCES mail_log(id) ON DELETE CASCADE,
event TEXT NOT NULL,
at INTEGER NOT NULL,
reason TEXT NOT NULL DEFAULT '',
UNIQUE (mail_id, event, at)
)`,
},
{ // v2: sub-project B, settings sync (one document per user)
`CREATE TABLE user_settings (
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
doc TEXT NOT NULL,
revision INTEGER NOT NULL,
generation TEXT NOT NULL,
updated_at INTEGER NOT NULL
)`,
},
{ // v3: sub-project C, global audit list ordered and filtered by time
`CREATE INDEX audit_at ON audit_log(at)`,
},
{ // v4: sub-project D, proposals (one generic table; this version ships the kind 'hashtag_channel')
`CREATE TABLE proposals (
id INTEGER PRIMARY KEY,
kind TEXT NOT NULL,
subject TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('pending','approved','rejected','revoked')),
proposer_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
reviewer_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
note TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
decided_at INTEGER
)`,
`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 {
if _, err := s.db.Exec(`CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL)`); err != nil {
return fmt.Errorf("users: schema_version: %w", err)
}
v, err := s.SchemaVersion()
if errors.Is(err, sql.ErrNoRows) {
if _, err := s.db.Exec(`INSERT INTO schema_version (version) VALUES (0)`); err != nil {
return fmt.Errorf("users: init schema_version: %w", err)
}
v = 0
} else if err != nil {
return fmt.Errorf("users: read schema_version: %w", err)
}
if v > len(migrations) {
return fmt.Errorf("users: database schema version %d is newer than this binary supports (%d)", v, len(migrations))
}
for i := v; i < len(migrations); i++ {
tx, err := s.db.Begin()
if err != nil {
return err
}
for _, stmt := range migrations[i] {
if _, err := tx.Exec(stmt); err != nil {
tx.Rollback()
return fmt.Errorf("users: migration %d: %w", i+1, err)
}
}
if _, err := tx.Exec(`UPDATE schema_version SET version = ?`, i+1); err != nil {
tx.Rollback()
return fmt.Errorf("users: migration %d: %w", i+1, err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("users: migration %d: %w", i+1, err)
}
}
return nil
}
// SchemaVersion returns the applied schema version (sql.ErrNoRows on a
// database that has never been migrated).
func (s *Store) SchemaVersion() (int, error) {
var v int
err := s.db.QueryRow(`SELECT version FROM schema_version`).Scan(&v)
return v, err
}