// 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" "net/mail" "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 FromAddress string WhitelistIn, WhitelistOut, ProcessBacklog bool SubjectRegex string } // ValidFromAddress returns an error if s is set but is not a valid RFC 5322 // address (bare or "Display Name "). A blank value is valid: sending // falls back to the login username. func ValidFromAddress(s string) error { if strings.TrimSpace(s) == "" { return nil } if _, err := mail.ParseAddress(s); err != nil { return errors.New("from address must be a valid email address or \"Name \"") } return nil } 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") } } if err := ValidFromAddress(f.FromAddress); err != nil { return err } 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, FromAddress: f.FromAddress, 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, FromAddress: a.FromAddress, 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: "from_address", label: "From address (optional)"}, {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 "from_address": return f.FromAddress 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.SMTPPort == "" { initial.SMTPPort = "465" } 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 "from_address": f.FromAddress = 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 }