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
+9
View File
@@ -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
+1 -1
View File
@@ -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@<hostname>`, message text
- [ ] Background checker: DNS, certificate, queue; the rules of *When a message is sent*
- [ ] Level-2 refusal event
+1 -1
View File
@@ -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 | — | — |
+7 -4
View File
@@ -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:<domain>`, `cert`, `queue`, `limit:<domain>:<login>`), 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
@@ -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
}