From a837b25d73779853821fa4988803f499651e3c02 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Mon, 22 Jun 2026 20:09:43 +0100 Subject: [PATCH] =?UTF-8?q?feat(admin):=20Phase=204=20=E2=80=94=20doctor,?= =?UTF-8?q?=20admin=20completeness,=20and=20bubbletea=20TUI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- go.mod | 21 ++ go.sum | 45 +++ internal/cli/admin.go | 179 ++++++++++ internal/cli/admin_test.go | 115 ++++++ internal/cli/agent.go | 12 +- internal/cli/agent_test.go | 4 +- internal/cli/doctor.go | 61 ++++ internal/cli/doctor_test.go | 107 ++++++ internal/cli/interactive.go | 93 +++++ internal/cli/run.go | 49 ++- internal/cli/run_test.go | 2 +- internal/mail/check.go | 50 +++ internal/mail/check_test.go | 30 ++ internal/store/account.go | 36 ++ internal/store/audit.go | 17 +- internal/store/update_test.go | 82 +++++ internal/tui/account.go | 333 ++++++++++++++++++ internal/tui/account_test.go | 148 ++++++++ specifications/PHASE4-STATUS.md | 86 +++++ .../2026-06-22-phase4-admin-tui-doctor.md | 75 ++++ 20 files changed, 1535 insertions(+), 10 deletions(-) create mode 100644 internal/cli/admin_test.go create mode 100644 internal/cli/doctor.go create mode 100644 internal/cli/doctor_test.go create mode 100644 internal/cli/interactive.go create mode 100644 internal/mail/check.go create mode 100644 internal/mail/check_test.go create mode 100644 internal/store/update_test.go create mode 100644 internal/tui/account.go create mode 100644 internal/tui/account_test.go create mode 100644 specifications/PHASE4-STATUS.md create mode 100644 specifications/plans/2026-06-22-phase4-admin-tui-doctor.md diff --git a/go.mod b/go.mod index edce97c..c52d0ac 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index b91da8a..e962fc8 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/cli/admin.go b/internal/cli/admin.go index 202209a..d251bd5 100644 --- a/internal/cli/admin.go +++ b/internal/cli/admin.go @@ -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 ` and `config get `. +func runConfig(args []string, out, errOut io.Writer) int { + if len(args) < 2 { + fmt.Fprintln(errOut, "usage: emcli config [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 ") + 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 ] [--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 ] [--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 add --account NAME --address A`. func runWhitelist(args []string, out, errOut io.Writer) int { if len(args) < 2 { diff --git a/internal/cli/admin_test.go b/internal/cli/admin_test.go new file mode 100644 index 0000000..15eee21 --- /dev/null +++ b/internal/cli/admin_test.go @@ -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) } diff --git a/internal/cli/agent.go b/internal/cli/agent.go index 1f9a32d..e5764b7 100644 --- a/internal/cli/agent.go +++ b/internal/cli/agent.go @@ -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 { diff --git a/internal/cli/agent_test.go b/internal/cli/agent_test.go index 2d9918a..1ae7766 100644 --- a/internal/cli/agent_test.go +++ b/internal/cli/agent_test.go @@ -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 diff --git a/internal/cli/doctor.go b/internal/cli/doctor.go new file mode 100644 index 0000000..19137e3 --- /dev/null +++ b/internal/cli/doctor.go @@ -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 +} diff --git a/internal/cli/doctor_test.go b/internal/cli/doctor_test.go new file mode 100644 index 0000000..c421e50 --- /dev/null +++ b/internal/cli/doctor_test.go @@ -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) + } +} diff --git a/internal/cli/interactive.go b/internal/cli/interactive.go new file mode 100644 index 0000000..58fc20c --- /dev/null +++ b/internal/cli/interactive.go @@ -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) +} diff --git a/internal/cli/run.go b/internal/cli/run.go index a5d7ac5..e446603 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -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 ]` (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 diff --git a/internal/cli/run_test.go b/internal/cli/run_test.go index ccf1a89..7bdbba1 100644 --- a/internal/cli/run_test.go +++ b/internal/cli/run_test.go @@ -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") diff --git a/internal/mail/check.go b/internal/mail/check.go new file mode 100644 index 0000000..6b948e7 --- /dev/null +++ b/internal/mail/check.go @@ -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() +} diff --git a/internal/mail/check_test.go b/internal/mail/check_test.go new file mode 100644 index 0000000..28f6f65 --- /dev/null +++ b/internal/mail/check_test.go @@ -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) + } +} diff --git a/internal/store/account.go b/internal/store/account.go index e3f8885..14988e2 100644 --- a/internal/store/account.go +++ b/internal/store/account.go @@ -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 { diff --git a/internal/store/audit.go b/internal/store/audit.go index bba65eb..e1bb498 100644 --- a/internal/store/audit.go +++ b/internal/store/audit.go @@ -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 } diff --git a/internal/store/update_test.go b/internal/store/update_test.go new file mode 100644 index 0000000..86f9ccf --- /dev/null +++ b/internal/store/update_test.go @@ -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) + } + } +} diff --git a/internal/tui/account.go b/internal/tui/account.go new file mode 100644 index 0000000..bc3d022 --- /dev/null +++ b/internal/tui/account.go @@ -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 } diff --git a/internal/tui/account_test.go b/internal/tui/account_test.go new file mode 100644 index 0000000..0cbc13c --- /dev/null +++ b/internal/tui/account_test.go @@ -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") + } +} diff --git a/specifications/PHASE4-STATUS.md b/specifications/PHASE4-STATUS.md new file mode 100644 index 0000000..f26310d --- /dev/null +++ b/specifications/PHASE4-STATUS.md @@ -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 ] +emcli config set # e.g. audit_retention_days +emcli config get +emcli audit list [--account ] [--limit N] +emcli account edit --name [--mode|--imap-host|--smtp-host|…] # flag partial-update +emcli account edit --name # interactive (TUI) +emcli account remove --name --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 1–4 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. diff --git a/specifications/plans/2026-06-22-phase4-admin-tui-doctor.md b/specifications/plans/2026-06-22-phase4-admin-tui-doctor.md new file mode 100644 index 0000000..2bd89df --- /dev/null +++ b/specifications/plans/2026-06-22-phase4-admin-tui-doctor.md @@ -0,0 +1,75 @@ +# emcli — Phase 4 Plan: Admin TUI + `doctor` + +**Date:** 2026-06-22 +**Depends on:** Phases 1–3 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 ]` — human-readable. Verifies `EMCLI_KEY` + DB open (via +`openStore`), then per account prints IMAP and (RW + smtp set) SMTP as `ok`/`FAIL: `. +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 ` / `emcli config get ` — wraps settings; known key + `audit_retention_days` (validate integer ≥ 0 on set). +- `emcli audit list [--account ] [--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 [--mode ...] [--imap-host ...] …` — flag-based partial update via + `UpdateAccount`; only provided flags change (others preserved). Bare `account edit --name ` + with no other flags drops into the TUI form (task 6). +- `account remove --name [--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.