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 go 1.25.0
require ( 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-imap v1.2.1
github.com/emersion/go-message v0.18.2 github.com/emersion/go-message v0.18.2
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6
@@ -11,11 +14,29 @@ require (
) )
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/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/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-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/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // 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/sys v0.44.0 // indirect
golang.org/x/text v0.14.0 // indirect golang.org/x/text v0.14.0 // indirect
modernc.org/libc v1.73.4 // 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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/emersion/go-imap v1.2.1 h1:+s9ZjMEjOB8NzZMVTM3cCenz2JrQIGGo5j1df19WjTA= 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 h1:g6AfoF140mvW0vLNPD/LuCBLEAdlxOjIXqbIkJIS6Wk=
github.com/emersion/go-smtp v0.24.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4McvHxCU2gsQ= 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/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 h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= 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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= 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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= 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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 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= 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-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/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.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.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= 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-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-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-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-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.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/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" "flag"
"fmt" "fmt"
"io" "io"
"strconv"
"git.dcglab.co.uk/steve/emcli/internal/store" "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). // 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 { switch sub {
case "add": 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 := flag.NewFlagSet("account add", flag.ContinueOnError)
fs.SetOutput(errOut) fs.SetOutput(errOut)
name := fs.String("name", "", "account name") 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) fmt.Fprintf(out, "account %q added (%s)\n", *name, *mode)
return 0 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": case "list":
accs, err := st.ListAccounts() accs, err := st.ListAccounts()
if err != nil { 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`. // runWhitelist handles `whitelist <in|out> add --account NAME --address A`.
func runWhitelist(args []string, out, errOut io.Writer) int { func runWhitelist(args []string, out, errOut io.Writer) int {
if len(args) < 2 { 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 { type Deps struct {
Store *store.Store Store *store.Store
Dial func(store.Account) (Mailer, error) Dial func(store.Account) (Mailer, error)
Send func(store.Account, mail.OutgoingMessage) error Send func(store.Account, mail.OutgoingMessage) error
Now func() time.Time CheckIMAP func(store.Account) error
Out io.Writer CheckSMTP func(store.Account) error
Now func() time.Time
Out io.Writer
} }
func (d Deps) emit(e Envelope) error { func (d Deps) emit(e Envelope) error {
+3 -1
View File
@@ -18,7 +18,9 @@ type fakeMailer struct {
full map[uint32]mail.Message 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) { func (f *fakeMailer) FetchHeaders(_ string, uids []uint32) ([]mail.Header, error) {
if len(uids) == 0 { if len(uids) == 0 {
return f.headers, nil 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) }, 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 { 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. // 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) return runAccount(rest, out, errOut)
case "whitelist": case "whitelist":
return runWhitelist(rest, out, errOut) 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: default:
fmt.Fprintf(errOut, "emcli: unknown command %q\n", cmd) fmt.Fprintf(errOut, "emcli: unknown command %q\n", cmd)
return 2 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. // Agent command with a missing required flag emits a JSON error envelope.
var out, errOut bytes.Buffer var out, errOut bytes.Buffer
t.Setenv("EMCLI_KEY", b64Key()) 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 code := Run([]string{"list"}, &out, &errOut) // missing --account
if code == 0 { if code == 0 {
t.Fatal("missing --account should be non-zero") 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() 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 { func (s *Store) DeleteAccount(name string) error {
res, err := s.db.Exec("DELETE FROM accounts WHERE name = ?", name) res, err := s.db.Exec("DELETE FROM accounts WHERE name = ?", name)
if err != nil { 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) { func (s *Store) RecentAudit(limit int) ([]AuditEntry, error) {
rows, err := s.db.Query( return s.RecentAuditFor("", limit)
"SELECT ts,account,action,target,result,COALESCE(reason,'') FROM audit_log ORDER BY id DESC LIMIT ?", 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 { if err != nil {
return nil, err 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.