feat(store): accounts CRUD with encrypted password column

This commit is contained in:
2026-06-21 23:38:51 +01:00
parent aaab744b15
commit 2db459d701
2 changed files with 208 additions and 0 deletions
+141
View File
@@ -0,0 +1,141 @@
package store
import (
"database/sql"
"errors"
"fmt"
"git.dcglab.co.uk/steve/emcli/internal/crypto"
)
var ErrAccountNotFound = errors.New("account not found")
// Account is the decrypted, in-memory view of a configured account.
type Account struct {
ID int64
Name string
Mode string // RO | RW
IMAPHost string
IMAPPort int
IMAPSecurity string // tls | starttls
AuthType string // password | oauth2
Username string
Password string // decrypted; empty in ListAccounts
WhitelistInEnabled bool
WhitelistOutEnabled bool
SubjectRegex string
ProcessBacklog bool
}
func (s *Store) AddAccount(a Account) (int64, error) {
var encPw []byte
if a.Password != "" {
b, err := crypto.Seal(s.key, []byte(a.Password))
if err != nil {
return 0, err
}
encPw = b
}
res, err := s.db.Exec(`
INSERT INTO accounts
(name,mode,imap_host,imap_port,imap_security,auth_type,username,
enc_password,whitelist_in_enabled,whitelist_out_enabled,subject_regex,process_backlog)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
a.Name, a.Mode, a.IMAPHost, a.IMAPPort, a.IMAPSecurity, a.AuthType, a.Username,
encPw, b2i(a.WhitelistInEnabled), b2i(a.WhitelistOutEnabled),
nullStr(a.SubjectRegex), b2i(a.ProcessBacklog))
if err != nil {
return 0, fmt.Errorf("insert account: %w", err)
}
return res.LastInsertId()
}
func (s *Store) GetAccount(name string) (Account, error) {
row := s.db.QueryRow(`
SELECT id,name,mode,imap_host,imap_port,imap_security,auth_type,username,
enc_password,whitelist_in_enabled,whitelist_out_enabled,subject_regex,process_backlog
FROM accounts WHERE name = ?`, name)
a, encPw, err := scanAccount(row)
if errors.Is(err, sql.ErrNoRows) {
return Account{}, ErrAccountNotFound
}
if err != nil {
return Account{}, err
}
if len(encPw) > 0 {
pw, err := crypto.Open(s.key, encPw)
if err != nil {
return Account{}, fmt.Errorf("decrypt password (wrong EMCLI_KEY?): %w", err)
}
a.Password = string(pw)
}
return a, nil
}
func (s *Store) ListAccounts() ([]Account, error) {
rows, err := s.db.Query(`
SELECT id,name,mode,imap_host,imap_port,imap_security,auth_type,username,
enc_password,whitelist_in_enabled,whitelist_out_enabled,subject_regex,process_backlog
FROM accounts ORDER BY name`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Account
for rows.Next() {
a, _, err := scanAccount(rows) // secrets discarded
if err != nil {
return nil, err
}
out = append(out, a)
}
return out, rows.Err()
}
func (s *Store) DeleteAccount(name string) error {
res, err := s.db.Exec("DELETE FROM accounts WHERE name = ?", name)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrAccountNotFound
}
return nil
}
// scanner is satisfied by *sql.Row and *sql.Rows.
type scanner interface{ Scan(dest ...any) error }
func scanAccount(sc scanner) (Account, []byte, error) {
var (
a Account
encPw []byte
subj sql.NullString
wlIn, wlOut int
backlog int
)
err := sc.Scan(&a.ID, &a.Name, &a.Mode, &a.IMAPHost, &a.IMAPPort, &a.IMAPSecurity,
&a.AuthType, &a.Username, &encPw, &wlIn, &wlOut, &subj, &backlog)
if err != nil {
return Account{}, nil, err
}
a.WhitelistInEnabled = wlIn != 0
a.WhitelistOutEnabled = wlOut != 0
a.ProcessBacklog = backlog != 0
a.SubjectRegex = subj.String
return a, encPw, nil
}
func b2i(b bool) int {
if b {
return 1
}
return 0
}
func nullStr(s string) any {
if s == "" {
return nil
}
return s
}