mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-12 08:37:25 +00:00
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>
188 lines
8.0 KiB
Go
188 lines
8.0 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"html"
|
|
"log"
|
|
"net/url"
|
|
"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
|
|
// request Host, see the spec's Configuration section).
|
|
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 {
|
|
var text, h strings.Builder
|
|
text.WriteString(c.greeting + "\n\n")
|
|
h.WriteString("<p>" + html.EscapeString(c.greeting) + "</p>")
|
|
for _, p := range c.paragraphs {
|
|
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>`)
|
|
}
|
|
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}
|
|
}
|
|
|
|
func (a *authService) activationMail(u *users.User, token string) mailer.Message {
|
|
return a.render(u.Email, u.DisplayName, "activate", mailContent{
|
|
subject: "Activate your account", greeting: "Hello " + u.DisplayName + ",",
|
|
paragraphs: []string{"Confirm your address to activate your account. The link works once and expires in 48 hours.",
|
|
"When you open the link, enter the password you chose when you registered."},
|
|
actionLabel: "Activate my account", actionURL: a.link("activate", token)})
|
|
}
|
|
|
|
func (a *authService) resetMail(u *users.User, token string) mailer.Message {
|
|
return a.render(u.Email, u.DisplayName, "reset", mailContent{
|
|
subject: "Reset your password", greeting: "Hello " + u.DisplayName + ",",
|
|
paragraphs: []string{"Someone asked to reset the password of your account. The link works once and expires in 1 hour. Using it logs out all your devices."},
|
|
actionLabel: "Choose a new password", actionURL: a.link("reset", token)})
|
|
}
|
|
|
|
func (a *authService) registerNoticeMail(u *users.User) mailer.Message {
|
|
return a.render(u.Email, u.DisplayName, "register-notice", mailContent{
|
|
subject: "Registration attempt with your address", greeting: "Hello " + u.DisplayName + ",",
|
|
paragraphs: []string{"Someone tried to register a new account with your address, but you already have one.",
|
|
"If it was you, log in, or use \"forgot password\" if you lost it."},
|
|
actionLabel: "Log in", actionURL: a.set.baseURL.String() + "/#/account/login"})
|
|
}
|
|
|
|
func (a *authService) emailChangeConfirmMail(u *users.User, newEmail, token string) mailer.Message {
|
|
return a.render(newEmail, u.DisplayName, "email-change", mailContent{
|
|
subject: "Confirm your new address", greeting: "Hello " + u.DisplayName + ",",
|
|
paragraphs: []string{"Confirm that this address should replace the one on your account. The link expires in 24 hours."},
|
|
actionLabel: "Confirm new address", actionURL: a.link("confirm-email", token)})
|
|
}
|
|
|
|
func (a *authService) emailChangeNoticeMail(u *users.User, newEmail string) mailer.Message {
|
|
return a.render(u.Email, u.DisplayName, "email-change-notice", mailContent{
|
|
subject: "Address change requested", greeting: "Hello " + u.DisplayName + ",",
|
|
paragraphs: []string{"A change of your account address to " + newEmail + " was requested. It takes effect only when confirmed from the new address.",
|
|
"If this was not you, change your password now."}})
|
|
}
|
|
|
|
// addrRE matches anything around an @ up to whitespace or a character that
|
|
// cannot appear unquoted in an address, so RFC 5322 atext specials and
|
|
// UTF-8 local parts and domains are covered.
|
|
var addrRE = regexp.MustCompile(`[^\s"<>()\[\],;:]+@[^\s"<>()\[\],;:]+`)
|
|
|
|
// redactAddrs renders a mailer/provider error for the server log with every
|
|
// email address replaced by <addr>: provider messages can echo the
|
|
// recipient, and addresses belong only in the audit and mail tables.
|
|
func redactAddrs(err error) string {
|
|
return addrRE.ReplaceAllString(err.Error(), "<addr>")
|
|
}
|
|
|
|
// sendMail sends msg and records it in the mail log. The error is logged
|
|
// (without tokens) and returned so the caller can answer 503.
|
|
func (a *authService) sendMail(ctx context.Context, u *users.User, purpose string, msg mailer.Message) error {
|
|
id, err := a.mail.Send(ctx, msg)
|
|
if err != nil {
|
|
log.Printf("[users] mail %s for user #%d failed: %s", purpose, u.ID, redactAddrs(err))
|
|
return err
|
|
}
|
|
if _, err := a.st.LogMail(idPtr(u.ID), msg.To, purpose, id); err != nil {
|
|
log.Printf("[users] mail log for user #%d: %v", u.ID, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ingestMailEvents records provider events and flags undeliverable addresses.
|
|
// Shared by the webhook and the admin "refresh" pull.
|
|
func (a *authService) ingestMailEvents(evs []mailer.Event) {
|
|
for _, ev := range evs {
|
|
uid, found, err := a.st.RecordMailEvent(ev.MessageID, ev.Event, ev.At, ev.Reason)
|
|
if err != nil {
|
|
log.Printf("[users] record mail event: %v", err)
|
|
continue
|
|
}
|
|
if found && uid != nil && mailer.IsUndeliverable(ev.Event) {
|
|
if err := a.st.SetEmailBouncing(*uid, true); err != nil {
|
|
log.Printf("[users] flag bouncing for user #%d: %v", *uid, err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// mailToken issues a one-time token for u, builds the mail around it and
|
|
// sends it. If issuing or sending fails the purpose's outstanding tokens are
|
|
// invalidated, so no usable link exists for a mail that never left; the
|
|
// caller answers 503. newEmail is stored only for users.PurposeEmailChange.
|
|
// mailPurpose is the label recorded in the mail log.
|
|
func (a *authService) mailToken(ctx context.Context, u *users.User, tokenPurpose users.Purpose,
|
|
ttl time.Duration, newEmail, mailPurpose string, build func(token string) mailer.Message) error {
|
|
tok, err := a.st.IssueToken(u.ID, tokenPurpose, ttl, newEmail)
|
|
if err != nil {
|
|
log.Printf("[users] issue %s token for user #%d: %v", tokenPurpose, u.ID, err)
|
|
} else {
|
|
err = a.sendMail(ctx, u, mailPurpose, build(tok))
|
|
}
|
|
if err != nil {
|
|
if ierr := a.st.InvalidateTokens(u.ID, tokenPurpose); ierr != nil {
|
|
log.Printf("[users] invalidate %s tokens for user #%d: %v", tokenPurpose, u.ID, ierr)
|
|
}
|
|
return err
|
|
}
|
|
return nil
|
|
}
|