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>
334 lines
9.0 KiB
Go
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 }
|