package cli import ( "errors" "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 } // commandRole maps a command to the privilege it requires. Admin commands // mutate configuration or expose oversight data; everything else is agent. func commandRole(args []string) store.Role { switch args[0] { case "account": // account list is a read-only discovery view available to agents; // add/edit/remove mutate config and require admin. if len(args) >= 2 && args[1] == "list" { return store.RoleAgent } return store.RoleAdmin case "whitelist", "config", "audit": return store.RoleAdmin default: // list, get, search, ack, send, doctor return store.RoleAgent } } // openStore resolves the keys for the role, opens the DB, and unlocks the DEK. // Admin commands require EMCLI_ADMIN_KEY and unlock the admin slot only; agent // commands use EMCLI_KEY (falling back to the admin key if that is all there is). func openStore(role store.Role) (*store.Store, error) { adminKey, adminErr := crypto.AdminKeyFromEnv() agentKey, agentErr := crypto.AgentKeyFromEnv() switch role { case store.RoleAdmin: if adminErr != nil { return nil, fmt.Errorf("this command requires EMCLI_ADMIN_KEY (admin privilege)") } case store.RoleAgent: if agentErr != nil && adminErr != nil { return nil, agentErr // "EMCLI_KEY is not set" } } path, err := store.DefaultDBPath() if err != nil { return nil, err } st, err := store.Open(path) if err != nil { return nil, err } if err := st.Unlock(role, adminKey, agentKey); err != nil { st.Close() return nil, err } return st, nil } func realSender(acc store.Account, m mail.OutgoingMessage) error { return mail.SendSMTP(mail.SMTPConfig{ Host: acc.SMTPHost, Port: acc.SMTPPort, Security: acc.SMTPSecurity, Username: acc.Username, Password: acc.Password, }, m) } func realCheckIMAP(acc store.Account) error { return mail.CheckIMAP(mail.IMAPConfig{ Host: acc.IMAPHost, Port: acc.IMAPPort, Security: acc.IMAPSecurity, Username: acc.Username, Password: acc.Password, }) } func realCheckSMTP(acc store.Account) error { return mail.CheckSMTP(mail.SMTPConfig{ Host: acc.SMTPHost, Port: acc.SMTPPort, Security: acc.SMTPSecurity, Username: acc.Username, Password: acc.Password, }) } func newDepsLive(st *store.Store, out io.Writer) Deps { return Deps{ Store: st, Dial: realMailer, Send: realSender, CheckIMAP: realCheckIMAP, CheckSMTP: realCheckSMTP, Now: time.Now, Out: out, } } // runDoctor handles `doctor [--account ]` (human-readable diagnostics). func runDoctor(args []string, role store.Role, out, errOut io.Writer) int { fs := flag.NewFlagSet("doctor", flag.ContinueOnError) fs.SetOutput(errOut) usageFlags(fs, "doctor", errOut) account := fs.String("account", "", "check only this account") if err := fs.Parse(args); err != nil { if errors.Is(err, flag.ErrHelp) { return 0 } return 2 } st, err := openStore(role) if err != nil { fmt.Fprintf(errOut, "emcli: %v\n", err) return 1 } defer st.Close() d := newDepsLive(st, out) if err := DoctorCmd(d, *account); err != nil { return 1 } return 0 } // Run routes a command line and returns an exit code. func Run(args []string, out, errOut io.Writer) int { if len(args) == 0 || helpRequested(args[0]) { // `emcli`, `emcli help`, `emcli -h`, `emcli --help`, and `emcli help `. if len(args) >= 2 { printCmdUsage(out, args[1]) } else { printMainHelp(out) } return 0 } cmd, rest := args[0], args[1:] role := commandRole(args) switch cmd { case "list", "get", "search", "ack": return runAgent(cmd, rest, role, out, errOut) case "send": return runSend(rest, role, out, errOut) case "account": return runAccount(rest, role, out, errOut) case "whitelist": return runWhitelist(rest, role, out, errOut) case "config": return runConfig(rest, role, out, errOut) case "audit": return runAudit(rest, role, out, errOut) case "doctor": return runDoctor(rest, role, out, errOut) case "init": return runInit(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, role store.Role, out, errOut io.Writer) int { fs := flag.NewFlagSet(cmd, flag.ContinueOnError) fs.SetOutput(errOut) usageFlags(fs, cmd, 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 { if errors.Is(err, flag.ErrHelp) { return 0 // usage already printed to stderr; help isn't an error } _ = 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(role) 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 } // stringSlice is a repeatable string flag that also splits comma-separated // values, so `--to a@x --to b@x` and `--to a@x,b@x` both yield two recipients. type stringSlice []string func (s *stringSlice) String() string { return strings.Join(*s, ",") } func (s *stringSlice) Set(v string) error { for _, part := range strings.Split(v, ",") { if p := strings.TrimSpace(part); p != "" { *s = append(*s, p) } } return nil } // runSend handles the `send` agent command (JSON envelope output). func runSend(args []string, role store.Role, out, errOut io.Writer) int { fs := flag.NewFlagSet("send", flag.ContinueOnError) fs.SetOutput(errOut) usageFlags(fs, "send", errOut) account := fs.String("account", "", "account name") var to, cc, bcc, attach stringSlice fs.Var(&to, "to", "recipient (repeatable / comma-separated)") fs.Var(&cc, "cc", "cc recipient (repeatable / comma-separated)") fs.Var(&bcc, "bcc", "bcc recipient (repeatable / comma-separated)") fs.Var(&attach, "attach", "attachment file path (repeatable)") subject := fs.String("subject", "", "subject") body := fs.String("body", "", "plain-text body") replyTo := fs.Uint("reply-to", 0, "source UID to reply to (threading)") folder := fs.String("folder", "INBOX", "folder of the reply source") if err := fs.Parse(args); err != nil { if errors.Is(err, flag.ErrHelp) { return 0 } _ = Failure(CodeUsage, err.Error()).Write(out) return 2 } if *account == "" { _ = Failure(CodeUsage, "--account is required").Write(out) return 2 } st, err := openStore(role) if err != nil { _ = Failure(CodeConfig, err.Error()).Write(out) return 1 } defer st.Close() _, _ = st.PurgeAudit(time.Now()) d := newDepsLive(st, out) if err := SendCmd(d, *account, to, cc, bcc, *subject, *body, attach, u32(*replyTo), *folder); 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 }