Files
emcli/internal/tui/account.go
T
steve a837b25d73 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>
2026-06-22 20:09:43 +01:00

334 lines
9.0 KiB
Go

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