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:
@@ -4,8 +4,10 @@ import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
|
||||
"git.dcglab.co.uk/steve/emcli/internal/store"
|
||||
"git.dcglab.co.uk/steve/emcli/internal/tui"
|
||||
)
|
||||
|
||||
// runAccount handles `account add|list`. Human-readable output (never JSON).
|
||||
@@ -24,6 +26,9 @@ func runAccount(args []string, out, errOut io.Writer) int {
|
||||
|
||||
switch sub {
|
||||
case "add":
|
||||
if len(rest) == 0 { // no flags → interactive TUI form
|
||||
return addInteractive(st, tui.Fields{}, out, errOut)
|
||||
}
|
||||
fs := flag.NewFlagSet("account add", flag.ContinueOnError)
|
||||
fs.SetOutput(errOut)
|
||||
name := fs.String("name", "", "account name")
|
||||
@@ -63,6 +68,91 @@ func runAccount(args []string, out, errOut io.Writer) int {
|
||||
}
|
||||
fmt.Fprintf(out, "account %q added (%s)\n", *name, *mode)
|
||||
return 0
|
||||
case "edit":
|
||||
fs := flag.NewFlagSet("account edit", flag.ContinueOnError)
|
||||
fs.SetOutput(errOut)
|
||||
name := fs.String("name", "", "account name (required)")
|
||||
mode := fs.String("mode", "", "RO|RW")
|
||||
host := fs.String("imap-host", "", "IMAP host")
|
||||
port := fs.Int("imap-port", 0, "IMAP port")
|
||||
sec := fs.String("imap-security", "", "tls|starttls")
|
||||
smtpHost := fs.String("smtp-host", "", "SMTP host")
|
||||
smtpPort := fs.Int("smtp-port", 0, "SMTP port")
|
||||
smtpSec := fs.String("smtp-security", "", "tls|starttls")
|
||||
user := fs.String("username", "", "login username")
|
||||
pass := fs.String("password", "", "login password (blank keeps existing)")
|
||||
subj := fs.String("subject-regex", "", "inbound subject filter")
|
||||
if err := fs.Parse(rest); err != nil {
|
||||
return 2
|
||||
}
|
||||
if *name == "" {
|
||||
fmt.Fprintln(errOut, "--name is required")
|
||||
return 2
|
||||
}
|
||||
if fs.NFlag() == 1 { // only --name → interactive TUI form, prefilled
|
||||
return editInteractive(st, *name, out, errOut)
|
||||
}
|
||||
acc, err := st.GetAccount(*name)
|
||||
if err != nil {
|
||||
fmt.Fprintf(errOut, "edit: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
// Overlay only the flags the user actually set.
|
||||
fs.Visit(func(f *flag.Flag) {
|
||||
switch f.Name {
|
||||
case "mode":
|
||||
acc.Mode = *mode
|
||||
case "imap-host":
|
||||
acc.IMAPHost = *host
|
||||
case "imap-port":
|
||||
acc.IMAPPort = *port
|
||||
case "imap-security":
|
||||
acc.IMAPSecurity = *sec
|
||||
case "smtp-host":
|
||||
acc.SMTPHost = *smtpHost
|
||||
case "smtp-port":
|
||||
acc.SMTPPort = *smtpPort
|
||||
case "smtp-security":
|
||||
acc.SMTPSecurity = *smtpSec
|
||||
case "username":
|
||||
acc.Username = *user
|
||||
case "password":
|
||||
acc.Password = *pass
|
||||
case "subject-regex":
|
||||
acc.SubjectRegex = *subj
|
||||
}
|
||||
})
|
||||
// acc.Password holds the existing (decrypted) password from GetAccount; the
|
||||
// Visit above overwrites it only when --password was passed. UpdateAccount
|
||||
// re-seals whatever non-empty value is present, so the password is preserved.
|
||||
if err := st.UpdateAccount(acc); err != nil {
|
||||
fmt.Fprintf(errOut, "edit: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Fprintf(out, "account %q updated\n", *name)
|
||||
return 0
|
||||
case "remove":
|
||||
fs := flag.NewFlagSet("account remove", flag.ContinueOnError)
|
||||
fs.SetOutput(errOut)
|
||||
name := fs.String("name", "", "account name (required)")
|
||||
yes := fs.Bool("yes", false, "skip confirmation")
|
||||
if err := fs.Parse(rest); err != nil {
|
||||
return 2
|
||||
}
|
||||
if *name == "" {
|
||||
fmt.Fprintln(errOut, "--name is required")
|
||||
return 2
|
||||
}
|
||||
if !*yes {
|
||||
fmt.Fprintf(errOut, "refusing to remove %q without --yes\n", *name)
|
||||
return 2
|
||||
}
|
||||
if err := st.DeleteAccount(*name); err != nil {
|
||||
fmt.Fprintf(errOut, "remove: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Fprintf(out, "account %q removed\n", *name)
|
||||
return 0
|
||||
case "list":
|
||||
accs, err := st.ListAccounts()
|
||||
if err != nil {
|
||||
@@ -81,6 +171,95 @@ func runAccount(args []string, out, errOut io.Writer) int {
|
||||
}
|
||||
}
|
||||
|
||||
// auditList renders recent audit entries (account "" = all) to out.
|
||||
func auditList(st *store.Store, account string, limit int, out io.Writer) error {
|
||||
entries, err := st.RecentAuditFor(account, limit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(out, "%-20s %-12s %-8s %-8s %-20s %s\n",
|
||||
"TS", "ACCOUNT", "ACTION", "RESULT", "TARGET", "REASON")
|
||||
for _, e := range entries {
|
||||
fmt.Fprintf(out, "%-20s %-12s %-8s %-8s %-20s %s\n",
|
||||
e.TS, e.Account, e.Action, e.Result, e.Target, e.Reason)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// runConfig handles `config set <key> <value>` and `config get <key>`.
|
||||
func runConfig(args []string, out, errOut io.Writer) int {
|
||||
if len(args) < 2 {
|
||||
fmt.Fprintln(errOut, "usage: emcli config <set|get> <key> [value]")
|
||||
return 2
|
||||
}
|
||||
sub, key := args[0], args[1]
|
||||
st, err := openStore()
|
||||
if err != nil {
|
||||
fmt.Fprintf(errOut, "emcli: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
switch sub {
|
||||
case "set":
|
||||
if len(args) < 3 {
|
||||
fmt.Fprintln(errOut, "usage: emcli config set <key> <value>")
|
||||
return 2
|
||||
}
|
||||
value := args[2]
|
||||
if key == "audit_retention_days" {
|
||||
n, err := strconv.Atoi(value)
|
||||
if err != nil || n < 0 {
|
||||
fmt.Fprintf(errOut, "audit_retention_days must be an integer >= 0, got %q\n", value)
|
||||
return 2
|
||||
}
|
||||
}
|
||||
if err := st.SetSetting(key, value); err != nil {
|
||||
fmt.Fprintf(errOut, "config set: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Fprintf(out, "%s = %s\n", key, value)
|
||||
return 0
|
||||
case "get":
|
||||
v, err := st.GetSetting(key)
|
||||
if err != nil {
|
||||
fmt.Fprintf(errOut, "config get: %s not set\n", key)
|
||||
return 1
|
||||
}
|
||||
fmt.Fprintf(out, "%s = %s\n", key, v)
|
||||
return 0
|
||||
default:
|
||||
fmt.Fprintf(errOut, "unknown config subcommand %q\n", sub)
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
// runAudit handles `audit list [--account <name>] [--limit N]`.
|
||||
func runAudit(args []string, out, errOut io.Writer) int {
|
||||
if len(args) == 0 || args[0] != "list" {
|
||||
fmt.Fprintln(errOut, "usage: emcli audit list [--account <name>] [--limit N]")
|
||||
return 2
|
||||
}
|
||||
fs := flag.NewFlagSet("audit list", flag.ContinueOnError)
|
||||
fs.SetOutput(errOut)
|
||||
account := fs.String("account", "", "filter by account")
|
||||
limit := fs.Int("limit", 50, "max rows")
|
||||
if err := fs.Parse(args[1:]); err != nil {
|
||||
return 2
|
||||
}
|
||||
st, err := openStore()
|
||||
if err != nil {
|
||||
fmt.Fprintf(errOut, "emcli: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
defer st.Close()
|
||||
if err := auditList(st, *account, *limit, out); err != nil {
|
||||
fmt.Fprintf(errOut, "audit list: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// runWhitelist handles `whitelist <in|out> add --account NAME --address A`.
|
||||
func runWhitelist(args []string, out, errOut io.Writer) int {
|
||||
if len(args) < 2 {
|
||||
|
||||
Reference in New Issue
Block a user