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:
@@ -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 ''
|
||||
);
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user