cdffb15004
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
209 lines
6.0 KiB
Go
209 lines
6.0 KiB
Go
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
|
|
FromAddress string // send-as identity; blank ⇒ fall back to Username
|
|
Password string // decrypted; empty in ListAccounts
|
|
WhitelistInEnabled bool
|
|
WhitelistOutEnabled bool
|
|
SubjectRegex string
|
|
ProcessBacklog bool
|
|
}
|
|
|
|
// SendFrom returns the From identity for outgoing mail, falling back to the
|
|
// login username when no explicit from-address is configured.
|
|
func (a Account) SendFrom() string {
|
|
if a.FromAddress != "" {
|
|
return a.FromAddress
|
|
}
|
|
return a.Username
|
|
}
|
|
|
|
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,from_address,
|
|
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, nullStr(a.FromAddress),
|
|
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,from_address,
|
|
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,from_address,
|
|
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=?, from_address=?,
|
|
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, nullStr(a.FromAddress),
|
|
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, fromAddr 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, &fromAddr, &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
|
|
a.FromAddress = fromAddr.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
|
|
}
|