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 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, out, errOut io.Writer) int { fs := flag.NewFlagSet("doctor", flag.ContinueOnError) fs.SetOutput(errOut) account := fs.String("account", "", "check only this account") if err := fs.Parse(args); err != nil { return 2 } st, err := openStore() 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 { 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 "send": return runSend(rest, out, errOut) case "account": return runAccount(rest, out, errOut) case "whitelist": return runWhitelist(rest, out, errOut) case "config": return runConfig(rest, out, errOut) case "audit": return runAudit(rest, out, errOut) case "doctor": return runDoctor(rest, 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, 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 } // 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, out, errOut io.Writer) int { fs := flag.NewFlagSet("send", flag.ContinueOnError) fs.SetOutput(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 { _ = Failure(CodeUsage, err.Error()).Write(out) return 2 } 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) 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 }