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
+14 -1
View File
@@ -1,3 +1,16 @@
module git.dcglab.co.uk/steve/emcli
go 1.22
go 1.25.0
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.44.0 // indirect
modernc.org/libc v1.73.4 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.53.0 // indirect
)
+21
View File
@@ -0,0 +1,21 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA=
modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M=
modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s=
+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)
}
}