release: 0.1.0: Phases 0-11: panel, Postfix/DKIM, milter, backup, Docker image

Initial feature-complete implementation of the v1.0 specification (phases 0-11 of `docs/implementation-plan.md`).
Added:
  - Panel (Go, single static binary) with SQLite persistence, one-time
  - Domain management with per-domain DKIM (RSA-2048, generated in pure Go) and
  - Application (sender identity) management: SASL credentials via `sasldb2`,
  - Full Postfix relay config generated from env at container start: SMTPS 465,
  - Journal milter (pure Go, `go-milter`) recording every send to `send_log`;
  … (6 more in CHANGELOG)

Full detail: CHANGELOG.md (0.1.0).
This commit is contained in:
mix
2026-09-22 15:24:51 +03:00
commit 8ce050ef7f
157 changed files with 17631 additions and 0 deletions
+84
View File
@@ -0,0 +1,84 @@
package store
import (
"database/sql"
"errors"
"fmt"
"time"
)
// ErrNoAdmin is returned by GetAdmin when primary setup has not happened yet.
var ErrNoAdmin = errors.New("no administrator account")
// Admin is the single panel administrator (spec 7.6.1).
type Admin struct {
Username string
PasswordHash string
CreatedAt time.Time
}
// AdminExists reports whether the administrator account has been created. This
// doubles as the "primary setup complete" flag: once true, the /setup route is
// permanently gone (spec 7.6.1).
func (s *Store) AdminExists() (bool, error) {
var n int
if err := s.db.QueryRow("SELECT COUNT(*) FROM admin").Scan(&n); err != nil {
return false, fmt.Errorf("count admin: %w", err)
}
return n > 0, nil
}
// CreateAdmin inserts the administrator row. It fails if one already exists,
// which — combined with the id=1 constraint — makes admin creation one-shot
// even under a race between two setup submissions.
func (s *Store) CreateAdmin(username, passwordHash string) error {
_, err := s.db.Exec(
"INSERT INTO admin (id, username, password_hash, created_at) VALUES (1, ?, ?, ?)",
username, passwordHash, time.Now().UTC().Format(time.RFC3339),
)
if err != nil {
return fmt.Errorf("create admin: %w", err)
}
return nil
}
// UpdateAdmin replaces the administrator's username and password hash. It
// touches only the admin row (id = 1): panel credentials are unrelated to the
// SASL logins applications authenticate with, which live in their own table.
// ErrNoAdmin is returned if setup has not happened yet, so a change can never
// silently create an account.
func (s *Store) UpdateAdmin(username, passwordHash string) error {
res, err := s.db.Exec(
"UPDATE admin SET username = ?, password_hash = ? WHERE id = 1",
username, passwordHash,
)
if err != nil {
return fmt.Errorf("update admin: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("update admin: %w", err)
}
if n == 0 {
return ErrNoAdmin
}
return nil
}
// GetAdmin returns the administrator account, or ErrNoAdmin if setup is pending.
func (s *Store) GetAdmin() (Admin, error) {
var (
a Admin
createdAt string
)
err := s.db.QueryRow("SELECT username, password_hash, created_at FROM admin WHERE id = 1").
Scan(&a.Username, &a.PasswordHash, &createdAt)
if errors.Is(err, sql.ErrNoRows) {
return Admin{}, ErrNoAdmin
}
if err != nil {
return Admin{}, fmt.Errorf("get admin: %w", err)
}
a.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
return a, nil
}
+45
View File
@@ -0,0 +1,45 @@
package store
import (
"errors"
"testing"
)
func TestUpdateAdmin(t *testing.T) {
st := openTestStore(t)
if err := st.CreateAdmin("admin", "hash-one"); err != nil {
t.Fatalf("CreateAdmin: %v", err)
}
if err := st.UpdateAdmin("operator", "hash-two"); err != nil {
t.Fatalf("UpdateAdmin: %v", err)
}
a, err := st.GetAdmin()
if err != nil {
t.Fatalf("GetAdmin: %v", err)
}
if a.Username != "operator" || a.PasswordHash != "hash-two" {
t.Fatalf("unexpected admin after update: %+v", a)
}
if a.CreatedAt.IsZero() {
t.Fatal("update dropped created_at")
}
}
// An update before setup must not create the account: only the one-time setup
// flow may do that (spec 7.6.1).
func TestUpdateAdminWithoutAdmin(t *testing.T) {
st := openTestStore(t)
if err := st.UpdateAdmin("operator", "hash"); !errors.Is(err, ErrNoAdmin) {
t.Fatalf("UpdateAdmin without admin = %v, want ErrNoAdmin", err)
}
exists, err := st.AdminExists()
if err != nil {
t.Fatalf("AdminExists: %v", err)
}
if exists {
t.Fatal("UpdateAdmin created an administrator")
}
}
+323
View File
@@ -0,0 +1,323 @@
package store
import (
"database/sql"
"errors"
"fmt"
"time"
)
// ErrLoginExists is returned when an application login is already taken. The
// login is globally unique because it is the SASL identity Postfix authenticates
// (one sasldb2 across all domains, spec 5.1).
var ErrLoginExists = errors.New("application login already exists")
// ErrApplicationNotFound is returned when an application id does not exist.
var ErrApplicationNotFound = errors.New("application not found")
// Address modes (spec 4.1). Kept in sync with the CHECK constraint in the schema.
const (
AddressModeWildcard = "wildcard" // any address within the application's domain
AddressModeList = "list" // only the explicitly listed addresses
)
// Application is a SASL account bound to a single domain (spec 4.1, 5.1). The
// password is never stored here — only in sasldb2, hashed — so it can be shown
// exactly once at creation/regeneration (spec 7.6.1). Addresses is populated only
// in 'list' mode.
type Application struct {
ID int64
DomainID int64
Login string
AddressMode string
CreatedAt time.Time
Addresses []string
}
// Binding is one sender-address → login pair, as consumed by the
// smtpd_sender_login_maps generator (spec 5.1). For a wildcard application the
// Address is the domain wildcard "@example.com"; for a list application there is
// one Binding per listed address.
type Binding struct {
Address string
Login string
}
// AddApplication inserts an application and, in list mode, its addresses, in a
// single transaction. The caller must have validated login and every address
// (spec 7.6.2) beforehand; the query is parameterised regardless. A duplicate
// login maps to ErrLoginExists.
func (s *Store) AddApplication(domainID int64, login, mode string, addresses []string) (Application, error) {
now := time.Now().UTC()
tx, err := s.db.Begin()
if err != nil {
return Application{}, fmt.Errorf("begin add application: %w", err)
}
defer tx.Rollback()
res, err := tx.Exec(
"INSERT INTO applications (domain_id, login, address_mode, created_at) VALUES (?, ?, ?, ?)",
domainID, login, mode, now.Format(time.RFC3339),
)
if err != nil {
if isUniqueViolation(err) {
return Application{}, ErrLoginExists
}
return Application{}, fmt.Errorf("insert application: %w", err)
}
id, err := res.LastInsertId()
if err != nil {
return Application{}, fmt.Errorf("application id: %w", err)
}
if err := insertAddresses(tx, id, mode, addresses); err != nil {
return Application{}, err
}
if err := tx.Commit(); err != nil {
return Application{}, fmt.Errorf("commit add application: %w", err)
}
return Application{
ID: id, DomainID: domainID, Login: login, AddressMode: mode,
CreatedAt: now, Addresses: normalizedList(mode, addresses),
}, nil
}
// UpdateApplicationMode switches an application's address mode and replaces its
// address list atomically (spec 7.2.7). The login and password are untouched.
// Returns ErrApplicationNotFound if the id does not exist.
func (s *Store) UpdateApplicationMode(id int64, mode string, addresses []string) error {
tx, err := s.db.Begin()
if err != nil {
return fmt.Errorf("begin update application: %w", err)
}
defer tx.Rollback()
res, err := tx.Exec("UPDATE applications SET address_mode = ? WHERE id = ?", mode, id)
if err != nil {
return fmt.Errorf("update application mode: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("update application rows: %w", err)
}
if n == 0 {
return ErrApplicationNotFound
}
if _, err := tx.Exec("DELETE FROM application_addresses WHERE application_id = ?", id); err != nil {
return fmt.Errorf("clear addresses: %w", err)
}
if err := insertAddresses(tx, id, mode, addresses); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit update application: %w", err)
}
return nil
}
// insertAddresses writes the address rows for a list-mode application. In
// wildcard mode no address rows are stored (the wildcard is derived from the
// domain at map-generation time).
func insertAddresses(tx *sql.Tx, appID int64, mode string, addresses []string) error {
if mode != AddressModeList {
return nil
}
for _, addr := range addresses {
if _, err := tx.Exec(
"INSERT INTO application_addresses (application_id, address) VALUES (?, ?)",
appID, addr,
); err != nil {
if isUniqueViolation(err) {
continue // a repeated address in the same submission is harmless
}
return fmt.Errorf("insert address %q: %w", addr, err)
}
}
return nil
}
func normalizedList(mode string, addresses []string) []string {
if mode != AddressModeList {
return nil
}
return addresses
}
// GetApplication returns one application (with its addresses) by id, or
// ErrApplicationNotFound.
func (s *Store) GetApplication(id int64) (Application, error) {
row := s.db.QueryRow(
"SELECT id, domain_id, login, address_mode, created_at FROM applications WHERE id = ?", id)
a, err := scanApplication(row)
if errors.Is(err, sql.ErrNoRows) {
return Application{}, ErrApplicationNotFound
}
if err != nil {
return Application{}, err
}
addrs, err := s.applicationAddresses(a.ID)
if err != nil {
return Application{}, err
}
a.Addresses = addrs
return a, nil
}
// ListApplicationsByDomain returns a domain's applications ordered by login,
// each with its address list populated (spec 7.2.6).
func (s *Store) ListApplicationsByDomain(domainID int64) ([]Application, error) {
rows, err := s.db.Query(
"SELECT id, domain_id, login, address_mode, created_at FROM applications WHERE domain_id = ? ORDER BY login",
domainID)
if err != nil {
return nil, fmt.Errorf("list applications: %w", err)
}
defer rows.Close()
var out []Application
for rows.Next() {
a, err := scanApplication(rows)
if err != nil {
return nil, err
}
out = append(out, a)
}
if err := rows.Err(); err != nil {
return nil, err
}
// Fill address lists after the first query is drained (MaxOpenConns is 1).
for i := range out {
addrs, err := s.applicationAddresses(out[i].ID)
if err != nil {
return nil, err
}
out[i].Addresses = addrs
}
return out, nil
}
// ListApplicationLogins returns every application login across all domains,
// ordered, for the send-log monitoring screen's filter dropdown (spec 7.2).
// Logins are globally unique (spec 5.1), so no domain qualifier is needed.
func (s *Store) ListApplicationLogins() ([]string, error) {
rows, err := s.db.Query("SELECT login FROM applications ORDER BY login")
if err != nil {
return nil, fmt.Errorf("list application logins: %w", err)
}
defer rows.Close()
var out []string
for rows.Next() {
var login string
if err := rows.Scan(&login); err != nil {
return nil, err
}
out = append(out, login)
}
return out, rows.Err()
}
// ListLoginsByDomain returns the SASL logins of a domain's applications. Used to
// purge sasldb2 entries before a domain (and its applications via cascade) is
// deleted, while the logins are still known (spec 7.2.4).
func (s *Store) ListLoginsByDomain(domainID int64) ([]string, error) {
rows, err := s.db.Query("SELECT login FROM applications WHERE domain_id = ? ORDER BY login", domainID)
if err != nil {
return nil, fmt.Errorf("list logins: %w", err)
}
defer rows.Close()
var out []string
for rows.Next() {
var login string
if err := rows.Scan(&login); err != nil {
return nil, err
}
out = append(out, login)
}
return out, rows.Err()
}
// ListBindings returns every sender-address → login pair across all domains, the
// raw material for the smtpd_sender_login_maps file (spec 5.1). Wildcard
// applications yield a single "@domain" binding; list applications yield one
// binding per address. Ordered deterministically so the generated map is stable.
func (s *Store) ListBindings() ([]Binding, error) {
rows, err := s.db.Query(`
SELECT '@' || d.name, a.login
FROM applications a
JOIN domains d ON d.id = a.domain_id
WHERE a.address_mode = 'wildcard'
UNION ALL
SELECT aa.address, a.login
FROM application_addresses aa
JOIN applications a ON a.id = aa.application_id
WHERE a.address_mode = 'list'
ORDER BY 1, 2`)
if err != nil {
return nil, fmt.Errorf("list bindings: %w", err)
}
defer rows.Close()
var out []Binding
for rows.Next() {
var b Binding
if err := rows.Scan(&b.Address, &b.Login); err != nil {
return nil, err
}
out = append(out, b)
}
return out, rows.Err()
}
// DeleteApplication removes an application and its addresses (via cascade),
// returning the deleted application so the caller can drop its sasldb2 entry
// (spec 7.2.8). Returns ErrApplicationNotFound if no such row existed.
func (s *Store) DeleteApplication(id int64) (Application, error) {
a, err := s.GetApplication(id)
if err != nil {
return Application{}, err
}
res, err := s.db.Exec("DELETE FROM applications WHERE id = ?", id)
if err != nil {
return Application{}, fmt.Errorf("delete application: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return Application{}, fmt.Errorf("delete application rows: %w", err)
}
if n == 0 {
return Application{}, ErrApplicationNotFound
}
return a, nil
}
func (s *Store) applicationAddresses(appID int64) ([]string, error) {
rows, err := s.db.Query(
"SELECT address FROM application_addresses WHERE application_id = ? ORDER BY address", appID)
if err != nil {
return nil, fmt.Errorf("application addresses: %w", err)
}
defer rows.Close()
var out []string
for rows.Next() {
var a string
if err := rows.Scan(&a); err != nil {
return nil, err
}
out = append(out, a)
}
return out, rows.Err()
}
func scanApplication(r scanRow) (Application, error) {
var (
a Application
createdAt string
)
if err := r.Scan(&a.ID, &a.DomainID, &a.Login, &a.AddressMode, &createdAt); err != nil {
return Application{}, err
}
a.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
return a, nil
}
+189
View File
@@ -0,0 +1,189 @@
package store
import (
"errors"
"testing"
)
func addTestDomain(t *testing.T, st *Store, name string) Domain {
t.Helper()
d, err := st.AddDomain(name, "selfpost")
if err != nil {
t.Fatalf("AddDomain(%q): %v", name, err)
}
return d
}
func TestAddApplicationWildcard(t *testing.T) {
st := openTestStore(t)
d := addTestDomain(t, st, "example.com")
a, err := st.AddApplication(d.ID, "alerts", AddressModeWildcard, nil)
if err != nil {
t.Fatalf("AddApplication: %v", err)
}
if a.ID == 0 || a.Login != "alerts" || a.AddressMode != AddressModeWildcard {
t.Fatalf("unexpected application: %+v", a)
}
if len(a.Addresses) != 0 {
t.Errorf("wildcard app should have no addresses, got %v", a.Addresses)
}
got, err := st.GetApplication(a.ID)
if err != nil {
t.Fatalf("GetApplication: %v", err)
}
if got.Login != "alerts" || len(got.Addresses) != 0 {
t.Fatalf("round-trip mismatch: %+v", got)
}
}
func TestAddApplicationListStoresAddresses(t *testing.T) {
st := openTestStore(t)
d := addTestDomain(t, st, "example.com")
addrs := []string{"noreply@example.com", "alerts@example.com"}
a, err := st.AddApplication(d.ID, "app1", AddressModeList, addrs)
if err != nil {
t.Fatalf("AddApplication: %v", err)
}
got, err := st.GetApplication(a.ID)
if err != nil {
t.Fatal(err)
}
// Addresses come back sorted.
if len(got.Addresses) != 2 || got.Addresses[0] != "alerts@example.com" || got.Addresses[1] != "noreply@example.com" {
t.Fatalf("addresses = %v", got.Addresses)
}
}
func TestAddApplicationDuplicateLogin(t *testing.T) {
st := openTestStore(t)
d := addTestDomain(t, st, "example.com")
d2 := addTestDomain(t, st, "other.com")
if _, err := st.AddApplication(d.ID, "shared", AddressModeWildcard, nil); err != nil {
t.Fatal(err)
}
// Same login under a different domain must still collide (global uniqueness).
_, err := st.AddApplication(d2.ID, "shared", AddressModeWildcard, nil)
if !errors.Is(err, ErrLoginExists) {
t.Fatalf("duplicate login error = %v, want ErrLoginExists", err)
}
}
func TestUpdateApplicationMode(t *testing.T) {
st := openTestStore(t)
d := addTestDomain(t, st, "example.com")
a, err := st.AddApplication(d.ID, "app1", AddressModeList, []string{"a@example.com"})
if err != nil {
t.Fatal(err)
}
// list -> wildcard drops the addresses.
if err := st.UpdateApplicationMode(a.ID, AddressModeWildcard, nil); err != nil {
t.Fatalf("UpdateApplicationMode: %v", err)
}
got, _ := st.GetApplication(a.ID)
if got.AddressMode != AddressModeWildcard || len(got.Addresses) != 0 {
t.Fatalf("after wildcard switch: %+v", got)
}
// wildcard -> list adds a fresh set.
if err := st.UpdateApplicationMode(a.ID, AddressModeList, []string{"b@example.com", "c@example.com"}); err != nil {
t.Fatal(err)
}
got, _ = st.GetApplication(a.ID)
if got.AddressMode != AddressModeList || len(got.Addresses) != 2 {
t.Fatalf("after list switch: %+v", got)
}
}
func TestUpdateApplicationModeNotFound(t *testing.T) {
st := openTestStore(t)
if err := st.UpdateApplicationMode(999, AddressModeWildcard, nil); !errors.Is(err, ErrApplicationNotFound) {
t.Fatalf("UpdateApplicationMode(missing) = %v, want ErrApplicationNotFound", err)
}
}
func TestDeleteApplication(t *testing.T) {
st := openTestStore(t)
d := addTestDomain(t, st, "example.com")
a, err := st.AddApplication(d.ID, "app1", AddressModeList, []string{"a@example.com"})
if err != nil {
t.Fatal(err)
}
deleted, err := st.DeleteApplication(a.ID)
if err != nil {
t.Fatalf("DeleteApplication: %v", err)
}
if deleted.Login != "app1" {
t.Errorf("deleted login = %q, want app1", deleted.Login)
}
assertCount(t, st, "applications", 0)
assertCount(t, st, "application_addresses", 0)
if _, err := st.DeleteApplication(a.ID); !errors.Is(err, ErrApplicationNotFound) {
t.Fatalf("second delete = %v, want ErrApplicationNotFound", err)
}
}
func TestListBindingsMixedModes(t *testing.T) {
st := openTestStore(t)
d1 := addTestDomain(t, st, "example.com")
d2 := addTestDomain(t, st, "other.com")
if _, err := st.AddApplication(d1.ID, "wild", AddressModeWildcard, nil); err != nil {
t.Fatal(err)
}
if _, err := st.AddApplication(d1.ID, "listed", AddressModeList,
[]string{"alerts@example.com", "noreply@example.com"}); err != nil {
t.Fatal(err)
}
if _, err := st.AddApplication(d2.ID, "wild2", AddressModeWildcard, nil); err != nil {
t.Fatal(err)
}
bindings, err := st.ListBindings()
if err != nil {
t.Fatalf("ListBindings: %v", err)
}
want := []Binding{
{"@example.com", "wild"},
{"@other.com", "wild2"},
{"alerts@example.com", "listed"},
{"noreply@example.com", "listed"},
}
if len(bindings) != len(want) {
t.Fatalf("bindings = %+v, want %+v", bindings, want)
}
for i := range want {
if bindings[i] != want[i] {
t.Errorf("binding[%d] = %+v, want %+v", i, bindings[i], want[i])
}
}
}
func TestListLoginsByDomain(t *testing.T) {
st := openTestStore(t)
d := addTestDomain(t, st, "example.com")
other := addTestDomain(t, st, "other.com")
if _, err := st.AddApplication(d.ID, "a", AddressModeWildcard, nil); err != nil {
t.Fatal(err)
}
if _, err := st.AddApplication(d.ID, "b", AddressModeWildcard, nil); err != nil {
t.Fatal(err)
}
if _, err := st.AddApplication(other.ID, "c", AddressModeWildcard, nil); err != nil {
t.Fatal(err)
}
logins, err := st.ListLoginsByDomain(d.ID)
if err != nil {
t.Fatal(err)
}
if len(logins) != 2 || logins[0] != "a" || logins[1] != "b" {
t.Fatalf("logins = %v, want [a b]", logins)
}
}
+138
View File
@@ -0,0 +1,138 @@
package store
import (
"database/sql"
"errors"
"fmt"
"time"
"modernc.org/sqlite"
sqlite3 "modernc.org/sqlite/lib"
)
// ErrDomainExists is returned by AddDomain when the domain is already managed.
var ErrDomainExists = errors.New("domain already exists")
// ErrDomainNotFound is returned when a domain id/name does not exist.
var ErrDomainNotFound = errors.New("domain not found")
// Domain is a sending domain managed through the panel (spec 4.1). The DKIM key
// material itself lives on disk under /data; this row records the selector and
// metadata. AppCount is populated by the listing queries, not stored.
type Domain struct {
ID int64
Name string
DKIMSelector string
CreatedAt time.Time
AppCount int
}
// AddDomain inserts a new sending domain. The caller is responsible for having
// validated name (spec 7.6.2) before it reaches SQL; the query is parameterised
// regardless. A duplicate name maps to ErrDomainExists.
func (s *Store) AddDomain(name, selector string) (Domain, error) {
now := time.Now().UTC()
res, err := s.db.Exec(
"INSERT INTO domains (name, dkim_selector, created_at) VALUES (?, ?, ?)",
name, selector, now.Format(time.RFC3339),
)
if err != nil {
if isUniqueViolation(err) {
return Domain{}, ErrDomainExists
}
return Domain{}, fmt.Errorf("insert domain: %w", err)
}
id, err := res.LastInsertId()
if err != nil {
return Domain{}, fmt.Errorf("domain id: %w", err)
}
return Domain{ID: id, Name: name, DKIMSelector: selector, CreatedAt: now}, nil
}
// ListDomains returns every domain with its bound-application count (spec 7.2.2),
// ordered by name.
func (s *Store) ListDomains() ([]Domain, error) {
rows, err := s.db.Query(`
SELECT d.id, d.name, d.dkim_selector, d.created_at,
(SELECT COUNT(*) FROM applications a WHERE a.domain_id = d.id)
FROM domains d
ORDER BY d.name`)
if err != nil {
return nil, fmt.Errorf("list domains: %w", err)
}
defer rows.Close()
var out []Domain
for rows.Next() {
d, err := scanDomain(rows)
if err != nil {
return nil, err
}
out = append(out, d)
}
return out, rows.Err()
}
// GetDomain returns a single domain (with its application count) by id, or
// ErrDomainNotFound.
func (s *Store) GetDomain(id int64) (Domain, error) {
row := s.db.QueryRow(`
SELECT d.id, d.name, d.dkim_selector, d.created_at,
(SELECT COUNT(*) FROM applications a WHERE a.domain_id = d.id)
FROM domains d
WHERE d.id = ?`, id)
d, err := scanDomain(row)
if errors.Is(err, sql.ErrNoRows) {
return Domain{}, ErrDomainNotFound
}
if err != nil {
return Domain{}, err
}
return d, nil
}
// DeleteDomain removes a domain. Its applications and their address/binding rows
// go with it via ON DELETE CASCADE (spec 7.2.4). Returns ErrDomainNotFound if no
// such row existed.
func (s *Store) DeleteDomain(id int64) error {
res, err := s.db.Exec("DELETE FROM domains WHERE id = ?", id)
if err != nil {
return fmt.Errorf("delete domain: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("delete domain rows: %w", err)
}
if n == 0 {
return ErrDomainNotFound
}
return nil
}
// scanRow is the minimal surface shared by *sql.Row and *sql.Rows.
type scanRow interface {
Scan(dest ...any) error
}
func scanDomain(r scanRow) (Domain, error) {
var (
d Domain
createdAt string
)
if err := r.Scan(&d.ID, &d.Name, &d.DKIMSelector, &createdAt, &d.AppCount); err != nil {
return Domain{}, err
}
d.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
return d, nil
}
// isUniqueViolation reports whether err is a SQLite UNIQUE/PRIMARY-KEY conflict,
// so callers can turn a duplicate insert into a friendly domain-level error.
func isUniqueViolation(err error) bool {
var se *sqlite.Error
if errors.As(err, &se) {
code := se.Code()
return code == sqlite3.SQLITE_CONSTRAINT_UNIQUE || code == sqlite3.SQLITE_CONSTRAINT_PRIMARYKEY
}
return false
}
+117
View File
@@ -0,0 +1,117 @@
package store
import (
"errors"
"path/filepath"
"testing"
"time"
)
func openTestStore(t *testing.T) *Store {
t.Helper()
st, err := Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("open store: %v", err)
}
t.Cleanup(func() { st.Close() })
return st
}
func TestAddAndListDomains(t *testing.T) {
st := openTestStore(t)
d, err := st.AddDomain("example.com", "selfpost")
if err != nil {
t.Fatalf("AddDomain: %v", err)
}
if d.ID == 0 || d.Name != "example.com" || d.DKIMSelector != "selfpost" {
t.Fatalf("unexpected domain: %+v", d)
}
list, err := st.ListDomains()
if err != nil {
t.Fatalf("ListDomains: %v", err)
}
if len(list) != 1 || list[0].Name != "example.com" || list[0].AppCount != 0 {
t.Fatalf("unexpected list: %+v", list)
}
}
func TestAddDomainDuplicate(t *testing.T) {
st := openTestStore(t)
if _, err := st.AddDomain("example.com", "selfpost"); err != nil {
t.Fatal(err)
}
_, err := st.AddDomain("example.com", "other")
if !errors.Is(err, ErrDomainExists) {
t.Fatalf("duplicate AddDomain error = %v, want ErrDomainExists", err)
}
}
func TestGetDomainNotFound(t *testing.T) {
st := openTestStore(t)
if _, err := st.GetDomain(999); !errors.Is(err, ErrDomainNotFound) {
t.Fatalf("GetDomain(missing) = %v, want ErrDomainNotFound", err)
}
}
func TestDeleteDomainCascadesApplications(t *testing.T) {
st := openTestStore(t)
d, err := st.AddDomain("example.com", "selfpost")
if err != nil {
t.Fatal(err)
}
// Insert an application + address directly (the AddApplication API lands in
// Phase 4); this verifies the ON DELETE CASCADE wiring now.
now := time.Now().UTC().Format(time.RFC3339)
res, err := st.db.Exec(
"INSERT INTO applications (domain_id, login, address_mode, created_at) VALUES (?, ?, 'wildcard', ?)",
d.ID, "app1", now)
if err != nil {
t.Fatal(err)
}
appID, _ := res.LastInsertId()
if _, err := st.db.Exec(
"INSERT INTO application_addresses (application_id, address) VALUES (?, ?)",
appID, "noreply@example.com"); err != nil {
t.Fatal(err)
}
got, err := st.GetDomain(d.ID)
if err != nil {
t.Fatal(err)
}
if got.AppCount != 1 {
t.Fatalf("AppCount = %d, want 1", got.AppCount)
}
if err := st.DeleteDomain(d.ID); err != nil {
t.Fatalf("DeleteDomain: %v", err)
}
assertCount(t, st, "applications", 0)
assertCount(t, st, "application_addresses", 0)
if _, err := st.GetDomain(d.ID); !errors.Is(err, ErrDomainNotFound) {
t.Errorf("domain still present after delete: %v", err)
}
}
func TestDeleteDomainNotFound(t *testing.T) {
st := openTestStore(t)
if err := st.DeleteDomain(123); !errors.Is(err, ErrDomainNotFound) {
t.Fatalf("DeleteDomain(missing) = %v, want ErrDomainNotFound", err)
}
}
func assertCount(t *testing.T, st *Store, table string, want int) {
t.Helper()
var n int
// table is a trusted literal from the test, not user input.
if err := st.db.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&n); err != nil {
t.Fatalf("count %s: %v", table, err)
}
if n != want {
t.Errorf("%s count = %d, want %d", table, n, want)
}
}
+76
View File
@@ -0,0 +1,76 @@
-- Initial SelfPost schema (spec 9). One SQLite file under /data holds the whole
-- panel state so a single directory backup/restore is sufficient (spec 7.5.A).
-- Single administrator account (spec 7.6.1). Exactly one row is allowed; the
-- presence of that row is what marks primary setup as complete, which is why
-- the /setup route disappears once it exists.
CREATE TABLE admin (
id INTEGER PRIMARY KEY CHECK (id = 1),
username TEXT NOT NULL,
password_hash TEXT NOT NULL,
created_at TEXT NOT NULL
);
-- Free-form key/value panel settings (retention overrides, misc flags).
CREATE TABLE settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
-- Sending domains managed through the panel (spec 4.1). DKIM keys themselves
-- live on disk under /data; this row records the selector and metadata.
CREATE TABLE domains (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
dkim_selector TEXT NOT NULL,
created_at TEXT NOT NULL
);
-- Applications bound to a domain (spec 4.1). address_mode is either the domain
-- wildcard or an explicit address list; the SASL login is globally unique.
CREATE TABLE applications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
domain_id INTEGER NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
login TEXT NOT NULL UNIQUE,
address_mode TEXT NOT NULL CHECK (address_mode IN ('wildcard', 'list')),
created_at TEXT NOT NULL
);
-- Explicit sender addresses for applications in 'list' mode. Each address must
-- belong to the application's domain (validated in the panel, spec 7.6.2).
CREATE TABLE application_addresses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
application_id INTEGER NOT NULL REFERENCES applications(id) ON DELETE CASCADE,
address TEXT NOT NULL,
UNIQUE (application_id, address)
);
-- Structured send log (spec 7.3). One row per (queue-id, recipient); the
-- log-tailer advances status from queued to a final state.
CREATE TABLE send_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
queue_id TEXT,
domain TEXT,
app_login TEXT,
from_addr TEXT,
to_addr TEXT,
subject TEXT,
status TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX idx_send_log_queue_id ON send_log (queue_id);
CREATE INDEX idx_send_log_domain ON send_log (domain);
CREATE INDEX idx_send_log_created_at ON send_log (created_at);
-- Differentiated rate limits per domain/application (spec 7.4). Both the IP
-- binding and the message limit are optional.
CREATE TABLE rate_limits (
id INTEGER PRIMARY KEY AUTOINCREMENT,
scope TEXT NOT NULL CHECK (scope IN ('domain', 'application')),
ref_id INTEGER NOT NULL,
allowed_ips TEXT,
max_messages INTEGER,
window_seconds INTEGER,
UNIQUE (scope, ref_id)
);
@@ -0,0 +1,12 @@
-- Panel login sessions (spec 7.6.6, plan B.1). Persisted so a login survives a
-- container restart or redeploy; only the SHA-256 of the session token is
-- stored, never the token itself, so a stolen database file cannot be used to
-- sign in. expires_at implements the sliding idle timeout: it is pushed
-- forward on activity rather than being fixed at creation time.
CREATE TABLE sessions (
token_hash TEXT PRIMARY KEY,
username TEXT NOT NULL,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL
);
CREATE INDEX idx_sessions_expires_at ON sessions (expires_at);
+214
View File
@@ -0,0 +1,214 @@
package store
import (
"database/sql"
"errors"
"fmt"
"net"
"strings"
"time"
)
// Rate-limit scopes (spec 7.4). A level-2 limit is attached either to a domain
// (counted across all its applications and IPs) or to a single application.
const (
RateLimitScopeDomain = "domain"
RateLimitScopeApp = "application"
)
// RateLimit is a differentiated level-2 rate limit (spec 7.4): an optional set
// of expected client IPs plus a message ceiling over a sliding window, attached
// to a domain or an application. It is enforced in the journal-milter; level 1
// (Postfix anvil, spec 5) is the IP backstop that always applies even when this
// is absent or the milter is down.
//
// Both the IP binding and the ceiling are optional in the schema, but a limit is
// only enforced when it is Active(): the design deliberately allows an admin to
// leave the IP binding empty for apps that send from changing IPs, in which case
// only level 1 protects them (spec 7.4's caveat).
type RateLimit struct {
Scope string
RefID int64
AllowedIPs []string // canonical client IPs this limit applies to
MaxMessages int
WindowSeconds int
}
// Active reports whether the limit is fully configured and should be enforced.
// A missing IP binding, ceiling or window leaves the differentiated limit inert
// (spec 7.4): the IP binding is what scopes the limit to a known sender.
func (r RateLimit) Active() bool {
return len(r.AllowedIPs) > 0 && r.MaxMessages > 0 && r.WindowSeconds > 0
}
// AllowsIP reports whether ip is one of the limit's registered client IPs. The
// comparison parses both sides so equivalent textual forms of the same address
// match; a client IP outside the list means the differentiated limit does not
// apply to it (level 1 still does).
func (r RateLimit) AllowsIP(ip string) bool {
c := net.ParseIP(ip)
if c == nil {
return false
}
for _, a := range r.AllowedIPs {
if p := net.ParseIP(a); p != nil && p.Equal(c) {
return true
}
}
return false
}
// GetRateLimit loads the level-2 limit configured for a domain or application by
// its id, for the panel's edit form. ok is false when none is configured.
func (s *Store) GetRateLimit(scope string, refID int64) (RateLimit, bool, error) {
row := s.db.QueryRow(
`SELECT allowed_ips, max_messages, window_seconds
FROM rate_limits WHERE scope = ? AND ref_id = ?`, scope, refID)
rl, err := scanRateLimit(row)
if errors.Is(err, sql.ErrNoRows) {
return RateLimit{Scope: scope, RefID: refID}, false, nil
}
if err != nil {
return RateLimit{}, false, fmt.Errorf("get rate limit: %w", err)
}
rl.Scope, rl.RefID = scope, refID
return rl, true, nil
}
// SetRateLimit upserts the level-2 limit for a domain or application. The caller
// (panel) has already validated the IPs and numbers (spec 7.6.2); values are
// stored via bound parameters and read back live by the milter.
func (s *Store) SetRateLimit(rl RateLimit) error {
_, err := s.db.Exec(
`INSERT INTO rate_limits (scope, ref_id, allowed_ips, max_messages, window_seconds)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(scope, ref_id) DO UPDATE SET
allowed_ips = excluded.allowed_ips,
max_messages = excluded.max_messages,
window_seconds = excluded.window_seconds`,
rl.Scope, rl.RefID, strings.Join(rl.AllowedIPs, ","), rl.MaxMessages, rl.WindowSeconds,
)
if err != nil {
return fmt.Errorf("set rate limit: %w", err)
}
return nil
}
// DeleteRateLimit removes the level-2 limit for a domain or application, so the
// admin can clear it and fall back to level 1 only.
func (s *Store) DeleteRateLimit(scope string, refID int64) error {
if _, err := s.db.Exec(`DELETE FROM rate_limits WHERE scope = ? AND ref_id = ?`, scope, refID); err != nil {
return fmt.Errorf("delete rate limit: %w", err)
}
return nil
}
// DeleteRateLimitsForDomain removes the domain's own limit and the limits of all
// its applications in one statement. It is called on domain deletion, before the
// application rows are cascade-deleted, to avoid leaving orphan limit rows.
func (s *Store) DeleteRateLimitsForDomain(domainID int64) error {
_, err := s.db.Exec(
`DELETE FROM rate_limits
WHERE (scope = 'domain' AND ref_id = ?)
OR (scope = 'application' AND ref_id IN (SELECT id FROM applications WHERE domain_id = ?))`,
domainID, domainID,
)
if err != nil {
return fmt.Errorf("delete domain rate limits: %w", err)
}
return nil
}
// RateLimit resolves the level-2 limit that applies to a message, keyed by the
// human-readable reference the milter has on the receive path: the sending
// domain name (scope "domain") or the SASL login (scope "application"). ok is
// false when no limit is configured for that reference.
func (s *Store) RateLimit(scope, ref string) (RateLimit, bool, error) {
var query string
switch scope {
case RateLimitScopeDomain:
query = `SELECT rl.allowed_ips, rl.max_messages, rl.window_seconds
FROM rate_limits rl JOIN domains d ON d.id = rl.ref_id
WHERE rl.scope = 'domain' AND d.name = ?`
case RateLimitScopeApp:
query = `SELECT rl.allowed_ips, rl.max_messages, rl.window_seconds
FROM rate_limits rl JOIN applications a ON a.id = rl.ref_id
WHERE rl.scope = 'application' AND a.login = ?`
default:
return RateLimit{}, false, fmt.Errorf("unknown rate-limit scope %q", scope)
}
rl, err := scanRateLimit(s.db.QueryRow(query, ref))
if errors.Is(err, sql.ErrNoRows) {
return RateLimit{}, false, nil
}
if err != nil {
return RateLimit{}, false, fmt.Errorf("rate limit for %s %q: %w", scope, ref, err)
}
rl.Scope = scope
return rl, true, nil
}
// CountMessages returns how many distinct messages the reference (a domain name
// or an application login) has queued since t, for the level-2 sliding window
// (spec 7.4). It counts distinct queue-ids — one message with many recipients is
// one message, matching level 1's per-message semantics — and excludes rows that
// were themselves rejected by a limit (they were never sent). It reuses the send
// log the journal already writes (spec 7.4: "переиспользует данные журнала").
func (s *Store) CountMessages(scope, ref string, since time.Time) (int64, error) {
var column string
switch scope {
case RateLimitScopeDomain:
column = "domain"
case RateLimitScopeApp:
column = "app_login"
default:
return 0, fmt.Errorf("unknown rate-limit scope %q", scope)
}
var n int64
// created_at is stored as RFC3339 UTC, so a lexical comparison against the
// same format is chronologically correct (as in DeleteSendLogBefore).
err := s.db.QueryRow(
`SELECT COUNT(DISTINCT queue_id) FROM send_log
WHERE `+column+` = ? AND status != ? AND created_at >= ?`,
ref, StatusRejected, since.UTC().Format(time.RFC3339),
).Scan(&n)
if err != nil {
return 0, fmt.Errorf("count messages for %s %q: %w", scope, ref, err)
}
return n, nil
}
// scanRateLimit reads the three stored columns, tolerating NULL numeric columns
// (an IP-only draft) by leaving the corresponding field zero, which makes the
// limit inert via Active().
func scanRateLimit(r scanRow) (RateLimit, error) {
var (
ips sql.NullString
maxMsgs sql.NullInt64
windowSecs sql.NullInt64
)
if err := r.Scan(&ips, &maxMsgs, &windowSecs); err != nil {
return RateLimit{}, err
}
return RateLimit{
AllowedIPs: splitIPs(ips.String),
MaxMessages: int(maxMsgs.Int64),
WindowSeconds: int(windowSecs.Int64),
}, nil
}
// splitIPs parses the comma-separated storage form back into a slice, dropping
// empties so an empty column yields nil (an inactive limit).
func splitIPs(s string) []string {
if strings.TrimSpace(s) == "" {
return nil
}
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
+178
View File
@@ -0,0 +1,178 @@
package store
import (
"testing"
"time"
)
func TestRateLimitSetGetDelete(t *testing.T) {
st := openTestStore(t)
d, err := st.AddDomain("example.com", "selfpost")
if err != nil {
t.Fatalf("AddDomain: %v", err)
}
if _, ok, err := st.GetRateLimit(RateLimitScopeDomain, d.ID); err != nil || ok {
t.Fatalf("GetRateLimit on empty: ok=%v err=%v", ok, err)
}
want := RateLimit{
Scope: RateLimitScopeDomain,
RefID: d.ID,
AllowedIPs: []string{"203.0.113.1", "203.0.113.2"},
MaxMessages: 100,
WindowSeconds: 3600,
}
if err := st.SetRateLimit(want); err != nil {
t.Fatalf("SetRateLimit: %v", err)
}
got, ok, err := st.GetRateLimit(RateLimitScopeDomain, d.ID)
if err != nil || !ok {
t.Fatalf("GetRateLimit after set: ok=%v err=%v", ok, err)
}
if got.MaxMessages != 100 || got.WindowSeconds != 3600 || len(got.AllowedIPs) != 2 ||
got.AllowedIPs[0] != "203.0.113.1" || got.AllowedIPs[1] != "203.0.113.2" {
t.Fatalf("roundtrip mismatch: %+v", got)
}
// Upsert replaces in place (UNIQUE(scope, ref_id)).
want.MaxMessages = 5
want.AllowedIPs = []string{"198.51.100.9"}
if err := st.SetRateLimit(want); err != nil {
t.Fatalf("SetRateLimit upsert: %v", err)
}
got, _, _ = st.GetRateLimit(RateLimitScopeDomain, d.ID)
if got.MaxMessages != 5 || len(got.AllowedIPs) != 1 || got.AllowedIPs[0] != "198.51.100.9" {
t.Fatalf("upsert did not replace: %+v", got)
}
if err := st.DeleteRateLimit(RateLimitScopeDomain, d.ID); err != nil {
t.Fatalf("DeleteRateLimit: %v", err)
}
if _, ok, _ := st.GetRateLimit(RateLimitScopeDomain, d.ID); ok {
t.Fatalf("limit still present after delete")
}
}
func TestRateLimitByNameAndLogin(t *testing.T) {
st := openTestStore(t)
d, _ := st.AddDomain("example.com", "selfpost")
a, err := st.AddApplication(d.ID, "app1", AddressModeWildcard, nil)
if err != nil {
t.Fatalf("AddApplication: %v", err)
}
if err := st.SetRateLimit(RateLimit{Scope: RateLimitScopeDomain, RefID: d.ID, AllowedIPs: []string{"203.0.113.1"}, MaxMessages: 10, WindowSeconds: 60}); err != nil {
t.Fatalf("set domain limit: %v", err)
}
if err := st.SetRateLimit(RateLimit{Scope: RateLimitScopeApp, RefID: a.ID, AllowedIPs: []string{"203.0.113.2"}, MaxMessages: 3, WindowSeconds: 60}); err != nil {
t.Fatalf("set app limit: %v", err)
}
// The milter resolves limits by domain name and by SASL login.
rl, ok, err := st.RateLimit(RateLimitScopeDomain, "example.com")
if err != nil || !ok || rl.MaxMessages != 10 {
t.Fatalf("RateLimit domain: ok=%v err=%v rl=%+v", ok, err, rl)
}
rl, ok, err = st.RateLimit(RateLimitScopeApp, "app1")
if err != nil || !ok || rl.MaxMessages != 3 {
t.Fatalf("RateLimit app: ok=%v err=%v rl=%+v", ok, err, rl)
}
if _, ok, _ := st.RateLimit(RateLimitScopeDomain, "unknown.example"); ok {
t.Fatalf("RateLimit for unknown domain should be not-ok")
}
}
func TestCountMessagesDistinctAndWindowed(t *testing.T) {
st := openTestStore(t)
// Two recipients share a queue-id → one message. A second message → two.
for _, to := range []string{"a@x.net", "b@x.net"} {
if err := st.InsertQueued(SendLogEntry{QueueID: "Q1", Domain: "example.com", AppLogin: "app1", To: to}); err != nil {
t.Fatalf("InsertQueued: %v", err)
}
}
if err := st.InsertQueued(SendLogEntry{QueueID: "Q2", Domain: "example.com", AppLogin: "app1", To: "c@x.net"}); err != nil {
t.Fatalf("InsertQueued: %v", err)
}
// A rejected row must not count toward the window.
if err := st.InsertRejected(SendLogEntry{Domain: "example.com", AppLogin: "app1", From: "s@example.com"}); err != nil {
t.Fatalf("InsertRejected: %v", err)
}
n, err := st.CountMessages(RateLimitScopeDomain, "example.com", time.Now().Add(-time.Hour))
if err != nil {
t.Fatalf("CountMessages: %v", err)
}
if n != 2 {
t.Fatalf("distinct-message count = %d, want 2 (two queue-ids, rejected excluded)", n)
}
n, _ = st.CountMessages(RateLimitScopeApp, "app1", time.Now().Add(-time.Hour))
if n != 2 {
t.Fatalf("app count = %d, want 2", n)
}
// Backdate Q1 beyond the window: only Q2 remains inside a 30-minute window.
old := time.Now().UTC().Add(-2 * time.Hour).Format(time.RFC3339)
if _, err := st.db.Exec(`UPDATE send_log SET created_at = ? WHERE queue_id = 'Q1'`, old); err != nil {
t.Fatalf("backdate: %v", err)
}
n, _ = st.CountMessages(RateLimitScopeDomain, "example.com", time.Now().Add(-30*time.Minute))
if n != 1 {
t.Fatalf("windowed count = %d, want 1 (Q1 aged out)", n)
}
}
func TestDeleteRateLimitsForDomain(t *testing.T) {
st := openTestStore(t)
d, _ := st.AddDomain("example.com", "selfpost")
a, _ := st.AddApplication(d.ID, "app1", AddressModeWildcard, nil)
other, _ := st.AddDomain("other.example", "selfpost")
_ = st.SetRateLimit(RateLimit{Scope: RateLimitScopeDomain, RefID: d.ID, AllowedIPs: []string{"203.0.113.1"}, MaxMessages: 10, WindowSeconds: 60})
_ = st.SetRateLimit(RateLimit{Scope: RateLimitScopeApp, RefID: a.ID, AllowedIPs: []string{"203.0.113.2"}, MaxMessages: 3, WindowSeconds: 60})
_ = st.SetRateLimit(RateLimit{Scope: RateLimitScopeDomain, RefID: other.ID, AllowedIPs: []string{"203.0.113.9"}, MaxMessages: 1, WindowSeconds: 60})
if err := st.DeleteRateLimitsForDomain(d.ID); err != nil {
t.Fatalf("DeleteRateLimitsForDomain: %v", err)
}
if _, ok, _ := st.GetRateLimit(RateLimitScopeDomain, d.ID); ok {
t.Fatalf("domain limit survived")
}
if _, ok, _ := st.GetRateLimit(RateLimitScopeApp, a.ID); ok {
t.Fatalf("application limit survived")
}
// The unrelated domain's limit is untouched.
if _, ok, _ := st.GetRateLimit(RateLimitScopeDomain, other.ID); !ok {
t.Fatalf("unrelated domain limit was deleted")
}
}
func TestRateLimitActiveAndAllowsIP(t *testing.T) {
inactive := []RateLimit{
{},
{AllowedIPs: []string{"203.0.113.1"}}, // no ceiling
{AllowedIPs: []string{"203.0.113.1"}, MaxMessages: 5}, // no window
{MaxMessages: 5, WindowSeconds: 60}, // no IPs
}
for i, rl := range inactive {
if rl.Active() {
t.Fatalf("case %d: %+v should be inactive", i, rl)
}
}
active := RateLimit{AllowedIPs: []string{"203.0.113.1", "2001:db8::1"}, MaxMessages: 5, WindowSeconds: 60}
if !active.Active() {
t.Fatalf("should be active: %+v", active)
}
if !active.AllowsIP("203.0.113.1") || !active.AllowsIP("2001:db8::1") {
t.Fatalf("registered IPs should match")
}
// Equivalent textual form of the IPv6 address must still match.
if !active.AllowsIP("2001:0db8:0000:0000:0000:0000:0000:0001") {
t.Fatalf("expanded IPv6 form should match")
}
if active.AllowsIP("198.51.100.7") || active.AllowsIP("not-an-ip") || active.AllowsIP("") {
t.Fatalf("unregistered/invalid IPs must not match")
}
}
+188
View File
@@ -0,0 +1,188 @@
package store
import (
"fmt"
"strings"
"time"
)
// Send-log status values (spec 7.3). "queued" is written by the journal-milter
// when a message is accepted; the log-tailer advances it to one of the final
// states as Postfix reports delivery per recipient.
const (
StatusQueued = "queued"
StatusSent = "sent"
StatusDeferred = "deferred"
StatusBounced = "bounced"
// StatusRejected marks a message the journal-milter refused with a 4xx under
// a level-2 rate limit (spec 7.4). Such a row never gets a queue-id and is
// excluded from the level-2 message count (it was never sent).
StatusRejected = "rejected"
)
// SendLogEntry is a single queued send-log row. The journal-milter creates one
// per (queue-id, recipient) pair at end-of-message (spec 7.3.3); every field
// except the status/timestamps comes from the accepted message.
type SendLogEntry struct {
QueueID string
Domain string
AppLogin string
From string
To string
Subject string
}
// InsertQueued records an accepted message in the send log with status
// "queued". It is called from the journal-milter hot path, so it returns any
// error for the caller to log rather than deciding policy here; the milter must
// stay fail-open regardless (spec 7.3).
func (s *Store) InsertQueued(e SendLogEntry) error {
now := time.Now().UTC().Format(time.RFC3339)
_, err := s.db.Exec(
`INSERT INTO send_log
(queue_id, domain, app_login, from_addr, to_addr, subject, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
e.QueueID, e.Domain, e.AppLogin, e.From, e.To, e.Subject, StatusQueued, now, now,
)
if err != nil {
return fmt.Errorf("insert send_log: %w", err)
}
return nil
}
// InsertRejected records a message the journal-milter refused under a level-2
// rate limit (spec 7.4), so the rejection is visible in the send-log UI. Only
// the fields known at MAIL FROM are set (domain, sender, app login); there is no
// queue-id or recipient because the message was rejected before it was queued.
func (s *Store) InsertRejected(e SendLogEntry) error {
now := time.Now().UTC().Format(time.RFC3339)
_, err := s.db.Exec(
`INSERT INTO send_log
(queue_id, domain, app_login, from_addr, to_addr, subject, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
e.QueueID, e.Domain, e.AppLogin, e.From, e.To, e.Subject, StatusRejected, now, now,
)
if err != nil {
return fmt.Errorf("insert rejected send_log: %w", err)
}
return nil
}
// UpdateStatus advances the delivery status of the send-log rows matching a
// (queue-id, recipient) pair, which the log-tailer parses out of mail.log.
// Recipient matching is case-insensitive because Postfix may normalise address
// case between the milter (envelope) and the delivery log. It returns the
// number of rows updated so the caller can tell whether the line matched a
// journal entry.
func (s *Store) UpdateStatus(queueID, recipient, status string) (int64, error) {
now := time.Now().UTC().Format(time.RFC3339)
res, err := s.db.Exec(
`UPDATE send_log SET status = ?, updated_at = ?
WHERE queue_id = ? AND to_addr = ? COLLATE NOCASE`,
status, now, queueID, recipient,
)
if err != nil {
return 0, fmt.Errorf("update send_log status: %w", err)
}
n, _ := res.RowsAffected()
return n, nil
}
// SendLogRow is one row as returned to the monitoring UI (spec 7.2, 7.3.3): a
// SendLogEntry plus the fields that only exist once a row has been written
// (id, current status, timestamps).
type SendLogRow struct {
ID int64
QueueID string
Domain string
AppLogin string
From string
To string
Subject string
Status string
CreatedAt time.Time
}
// SendLogFilter narrows QuerySendLog/CountSendLog by domain and/or
// application login. An empty field matches everything.
type SendLogFilter struct {
Domain string
AppLogin string
}
// QuerySendLog returns send-log rows matching filter, newest first, for the
// monitoring screen's server-side pagination (spec 7.2's send-log view).
func (s *Store) QuerySendLog(filter SendLogFilter, limit, offset int) ([]SendLogRow, error) {
where, args := sendLogWhere(filter)
args = append(args, limit, offset)
rows, err := s.db.Query(
`SELECT id, queue_id, domain, app_login, from_addr, to_addr, subject, status, created_at
FROM send_log`+where+`
ORDER BY id DESC
LIMIT ? OFFSET ?`,
args...,
)
if err != nil {
return nil, fmt.Errorf("query send_log: %w", err)
}
defer rows.Close()
var out []SendLogRow
for rows.Next() {
var (
row SendLogRow
createdAt string
)
if err := rows.Scan(&row.ID, &row.QueueID, &row.Domain, &row.AppLogin,
&row.From, &row.To, &row.Subject, &row.Status, &createdAt); err != nil {
return nil, fmt.Errorf("scan send_log row: %w", err)
}
row.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
out = append(out, row)
}
return out, rows.Err()
}
// CountSendLog returns how many send-log rows match filter, so the monitoring
// screen can render page numbers/next-prev links.
func (s *Store) CountSendLog(filter SendLogFilter) (int64, error) {
where, args := sendLogWhere(filter)
var n int64
if err := s.db.QueryRow(`SELECT COUNT(*) FROM send_log`+where, args...).Scan(&n); err != nil {
return 0, fmt.Errorf("count send_log: %w", err)
}
return n, nil
}
func sendLogWhere(f SendLogFilter) (string, []any) {
var clauses []string
var args []any
if f.Domain != "" {
clauses = append(clauses, "domain = ?")
args = append(args, f.Domain)
}
if f.AppLogin != "" {
clauses = append(clauses, "app_login = ?")
args = append(args, f.AppLogin)
}
if len(clauses) == 0 {
return "", nil
}
return " WHERE " + strings.Join(clauses, " AND "), args
}
// DeleteSendLogBefore removes send-log rows created before cutoff, implementing
// the configurable retention window (spec 7.3, SEND_LOG_RETENTION_DAYS). It
// returns the number of rows pruned. created_at is stored as RFC3339 UTC, so a
// lexical comparison against the same format is chronologically correct.
func (s *Store) DeleteSendLogBefore(cutoff time.Time) (int64, error) {
res, err := s.db.Exec(
`DELETE FROM send_log WHERE created_at < ?`,
cutoff.UTC().Format(time.RFC3339),
)
if err != nil {
return 0, fmt.Errorf("prune send_log: %w", err)
}
n, _ := res.RowsAffected()
return n, nil
}
+140
View File
@@ -0,0 +1,140 @@
package store
import (
"testing"
"time"
)
// readSendLog returns every send_log row ordered by id. Phase 6 has no read
// query yet (the monitoring UI is Phase 7), so tests read the table directly.
type sendLogRow struct {
QueueID string
Domain string
AppLogin string
From string
To string
Subject string
Status string
}
func readSendLog(t *testing.T, s *Store) []sendLogRow {
t.Helper()
rows, err := s.db.Query(
`SELECT queue_id, domain, app_login, from_addr, to_addr, subject, status
FROM send_log ORDER BY id`)
if err != nil {
t.Fatalf("query send_log: %v", err)
}
defer rows.Close()
var out []sendLogRow
for rows.Next() {
var r sendLogRow
if err := rows.Scan(&r.QueueID, &r.Domain, &r.AppLogin, &r.From, &r.To, &r.Subject, &r.Status); err != nil {
t.Fatalf("scan: %v", err)
}
out = append(out, r)
}
return out
}
func TestInsertQueuedAndUpdateStatus(t *testing.T) {
st := openTestStore(t)
// Two recipients on the same queue-id → two independent rows (spec 7.3.3).
for _, to := range []string{"a@example.net", "b@example.net"} {
if err := st.InsertQueued(SendLogEntry{
QueueID: "ABC123",
Domain: "example.com",
AppLogin: "app1",
From: "noreply@example.com",
To: to,
Subject: "Hello",
}); err != nil {
t.Fatalf("InsertQueued: %v", err)
}
}
rows := readSendLog(t, st)
if len(rows) != 2 {
t.Fatalf("want 2 rows, got %d: %+v", len(rows), rows)
}
for _, r := range rows {
if r.Status != StatusQueued {
t.Fatalf("new row should be queued, got %q", r.Status)
}
}
// One recipient goes to sent; the other stays queued.
n, err := st.UpdateStatus("ABC123", "a@example.net", StatusSent)
if err != nil {
t.Fatalf("UpdateStatus: %v", err)
}
if n != 1 {
t.Fatalf("want 1 row updated, got %d", n)
}
rows = readSendLog(t, st)
if rows[0].Status != StatusSent || rows[1].Status != StatusQueued {
t.Fatalf("unexpected statuses: %+v", rows)
}
}
func TestUpdateStatusRecipientCaseInsensitive(t *testing.T) {
st := openTestStore(t)
if err := st.InsertQueued(SendLogEntry{QueueID: "Q1", To: "User@Example.NET"}); err != nil {
t.Fatalf("InsertQueued: %v", err)
}
// mail.log may report a differently-cased recipient; matching must still hit.
n, err := st.UpdateStatus("Q1", "user@example.net", StatusBounced)
if err != nil {
t.Fatalf("UpdateStatus: %v", err)
}
if n != 1 {
t.Fatalf("case-insensitive match failed, updated %d rows", n)
}
}
func TestUpdateStatusNoMatch(t *testing.T) {
st := openTestStore(t)
if err := st.InsertQueued(SendLogEntry{QueueID: "Q1", To: "a@example.net"}); err != nil {
t.Fatalf("InsertQueued: %v", err)
}
// A queue-id/recipient the milter never recorded must be a no-op, not an error.
n, err := st.UpdateStatus("Q1", "unknown@example.net", StatusSent)
if err != nil {
t.Fatalf("UpdateStatus: %v", err)
}
if n != 0 {
t.Fatalf("want 0 rows updated, got %d", n)
}
}
func TestDeleteSendLogBefore(t *testing.T) {
st := openTestStore(t)
// Insert one row, then backdate it beyond the retention window by rewriting
// created_at directly (InsertQueued always stamps "now").
if err := st.InsertQueued(SendLogEntry{QueueID: "OLD", To: "a@example.net"}); err != nil {
t.Fatalf("InsertQueued: %v", err)
}
old := time.Now().UTC().AddDate(0, 0, -100).Format(time.RFC3339)
if _, err := st.db.Exec(`UPDATE send_log SET created_at = ? WHERE queue_id = 'OLD'`, old); err != nil {
t.Fatalf("backdate: %v", err)
}
if err := st.InsertQueued(SendLogEntry{QueueID: "NEW", To: "b@example.net"}); err != nil {
t.Fatalf("InsertQueued: %v", err)
}
cutoff := time.Now().UTC().AddDate(0, 0, -90)
n, err := st.DeleteSendLogBefore(cutoff)
if err != nil {
t.Fatalf("DeleteSendLogBefore: %v", err)
}
if n != 1 {
t.Fatalf("want 1 row pruned, got %d", n)
}
rows := readSendLog(t, st)
if len(rows) != 1 || rows[0].QueueID != "NEW" {
t.Fatalf("retention kept wrong rows: %+v", rows)
}
}
+113
View File
@@ -0,0 +1,113 @@
package store
import (
"database/sql"
"errors"
"fmt"
"time"
)
// SessionRow is a persisted login session, keyed by the SHA-256 of its token
// (see internal/web, which owns the token itself).
type SessionRow struct {
Username string
ExpiresAt time.Time
}
// CreateSession inserts a new session row.
func (s *Store) CreateSession(tokenHash, username string, expiresAt time.Time) error {
now := time.Now().UTC().Format(time.RFC3339)
_, err := s.db.Exec(
`INSERT INTO sessions (token_hash, username, created_at, expires_at) VALUES (?, ?, ?, ?)`,
tokenHash, username, now, expiresAt.UTC().Format(time.RFC3339),
)
if err != nil {
return fmt.Errorf("insert session: %w", err)
}
return nil
}
// LookupSession returns the session for tokenHash, if any. It does not check
// expiry itself — callers compare ExpiresAt against time.Now() and call
// DeleteSession on an expired row, keeping the read side lock-free.
func (s *Store) LookupSession(tokenHash string) (SessionRow, bool, error) {
var (
row SessionRow
expiresAt string
)
err := s.db.QueryRow(
`SELECT username, expires_at FROM sessions WHERE token_hash = ?`,
tokenHash,
).Scan(&row.Username, &expiresAt)
if errors.Is(err, sql.ErrNoRows) {
return SessionRow{}, false, nil
}
if err != nil {
return SessionRow{}, false, fmt.Errorf("lookup session: %w", err)
}
row.ExpiresAt, err = time.Parse(time.RFC3339, expiresAt)
if err != nil {
return SessionRow{}, false, fmt.Errorf("parse session expiry: %w", err)
}
return row, true, nil
}
// RenewSession pushes a session's expiry forward, implementing the sliding
// idle timeout.
func (s *Store) RenewSession(tokenHash string, expiresAt time.Time) error {
_, err := s.db.Exec(
`UPDATE sessions SET expires_at = ? WHERE token_hash = ?`,
expiresAt.UTC().Format(time.RFC3339), tokenHash,
)
if err != nil {
return fmt.Errorf("renew session: %w", err)
}
return nil
}
// RenameSession updates the username carried by a session, keeping its
// expiry, so a session stays usable after the administrator renames their own
// account.
func (s *Store) RenameSession(tokenHash, username string) error {
_, err := s.db.Exec(
`UPDATE sessions SET username = ? WHERE token_hash = ?`,
username, tokenHash,
)
if err != nil {
return fmt.Errorf("rename session: %w", err)
}
return nil
}
// DeleteSession removes a session row (logout, or a lookup finding it
// expired).
func (s *Store) DeleteSession(tokenHash string) error {
if _, err := s.db.Exec(`DELETE FROM sessions WHERE token_hash = ?`, tokenHash); err != nil {
return fmt.Errorf("delete session: %w", err)
}
return nil
}
// DeleteOtherSessions removes every session except keepHash. It is called
// when the administrator changes their password: a stolen cookie issued
// under the old password must stop working, while the session performing the
// change stays signed in.
func (s *Store) DeleteOtherSessions(keepHash string) error {
if _, err := s.db.Exec(`DELETE FROM sessions WHERE token_hash != ?`, keepHash); err != nil {
return fmt.Errorf("delete other sessions: %w", err)
}
return nil
}
// DeleteExpiredSessions prunes rows whose expiry has already passed, so an
// abandoned session (cookie never presented again) does not sit in the table
// forever. It piggybacks on session creation rather than running as its own
// background loop.
func (s *Store) DeleteExpiredSessions(now time.Time) (int64, error) {
res, err := s.db.Exec(`DELETE FROM sessions WHERE expires_at < ?`, now.UTC().Format(time.RFC3339))
if err != nil {
return 0, fmt.Errorf("prune sessions: %w", err)
}
n, _ := res.RowsAffected()
return n, nil
}
+100
View File
@@ -0,0 +1,100 @@
// Package store owns the SelfPost SQLite database: the single file under /data
// that persists the administrator account, sending domains and applications,
// the send log and rate-limit settings (spec 9). It exposes typed queries so
// the rest of the panel never builds SQL by hand.
package store
import (
"database/sql"
"embed"
"fmt"
"io/fs"
"sort"
_ "modernc.org/sqlite" // pure-Go SQLite driver (no cgo), keeps the static build
)
//go:embed migrations/*.sql
var migrationsFS embed.FS
// Store wraps the database connection pool.
type Store struct {
db *sql.DB
}
// Open opens (creating if needed) the SQLite database at path, enables WAL and
// foreign keys, and applies any pending migrations. The caller owns Close.
func Open(path string) (*Store, error) {
// _pragma parameters are applied on every pooled connection by the driver,
// so foreign-key enforcement and WAL survive connection churn.
dsn := fmt.Sprintf("file:%s?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=foreign_keys(ON)", path)
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("open database: %w", err)
}
// modernc's driver serializes writes anyway; a small pool avoids
// "database is locked" surprises under WAL.
db.SetMaxOpenConns(1)
s := &Store{db: db}
if err := s.migrate(); err != nil {
db.Close()
return nil, err
}
return s, nil
}
// Close closes the underlying database.
func (s *Store) Close() error {
return s.db.Close()
}
// migrate applies embedded migrations in filename order, tracking progress via
// SQLite's PRAGMA user_version so each migration runs at most once.
func (s *Store) migrate() error {
entries, err := fs.ReadDir(migrationsFS, "migrations")
if err != nil {
return fmt.Errorf("read migrations: %w", err)
}
names := make([]string, 0, len(entries))
for _, e := range entries {
if !e.IsDir() {
names = append(names, e.Name())
}
}
sort.Strings(names)
var version int
if err := s.db.QueryRow("PRAGMA user_version").Scan(&version); err != nil {
return fmt.Errorf("read schema version: %w", err)
}
for i, name := range names {
target := i + 1
if target <= version {
continue
}
sqlBytes, err := migrationsFS.ReadFile("migrations/" + name)
if err != nil {
return fmt.Errorf("read migration %s: %w", name, err)
}
tx, err := s.db.Begin()
if err != nil {
return fmt.Errorf("begin migration %s: %w", name, err)
}
if _, err := tx.Exec(string(sqlBytes)); err != nil {
tx.Rollback()
return fmt.Errorf("apply migration %s: %w", name, err)
}
// PRAGMA does not accept a bound parameter, and target is a trusted
// loop index, so formatting it in is safe.
if _, err := tx.Exec(fmt.Sprintf("PRAGMA user_version = %d", target)); err != nil {
tx.Rollback()
return fmt.Errorf("bump schema version for %s: %w", name, err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit migration %s: %w", name, err)
}
}
return nil
}