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
+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
}