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 SMTPHost string // nullable for RO accounts SMTPPort int SMTPSecurity 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,smtp_host,smtp_port,smtp_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, nullStr(a.SMTPHost), nullInt(a.SMTPPort), nullStr(a.SMTPSecurity), 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,smtp_host,smtp_port,smtp_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,smtp_host,smtp_port,smtp_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() } // UpdateAccount updates an existing account's mutable fields, matched by Name. // The password and OAuth secrets are re-encrypted only when a non-empty value is // supplied; a blank value preserves whatever is already stored. Returns // ErrAccountNotFound if no account has that name. func (s *Store) UpdateAccount(a Account) error { // Build the SET clause, conditionally including secret columns. set := `mode=?, imap_host=?, imap_port=?, imap_security=?, smtp_host=?, smtp_port=?, smtp_security=?, auth_type=?, username=?, whitelist_in_enabled=?, whitelist_out_enabled=?, subject_regex=?, process_backlog=?` args := []any{ a.Mode, a.IMAPHost, a.IMAPPort, a.IMAPSecurity, nullStr(a.SMTPHost), nullInt(a.SMTPPort), nullStr(a.SMTPSecurity), a.AuthType, a.Username, b2i(a.WhitelistInEnabled), b2i(a.WhitelistOutEnabled), nullStr(a.SubjectRegex), b2i(a.ProcessBacklog), } if a.Password != "" { enc, err := crypto.Seal(s.key, []byte(a.Password)) if err != nil { return err } set += ", enc_password=?" args = append(args, enc) } args = append(args, a.Name) res, err := s.db.Exec("UPDATE accounts SET "+set+" WHERE name=?", args...) if err != nil { return fmt.Errorf("update account: %w", err) } if n, _ := res.RowsAffected(); n == 0 { return ErrAccountNotFound } return nil } 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, smtpHost, smtpSec sql.NullString smtpPort sql.NullInt64 wlIn, wlOut int backlog int ) err := sc.Scan(&a.ID, &a.Name, &a.Mode, &a.IMAPHost, &a.IMAPPort, &a.IMAPSecurity, &smtpHost, &smtpPort, &smtpSec, &a.AuthType, &a.Username, &encPw, &wlIn, &wlOut, &subj, &backlog) if err != nil { return Account{}, nil, err } a.SMTPHost = smtpHost.String a.SMTPPort = int(smtpPort.Int64) a.SMTPSecurity = smtpSec.String 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 } func nullInt(n int) any { if n == 0 { return nil } return n }