feat(store): open encrypted SQLite, schema v1, settings

This commit is contained in:
2026-06-21 23:34:31 +01:00
parent 8d04b0fde9
commit 673ed5f350
6 changed files with 257 additions and 1 deletions
+71
View File
@@ -0,0 +1,71 @@
package store
const schemaVersion = 1
// schemaSQL is the full v1 schema. All statements are idempotent via IF NOT EXISTS.
const schemaSQL = `
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
mode TEXT NOT NULL CHECK (mode IN ('RO','RW')),
imap_host TEXT NOT NULL,
imap_port INTEGER NOT NULL,
imap_security TEXT NOT NULL CHECK (imap_security IN ('tls','starttls')),
smtp_host TEXT,
smtp_port INTEGER,
smtp_security TEXT,
auth_type TEXT NOT NULL CHECK (auth_type IN ('password','oauth2')),
username TEXT NOT NULL,
enc_password BLOB,
enc_oauth_client_id BLOB,
enc_oauth_client_secret BLOB,
enc_oauth_refresh_token BLOB,
whitelist_in_enabled INTEGER NOT NULL DEFAULT 0,
whitelist_out_enabled INTEGER NOT NULL DEFAULT 0,
subject_regex TEXT,
process_backlog INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS whitelist_in (
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
address TEXT NOT NULL,
PRIMARY KEY (account_id, address)
);
CREATE TABLE IF NOT EXISTS whitelist_out (
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
address TEXT NOT NULL,
PRIMARY KEY (account_id, address)
);
CREATE TABLE IF NOT EXISTS folder_state (
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
folder TEXT NOT NULL,
uidvalidity INTEGER NOT NULL,
floor_uid INTEGER NOT NULL,
PRIMARY KEY (account_id, folder)
);
CREATE TABLE IF NOT EXISTS acked (
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
folder TEXT NOT NULL,
uidvalidity INTEGER NOT NULL,
uid INTEGER NOT NULL,
PRIMARY KEY (account_id, folder, uid)
);
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts TEXT NOT NULL,
account TEXT NOT NULL,
action TEXT NOT NULL,
target TEXT NOT NULL,
result TEXT NOT NULL,
reason TEXT
);
`
+21
View File
@@ -0,0 +1,21 @@
package store
import "database/sql"
// GetSetting returns a setting value or sql.ErrNoRows if absent.
func (s *Store) GetSetting(name string) (string, error) {
var v string
err := s.db.QueryRow("SELECT value FROM settings WHERE key = ?", name).Scan(&v)
return v, err
}
// SetSetting upserts a setting.
func (s *Store) SetSetting(name, value string) error {
_, err := s.db.Exec(
"INSERT INTO settings(key,value) VALUES(?,?) "+
"ON CONFLICT(key) DO UPDATE SET value=excluded.value",
name, value)
return err
}
var _ = sql.ErrNoRows
+65
View File
@@ -0,0 +1,65 @@
// Package store owns the encrypted SQLite config and read state.
package store
import (
"database/sql"
"fmt"
"os"
"path/filepath"
"runtime"
"strconv"
_ "modernc.org/sqlite"
)
// Store wraps the database and the field-encryption key.
type Store struct {
db *sql.DB
key []byte
}
// Open opens (creating if needed) the DB at path and applies the schema.
func Open(path string, key []byte) (*Store, error) {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return nil, fmt.Errorf("create db dir: %w", err)
}
db, err := sql.Open("sqlite", path)
if err != nil {
return nil, err
}
if _, err := db.Exec("PRAGMA foreign_keys = ON;"); err != nil {
db.Close()
return nil, err
}
if _, err := db.Exec(schemaSQL); err != nil {
db.Close()
return nil, fmt.Errorf("apply schema: %w", err)
}
s := &Store{db: db, key: key}
if _, err := s.GetSetting("schema_version"); err != nil {
if err := s.SetSetting("schema_version", strconv.Itoa(schemaVersion)); err != nil {
db.Close()
return nil, err
}
}
return s, nil
}
func (s *Store) Close() error { return s.db.Close() }
// DefaultDBPath resolves EMCLI_DB or the per-OS default location.
func DefaultDBPath() (string, error) {
if p := os.Getenv("EMCLI_DB"); p != "" {
return p, nil
}
if runtime.GOOS == "windows" {
if dir := os.Getenv("AppData"); dir != "" {
return filepath.Join(dir, "emcli", "emcli.db"), nil
}
}
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".config", "emcli", "emcli.db"), nil
}
+65
View File
@@ -0,0 +1,65 @@
package store
import (
"path/filepath"
"testing"
)
func testKey() []byte {
k := make([]byte, 32)
for i := range k {
k[i] = byte(i)
}
return k
}
// openTemp opens a fresh store in a temp dir.
func openTemp(t *testing.T) *Store {
t.Helper()
p := filepath.Join(t.TempDir(), "emcli.db")
s, err := Open(p, testKey())
if err != nil {
t.Fatalf("Open: %v", err)
}
t.Cleanup(func() { s.Close() })
return s
}
func TestOpenCreatesSchemaAndIsIdempotent(t *testing.T) {
p := filepath.Join(t.TempDir(), "emcli.db")
s, err := Open(p, testKey())
if err != nil {
t.Fatalf("first Open: %v", err)
}
v, err := s.GetSetting("schema_version")
if err != nil || v != "1" {
t.Fatalf("schema_version: %q err=%v", v, err)
}
s.Close()
// Re-open: must not error or duplicate.
s2, err := Open(p, testKey())
if err != nil {
t.Fatalf("second Open: %v", err)
}
defer s2.Close()
if v, _ := s2.GetSetting("schema_version"); v != "1" {
t.Fatalf("schema_version after reopen: %q", v)
}
}
func TestSettingsRoundTrip(t *testing.T) {
s := openTemp(t)
if err := s.SetSetting("audit_retention_days", "30"); err != nil {
t.Fatalf("SetSetting: %v", err)
}
got, err := s.GetSetting("audit_retention_days")
if err != nil || got != "30" {
t.Fatalf("got %q err=%v", got, err)
}
// Upsert overwrites.
_ = s.SetSetting("audit_retention_days", "7")
if got, _ := s.GetSetting("audit_retention_days"); got != "7" {
t.Fatalf("upsert failed: %q", got)
}
}