feat(admin): Phase 4 — doctor, admin completeness, and bubbletea TUI

Adds the admin/diagnostics surface from SPEC §7.2:

- doctor [--account]: per-account IMAP + (RW) SMTP connectivity/auth checks via
  new mail.CheckIMAP/CheckSMTP (connect+auth only, no mail). Exit non-zero on any
  failure; secrets never printed.
- store.UpdateAccount: partial edit, re-encrypts password/secrets only when a
  non-empty value is supplied (blank keeps existing). RecentAuditFor(account).
- config set/get (validates audit_retention_days), audit list [--account][--limit],
  account edit (flag partial-update) / remove [--yes].
- internal/tui: bubbletea AccountForm with pure, fully-tested Fields (validation +
  store.Account assembly + edit prefill). init / bare `account add` / `account edit
  --name X` drop into the TUI; flag forms remain for scripting.

Built test-first; full suite green incl -race. Validated live against the mxlogin
(password) and Gmail (app-password) accounts. Live validation caught a real bug:
doctor authenticated with empty passwords because it iterated ListAccounts (which
strips secrets) — fixed to re-fetch via GetAccount, locked in by a regression test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-22 20:09:43 +01:00
parent 193815dd25
commit a837b25d73
20 changed files with 1535 additions and 10 deletions
+21
View File
@@ -3,6 +3,9 @@ module git.dcglab.co.uk/steve/emcli
go 1.25.0
require (
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/emersion/go-imap v1.2.1
github.com/emersion/go-message v0.18.2
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6
@@ -11,11 +14,29 @@ require (
)
require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.9.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.44.0 // indirect
golang.org/x/text v0.14.0 // indirect
modernc.org/libc v1.73.4 // indirect
+45
View File
@@ -1,3 +1,27 @@
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
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/emersion/go-imap v1.2.1 h1:+s9ZjMEjOB8NzZMVTM3cCenz2JrQIGGo5j1df19WjTA=
@@ -11,21 +35,41 @@ github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTe
github.com/emersion/go-smtp v0.24.0 h1:g6AfoF140mvW0vLNPD/LuCBLEAdlxOjIXqbIkJIS6Wk=
github.com/emersion/go-smtp v0.24.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4McvHxCU2gsQ=
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
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/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
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/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
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=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
@@ -42,6 +86,7 @@ golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+179
View File
@@ -4,8 +4,10 @@ import (
"flag"
"fmt"
"io"
"strconv"
"git.dcglab.co.uk/steve/emcli/internal/store"
"git.dcglab.co.uk/steve/emcli/internal/tui"
)
// runAccount handles `account add|list`. Human-readable output (never JSON).
@@ -24,6 +26,9 @@ func runAccount(args []string, out, errOut io.Writer) int {
switch sub {
case "add":
if len(rest) == 0 { // no flags → interactive TUI form
return addInteractive(st, tui.Fields{}, out, errOut)
}
fs := flag.NewFlagSet("account add", flag.ContinueOnError)
fs.SetOutput(errOut)
name := fs.String("name", "", "account name")
@@ -63,6 +68,91 @@ func runAccount(args []string, out, errOut io.Writer) int {
}
fmt.Fprintf(out, "account %q added (%s)\n", *name, *mode)
return 0
case "edit":
fs := flag.NewFlagSet("account edit", flag.ContinueOnError)
fs.SetOutput(errOut)
name := fs.String("name", "", "account name (required)")
mode := fs.String("mode", "", "RO|RW")
host := fs.String("imap-host", "", "IMAP host")
port := fs.Int("imap-port", 0, "IMAP port")
sec := fs.String("imap-security", "", "tls|starttls")
smtpHost := fs.String("smtp-host", "", "SMTP host")
smtpPort := fs.Int("smtp-port", 0, "SMTP port")
smtpSec := fs.String("smtp-security", "", "tls|starttls")
user := fs.String("username", "", "login username")
pass := fs.String("password", "", "login password (blank keeps existing)")
subj := fs.String("subject-regex", "", "inbound subject filter")
if err := fs.Parse(rest); err != nil {
return 2
}
if *name == "" {
fmt.Fprintln(errOut, "--name is required")
return 2
}
if fs.NFlag() == 1 { // only --name → interactive TUI form, prefilled
return editInteractive(st, *name, out, errOut)
}
acc, err := st.GetAccount(*name)
if err != nil {
fmt.Fprintf(errOut, "edit: %v\n", err)
return 1
}
// Overlay only the flags the user actually set.
fs.Visit(func(f *flag.Flag) {
switch f.Name {
case "mode":
acc.Mode = *mode
case "imap-host":
acc.IMAPHost = *host
case "imap-port":
acc.IMAPPort = *port
case "imap-security":
acc.IMAPSecurity = *sec
case "smtp-host":
acc.SMTPHost = *smtpHost
case "smtp-port":
acc.SMTPPort = *smtpPort
case "smtp-security":
acc.SMTPSecurity = *smtpSec
case "username":
acc.Username = *user
case "password":
acc.Password = *pass
case "subject-regex":
acc.SubjectRegex = *subj
}
})
// acc.Password holds the existing (decrypted) password from GetAccount; the
// Visit above overwrites it only when --password was passed. UpdateAccount
// re-seals whatever non-empty value is present, so the password is preserved.
if err := st.UpdateAccount(acc); err != nil {
fmt.Fprintf(errOut, "edit: %v\n", err)
return 1
}
fmt.Fprintf(out, "account %q updated\n", *name)
return 0
case "remove":
fs := flag.NewFlagSet("account remove", flag.ContinueOnError)
fs.SetOutput(errOut)
name := fs.String("name", "", "account name (required)")
yes := fs.Bool("yes", false, "skip confirmation")
if err := fs.Parse(rest); err != nil {
return 2
}
if *name == "" {
fmt.Fprintln(errOut, "--name is required")
return 2
}
if !*yes {
fmt.Fprintf(errOut, "refusing to remove %q without --yes\n", *name)
return 2
}
if err := st.DeleteAccount(*name); err != nil {
fmt.Fprintf(errOut, "remove: %v\n", err)
return 1
}
fmt.Fprintf(out, "account %q removed\n", *name)
return 0
case "list":
accs, err := st.ListAccounts()
if err != nil {
@@ -81,6 +171,95 @@ func runAccount(args []string, out, errOut io.Writer) int {
}
}
// auditList renders recent audit entries (account "" = all) to out.
func auditList(st *store.Store, account string, limit int, out io.Writer) error {
entries, err := st.RecentAuditFor(account, limit)
if err != nil {
return err
}
fmt.Fprintf(out, "%-20s %-12s %-8s %-8s %-20s %s\n",
"TS", "ACCOUNT", "ACTION", "RESULT", "TARGET", "REASON")
for _, e := range entries {
fmt.Fprintf(out, "%-20s %-12s %-8s %-8s %-20s %s\n",
e.TS, e.Account, e.Action, e.Result, e.Target, e.Reason)
}
return nil
}
// runConfig handles `config set <key> <value>` and `config get <key>`.
func runConfig(args []string, out, errOut io.Writer) int {
if len(args) < 2 {
fmt.Fprintln(errOut, "usage: emcli config <set|get> <key> [value]")
return 2
}
sub, key := args[0], args[1]
st, err := openStore()
if err != nil {
fmt.Fprintf(errOut, "emcli: %v\n", err)
return 1
}
defer st.Close()
switch sub {
case "set":
if len(args) < 3 {
fmt.Fprintln(errOut, "usage: emcli config set <key> <value>")
return 2
}
value := args[2]
if key == "audit_retention_days" {
n, err := strconv.Atoi(value)
if err != nil || n < 0 {
fmt.Fprintf(errOut, "audit_retention_days must be an integer >= 0, got %q\n", value)
return 2
}
}
if err := st.SetSetting(key, value); err != nil {
fmt.Fprintf(errOut, "config set: %v\n", err)
return 1
}
fmt.Fprintf(out, "%s = %s\n", key, value)
return 0
case "get":
v, err := st.GetSetting(key)
if err != nil {
fmt.Fprintf(errOut, "config get: %s not set\n", key)
return 1
}
fmt.Fprintf(out, "%s = %s\n", key, v)
return 0
default:
fmt.Fprintf(errOut, "unknown config subcommand %q\n", sub)
return 2
}
}
// runAudit handles `audit list [--account <name>] [--limit N]`.
func runAudit(args []string, out, errOut io.Writer) int {
if len(args) == 0 || args[0] != "list" {
fmt.Fprintln(errOut, "usage: emcli audit list [--account <name>] [--limit N]")
return 2
}
fs := flag.NewFlagSet("audit list", flag.ContinueOnError)
fs.SetOutput(errOut)
account := fs.String("account", "", "filter by account")
limit := fs.Int("limit", 50, "max rows")
if err := fs.Parse(args[1:]); err != nil {
return 2
}
st, err := openStore()
if err != nil {
fmt.Fprintf(errOut, "emcli: %v\n", err)
return 1
}
defer st.Close()
if err := auditList(st, *account, *limit, out); err != nil {
fmt.Fprintf(errOut, "audit list: %v\n", err)
return 1
}
return 0
}
// runWhitelist handles `whitelist <in|out> add --account NAME --address A`.
func runWhitelist(args []string, out, errOut io.Writer) int {
if len(args) < 2 {
+115
View File
@@ -0,0 +1,115 @@
package cli
import (
"bytes"
"path/filepath"
"strings"
"testing"
"time"
"git.dcglab.co.uk/steve/emcli/internal/store"
)
// adminEnv points EMCLI_KEY/EMCLI_DB at a fresh temp DB and returns its path.
func adminEnv(t *testing.T) string {
t.Helper()
db := filepath.Join(t.TempDir(), "emcli.db")
t.Setenv("EMCLI_KEY", b64Key())
t.Setenv("EMCLI_DB", db)
return db
}
func run(t *testing.T, args ...string) (int, string, string) {
t.Helper()
var out, errOut bytes.Buffer
code := Run(args, &out, &errOut)
return code, out.String(), errOut.String()
}
func TestConfigSetGet(t *testing.T) {
adminEnv(t)
if code, _, e := run(t, "config", "set", "audit_retention_days", "30"); code != 0 {
t.Fatalf("config set failed: %s", e)
}
code, out, _ := run(t, "config", "get", "audit_retention_days")
if code != 0 || !strings.Contains(out, "30") {
t.Fatalf("config get: code=%d out=%q", code, out)
}
}
func TestConfigSetRejectsBadRetention(t *testing.T) {
adminEnv(t)
if code, _, _ := run(t, "config", "set", "audit_retention_days", "-5"); code == 0 {
t.Fatal("negative retention must be rejected")
}
if code, _, _ := run(t, "config", "set", "audit_retention_days", "abc"); code == 0 {
t.Fatal("non-integer retention must be rejected")
}
}
func TestAccountRemove(t *testing.T) {
adminEnv(t)
run(t, "account", "add", "--name", "gone", "--imap-host", "h", "--username", "u@x.com")
if code, _, e := run(t, "account", "remove", "--name", "gone", "--yes"); code != 0 {
t.Fatalf("remove failed: %s", e)
}
_, out, _ := run(t, "account", "list")
if strings.Contains(out, "gone") {
t.Fatalf("account still listed after remove:\n%s", out)
}
}
func TestAccountRemoveMissing(t *testing.T) {
adminEnv(t)
if code, _, _ := run(t, "account", "remove", "--name", "nope", "--yes"); code == 0 {
t.Fatal("removing a missing account must be non-zero")
}
}
func TestAccountEditPartialPreservesOtherFields(t *testing.T) {
db := adminEnv(t)
run(t, "account", "add", "--name", "ed", "--mode", "RO",
"--imap-host", "imap.x.com", "--username", "u@x.com", "--password", "orig")
// Edit only mode + add SMTP; imap-host, username, password must be preserved.
if code, _, e := run(t, "account", "edit", "--name", "ed", "--mode", "RW",
"--smtp-host", "smtp.x.com", "--smtp-port", "587", "--smtp-security", "starttls"); code != 0 {
t.Fatalf("edit failed: %s", e)
}
st, err := store.Open(db, mustKey())
if err != nil {
t.Fatalf("open: %v", err)
}
defer st.Close()
got, err := st.GetAccount("ed")
if err != nil {
t.Fatalf("GetAccount: %v", err)
}
if got.Mode != "RW" || got.SMTPHost != "smtp.x.com" || got.SMTPPort != 587 {
t.Fatalf("edit didn't apply: %+v", got)
}
if got.IMAPHost != "imap.x.com" || got.Username != "u@x.com" || got.Password != "orig" {
t.Fatalf("edit clobbered preserved fields: %+v", got)
}
}
func TestAuditListCoreRenders(t *testing.T) {
st, err := store.Open(filepath.Join(t.TempDir(), "e.db"), testKey())
if err != nil {
t.Fatalf("open: %v", err)
}
defer st.Close()
now := time.Date(2026, 6, 22, 0, 0, 0, 0, time.UTC)
_ = st.Audit(now, store.AuditEntry{Account: "a", Action: "list", Target: "INBOX", Result: "allowed"})
_ = st.Audit(now, store.AuditEntry{Account: "a", Action: "send", Target: "x@y.com", Result: "blocked", Reason: "whitelist_out"})
var buf bytes.Buffer
if err := auditList(st, "", 50, &buf); err != nil {
t.Fatalf("auditList: %v", err)
}
out := buf.String()
if !strings.Contains(out, "list") || !strings.Contains(out, "whitelist_out") {
t.Fatalf("audit rows not rendered:\n%s", out)
}
}
// mustKey decodes the same 32-zero-byte key used by b64Key for store reopen.
func mustKey() []byte { return make([]byte, 32) }
+7 -5
View File
@@ -25,11 +25,13 @@ type Mailer interface {
}
type Deps struct {
Store *store.Store
Dial func(store.Account) (Mailer, error)
Send func(store.Account, mail.OutgoingMessage) error
Now func() time.Time
Out io.Writer
Store *store.Store
Dial func(store.Account) (Mailer, error)
Send func(store.Account, mail.OutgoingMessage) error
CheckIMAP func(store.Account) error
CheckSMTP func(store.Account) error
Now func() time.Time
Out io.Writer
}
func (d Deps) emit(e Envelope) error {
+3 -1
View File
@@ -18,7 +18,9 @@ type fakeMailer struct {
full map[uint32]mail.Message
}
func (f *fakeMailer) SelectFolder(string) (uint32, uint32, error) { return f.uidValidity, f.maxUID, nil }
func (f *fakeMailer) SelectFolder(string) (uint32, uint32, error) {
return f.uidValidity, f.maxUID, nil
}
func (f *fakeMailer) FetchHeaders(_ string, uids []uint32) ([]mail.Header, error) {
if len(uids) == 0 {
return f.headers, nil
+61
View File
@@ -0,0 +1,61 @@
package cli
import "fmt"
// DoctorCmd runs connectivity/auth diagnostics. For each account (optionally
// filtered to one), it checks IMAP and — for RW accounts with an SMTP host —
// SMTP, printing a human-readable per-check result. It returns errCommandFailed
// if any check fails so the process can exit non-zero. Secrets are never printed.
func DoctorCmd(d Deps, account string) error {
accounts, err := d.Store.ListAccounts()
if err != nil {
fmt.Fprintf(d.Out, "FAIL: cannot list accounts: %v\n", err)
return errCommandFailed
}
anyFail := false
checked := 0
for _, listed := range accounts {
if account != "" && listed.Name != account {
continue
}
checked++
// ListAccounts strips secrets; re-fetch to get decrypted credentials.
a, err := d.Store.GetAccount(listed.Name)
if err != nil {
fmt.Fprintf(d.Out, "%s\n FAIL: %v\n", listed.Name, err)
anyFail = true
continue
}
fmt.Fprintf(d.Out, "%s (%s)\n", a.Name, a.Mode)
if err := d.CheckIMAP(a); err != nil {
fmt.Fprintf(d.Out, " IMAP FAIL: %v\n", err)
anyFail = true
} else {
fmt.Fprintf(d.Out, " IMAP ok\n")
}
switch {
case a.Mode != "RW":
fmt.Fprintf(d.Out, " SMTP n/a (read-only)\n")
case a.SMTPHost == "":
fmt.Fprintf(d.Out, " SMTP n/a (no smtp host configured)\n")
default:
if err := d.CheckSMTP(a); err != nil {
fmt.Fprintf(d.Out, " SMTP FAIL: %v\n", err)
anyFail = true
} else {
fmt.Fprintf(d.Out, " SMTP ok\n")
}
}
}
if account != "" && checked == 0 {
fmt.Fprintf(d.Out, "FAIL: account not found: %s\n", account)
return errCommandFailed
}
if anyFail {
return errCommandFailed
}
return nil
}
+107
View File
@@ -0,0 +1,107 @@
package cli
import (
"errors"
"path/filepath"
"strings"
"testing"
"git.dcglab.co.uk/steve/emcli/internal/store"
)
func doctorDeps(t *testing.T, accounts []store.Account, imap, smtp func(store.Account) error) (Deps, *[]byte) {
t.Helper()
st, err := store.Open(filepath.Join(t.TempDir(), "e.db"), testKey())
if err != nil {
t.Fatalf("store: %v", err)
}
t.Cleanup(func() { st.Close() })
for _, a := range accounts {
if _, err := st.AddAccount(a); err != nil {
t.Fatalf("AddAccount %s: %v", a.Name, err)
}
}
buf := &[]byte{}
d := Deps{Store: st, CheckIMAP: imap, CheckSMTP: smtp, Out: bufWriter{buf}}
return d, buf
}
func roAcc(name string) store.Account {
return store.Account{Name: name, Mode: "RO", IMAPHost: "h", IMAPPort: 993, IMAPSecurity: "tls",
AuthType: "password", Username: "u@x.com", Password: "p"}
}
func rwAcc(name string) store.Account {
a := roAcc(name)
a.Mode = "RW"
a.SMTPHost, a.SMTPPort, a.SMTPSecurity = "h", 465, "tls"
return a
}
func TestDoctorAllOK(t *testing.T) {
ok := func(store.Account) error { return nil }
d, buf := doctorDeps(t, []store.Account{rwAcc("work")}, ok, ok)
if err := DoctorCmd(d, ""); err != nil {
t.Fatalf("DoctorCmd returned error when all checks pass: %v", err)
}
out := string(*buf)
if !strings.Contains(out, "work") || strings.Contains(strings.ToLower(out), "fail") {
t.Fatalf("unexpected report:\n%s", out)
}
}
func TestDoctorReportsSMTPFailure(t *testing.T) {
ok := func(store.Account) error { return nil }
bad := func(store.Account) error { return errors.New("auth rejected") }
d, buf := doctorDeps(t, []store.Account{rwAcc("work")}, ok, bad)
err := DoctorCmd(d, "")
if err == nil {
t.Fatal("DoctorCmd must return error when a check fails (non-zero exit)")
}
out := string(*buf)
if !strings.Contains(strings.ToLower(out), "fail") || !strings.Contains(out, "auth rejected") {
t.Fatalf("failure not reported:\n%s", out)
}
}
func TestDoctorSkipsSMTPForRO(t *testing.T) {
ok := func(store.Account) error { return nil }
smtpCalled := false
smtp := func(store.Account) error { smtpCalled = true; return nil }
d, _ := doctorDeps(t, []store.Account{roAcc("ro")}, ok, smtp)
if err := DoctorCmd(d, ""); err != nil {
t.Fatalf("DoctorCmd: %v", err)
}
if smtpCalled {
t.Fatal("SMTP check must be skipped for RO accounts")
}
}
func TestDoctorUsesDecryptedCredentials(t *testing.T) {
// roAcc has Password "p". ListAccounts strips secrets, so doctor must
// re-fetch the decrypted account before checking — otherwise the live
// check runs with an empty password.
var gotPassword string
imap := func(a store.Account) error { gotPassword = a.Password; return nil }
ok := func(store.Account) error { return nil }
d, _ := doctorDeps(t, []store.Account{roAcc("work")}, imap, ok)
if err := DoctorCmd(d, ""); err != nil {
t.Fatalf("DoctorCmd: %v", err)
}
if gotPassword != "p" {
t.Fatalf("check received password %q, want decrypted \"p\"", gotPassword)
}
}
func TestDoctorFiltersByAccount(t *testing.T) {
ok := func(store.Account) error { return nil }
checked := map[string]bool{}
imap := func(a store.Account) error { checked[a.Name] = true; return nil }
d, _ := doctorDeps(t, []store.Account{roAcc("a"), roAcc("b")}, imap, ok)
if err := DoctorCmd(d, "b"); err != nil {
t.Fatalf("DoctorCmd: %v", err)
}
if checked["a"] || !checked["b"] {
t.Fatalf("account filter wrong: %v", checked)
}
}
+93
View File
@@ -0,0 +1,93 @@
package cli
import (
"fmt"
"io"
tea "github.com/charmbracelet/bubbletea"
"git.dcglab.co.uk/steve/emcli/internal/store"
"git.dcglab.co.uk/steve/emcli/internal/tui"
)
// runForm launches the bubbletea account form and returns the final model.
// This is interactive terminal glue (not unit-tested); all logic it relies on
// lives in the tui and store packages, which are tested.
func runForm(initial tui.Fields, editing bool) (tui.AccountForm, error) {
p := tea.NewProgram(tui.NewAccountForm(initial, editing))
m, err := p.Run()
if err != nil {
return tui.AccountForm{}, err
}
return m.(tui.AccountForm), nil
}
// addInteractive runs the form for a new account and persists it.
func addInteractive(st *store.Store, initial tui.Fields, out, errOut io.Writer) int {
form, err := runForm(initial, false)
if err != nil {
fmt.Fprintf(errOut, "form: %v\n", err)
return 1
}
if form.Cancelled() {
fmt.Fprintln(out, "cancelled")
return 1
}
acc := form.Account()
if _, err := st.AddAccount(acc); err != nil {
fmt.Fprintf(errOut, "add account: %v\n", err)
return 1
}
fmt.Fprintf(out, "account %q added (%s)\n", acc.Name, acc.Mode)
return 0
}
// editInteractive runs the form prefilled from an existing account and saves it.
func editInteractive(st *store.Store, name string, out, errOut io.Writer) int {
acc, err := st.GetAccount(name)
if err != nil {
fmt.Fprintf(errOut, "edit: %v\n", err)
return 1
}
form, err := runForm(tui.FieldsFromAccount(acc), true)
if err != nil {
fmt.Fprintf(errOut, "form: %v\n", err)
return 1
}
if form.Cancelled() {
fmt.Fprintln(out, "cancelled")
return 1
}
updated := form.Account()
if !form.PasswordSet() {
updated.Password = "" // blank ⇒ UpdateAccount keeps the existing password
}
if err := st.UpdateAccount(updated); err != nil {
fmt.Fprintf(errOut, "edit: %v\n", err)
return 1
}
fmt.Fprintf(out, "account %q updated\n", name)
return 0
}
// runInit creates/opens the DB and adds the first account via the TUI form,
// seeding a default audit retention if unset.
func runInit(args []string, out, errOut io.Writer) int {
st, err := openStore()
if err != nil {
fmt.Fprintf(errOut, "emcli: %v\n", err)
return 1
}
defer st.Close()
if _, err := st.GetSetting("audit_retention_days"); err != nil {
_ = st.SetSetting("audit_retention_days", "90")
}
accs, _ := st.ListAccounts()
if len(accs) > 0 {
fmt.Fprintf(out, "emcli is already initialized (%d account(s)); adding another.\n", len(accs))
} else {
fmt.Fprintln(out, "Initializing emcli — add your first account.")
}
return addInteractive(st, tui.Fields{}, out, errOut)
}
+48 -1
View File
@@ -44,8 +44,47 @@ func realSender(acc store.Account, m mail.OutgoingMessage) error {
}, m)
}
func realCheckIMAP(acc store.Account) error {
return mail.CheckIMAP(mail.IMAPConfig{
Host: acc.IMAPHost, Port: acc.IMAPPort, Security: acc.IMAPSecurity,
Username: acc.Username, Password: acc.Password,
})
}
func realCheckSMTP(acc store.Account) error {
return mail.CheckSMTP(mail.SMTPConfig{
Host: acc.SMTPHost, Port: acc.SMTPPort, Security: acc.SMTPSecurity,
Username: acc.Username, Password: acc.Password,
})
}
func newDepsLive(st *store.Store, out io.Writer) Deps {
return Deps{Store: st, Dial: realMailer, Send: realSender, Now: time.Now, Out: out}
return Deps{
Store: st, Dial: realMailer, Send: realSender,
CheckIMAP: realCheckIMAP, CheckSMTP: realCheckSMTP,
Now: time.Now, Out: out,
}
}
// runDoctor handles `doctor [--account <name>]` (human-readable diagnostics).
func runDoctor(args []string, out, errOut io.Writer) int {
fs := flag.NewFlagSet("doctor", flag.ContinueOnError)
fs.SetOutput(errOut)
account := fs.String("account", "", "check only this account")
if err := fs.Parse(args); err != nil {
return 2
}
st, err := openStore()
if err != nil {
fmt.Fprintf(errOut, "emcli: %v\n", err)
return 1
}
defer st.Close()
d := newDepsLive(st, out)
if err := DoctorCmd(d, *account); err != nil {
return 1
}
return 0
}
// Run routes a command line and returns an exit code.
@@ -64,6 +103,14 @@ func Run(args []string, out, errOut io.Writer) int {
return runAccount(rest, out, errOut)
case "whitelist":
return runWhitelist(rest, out, errOut)
case "config":
return runConfig(rest, out, errOut)
case "audit":
return runAudit(rest, out, errOut)
case "doctor":
return runDoctor(rest, out, errOut)
case "init":
return runInit(rest, out, errOut)
default:
fmt.Fprintf(errOut, "emcli: unknown command %q\n", cmd)
return 2
+1 -1
View File
@@ -36,7 +36,7 @@ func TestListUsageErrorIsJSON(t *testing.T) {
// Agent command with a missing required flag emits a JSON error envelope.
var out, errOut bytes.Buffer
t.Setenv("EMCLI_KEY", b64Key())
t.Setenv("EMCLI_DB", "") // default path is fine; command fails before connecting
t.Setenv("EMCLI_DB", "") // default path is fine; command fails before connecting
code := Run([]string{"list"}, &out, &errOut) // missing --account
if code == 0 {
t.Fatal("missing --account should be non-zero")
+50
View File
@@ -0,0 +1,50 @@
package mail
import (
"crypto/tls"
"fmt"
"github.com/emersion/go-sasl"
"github.com/emersion/go-smtp"
)
// CheckIMAP verifies that the IMAP endpoint connects and the credentials
// authenticate, then logs out. It transfers no mail. A nil return means the
// account can read.
func CheckIMAP(cfg IMAPConfig) error {
c, err := Dial(cfg) // Dial connects and logs in
if err != nil {
return err
}
return c.Logout()
}
// CheckSMTP verifies that the SMTP endpoint connects and the credentials
// authenticate (SASL PLAIN), then quits. It sends no mail. A nil return means
// the account can send.
func CheckSMTP(cfg SMTPConfig) error {
addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
tlsConf := &tls.Config{ServerName: cfg.Host}
var (
c *smtp.Client
err error
)
switch cfg.Security {
case "tls":
c, err = smtp.DialTLS(addr, tlsConf)
case "starttls":
c, err = smtp.DialStartTLS(addr, tlsConf)
default:
return fmt.Errorf("unknown smtp security %q", cfg.Security)
}
if err != nil {
return fmt.Errorf("smtp connect: %w", err)
}
defer c.Close()
if err := c.Auth(sasl.NewPlainClient("", cfg.Username, cfg.Password)); err != nil {
return fmt.Errorf("smtp auth: %w", err)
}
return c.Quit()
}
+30
View File
@@ -0,0 +1,30 @@
package mail
import (
"strings"
"testing"
)
// Both checks must fail cleanly (error, no panic) against an unroutable host.
// 127.0.0.1:1 has nothing listening, so the dial fails fast.
func TestCheckIMAPFailsCleanly(t *testing.T) {
err := CheckIMAP(IMAPConfig{Host: "127.0.0.1", Port: 1, Security: "tls", Username: "u", Password: "p"})
if err == nil {
t.Fatal("expected connection error")
}
}
func TestCheckSMTPFailsCleanly(t *testing.T) {
err := CheckSMTP(SMTPConfig{Host: "127.0.0.1", Port: 1, Security: "tls", Username: "u", Password: "p"})
if err == nil {
t.Fatal("expected connection error")
}
}
func TestCheckSMTPRejectsUnknownSecurity(t *testing.T) {
err := CheckSMTP(SMTPConfig{Host: "127.0.0.1", Port: 1, Security: "bogus"})
if err == nil || !strings.Contains(err.Error(), "security") {
t.Fatalf("want security error, got %v", err)
}
}
+36
View File
@@ -100,6 +100,42 @@ func (s *Store) ListAccounts() ([]Account, error) {
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 {
+15 -2
View File
@@ -43,8 +43,21 @@ func (s *Store) PurgeAudit(now time.Time) (int64, error) {
}
func (s *Store) RecentAudit(limit int) ([]AuditEntry, error) {
rows, err := s.db.Query(
"SELECT ts,account,action,target,result,COALESCE(reason,'') FROM audit_log ORDER BY id DESC LIMIT ?", limit)
return s.RecentAuditFor("", limit)
}
// RecentAuditFor returns recent audit entries, newest first. An empty account
// returns entries for all accounts; otherwise only that account's entries.
func (s *Store) RecentAuditFor(account string, limit int) ([]AuditEntry, error) {
q := "SELECT ts,account,action,target,result,COALESCE(reason,'') FROM audit_log"
var args []any
if account != "" {
q += " WHERE account=?"
args = append(args, account)
}
q += " ORDER BY id DESC LIMIT ?"
args = append(args, limit)
rows, err := s.db.Query(q, args...)
if err != nil {
return nil, err
}
+82
View File
@@ -0,0 +1,82 @@
package store
import (
"testing"
"time"
)
func TestUpdateAccountChangesFieldsKeepsPasswordWhenBlank(t *testing.T) {
s := openTemp(t)
if _, err := s.AddAccount(sampleAccount()); err != nil { // RO, password "s3cr3t"
t.Fatalf("AddAccount: %v", err)
}
upd := sampleAccount()
upd.Mode = "RW"
upd.IMAPPort = 143
upd.SMTPHost = "smtp.example.com"
upd.SMTPPort = 587
upd.SMTPSecurity = "starttls"
upd.Password = "" // blank => keep existing password
if err := s.UpdateAccount(upd); err != nil {
t.Fatalf("UpdateAccount: %v", err)
}
got, err := s.GetAccount("work")
if err != nil {
t.Fatalf("GetAccount: %v", err)
}
if got.Mode != "RW" || got.IMAPPort != 143 || got.SMTPHost != "smtp.example.com" || got.SMTPPort != 587 {
t.Fatalf("fields not updated: %+v", got)
}
if got.Password != "s3cr3t" {
t.Fatalf("blank password should preserve existing, got %q", got.Password)
}
}
func TestUpdateAccountReEncryptsNewPassword(t *testing.T) {
s := openTemp(t)
_, _ = s.AddAccount(sampleAccount())
upd := sampleAccount()
upd.Password = "n3wpass"
if err := s.UpdateAccount(upd); err != nil {
t.Fatalf("UpdateAccount: %v", err)
}
got, _ := s.GetAccount("work")
if got.Password != "n3wpass" {
t.Fatalf("password not updated: %q", got.Password)
}
// And it is encrypted at rest.
var blob []byte
_ = s.db.QueryRow("SELECT enc_password FROM accounts WHERE name='work'").Scan(&blob)
if string(blob) == "n3wpass" || len(blob) == 0 {
t.Fatalf("new password not encrypted at rest")
}
}
func TestUpdateAccountMissing(t *testing.T) {
s := openTemp(t)
if err := s.UpdateAccount(sampleAccount()); err == nil {
t.Fatal("updating a non-existent account must error")
}
}
func TestRecentAuditForFiltersByAccount(t *testing.T) {
s := openTemp(t)
now := time.Date(2026, 6, 22, 0, 0, 0, 0, time.UTC)
_ = s.Audit(now, AuditEntry{Account: "a", Action: "list", Target: "INBOX", Result: "allowed"})
_ = s.Audit(now, AuditEntry{Account: "b", Action: "send", Target: "x@y.com", Result: "allowed"})
_ = s.Audit(now, AuditEntry{Account: "a", Action: "get", Target: "1", Result: "allowed"})
all, err := s.RecentAuditFor("", 50)
if err != nil || len(all) != 3 {
t.Fatalf("RecentAuditFor all: len=%d err=%v", len(all), err)
}
onlyA, err := s.RecentAuditFor("a", 50)
if err != nil || len(onlyA) != 2 {
t.Fatalf("RecentAuditFor a: len=%d err=%v", len(onlyA), err)
}
for _, e := range onlyA {
if e.Account != "a" {
t.Fatalf("filter leaked account %q", e.Account)
}
}
}
+333
View File
@@ -0,0 +1,333 @@
// Package tui holds the bubbletea admin forms (init / interactive account
// add/edit). The form's validation and assembly logic lives in the pure Fields
// type so it can be unit-tested without a terminal.
package tui
import (
"errors"
"fmt"
"strconv"
"strings"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"git.dcglab.co.uk/steve/emcli/internal/store"
)
// Fields is the string/bool form state for an account, independent of bubbletea.
type Fields struct {
Name, Mode string
IMAPHost, IMAPPort, IMAPSecurity string
SMTPHost, SMTPPort, SMTPSecurity string
Username, Password string
WhitelistIn, WhitelistOut, ProcessBacklog bool
SubjectRegex string
}
func validSecurity(s string) bool { return s == "tls" || s == "starttls" }
// Validate checks required fields, enum fields, and numeric ports. RW accounts
// additionally require a valid SMTP host/port/security.
func (f Fields) Validate() error {
if strings.TrimSpace(f.Name) == "" {
return errors.New("name is required")
}
if strings.TrimSpace(f.IMAPHost) == "" {
return errors.New("imap host is required")
}
if strings.TrimSpace(f.Username) == "" {
return errors.New("username is required")
}
if f.Mode != "RO" && f.Mode != "RW" {
return errors.New("mode must be RO or RW")
}
if !validSecurity(f.IMAPSecurity) {
return errors.New("imap security must be tls or starttls")
}
if _, err := strconv.Atoi(f.IMAPPort); err != nil {
return errors.New("imap port must be a number")
}
if f.Mode == "RW" {
if strings.TrimSpace(f.SMTPHost) == "" {
return errors.New("RW account requires an smtp host")
}
if !validSecurity(f.SMTPSecurity) {
return errors.New("smtp security must be tls or starttls")
}
if _, err := strconv.Atoi(f.SMTPPort); err != nil {
return errors.New("smtp port must be a number")
}
}
return nil
}
// ToAccount assembles a store.Account. The second return is whether a password
// was supplied (false ⇒ an edit should keep the existing password).
func (f Fields) ToAccount() (store.Account, bool) {
ip, _ := strconv.Atoi(f.IMAPPort)
a := store.Account{
Name: strings.TrimSpace(f.Name), Mode: f.Mode,
IMAPHost: f.IMAPHost, IMAPPort: ip, IMAPSecurity: f.IMAPSecurity,
AuthType: "password", Username: f.Username, Password: f.Password,
WhitelistInEnabled: f.WhitelistIn, WhitelistOutEnabled: f.WhitelistOut,
SubjectRegex: f.SubjectRegex, ProcessBacklog: f.ProcessBacklog,
}
if f.Mode == "RW" {
sp, _ := strconv.Atoi(f.SMTPPort)
a.SMTPHost, a.SMTPPort, a.SMTPSecurity = f.SMTPHost, sp, f.SMTPSecurity
}
return a, f.Password != ""
}
func itoaPort(p int) string {
if p == 0 {
return ""
}
return strconv.Itoa(p)
}
// FieldsFromAccount prefills a form from an existing account. The password is
// never read back (it stays blank; a blank password on save keeps the existing).
func FieldsFromAccount(a store.Account) Fields {
return Fields{
Name: a.Name, Mode: a.Mode,
IMAPHost: a.IMAPHost, IMAPPort: itoaPort(a.IMAPPort), IMAPSecurity: a.IMAPSecurity,
SMTPHost: a.SMTPHost, SMTPPort: itoaPort(a.SMTPPort), SMTPSecurity: a.SMTPSecurity,
Username: a.Username,
WhitelistIn: a.WhitelistInEnabled,
WhitelistOut: a.WhitelistOutEnabled,
ProcessBacklog: a.ProcessBacklog,
SubjectRegex: a.SubjectRegex,
}
}
// ---- bubbletea model ----
type fieldDef struct {
key string
label string
isBool bool
password bool
}
var fieldDefs = []fieldDef{
{key: "name", label: "Name"},
{key: "mode", label: "Mode (RO/RW)"},
{key: "imap_host", label: "IMAP host"},
{key: "imap_port", label: "IMAP port"},
{key: "imap_security", label: "IMAP security (tls/starttls)"},
{key: "smtp_host", label: "SMTP host (RW)"},
{key: "smtp_port", label: "SMTP port (RW)"},
{key: "smtp_security", label: "SMTP security (tls/starttls)"},
{key: "username", label: "Username"},
{key: "password", label: "Password", password: true},
{key: "whitelist_in", label: "Whitelist inbound (y/n)", isBool: true},
{key: "whitelist_out", label: "Whitelist outbound (y/n)", isBool: true},
{key: "process_backlog", label: "Process backlog (y/n)", isBool: true},
{key: "subject_regex", label: "Subject regex (optional)"},
}
func boolStr(b bool) string {
if b {
return "y"
}
return "n"
}
func parseBool(s string) bool {
switch strings.ToLower(strings.TrimSpace(s)) {
case "y", "yes", "true", "1":
return true
}
return false
}
func fieldValue(f Fields, key string) string {
switch key {
case "name":
return f.Name
case "mode":
return f.Mode
case "imap_host":
return f.IMAPHost
case "imap_port":
return f.IMAPPort
case "imap_security":
return f.IMAPSecurity
case "smtp_host":
return f.SMTPHost
case "smtp_port":
return f.SMTPPort
case "smtp_security":
return f.SMTPSecurity
case "username":
return f.Username
case "password":
return f.Password
case "whitelist_in":
return boolStr(f.WhitelistIn)
case "whitelist_out":
return boolStr(f.WhitelistOut)
case "process_backlog":
return boolStr(f.ProcessBacklog)
case "subject_regex":
return f.SubjectRegex
}
return ""
}
// AccountForm is the bubbletea Model for adding/editing an account.
type AccountForm struct {
inputs []textinput.Model
focus int
editing bool
done bool
cancelled bool
err error
result store.Account
pwSet bool
}
// NewAccountForm builds a form pre-populated from initial. editing only affects
// the displayed hint (password may be left blank to keep the existing one).
func NewAccountForm(initial Fields, editing bool) AccountForm {
// Sensible defaults for a fresh form.
if initial.Mode == "" {
initial.Mode = "RO"
}
if initial.IMAPPort == "" {
initial.IMAPPort = "993"
}
if initial.IMAPSecurity == "" {
initial.IMAPSecurity = "tls"
}
if initial.SMTPSecurity == "" {
initial.SMTPSecurity = "tls"
}
inputs := make([]textinput.Model, len(fieldDefs))
for i, d := range fieldDefs {
ti := textinput.New()
ti.Prompt = ""
ti.SetValue(fieldValue(initial, d.key))
if d.password {
ti.EchoMode = textinput.EchoPassword
}
if i == 0 {
ti.Focus()
}
inputs[i] = ti
}
return AccountForm{inputs: inputs, editing: editing}
}
func (m AccountForm) collect() Fields {
get := func(i int) string { return strings.TrimSpace(m.inputs[i].Value()) }
f := Fields{}
for i, d := range fieldDefs {
v := get(i)
switch d.key {
case "name":
f.Name = v
case "mode":
f.Mode = strings.ToUpper(v)
case "imap_host":
f.IMAPHost = v
case "imap_port":
f.IMAPPort = v
case "imap_security":
f.IMAPSecurity = strings.ToLower(v)
case "smtp_host":
f.SMTPHost = v
case "smtp_port":
f.SMTPPort = v
case "smtp_security":
f.SMTPSecurity = strings.ToLower(v)
case "username":
f.Username = v
case "password":
f.Password = m.inputs[i].Value() // do not trim a password
case "whitelist_in":
f.WhitelistIn = parseBool(v)
case "whitelist_out":
f.WhitelistOut = parseBool(v)
case "process_backlog":
f.ProcessBacklog = parseBool(v)
case "subject_regex":
f.SubjectRegex = v
}
}
return f
}
func (m AccountForm) Init() tea.Cmd { return textinput.Blink }
// Update implements tea.Model.
func (m AccountForm) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
switch key.String() {
case "ctrl+c", "esc":
m.cancelled = true
return m, tea.Quit
case "enter":
f := m.collect()
if err := f.Validate(); err != nil {
m.err = err
return m, nil
}
m.result, m.pwSet = f.ToAccount()
m.done = true
return m, tea.Quit
case "tab", "down":
m.focusNext(1)
return m, nil
case "shift+tab", "up":
m.focusNext(-1)
return m, nil
}
}
// Delegate other messages (typing) to the focused input.
var cmd tea.Cmd
m.inputs[m.focus], cmd = m.inputs[m.focus].Update(msg)
return m, cmd
}
func (m *AccountForm) focusNext(delta int) {
m.inputs[m.focus].Blur()
m.focus = (m.focus + delta + len(m.inputs)) % len(m.inputs)
m.inputs[m.focus].Focus()
}
var labelStyle = lipgloss.NewStyle().Bold(true)
// View implements tea.Model.
func (m AccountForm) View() string {
if m.done || m.cancelled {
return ""
}
var b strings.Builder
title := "Add account"
if m.editing {
title = "Edit account (blank password keeps existing)"
}
b.WriteString(labelStyle.Render(title) + "\n\n")
for i, d := range fieldDefs {
cursor := " "
if i == m.focus {
cursor = "> "
}
b.WriteString(fmt.Sprintf("%s%-32s %s\n", cursor, d.label+":", m.inputs[i].View()))
}
b.WriteString("\n[tab] next [shift+tab] prev [enter] save [esc] cancel\n")
if m.err != nil {
b.WriteString("\nerror: " + m.err.Error() + "\n")
}
return b.String()
}
func (m AccountForm) Done() bool { return m.done }
func (m AccountForm) Cancelled() bool { return m.cancelled }
func (m AccountForm) Err() error { return m.err }
func (m AccountForm) Account() store.Account { return m.result }
func (m AccountForm) PasswordSet() bool { return m.pwSet }
+148
View File
@@ -0,0 +1,148 @@
package tui
import (
"testing"
tea "github.com/charmbracelet/bubbletea"
"git.dcglab.co.uk/steve/emcli/internal/store"
)
func validFields() Fields {
return Fields{
Name: "work", Mode: "RW",
IMAPHost: "imap.x.com", IMAPPort: "993", IMAPSecurity: "tls",
SMTPHost: "smtp.x.com", SMTPPort: "465", SMTPSecurity: "tls",
Username: "u@x.com", Password: "pw",
}
}
func TestFieldsValidateRequired(t *testing.T) {
f := validFields()
f.Name = ""
if err := f.Validate(); err == nil {
t.Fatal("missing name must fail validation")
}
f = validFields()
f.Username = ""
if err := f.Validate(); err == nil {
t.Fatal("missing username must fail validation")
}
f = validFields()
f.IMAPHost = ""
if err := f.Validate(); err == nil {
t.Fatal("missing imap host must fail validation")
}
}
func TestFieldsValidateEnums(t *testing.T) {
f := validFields()
f.Mode = "XX"
if err := f.Validate(); err == nil {
t.Fatal("mode must be RO or RW")
}
f = validFields()
f.IMAPSecurity = "ssl"
if err := f.Validate(); err == nil {
t.Fatal("security must be tls or starttls")
}
f = validFields()
f.IMAPPort = "notnum"
if err := f.Validate(); err == nil {
t.Fatal("port must be numeric")
}
}
func TestFieldsValidateRWNeedsSMTP(t *testing.T) {
f := validFields()
f.SMTPHost = ""
if err := f.Validate(); err == nil {
t.Fatal("RW account requires an SMTP host")
}
// RO without SMTP is fine.
f = validFields()
f.Mode = "RO"
f.SMTPHost, f.SMTPPort, f.SMTPSecurity = "", "", ""
if err := f.Validate(); err != nil {
t.Fatalf("RO without SMTP should validate: %v", err)
}
}
func TestFieldsToAccount(t *testing.T) {
f := validFields()
f.WhitelistIn = true
f.SubjectRegex = "^urgent"
acc, pwSet := f.ToAccount()
if !pwSet {
t.Fatal("password was provided, PasswordSet should be true")
}
if acc.Name != "work" || acc.Mode != "RW" || acc.IMAPPort != 993 || acc.SMTPPort != 465 {
t.Fatalf("account not assembled: %+v", acc)
}
if acc.AuthType != "password" || !acc.WhitelistInEnabled || acc.SubjectRegex != "^urgent" {
t.Fatalf("account flags wrong: %+v", acc)
}
if acc.Password != "pw" {
t.Fatalf("password not carried: %q", acc.Password)
}
}
func TestFieldsToAccountBlankPassword(t *testing.T) {
f := validFields()
f.Password = ""
_, pwSet := f.ToAccount()
if pwSet {
t.Fatal("blank password should report PasswordSet=false (edit keeps existing)")
}
}
func TestFieldsFromAccountRoundTrip(t *testing.T) {
a := store.Account{
Name: "g", Mode: "RW", IMAPHost: "i", IMAPPort: 993, IMAPSecurity: "tls",
SMTPHost: "s", SMTPPort: 587, SMTPSecurity: "starttls",
Username: "u@x.com", WhitelistOutEnabled: true, SubjectRegex: "re:",
}
f := FieldsFromAccount(a)
if f.Name != "g" || f.IMAPPort != "993" || f.SMTPPort != "587" || !f.WhitelistOut || f.SubjectRegex != "re:" {
t.Fatalf("FieldsFromAccount wrong: %+v", f)
}
// Password is never read back from an account.
if f.Password != "" {
t.Fatalf("password must not be prefilled: %q", f.Password)
}
}
func TestAccountFormSubmitValid(t *testing.T) {
m := NewAccountForm(validFields(), false)
// Enter submits; with valid fields the form completes.
nm, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
m = nm.(AccountForm)
if !m.Done() || m.Cancelled() {
t.Fatalf("valid submit should be Done, not cancelled (err=%v)", m.Err())
}
if m.Account().Name != "work" {
t.Fatalf("submitted account wrong: %+v", m.Account())
}
}
func TestAccountFormSubmitInvalidStays(t *testing.T) {
f := validFields()
f.Name = ""
m := NewAccountForm(f, false)
nm, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
m = nm.(AccountForm)
if m.Done() {
t.Fatal("invalid submit must not complete the form")
}
if m.Err() == nil {
t.Fatal("invalid submit should set an error to show the user")
}
}
func TestAccountFormCancel(t *testing.T) {
m := NewAccountForm(validFields(), false)
nm, _ := m.Update(tea.KeyMsg{Type: tea.KeyEsc})
m = nm.(AccountForm)
if !m.Cancelled() {
t.Fatal("esc should cancel the form")
}
}
+86
View File
@@ -0,0 +1,86 @@
# emcli — Phase 4 Status Report
**Date:** 2026-06-22
**Branch:** `main`
**Phase 4 scope (SPEC §7.2):** `doctor` connectivity/auth diagnostics; admin completeness
(`account edit`/`remove`, `config set`/`get`, `audit list`); and a **bubbletea TUI** for `init`
and interactive `account add`/`edit`.
## TL;DR
**Phase 4 is complete and validated live.** Built test-first; the binary builds as a single static
CGO-free executable, `go vet` is clean, and the full suite passes including `-race`. `doctor` and
the admin commands were exercised against the two real accounts (mxlogin password auth + Gmail
app-password). A real bug — `doctor` running checks with stripped (empty) credentials — was caught
by live validation, reproduced with a regression test, and fixed.
## What was built
| Area | Change | Status |
|---|---|---|
| `store` | `UpdateAccount` (partial edit; re-encrypts password/secrets only when non-empty, blank keeps existing); `RecentAuditFor(account, limit)`. | ✅ |
| `mail` | `CheckIMAP` (connect+login+logout) and `CheckSMTP` (connect+SASL-PLAIN auth+quit) — no mail transferred. | ✅ |
| `cli` | `doctor [--account]` (per-account IMAP/SMTP `ok`/`FAIL`, exit non-zero on any failure, no secrets); `config set`/`get` (validates `audit_retention_days`); `audit list [--account] [--limit]`; `account edit` (flag partial-update) / `account remove [--yes]`. | ✅ |
| `tui` (new pkg) | `AccountForm` bubbletea model over `bubbles/textinput`, with pure, fully-tested `Fields` (validation + `store.Account` assembly + edit prefill). | ✅ |
| `cli` wiring | `init` (create/open DB, seed `audit_retention_days=90`, add first account via TUI); bare `account add` → TUI; `account edit --name X` (only `--name`) → TUI prefilled. | ✅ |
### Commands added
```
emcli doctor [--account <name>]
emcli config set <key> <value> # e.g. audit_retention_days
emcli config get <key>
emcli audit list [--account <name>] [--limit N]
emcli account edit --name <n> [--mode|--imap-host|--smtp-host|…] # flag partial-update
emcli account edit --name <n> # interactive (TUI)
emcli account remove --name <n> --yes
emcli account add # interactive (TUI)
emcli init # interactive (TUI)
```
## Live validation
Against the two real accounts (`mxlogin` password auth, `gmail` app-password) in an isolated DB:
- **`doctor`** — `mxlogin` and `gmail` both report `IMAP ok` / `SMTP ok`; a deliberately
bad-password account reports `IMAP FAIL: Invalid credentials` (clean error, **no crash**); SMTP
shown `n/a` for RO. Exit `1` when any check fails, `0` when `--account` targets a passing one.
- **`config`** — `set`/`get` round-trip; `audit_retention_days=-5` rejected (exit 2).
- **`audit list`** — rendered a real `list`/`allowed` row.
- **`account edit`** (flag) — set a subject-regex on `mxlogin`; a follow-up `doctor` still passed,
proving `UpdateAccount` **preserved the encrypted password** through the edit.
- **`account remove --yes`** — deleted an account; gone from `account list`.
- **TUI** (`init`/interactive) requires a real terminal; without a TTY it fails cleanly
(`could not open a new TTY`), no panic. Drive it interactively to use.
## Bug found and fixed during live validation
`doctor` initially authenticated with **empty passwords** for every account — it iterated
`ListAccounts()`, which deliberately strips secrets, and passed those credential-less structs to the
live checks. Caught immediately against real servers ("Empty username or password"). Fixed by
re-fetching each account with `GetAccount` (which decrypts) before checking; locked in with
`TestDoctorUsesDecryptedCredentials`.
## Verification
```
CGO_ENABLED=0 go build ./... → OK, single static binary
go vet ./... → clean
go test ./... → all packages pass (incl. new internal/tui)
go test -race ./... → all packages pass
```
New tests: `store` update/audit-filter; `mail` check-fails-cleanly; `cli` doctor (all-ok, failure,
RO-skip, account-filter, decrypted-creds regression), config/audit/edit/remove via `Run()`; `tui`
Fields validation/assembly/prefill and form submit/cancel.
## Known limitations / deferred
- TUI is a minimal keyboard-driven form (bool fields entered as `y/n`, enums as text); no mouse or
theming. Sufficient for admin use.
- OAuth consent in `init` omitted (OAuth deferred in Phase 3).
- Carry-over Minor items from Phase 1 (audit-row completeness, some CLI polish) remain open.
## Project status
Phases 14 complete: read path, send path, Gmail (app password), and admin/TUI/doctor. The core
`emcli` surface from the SPEC is implemented and validated live, with OAuth2 (§10) the one
deliberately-deferred item.
@@ -0,0 +1,75 @@
# emcli — Phase 4 Plan: Admin TUI + `doctor`
**Date:** 2026-06-22
**Depends on:** Phases 13 complete (read, send, Gmail-via-app-password).
**Scope (SPEC §7.2):** `doctor` connectivity/auth diagnostics; admin completeness
(`account edit/remove`, `config set/get`, `audit list`); and a **bubbletea TUI** for `init` and
interactive `account add/edit`. (OAuth consent in `init` is omitted — OAuth deferred in Phase 3.)
## Building blocks already present
- store: `GetSetting`/`SetSetting`, `RecentAudit(limit)`, `DeleteAccount`, `AddAccount`,
`GetAccount`, `ListAccounts`. mail: `Dial` (connect+login), `SendSMTP`.
- Need new: store `UpdateAccount` + account-filtered audit; mail `CheckIMAP`/`CheckSMTP`
(connect+auth only, no traffic); the TUI form.
## Tasks
### 1. `store`: `UpdateAccount` + account-filtered audit
- `UpdateAccount(a Account) error` — updates mutable fields by name; re-encrypts the password
**only if a non-empty one is supplied** (blank = keep existing); same for OAuth secrets.
- `RecentAuditFor(account string, limit int)` (account `""` = all) for `audit list --account`.
**Tests:** update changes fields + preserves password when blank; password re-encrypts when set;
audit filter returns only the named account.
### 2. `mail`: `CheckIMAP` / `CheckSMTP` (for doctor)
- `CheckIMAP(IMAPConfig) error``Dial` (logs in) then `Logout`. Surfaces connect/auth failure.
- `CheckSMTP(SMTPConfig) error` — dial (tls/starttls), `Auth` (SASL PLAIN), `Quit`. No mail sent.
**Tests:** both fail cleanly on an unroutable host (error, no panic). Live auth covered in task 8.
### 3. `cli`: `doctor`
`emcli doctor [--account <name>]` — human-readable. Verifies `EMCLI_KEY` + DB open (via
`openStore`), then per account prints IMAP and (RW + smtp set) SMTP as `ok`/`FAIL: <reason>`.
Exit non-zero if any check fails. Secrets never printed.
**Tests:** table rendering + pass/fail aggregation with injected check funcs (no network).
### 4. `cli`: `config set/get` + `audit list`
- `emcli config set <key> <value>` / `emcli config get <key>` — wraps settings; known key
`audit_retention_days` (validate integer ≥ 0 on set).
- `emcli audit list [--account <name>] [--limit N]` — table of recent entries (default 50).
**Tests:** set→get round-trip; retention validation; audit list renders rows.
### 5. `cli`: `account edit` / `account remove`
- `account edit --name <n> [--mode ...] [--imap-host ...] …` — flag-based partial update via
`UpdateAccount`; only provided flags change (others preserved). Bare `account edit --name <n>`
with no other flags drops into the TUI form (task 6).
- `account remove --name <n> [--yes]``DeleteAccount`; require `--yes` or interactive confirm.
**Tests:** edit changes only supplied fields; remove deletes; remove missing → error.
### 6. `tui`: bubbletea account form (testable model)
New `internal/tui` package (keeps bubbletea out of `cli`'s testable core, no store dependency).
- `AccountForm` — a bubbletea `Model` over `bubbles/textinput` fields: name, mode, imap
host/port/security, smtp host/port/security, username, password, whitelist-in/out toggles,
process-backlog. Validates; produces a `store.Account` (+ `PasswordSet bool`).
- Driven by `Update(tea.Msg)`; exposes `Account()`, `Done()`, `Cancelled()`, `Err()` so the
logic is unit-testable by feeding key messages — no terminal needed.
**Tests:** feed keystrokes → assert assembled Account; required-field validation blocks submit;
edit-mode prefill round-trips; blank password in edit ⇒ `PasswordSet == false`.
### 7. `cli`: wire `init` + interactive add/edit
- `emcli init` — if no accounts exist, run the TUI form, persist the first account, set
`audit_retention_days` default. Idempotent-ish: warns if an account already exists.
- `account add` with no flags → TUI form; `account edit --name X` with no other flags → TUI
prefilled. Flag forms remain for scripting.
- `runTUIAccount` glue persists the form's `store.Account` via Add/Update.
### 8. Build/vet/test (incl `-race`) + live `doctor` validation
`CGO_ENABLED=0 go build`, `go vet`, `go test ./...`/`-race`. Live: run `doctor` against the
mxlogin (password) and Gmail (app-password) accounts — assert IMAP+SMTP `ok`; assert a bad
password reports `FAIL` (auth), not a crash.
### 9. Status report + commit/push
`PHASE4-STATUS.md`; commit to `main`; push via tea token.
## Out of scope
- OAuth consent in `init` (Phase 3 deferred OAuth).
- Mouse/advanced TUI theming; a minimal, keyboard-driven lipgloss-styled form is enough.