From e1e5f245e1f83e8098f5ab90352284f06a2ba6a5 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Mon, 22 Jun 2026 00:09:38 +0100 Subject: [PATCH] feat(cli): command router, real IMAP wiring, flag-based admin Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/emcli/main.go | 4 +- internal/cli/admin.go | 130 ++++++++++++++++++++++++++++++++ internal/cli/run.go | 157 +++++++++++++++++++++++++++++++++++++++ internal/cli/run_test.go | 56 ++++++++++++++ 4 files changed, 345 insertions(+), 2 deletions(-) create mode 100644 internal/cli/admin.go create mode 100644 internal/cli/run.go create mode 100644 internal/cli/run_test.go diff --git a/cmd/emcli/main.go b/cmd/emcli/main.go index d946df3..50bc90a 100644 --- a/cmd/emcli/main.go +++ b/cmd/emcli/main.go @@ -4,6 +4,7 @@ import ( "fmt" "os" + "git.dcglab.co.uk/steve/emcli/internal/cli" "git.dcglab.co.uk/steve/emcli/internal/version" ) @@ -12,6 +13,5 @@ func main() { fmt.Println(version.String) return } - fmt.Fprintln(os.Stderr, "emcli: no command given") - os.Exit(2) + os.Exit(cli.Run(os.Args[1:], os.Stdout, os.Stderr)) } diff --git a/internal/cli/admin.go b/internal/cli/admin.go new file mode 100644 index 0000000..92fc98f --- /dev/null +++ b/internal/cli/admin.go @@ -0,0 +1,130 @@ +package cli + +import ( + "flag" + "fmt" + "io" + + "git.dcglab.co.uk/steve/emcli/internal/store" +) + +// runAccount handles `account add|list`. Human-readable output (never JSON). +func runAccount(args []string, out, errOut io.Writer) int { + if len(args) == 0 { + fmt.Fprintln(errOut, "usage: emcli account ") + return 2 + } + sub, rest := args[0], args[1:] + st, err := openStore() + if err != nil { + fmt.Fprintf(errOut, "emcli: %v\n", err) + return 1 + } + defer st.Close() + + switch sub { + case "add": + fs := flag.NewFlagSet("account add", flag.ContinueOnError) + fs.SetOutput(errOut) + name := fs.String("name", "", "account name") + 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") + user := fs.String("username", "", "login username") + pass := fs.String("password", "", "login password") + subj := fs.String("subject-regex", "", "inbound subject filter") + wlIn := fs.Bool("whitelist-in", false, "enable inbound whitelist") + wlOut := fs.Bool("whitelist-out", false, "enable outbound whitelist") + backlog := fs.Bool("process-backlog", false, "treat existing mail as new") + if err := fs.Parse(rest); err != nil { + return 2 + } + if *name == "" || *host == "" || *user == "" { + fmt.Fprintln(errOut, "name, imap-host, and username are required") + return 2 + } + _, err := st.AddAccount(store.Account{ + Name: *name, Mode: *mode, IMAPHost: *host, IMAPPort: *port, IMAPSecurity: *sec, + AuthType: "password", Username: *user, Password: *pass, + SubjectRegex: *subj, WhitelistInEnabled: *wlIn, WhitelistOutEnabled: *wlOut, + ProcessBacklog: *backlog, + }) + if 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 "list": + accs, err := st.ListAccounts() + if err != nil { + fmt.Fprintf(errOut, "list: %v\n", err) + return 1 + } + 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\n", sub) + return 2 + } +} + +// runWhitelist handles `whitelist add --account NAME --address A`. +func runWhitelist(args []string, out, errOut io.Writer) int { + if len(args) < 2 { + fmt.Fprintln(errOut, "usage: emcli whitelist [flags]") + return 2 + } + dir := store.Direction(args[0]) + sub, rest := args[1], args[2:] + fs := flag.NewFlagSet("whitelist", flag.ContinueOnError) + fs.SetOutput(errOut) + account := fs.String("account", "", "account name") + address := fs.String("address", "", "email or @domain") + if err := fs.Parse(rest); err != nil { + return 2 + } + if *account == "" { + fmt.Fprintln(errOut, "--account is required") + return 2 + } + st, err := openStore() + if err != nil { + fmt.Fprintf(errOut, "emcli: %v\n", err) + return 1 + } + defer st.Close() + + switch sub { + case "add": + if err := st.AddWhitelist(*account, dir, *address); err != nil { + fmt.Fprintf(errOut, "add: %v\n", err) + return 1 + } + fmt.Fprintf(out, "added %s to %s whitelist of %q\n", *address, dir, *account) + case "remove": + if err := st.RemoveWhitelist(*account, dir, *address); err != nil { + fmt.Fprintf(errOut, "remove: %v\n", err) + return 1 + } + fmt.Fprintf(out, "removed %s\n", *address) + case "list": + addrs, err := st.ListWhitelist(*account, dir) + if err != nil { + fmt.Fprintf(errOut, "list: %v\n", err) + return 1 + } + for _, a := range addrs { + fmt.Fprintln(out, a) + } + default: + fmt.Fprintf(errOut, "unknown whitelist subcommand %q\n", sub) + return 2 + } + return 0 +} diff --git a/internal/cli/run.go b/internal/cli/run.go new file mode 100644 index 0000000..2e82a8c --- /dev/null +++ b/internal/cli/run.go @@ -0,0 +1,157 @@ +package cli + +import ( + "flag" + "fmt" + "io" + "strconv" + "strings" + "time" + + "git.dcglab.co.uk/steve/emcli/internal/crypto" + "git.dcglab.co.uk/steve/emcli/internal/mail" + "git.dcglab.co.uk/steve/emcli/internal/store" +) + +func realMailer(acc store.Account) (Mailer, error) { + c, err := mail.Dial(mail.IMAPConfig{ + Host: acc.IMAPHost, Port: acc.IMAPPort, Security: acc.IMAPSecurity, + Username: acc.Username, Password: acc.Password, + }) + if err != nil { + return nil, err + } + return c, nil +} + +// openStore loads the key and opens the DB, returning a human-readable error string. +func openStore() (*store.Store, error) { + key, err := crypto.KeyFromEnv() + if err != nil { + return nil, err + } + path, err := store.DefaultDBPath() + if err != nil { + return nil, err + } + return store.Open(path, key) +} + +func newDepsLive(st *store.Store, out io.Writer) Deps { + return Deps{Store: st, Dial: realMailer, Now: time.Now, Out: out} +} + +// Run routes a command line and returns an exit code. +func Run(args []string, out, errOut io.Writer) int { + if len(args) == 0 { + fmt.Fprintln(errOut, "emcli: no command given") + return 2 + } + cmd, rest := args[0], args[1:] + switch cmd { + case "list", "get", "search", "ack": + return runAgent(cmd, rest, out, errOut) + case "account": + return runAccount(rest, out, errOut) + case "whitelist": + return runWhitelist(rest, out, errOut) + default: + fmt.Fprintf(errOut, "emcli: unknown command %q\n", cmd) + return 2 + } +} + +// runAgent handles JSON-emitting commands. Errors are emitted as JSON envelopes. +func runAgent(cmd string, args []string, out, errOut io.Writer) int { + fs := flag.NewFlagSet(cmd, flag.ContinueOnError) + fs.SetOutput(errOut) + account := fs.String("account", "", "account name") + folder := fs.String("folder", "INBOX", "folder/mailbox") + onlyNew := fs.Bool("new", false, "only new (unacked) messages") + limit := fs.Int("limit", 50, "max results (cap 500)") + uid := fs.Uint("uid", 0, "message UID (get)") + before := fs.Uint("before", 0, "list: UID cursor, older than") + since := fs.Uint("since", 0, "list: UID cursor, newer than") + from := fs.String("from", "", "search: sender") + subjectContains := fs.String("subject-contains", "", "search: subject substring") + text := fs.String("text", "", "search: full-text") + sinceDate := fs.String("since-date", "", "search: RFC3339 date lower bound") + beforeDate := fs.String("before-date", "", "search: RFC3339 date upper bound") + ackUIDs := fs.String("uid-list", "", "ack: comma-separated UIDs") + if err := fs.Parse(args); err != nil { + _ = Failure(CodeUsage, err.Error()).Write(out) + return 2 + } + if *limit > 500 { + *limit = 500 + } + if *account == "" { + _ = Failure(CodeUsage, "--account is required").Write(out) + return 2 + } + st, err := openStore() + if err != nil { + _ = Failure(CodeConfig, err.Error()).Write(out) + return 1 + } + defer st.Close() + _, _ = st.PurgeAudit(time.Now()) + d := newDepsLive(st, out) + + switch cmd { + case "list": + if err := ListCmd(d, *account, *folder, *onlyNew, u32(*before), u32(*since), *limit); err != nil { + return 1 + } + case "get": + if *uid == 0 { + _ = Failure(CodeUsage, "--uid is required").Write(out) + return 2 + } + if err := GetCmd(d, *account, *folder, u32(*uid)); err != nil { + return 1 + } + case "search": + sc := mail.SearchCriteria{From: *from, SubjectContains: *subjectContains, Text: *text} + if *sinceDate != "" { + if tm, err := time.Parse(time.RFC3339, *sinceDate); err == nil { + sc.Since = tm + } + } + if *beforeDate != "" { + if tm, err := time.Parse(time.RFC3339, *beforeDate); err == nil { + sc.Before = tm + } + } + if err := SearchCmd(d, *account, *folder, sc, *limit); err != nil { + return 1 + } + case "ack": + uids, err := parseUIDList(*ackUIDs) + if err != nil { + _ = Failure(CodeUsage, err.Error()).Write(out) + return 2 + } + if err := AckCmd(d, *account, *folder, uids); err != nil { + return 1 + } + } + return 0 +} + +func u32(u uint) uint32 { return uint32(u) } + +func parseUIDList(s string) ([]uint32, error) { + if strings.TrimSpace(s) == "" { + return nil, fmt.Errorf("--uid-list is required") + } + var out []uint32 + for _, part := range strings.Split(s, ",") { + n, err := strconv.ParseUint(strings.TrimSpace(part), 10, 32) + if err != nil { + return nil, fmt.Errorf("invalid uid %q", part) + } + out = append(out, uint32(n)) + } + return out, nil +} diff --git a/internal/cli/run_test.go b/internal/cli/run_test.go new file mode 100644 index 0000000..ccf1a89 --- /dev/null +++ b/internal/cli/run_test.go @@ -0,0 +1,56 @@ +package cli + +import ( + "bytes" + "encoding/json" + "strings" + "testing" +) + +func TestRunUnknownCommand(t *testing.T) { + var out, errOut bytes.Buffer + code := Run([]string{"frobnicate"}, &out, &errOut) + if code == 0 { + t.Fatal("unknown command should be non-zero exit") + } + if !strings.Contains(errOut.String(), "unknown") { + t.Fatalf("stderr should mention unknown command: %q", errOut.String()) + } +} + +func TestRunVersionIsJSONForAgentButTextHere(t *testing.T) { + // `account list` with no DB key should fail closed with a usage/config error, + // proving the key check happens before any DB work. + var out, errOut bytes.Buffer + t.Setenv("EMCLI_KEY", "") + code := Run([]string{"account", "list"}, &out, &errOut) + if code == 0 { + t.Fatal("missing EMCLI_KEY must fail") + } + if !strings.Contains(out.String()+errOut.String(), "EMCLI_KEY") { + t.Fatalf("should mention EMCLI_KEY, got out=%q err=%q", out.String(), errOut.String()) + } +} + +func TestListUsageErrorIsJSON(t *testing.T) { + // Agent command with a missing required flag emits a JSON error envelope. + var out, errOut bytes.Buffer + t.Setenv("EMCLI_KEY", b64Key()) + t.Setenv("EMCLI_DB", "") // default path is fine; command fails before connecting + code := Run([]string{"list"}, &out, &errOut) // missing --account + if code == 0 { + t.Fatal("missing --account should be non-zero") + } + var env map[string]any + if err := json.Unmarshal(out.Bytes(), &env); err != nil { + t.Fatalf("agent usage error must be JSON, got %q", out.String()) + } + if env["error"] != true { + t.Fatalf("want error envelope: %v", env) + } +} + +func b64Key() string { + // 32 zero bytes, base64. + return "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" +}