a837b25d73
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>
62 lines
1.6 KiB
Go
62 lines
1.6 KiB
Go
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
|
|
}
|