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:
@@ -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 }
|
||||
Reference in New Issue
Block a user