package cli import ( "bufio" "flag" "fmt" "io" "os" "strings" "github.com/mattn/go-isatty" "git.dcglab.co.uk/steve/emcli/internal/crypto" "git.dcglab.co.uk/steve/emcli/internal/policy" "git.dcglab.co.uk/steve/emcli/internal/store" "git.dcglab.co.uk/steve/emcli/internal/tui" ) // confirmRemoval prompts on a TTY for a y/N answer. Non-TTY callers never reach // here (the caller requires --yes when stdin is not a terminal). func confirmRemoval(name string, out io.Writer) bool { fmt.Fprintf(out, "Remove account %q? [y/N]: ", name) line, _ := bufio.NewReader(os.Stdin).ReadString('\n') line = strings.ToLower(strings.TrimSpace(line)) return line == "y" || line == "yes" } // runAccount handles `account `. Human-readable // output (except the agent-only reduced-JSON branch of `list`). func runAccount(args []string, role store.Role, out, errOut io.Writer) int { if len(args) == 0 || helpRequested(args[0]) { printCmdUsage(out, "account") fmt.Fprintln(out, "\nSubcommands: add, edit, remove, show, list") if len(args) > 0 { return 0 } return 2 } sub := normalizeVerb(args[0]) rest := args[1:] st, err := openStore(role) if err != nil { if sub == "list" { _ = Failure(CodeConfig, err.Error()).Write(out) } else { fmt.Fprintf(errOut, "emcli: %v\n", err) } return 1 } defer st.Close() switch sub { case "add": if len(rest) == 0 { // no args → interactive TUI form return addInteractive(st, tui.Fields{}, out, errOut) } // Peel a leading positional name (if present) before flag parsing. var name string if !strings.HasPrefix(rest[0], "-") { name, rest = rest[0], rest[1:] } fs := flag.NewFlagSet("account add", flag.ContinueOnError) fs.SetOutput(errOut) mode := fs.String("mode", "RO", "RO|RW") host := fs.String("imap-host", "", "IMAP host") port := fs.Int("imap-port", 993, "IMAP port") sec := fs.String("imap-security", "tls", "tls|starttls") smtpHost := fs.String("smtp-host", "", "SMTP host (RW accounts)") smtpPort := fs.Int("smtp-port", 465, "SMTP port") smtpSec := fs.String("smtp-security", "tls", "tls|starttls") user := fs.String("username", "", "login username") pass := fs.String("password", "", "login password") from := fs.String("from", "", "send-as address (blank = use username)") subj := fs.String("subject-regex", "", "inbound subject filter") backlog := fs.Bool("process-backlog", false, "treat existing mail as new") if err := fs.Parse(rest); err != nil { return 2 } if fs.NArg() > 0 { fmt.Fprintf(errOut, "unexpected argument %q\n", fs.Arg(0)) return 2 } if name == "" || *host == "" || *user == "" { fmt.Fprintln(errOut, "name, --imap-host, and --username are required") return 2 } if err := tui.ValidFromAddress(*from); err != nil { fmt.Fprintln(errOut, err) return 2 } acc := store.Account{ Name: name, Mode: *mode, IMAPHost: *host, IMAPPort: *port, IMAPSecurity: *sec, AuthType: "password", Username: *user, Password: *pass, FromAddress: *from, SubjectRegex: *subj, ProcessBacklog: *backlog, } if *mode == "RW" { acc.SMTPHost, acc.SMTPPort, acc.SMTPSecurity = *smtpHost, *smtpPort, *smtpSec } if _, err := st.AddAccount(acc); err != nil { fmt.Fprintf(errOut, "add account: %v\n", err) return 1 } fmt.Fprintf(out, "account %q added (%s)\n", name, *mode) return 0 case "edit": if len(rest) == 0 || strings.HasPrefix(rest[0], "-") { fmt.Fprintln(errOut, "usage: emcli account edit [flags]") return 2 } name := rest[0] flagArgs := rest[1:] if len(flagArgs) == 0 { // only name → interactive prefilled form return editInteractive(st, name, out, errOut) } fs := flag.NewFlagSet("account edit", flag.ContinueOnError) fs.SetOutput(errOut) mode := fs.String("mode", "", "RO|RW") host := fs.String("imap-host", "", "IMAP host") port := fs.Int("imap-port", 0, "IMAP port") sec := fs.String("imap-security", "", "tls|starttls") smtpHost := fs.String("smtp-host", "", "SMTP host") smtpPort := fs.Int("smtp-port", 0, "SMTP port") smtpSec := fs.String("smtp-security", "", "tls|starttls") user := fs.String("username", "", "login username") pass := fs.String("password", "", "login password (blank keeps existing)") from := fs.String("from", "", "send-as address (empty reverts to username)") subj := fs.String("subject-regex", "", "inbound subject filter") if err := fs.Parse(flagArgs); err != nil { return 2 } if fs.NArg() > 0 { fmt.Fprintf(errOut, "unexpected argument %q\n", fs.Arg(0)) return 2 } if err := tui.ValidFromAddress(*from); err != nil { fmt.Fprintln(errOut, err) return 2 } acc, err := st.GetAccount(name) if err != nil { fmt.Fprintf(errOut, "edit: %v\n", err) return 1 } fs.Visit(func(f *flag.Flag) { switch f.Name { case "mode": acc.Mode = *mode case "imap-host": acc.IMAPHost = *host case "imap-port": acc.IMAPPort = *port case "imap-security": acc.IMAPSecurity = *sec case "smtp-host": acc.SMTPHost = *smtpHost case "smtp-port": acc.SMTPPort = *smtpPort case "smtp-security": acc.SMTPSecurity = *smtpSec case "username": acc.Username = *user case "password": acc.Password = *pass case "from": acc.FromAddress = *from case "subject-regex": acc.SubjectRegex = *subj } }) if err := st.UpdateAccount(acc); err != nil { fmt.Fprintf(errOut, "edit: %v\n", err) return 1 } fmt.Fprintf(out, "account %q updated\n", name) return 0 case "remove": if len(rest) == 0 || strings.HasPrefix(rest[0], "-") { fmt.Fprintln(errOut, "usage: emcli account remove [--yes]") return 2 } name := rest[0] fs := flag.NewFlagSet("account remove", flag.ContinueOnError) fs.SetOutput(errOut) yes := fs.Bool("yes", false, "skip confirmation") if err := fs.Parse(rest[1:]); err != nil { return 2 } if fs.NArg() > 0 { fmt.Fprintf(errOut, "unexpected argument %q\n", fs.Arg(0)) return 2 } if !*yes { if !isatty.IsTerminal(os.Stdin.Fd()) { fmt.Fprintf(errOut, "refusing to remove %q without --yes (no terminal for confirmation)\n", name) return 2 } if !confirmRemoval(name, out) { fmt.Fprintln(out, "aborted") return 1 } } if err := st.DeleteAccount(name); err != nil { fmt.Fprintf(errOut, "remove: %v\n", err) return 1 } fmt.Fprintf(out, "account %q removed\n", name) return 0 case "show": return accountShow(st, rest, out, errOut) case "list": if len(rest) > 0 { fmt.Fprintf(errOut, "unexpected argument %q\n", rest[0]) return 2 } _, adminErr := crypto.AdminKeyFromEnv() isAdmin := adminErr == nil accs, err := st.ListAccounts() if err != nil { if isAdmin { fmt.Fprintf(errOut, "list: %v\n", err) } else { _ = Failure(CodeDB, err.Error()).Write(out) } return 1 } if !isAdmin { items := make([]map[string]any, 0, len(accs)) for _, a := range accs { items = append(items, map[string]any{ "name": a.Name, "from": a.SendFrom(), "can_send": a.Mode == "RW", }) } _ = Success(map[string]any{"accounts": items}).Write(out) return 0 } fmt.Fprintf(out, "%-16s %-4s %-28s %s\n", "NAME", "MODE", "IMAP", "USER") for _, a := range accs { fmt.Fprintf(out, "%-16s %-4s %-28s %s\n", a.Name, a.Mode, fmt.Sprintf("%s:%d", a.IMAPHost, a.IMAPPort), a.Username) } return 0 default: fmt.Fprintf(errOut, "unknown account subcommand %q (want add|edit|remove|show|list)\n", sub) return 2 } } // auditList renders recent audit entries (account "" = all) to out. func auditList(st *store.Store, account string, limit int, out io.Writer) error { entries, err := st.RecentAuditFor(account, limit) if err != nil { return err } fmt.Fprintf(out, "%-20s %-12s %-8s %-8s %-20s %s\n", "TS", "ACCOUNT", "ACTION", "RESULT", "TARGET", "REASON") for _, e := range entries { fmt.Fprintf(out, "%-20s %-12s %-8s %-8s %-20s %s\n", e.TS, e.Account, e.Action, e.Result, e.Target, e.Reason) } return nil } // runConfig handles `config ` against the settings registry. func runConfig(args []string, role store.Role, out, errOut io.Writer) int { if len(args) == 0 || helpRequested(args[0]) { printCmdUsage(out, "config") if len(args) > 0 { return 0 } return 2 } sub := normalizeVerb(args[0]) rest := args[1:] st, err := openStore(role) if err != nil { fmt.Fprintf(errOut, "emcli: %v\n", err) return 1 } defer st.Close() switch sub { case "list": if len(rest) > 0 { fmt.Fprintf(errOut, "unexpected argument %q\n", rest[0]) return 2 } fmt.Fprintf(out, "%-22s %-8s %s\n", "KEY", "VALUE", "DESCRIPTION") for _, k := range settingKeys() { v, err := st.GetSetting(k) if err != nil { v = "(unset)" } fmt.Fprintf(out, "%-22s %-8s %s\n", k, v, settingsRegistry[k].desc) } return 0 case "get": if len(rest) != 1 { fmt.Fprintln(errOut, "usage: emcli config get ") return 2 } key := rest[0] if _, ok := settingsRegistry[key]; !ok { fmt.Fprintf(errOut, "unknown setting %q (see: emcli config list)\n", key) return 2 } v, err := st.GetSetting(key) if err != nil { fmt.Fprintf(errOut, "config get: %s not set\n", key) return 1 } fmt.Fprintf(out, "%s = %s\n", key, v) return 0 case "set": if len(rest) != 2 { fmt.Fprintln(errOut, "usage: emcli config set ") return 2 } key, value := rest[0], rest[1] def, ok := settingsRegistry[key] if !ok { fmt.Fprintf(errOut, "unknown setting %q (see: emcli config list)\n", key) return 2 } if def.validate != nil { if err := def.validate(value); err != nil { fmt.Fprintf(errOut, "%s %v\n", key, err) return 2 } } if err := st.SetSetting(key, value); err != nil { fmt.Fprintf(errOut, "config set: %v\n", err) return 1 } fmt.Fprintf(out, "%s = %s\n", key, value) return 0 default: fmt.Fprintf(errOut, "unknown config subcommand %q (want list|get|set)\n", sub) return 2 } } // runAudit handles `audit list [--account ] [--limit N]`. func runAudit(args []string, role store.Role, out, errOut io.Writer) int { if len(args) > 0 && helpRequested(args[0]) { printCmdUsage(out, "audit") return 0 } if len(args) == 0 || args[0] != "list" { fmt.Fprintln(errOut, "usage: emcli audit list [--account ] [--limit N]") return 2 } fs := flag.NewFlagSet("audit list", flag.ContinueOnError) fs.SetOutput(errOut) account := fs.String("account", "", "filter by account") limit := fs.Int("limit", 50, "max rows") if err := fs.Parse(args[1:]); err != nil { return 2 } st, err := openStore(role) if err != nil { fmt.Fprintf(errOut, "emcli: %v\n", err) return 1 } defer st.Close() if err := auditList(st, *account, *limit, out); err != nil { fmt.Fprintf(errOut, "audit list: %v\n", err) return 1 } return 0 } // flowName renders a direction for human-facing prose. func flowName(dir store.Direction) string { if dir == store.DirOut { return "outbound" } return "inbound" } // runWhitelist handles `whitelist // [addr…] --in|--out`. func runWhitelist(args []string, role store.Role, out, errOut io.Writer) int { if len(args) == 0 || helpRequested(args[0]) { printCmdUsage(out, "whitelist") if len(args) > 0 { return 0 } return 2 } sub := normalizeVerb(args[0]) switch sub { case "add", "remove", "list", "enable", "disable": // valid default: fmt.Fprintf(errOut, "unknown whitelist subcommand %q (want add|remove|list|enable|disable)\n", sub) return 2 } // Split the remaining tokens into the direction flag and positionals. var dir store.Direction var dirSet bool var pos []string for _, a := range args[1:] { switch a { case "--in", "-in": dir, dirSet = store.DirIn, true case "--out", "-out": dir, dirSet = store.DirOut, true default: if strings.HasPrefix(a, "-") { fmt.Fprintf(errOut, "unknown flag %q (use --in or --out)\n", a) return 2 } pos = append(pos, a) } } if !dirSet { fmt.Fprintln(errOut, "direction is required: pass --in or --out") return 2 } if len(pos) == 0 { fmt.Fprintln(errOut, "account is required") return 2 } account, addrs := pos[0], pos[1:] st, err := openStore(role) if err != nil { fmt.Fprintf(errOut, "emcli: %v\n", err) return 1 } defer st.Close() switch sub { case "add", "remove": if len(addrs) == 0 { fmt.Fprintln(errOut, "at least one address is required") return 2 } for _, addr := range addrs { if err := policy.ValidWhitelistAddress(addr); err != nil { fmt.Fprintln(errOut, err) return 2 } } for _, addr := range addrs { if sub == "add" { err = st.AddWhitelist(account, dir, addr) } else { err = st.RemoveWhitelist(account, dir, addr) } if err != nil { fmt.Fprintf(errOut, "%s: %v\n", sub, err) return 1 } } verb := "added" if sub == "remove" { verb = "removed" } fmt.Fprintf(out, "%s %d address(es) in the %s whitelist of %q\n", verb, len(addrs), dir, account) return 0 case "list": if len(addrs) > 0 { fmt.Fprintf(errOut, "unexpected argument %q\n", addrs[0]) return 2 } acc, err := st.GetAccount(account) if err != nil { fmt.Fprintf(errOut, "list: %v\n", err) return 1 } enabled := acc.WhitelistInEnabled if dir == store.DirOut { enabled = acc.WhitelistOutEnabled } state := "DISABLED" if enabled { state = "ENABLED" } entries, err := st.ListWhitelist(account, dir) if err != nil { fmt.Fprintf(errOut, "list: %v\n", err) return 1 } fmt.Fprintf(out, "%s whitelist of %q: %s\n", dir, account, state) for _, a := range entries { fmt.Fprintln(out, a) } return 0 case "enable", "disable": if len(addrs) > 0 { fmt.Fprintf(errOut, "unexpected argument %q\n", addrs[0]) return 2 } enable := sub == "enable" if enable { if entries, _ := st.ListWhitelist(account, dir); len(entries) == 0 { fmt.Fprintf(errOut, "warning: %s whitelist for %q is empty — this blocks ALL %s mail until you add addresses\n", dir, account, flowName(dir)) } } if err := st.SetWhitelistEnabled(account, dir, enable); err != nil { fmt.Fprintf(errOut, "%s: %v\n", sub, err) return 1 } state := "disabled" if enable { state = "enabled" } fmt.Fprintf(out, "%s whitelist of %q %s\n", dir, account, state) return 0 } return 0 }