panel-notifications: schema and store

Migration 0004_notifications.sql, DDL only: users.notify, a user's own
switch, on by default; notify_state, one row per subject the panel has
written about (dns:<domain>, cert, queue, limit:<domain>:<login>), so that
each thing is said once and a restart says nothing again.

Store: the instance switch as a settings key, on unless turned off;
SetUserNotify; NotifyRecipients - global users for everything, domain users
for the domains on their list or all of them, only with an e-mail and their
switch on, a shared mailbox once; the state's get, set, delete and list by
prefix; LimitRefusalsSince, the mail refused under a level-2 limit per domain
and application.

Nothing is sent yet. Checked on Windows: go vet clean, go test
./internal/store passes with the schema test at user_version 4. The page
guards fail from the design commit 87fa0a4 until the pages are built.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-12 15:48:56 +03:00
co-authored by Claude Opus 5.5
parent 87fa0a4381
commit 06ad174fb9
9 changed files with 421 additions and 17 deletions
@@ -0,0 +1,28 @@
-- Notifications: the panel writes to its users when something needs them
-- (panel-notifications, 2.3.0). DDL only; nothing existing changes, and every
-- user starts with notifications on.
-- A user's own switch, on Account. It decides nothing by itself: a user is
-- written to when it is on, the profile has an e-mail and the instance switch
-- (settings key "notifications") is on.
ALTER TABLE users ADD COLUMN notify INTEGER NOT NULL DEFAULT 1 CHECK (notify IN (0, 1));
-- What the panel has already said, so that it says each thing once and a
-- restart announces nothing again. One row per subject:
-- dns:<domain> a sending domain's records
-- cert the TLS certificate
-- queue a message older than three days
-- limit:<domain>:<login> a level-2 limit that refused mail
-- phase is where the subject stands ("pending": seen wrong, not yet said;
-- "open": said; for the certificate, the threshold last said). count is the
-- checks in a row that agree with the phase's direction, mark what the phase
-- refers to (the certificate's expiry), since when it began, sent_at when the
-- last message about it went out.
CREATE TABLE notify_state (
key TEXT PRIMARY KEY,
phase TEXT NOT NULL,
count INTEGER NOT NULL DEFAULT 0,
mark TEXT NOT NULL DEFAULT '',
since TEXT NOT NULL,
sent_at TEXT NOT NULL DEFAULT ''
);
+194
View File
@@ -0,0 +1,194 @@
package store
import (
"database/sql"
"errors"
"fmt"
"time"
)
// settingNotifications is the instance switch for notification e-mails. A
// missing value means on: the switch exists to turn them off.
const settingNotifications = "notifications"
// NotificationsEnabled reports whether the instance sends notification e-mails
// at all (Server › Settings).
func (s *Store) NotificationsEnabled() (bool, error) {
v, err := s.GetSetting(settingNotifications)
if err != nil {
return false, err
}
return v != "off", nil
}
// SetNotificationsEnabled turns notification e-mails on or off for everyone.
func (s *Store) SetNotificationsEnabled(on bool) error {
v := "on"
if !on {
v = "off"
}
return s.SetSetting(settingNotifications, v)
}
// SetUserNotify sets a user's own switch (Account).
func (s *Store) SetUserNotify(id int64, on bool) error {
res, err := s.db.Exec("UPDATE users SET notify = ? WHERE id = ?", on, id)
if err != nil {
return fmt.Errorf("set user notify: %w", err)
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrUserNotFound
}
return nil
}
// NotifyRecipients returns the addresses a notification goes to: every global
// user, and for a subject that is a sending domain's (domainName not empty)
// also the domain users it is assigned to - by name on their list, or by
// "all domains". Only users with an e-mail and their own switch on; an address
// two users share is returned once.
func (s *Store) NotifyRecipients(domainName string) ([]string, error) {
query := `SELECT DISTINCT email FROM users
WHERE notify = 1 AND email <> '' AND role = ?`
args := []any{RoleGlobal}
if domainName != "" {
query = `SELECT DISTINCT email FROM users
WHERE notify = 1 AND email <> '' AND (
role = ?
OR all_domains = 1
OR EXISTS (SELECT 1 FROM user_domains ud JOIN domains d ON d.id = ud.domain_id
WHERE ud.user_id = users.id AND d.name = ?))`
args = append(args, domainName)
}
rows, err := s.db.Query(query+" ORDER BY email", args...)
if err != nil {
return nil, fmt.Errorf("notify recipients: %w", err)
}
defer rows.Close()
var out []string
for rows.Next() {
var email string
if err := rows.Scan(&email); err != nil {
return nil, fmt.Errorf("notify recipients scan: %w", err)
}
out = append(out, email)
}
return out, rows.Err()
}
// NotifyState is what the panel remembers about one subject of notification
// (migration 0004 describes the keys and the fields).
type NotifyState struct {
Key string
Phase string
Count int
Mark string
Since time.Time
SentAt time.Time // zero when nothing was sent yet
}
// GetNotifyState returns the state kept under key; ok is false when there is
// none, which is the state of a subject nothing is wrong with.
func (s *Store) GetNotifyState(key string) (NotifyState, bool, error) {
st := NotifyState{Key: key}
var since, sentAt string
err := s.db.QueryRow(
"SELECT phase, count, mark, since, sent_at FROM notify_state WHERE key = ?", key,
).Scan(&st.Phase, &st.Count, &st.Mark, &since, &sentAt)
if errors.Is(err, sql.ErrNoRows) {
return NotifyState{}, false, nil
}
if err != nil {
return NotifyState{}, false, fmt.Errorf("get notify state %s: %w", key, err)
}
st.Since, _ = time.Parse(time.RFC3339, since)
if sentAt != "" {
st.SentAt, _ = time.Parse(time.RFC3339, sentAt)
}
return st, true, nil
}
// SetNotifyState writes the state of one subject, replacing what was there.
func (s *Store) SetNotifyState(st NotifyState) error {
sentAt := ""
if !st.SentAt.IsZero() {
sentAt = st.SentAt.UTC().Format(time.RFC3339)
}
_, err := s.db.Exec(
`INSERT INTO notify_state (key, phase, count, mark, since, sent_at) VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(key) DO UPDATE SET phase = excluded.phase, count = excluded.count,
mark = excluded.mark, since = excluded.since, sent_at = excluded.sent_at`,
st.Key, st.Phase, st.Count, st.Mark, st.Since.UTC().Format(time.RFC3339), sentAt,
)
if err != nil {
return fmt.Errorf("set notify state %s: %w", st.Key, err)
}
return nil
}
// DeleteNotifyState forgets a subject: nothing is wrong with it any more, or
// it no longer exists.
func (s *Store) DeleteNotifyState(key string) error {
if _, err := s.db.Exec("DELETE FROM notify_state WHERE key = ?", key); err != nil {
return fmt.Errorf("delete notify state %s: %w", key, err)
}
return nil
}
// ListNotifyStateKeys returns the keys that begin with prefix, so that the
// states of subjects that are gone - a deleted domain - can be found.
func (s *Store) ListNotifyStateKeys(prefix string) ([]string, error) {
rows, err := s.db.Query(
"SELECT key FROM notify_state WHERE substr(key, 1, ?) = ? ORDER BY key", len(prefix), prefix)
if err != nil {
return nil, fmt.Errorf("list notify state: %w", err)
}
defer rows.Close()
var out []string
for rows.Next() {
var key string
if err := rows.Scan(&key); err != nil {
return nil, fmt.Errorf("list notify state scan: %w", err)
}
out = append(out, key)
}
return out, rows.Err()
}
// LimitRefusal is the mail one application was refused under a level-2 rate
// limit in a span of time: how many messages and when the first.
type LimitRefusal struct {
Domain string
AppLogin string
Count int
First time.Time
}
// LimitRefusalsSince returns, per domain and application, the messages the
// journal milter refused under a level-2 limit at or after since (send-log
// rows with status "rejected").
func (s *Store) LimitRefusalsSince(since time.Time) ([]LimitRefusal, error) {
rows, err := s.db.Query(
`SELECT COALESCE(domain, ''), COALESCE(app_login, ''), COUNT(*), MIN(created_at) FROM send_log
WHERE status = ? AND created_at >= ?
GROUP BY domain, app_login ORDER BY domain, app_login`,
StatusRejected, since.UTC().Format(time.RFC3339),
)
if err != nil {
return nil, fmt.Errorf("limit refusals: %w", err)
}
defer rows.Close()
var out []LimitRefusal
for rows.Next() {
var (
r LimitRefusal
first string
)
if err := rows.Scan(&r.Domain, &r.AppLogin, &r.Count, &first); err != nil {
return nil, fmt.Errorf("limit refusals scan: %w", err)
}
r.First, _ = time.Parse(time.RFC3339, first)
out = append(out, r)
}
return out, rows.Err()
}
+166
View File
@@ -0,0 +1,166 @@
package store
import (
"strings"
"testing"
"time"
)
// The instance switch is on until someone turns it off: a database that never
// saw the setting sends notifications.
func TestNotificationsEnabledByDefault(t *testing.T) {
s := openTestStore(t)
if on, err := s.NotificationsEnabled(); err != nil || !on {
t.Fatalf("a fresh database: enabled = %v, %v; want on", on, err)
}
if err := s.SetNotificationsEnabled(false); err != nil {
t.Fatal(err)
}
if on, err := s.NotificationsEnabled(); err != nil || on {
t.Errorf("after turning off: enabled = %v, %v", on, err)
}
if err := s.SetNotificationsEnabled(true); err != nil {
t.Fatal(err)
}
if on, err := s.NotificationsEnabled(); err != nil || !on {
t.Errorf("after turning on again: enabled = %v, %v", on, err)
}
}
// Who is written to: every global user about everything, a domain user about
// the domains on their list or all of them - and nobody without an e-mail or
// with their own switch off.
func TestNotifyRecipients(t *testing.T) {
s := openTestStore(t)
mine, err := s.AddDomain("mine.example.com", "sp")
if err != nil {
t.Fatal(err)
}
other, err := s.AddDomain("other.example.com", "sp")
if err != nil {
t.Fatal(err)
}
user := func(name string, role Role, reach Reach, email string, notify bool) {
t.Helper()
id, err := s.CreateUser(name, "hash", role, reach)
if err != nil {
t.Fatalf("create %s: %v", name, err)
}
if err := s.UpdateUser(id, name, "hash", email); err != nil {
t.Fatalf("update %s: %v", name, err)
}
if u, err := s.GetUser(id); err != nil || !u.Notify {
t.Fatalf("%s: a new user's switch is %v, %v; want on", name, u.Notify, err)
}
if !notify {
if err := s.SetUserNotify(id, false); err != nil {
t.Fatalf("switch off %s: %v", name, err)
}
}
}
user("root", RoleGlobal, Reach{}, "root@example.org", true)
user("root2", RoleGlobal, Reach{}, "root@example.org", true) // the same mailbox
user("quiet", RoleGlobal, Reach{}, "quiet@example.org", false)
user("nomail", RoleGlobal, Reach{}, "", true)
user("mine", RoleDomain, Reach{DomainIDs: []int64{mine.ID}}, "mine@example.org", true)
user("theirs", RoleDomain, Reach{DomainIDs: []int64{other.ID}}, "theirs@example.org", true)
user("every", RoleDomain, Reach{AllDomains: true}, "every@example.org", true)
user("mineoff", RoleDomain, Reach{DomainIDs: []int64{mine.ID}}, "mineoff@example.org", false)
for _, c := range []struct{ domain, want string }{
{"", "root@example.org"},
{"mine.example.com", "every@example.org mine@example.org root@example.org"},
{"other.example.com", "every@example.org root@example.org theirs@example.org"},
{"gone.example.com", "every@example.org root@example.org"},
} {
got, err := s.NotifyRecipients(c.domain)
if err != nil {
t.Fatalf("recipients for %q: %v", c.domain, err)
}
if strings.Join(got, " ") != c.want {
t.Errorf("recipients for %q = %q, want %q", c.domain, strings.Join(got, " "), c.want)
}
}
if err := s.SetUserNotify(99999, true); err != ErrUserNotFound {
t.Errorf("switch of a user that does not exist: %v", err)
}
}
// What has been said is remembered by key, replaced whole, and forgotten.
func TestNotifyStateRoundTrip(t *testing.T) {
s := openTestStore(t)
if _, ok, err := s.GetNotifyState("dns:example.com"); err != nil || ok {
t.Fatalf("no state yet: ok = %v, %v", ok, err)
}
since := time.Date(2026, 10, 12, 9, 0, 0, 0, time.UTC)
st := NotifyState{Key: "dns:example.com", Phase: "pending", Count: 1, Since: since}
if err := s.SetNotifyState(st); err != nil {
t.Fatal(err)
}
got, ok, err := s.GetNotifyState("dns:example.com")
if err != nil || !ok || got.Phase != "pending" || got.Count != 1 || !got.Since.Equal(since) || !got.SentAt.IsZero() {
t.Fatalf("state = %+v, %v, %v", got, ok, err)
}
st.Phase, st.Count, st.Mark, st.SentAt = "open", 0, "x", since.Add(time.Hour)
if err := s.SetNotifyState(st); err != nil {
t.Fatal(err)
}
got, _, _ = s.GetNotifyState("dns:example.com")
if got.Phase != "open" || got.Mark != "x" || !got.SentAt.Equal(since.Add(time.Hour)) {
t.Errorf("replaced state = %+v", got)
}
for _, key := range []string{"dns:other.example.com", "queue", "limit:example.com:app"} {
if err := s.SetNotifyState(NotifyState{Key: key, Phase: "open", Since: since}); err != nil {
t.Fatal(err)
}
}
keys, err := s.ListNotifyStateKeys("dns:")
if err != nil || strings.Join(keys, " ") != "dns:example.com dns:other.example.com" {
t.Errorf("dns keys = %v, %v", keys, err)
}
if err := s.DeleteNotifyState("dns:example.com"); err != nil {
t.Fatal(err)
}
if _, ok, _ := s.GetNotifyState("dns:example.com"); ok {
t.Error("state still there after delete")
}
}
// Refused mail is counted per domain and application from a point in time;
// delivered mail and older refusals are not.
func TestLimitRefusalsSince(t *testing.T) {
s := openTestStore(t)
for _, e := range []SendLogEntry{
{Domain: "example.com", AppLogin: "app1", From: "a@example.com"},
{Domain: "example.com", AppLogin: "app1", From: "a@example.com"},
{Domain: "example.com", AppLogin: "app2", From: "b@example.com"},
} {
if err := s.InsertRejected(e); err != nil {
t.Fatal(err)
}
}
if err := s.InsertQueued(SendLogEntry{QueueID: "ABC123", Domain: "example.com", AppLogin: "app3", To: "x@example.net"}); err != nil {
t.Fatal(err)
}
if err := s.InsertRejected(SendLogEntry{Domain: "old.example.com", AppLogin: "old"}); err != nil {
t.Fatal(err)
}
if _, err := s.db.Exec(`UPDATE send_log SET created_at = '2026-01-01T00:00:00Z' WHERE app_login = 'old'`); err != nil {
t.Fatal(err)
}
got, err := s.LimitRefusalsSince(time.Now().UTC().Add(-time.Hour))
if err != nil {
t.Fatal(err)
}
if len(got) != 2 || got[0].AppLogin != "app1" || got[0].Count != 2 || got[1].AppLogin != "app2" || got[1].Count != 1 {
t.Fatalf("refusals = %+v", got)
}
if got[0].Domain != "example.com" || got[0].First.IsZero() {
t.Errorf("first refusal = %+v", got[0])
}
}
+7 -6
View File
@@ -40,7 +40,7 @@ func TestBaselineSchema(t *testing.T) {
st := openTestStore(t)
want := map[string]string{
"settings": "key value",
"users": "id username password_hash role email all_domains all_inbound_domains created_at",
"users": "id username password_hash role email all_domains all_inbound_domains created_at notify",
"sessions": "token_hash user_id created_at expires_at",
"domains": "id name dkim_selector dmarc_rua created_at",
"user_domains": "user_id domain_id",
@@ -58,6 +58,7 @@ func TestBaselineSchema(t *testing.T) {
"inbound_spam_log": "id inbound_domain client_ip from_addr to_addr subject decision engine detail created_at",
"inbound_spam_list": "id list_type entry_type value note created_at",
"send_log_lines": "id queue_id logged_at line",
"notify_state": "key phase count mark since sent_at",
}
rows, err := st.db.Query("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name")
if err != nil {
@@ -112,8 +113,8 @@ func TestBaselineSchema(t *testing.T) {
}
var version, appID int64
if err := st.db.QueryRow("PRAGMA user_version").Scan(&version); err != nil || version != 3 {
t.Errorf("user_version = %d, %v; want 3", version, err)
if err := st.db.QueryRow("PRAGMA user_version").Scan(&version); err != nil || version != 4 {
t.Errorf("user_version = %d, %v; want 4", version, err)
}
if err := st.db.QueryRow("PRAGMA application_id").Scan(&appID); err != nil || appID != applicationID {
t.Errorf("application_id = %#x, %v; want %#x", appID, err, applicationID)
@@ -189,13 +190,13 @@ func TestOpenMigratesA20Database(t *testing.T) {
}
defer st.Close()
var version int
if err := st.db.QueryRow("PRAGMA user_version").Scan(&version); err != nil || version != 3 {
t.Errorf("user_version = %d, %v; want 3", version, err)
if err := st.db.QueryRow("PRAGMA user_version").Scan(&version); err != nil || version != 4 {
t.Errorf("user_version = %d, %v; want 4", version, err)
}
if ok, err := st.UserExists(); err != nil || !ok {
t.Errorf("the user of the 2.0 database: exists = %v, %v", ok, err)
}
for _, table := range []string{"inbound_spam_log", "inbound_spam_list", "send_log_lines"} {
for _, table := range []string{"inbound_spam_log", "inbound_spam_list", "send_log_lines", "notify_state"} {
if len(tableColumns(t, st, table)) == 0 {
t.Errorf("table %s was not created", table)
}
+8 -5
View File
@@ -43,12 +43,15 @@ type User struct {
// are then ignored.
AllDomains bool
AllInboundDomains bool
CreatedAt time.Time
DomainIDs []int64
InboundDomainIDs []int64
// Notify is the user's own switch for notification e-mails (Account). It
// is on for a new user and does nothing without an e-mail.
Notify bool
CreatedAt time.Time
DomainIDs []int64
InboundDomainIDs []int64
}
const userColumns = "id, username, password_hash, role, email, all_domains, all_inbound_domains, created_at"
const userColumns = "id, username, password_hash, role, email, all_domains, all_inbound_domains, notify, created_at"
func scanUser(r scanRow) (User, error) {
var (
@@ -56,7 +59,7 @@ func scanUser(r scanRow) (User, error) {
createdAt string
)
err := r.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.Email,
&u.AllDomains, &u.AllInboundDomains, &createdAt)
&u.AllDomains, &u.AllInboundDomains, &u.Notify, &createdAt)
if err != nil {
return User{}, err
}