diff --git a/go.mod b/go.mod index a506e68..369acf1 100644 --- a/go.mod +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..0569467 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/store/schema.go b/internal/store/schema.go new file mode 100644 index 0000000..e0262e0 --- /dev/null +++ b/internal/store/schema.go @@ -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 +); +` diff --git a/internal/store/settings.go b/internal/store/settings.go new file mode 100644 index 0000000..24718b2 --- /dev/null +++ b/internal/store/settings.go @@ -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 diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..4380b49 --- /dev/null +++ b/internal/store/store.go @@ -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 +} diff --git a/internal/store/store_test.go b/internal/store/store_test.go new file mode 100644 index 0000000..eae0b48 --- /dev/null +++ b/internal/store/store_test.go @@ -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) + } +}