From 06ad174fb9ae15665c290d0e931b93ea6b041fc6 Mon Sep 17 00:00:00 2001 From: Mix Date: Mon, 12 Oct 2026 15:48:56 +0300 Subject: [PATCH] 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:, cert, queue, limit::), 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 --- CHANGELOG.md | 9 + docs/plans/panel-notifications.md | 2 +- docs/roadmap.md | 2 +- docs/schema-migrations.md | 11 +- .../store/migrations/0004_notifications.sql | 28 +++ internal/store/notify.go | 194 ++++++++++++++++++ internal/store/notify_test.go | 166 +++++++++++++++ internal/store/schema_test.go | 13 +- internal/store/users.go | 13 +- 9 files changed, 421 insertions(+), 17 deletions(-) create mode 100644 internal/store/migrations/0004_notifications.sql create mode 100644 internal/store/notify.go create mode 100644 internal/store/notify_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 210f4fa..cfad559 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version ## [Unreleased] +### Added + +- **Schema for notifications** (migration `0004_notifications.sql`, DDL only): + a user's own switch, `users.notify`, on for everyone; `notify_state`, what + the panel has already written to its users about. The instance switch is a + settings key, on unless turned off + ([plans/panel-notifications.md](docs/plans/panel-notifications.md)). Nothing + is sent yet. + ## [2.2.0] - 2026-10-12 ### Added diff --git a/docs/plans/panel-notifications.md b/docs/plans/panel-notifications.md index 8a20ec6..368fe03 100644 --- a/docs/plans/panel-notifications.md +++ b/docs/plans/panel-notifications.md @@ -108,7 +108,7 @@ to the owner before any code ([panel-redesign.md](panel-redesign.md) ## Implementation checklist - [x] Mockups: Account checkbox, Settings switch and records, Health status card, help topic; accepted by the owner 2026-10-12 (`design:` commit) -- [ ] Migration and store: user flag, instance switch, event state +- [x] Migration and store: user flag, instance switch, event state - [ ] Sending: local submission from `postmaster@`, message text - [ ] Background checker: DNS, certificate, queue; the rules of *When a message is sent* - [ ] Level-2 refusal event diff --git a/docs/roadmap.md b/docs/roadmap.md index a813878..97eadaf 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -35,7 +35,7 @@ in `git log` and [CHANGELOG.md](../CHANGELOG.md). | template-data-typing | Typed structs for template data instead of `map[string]any` | candidate | — | — | | structured-logging | `log/slog` with levels and fields | candidate | — | — | | review-2026-08-followups | Remaining findings of the 2026-08-19 code review | candidate | — | — | -| panel-notifications | E-mail notifications about important events | in progress | 1/10 | [plans/panel-notifications.md](plans/panel-notifications.md) | +| panel-notifications | E-mail notifications about important events | in progress | 2/10 | [plans/panel-notifications.md](plans/panel-notifications.md) | | delivery-log-storage | Keep a message's `mail.log` lines with its send-log row | shipped `2.2.0` | 8/8 | [plans/delivery-log-storage.md](plans/delivery-log-storage.md) | | password-reset | Password reset by e-mail | candidate | — | — | | phone-desktop-mode | A phone browser in "desktop site" mode gets the phone navigation | candidate | — | — | diff --git a/docs/schema-migrations.md b/docs/schema-migrations.md index 2aa7b54..14e2c74 100644 --- a/docs/schema-migrations.md +++ b/docs/schema-migrations.md @@ -17,8 +17,8 @@ When you add or review a migration, update this file in the same change. | Field | Value | |---|---| -| Chain head | `user_version = 3` | -| Files | `0001_init.sql` (the 2.0 baseline), `0002_inbound_spam_log.sql`, `0003_send_log_lines.sql` | +| Chain head | `user_version = 4` | +| Files | `0001_init.sql` (the 2.0 baseline), `0002_inbound_spam_log.sql`, `0003_send_log_lines.sql`, `0004_notifications.sql` | | File mark | `PRAGMA application_id = 0x53503230` ("SP20") | | Database file | `/data/selfpost.db` (bind mount) | | Compatibility | 2.x MINOR releases must boot a `2.0.0` data directory. **Nothing is carried over from 1.x.** | @@ -63,7 +63,8 @@ again and no file is deleted, renamed or reordered — every 2.x database has already run it. `2.0.0` and `2.0.1` shipped with the baseline alone; the features after them add files, the first being `0002_inbound_spam_log.sql` of inbound-antispam-panel (`2.1.0`), then `0003_send_log_lines.sql` of -delivery-log-storage (`2.2.0`). +delivery-log-storage (`2.2.0`) and `0004_notifications.sql` of +panel-notifications (`2.3.0`). --- @@ -74,6 +75,7 @@ delivery-log-storage (`2.2.0`). | 1 | `0001_init.sql` | 2.0.0 | DDL | The whole 2.0 schema, below | | 2 | `0002_inbound_spam_log.sql` | 2.1.0 | DDL | `inbound_spam_log`, `inbound_spam_list` and two indexes; nothing existing changes | | 3 | `0003_send_log_lines.sql` | 2.2.0 | DDL | `send_log_lines` and one index; nothing existing changes | +| 4 | `0004_notifications.sql` | 2.3.0 | DDL | `users.notify` (default 1: every user starts with notifications on) and `notify_state` | **Kind:** *DDL* — schema only; *data* — `INSERT`/`UPDATE` that must stay correct for operators upgrading from older 2.x images. @@ -85,7 +87,7 @@ for operators upgrading from older 2.x images. | Table | Role | |-------|------| | `settings` | Key/value settings of the instance — what is true whoever is signed in (send-log retention). Nothing that belongs to a user | -| `users` | Panel logins. `role` is `global` or `domain`; `email` is the user's own address; `all_domains` / `all_inbound_domains` widen a `domain` user to every domain of that direction | +| `users` | Panel logins. `role` is `global` or `domain`; `email` is the user's own address; `notify` is the user's own switch for notification e-mails (v4); `all_domains` / `all_inbound_domains` widen a `domain` user to every domain of that direction | | `sessions` | Panel login sessions (token hash, owning user by id and deleted with them, sliding idle expiry) | | `domains` | Sending (outbound) domains. `dmarc_rua` is where aggregate reports go (`''` = no reports) | | `user_domains` | Outbound domains assigned to a `domain` user | @@ -102,6 +104,7 @@ for operators upgrading from older 2.x images. | `dmarc_report_records` | Per-source rows inside a report | | `inbound_spam_log` | Inbound antispam journal: one row per recipient of a message offered on port 25, with the decision (`accept`, `reject`, `tempfail`, `quarantine`) and who made it (`rspamd`, `postfix`, `selfpost`). Metadata only (v2) | | `inbound_spam_list` | Allow / deny entries for the external filter (`address`, `domain`, `ip`), instance-wide; unique per list (v2) | +| `notify_state` | What the panel has already written to its users about, one row per subject (`dns:`, `cert`, `queue`, `limit::`), so that each thing is said once and a restart says nothing again (v4) | | `send_log_lines` | The `mail.log` lines Postfix wrote under a queue id, with the line's own time; what a message's page shows once the log has rotated. No reference to `send_log`: a line belongs to a row by queue id and time, and goes when no row carries its id (v3) | `TestBaselineSchema` (`internal/store/schema_test.go`) pins every table, column diff --git a/internal/store/migrations/0004_notifications.sql b/internal/store/migrations/0004_notifications.sql new file mode 100644 index 0000000..7cf78d9 --- /dev/null +++ b/internal/store/migrations/0004_notifications.sql @@ -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: a sending domain's records +-- cert the TLS certificate +-- queue a message older than three days +-- limit:: 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 '' +); diff --git a/internal/store/notify.go b/internal/store/notify.go new file mode 100644 index 0000000..e1beb9a --- /dev/null +++ b/internal/store/notify.go @@ -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() +} diff --git a/internal/store/notify_test.go b/internal/store/notify_test.go new file mode 100644 index 0000000..d90815d --- /dev/null +++ b/internal/store/notify_test.go @@ -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]) + } +} diff --git a/internal/store/schema_test.go b/internal/store/schema_test.go index d0d86a0..17583f6 100644 --- a/internal/store/schema_test.go +++ b/internal/store/schema_test.go @@ -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) } diff --git a/internal/store/users.go b/internal/store/users.go index 3526f61..bc5b0a4 100644 --- a/internal/store/users.go +++ b/internal/store/users.go @@ -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 }