diff --git a/.gitignore b/.gitignore index 45f7b09..0cd2d89 100644 --- a/.gitignore +++ b/.gitignore @@ -25,10 +25,12 @@ _testmain.go *.swp *.swo -# our binary +# our binaries /inbucket /inbucket.exe /target/** +/cmd/client/client +/cmd/client/client.exe # local goxc config .goxc.local.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a7a9ab..5817b71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ This project adheres to [Semantic Versioning](http://semver.org/). ### Added - `rest/client` types `MessageHeader` and `Message` with convenience methods; provides a more natural API +- Powerful command line REST + [client](https://github.com/jhillyerd/inbucket/wiki/cmd-client) ### Changed - `rest/client.NewV1` renamed to `New` diff --git a/cmd/client/list.go b/cmd/client/list.go new file mode 100644 index 0000000..eda64bb --- /dev/null +++ b/cmd/client/list.go @@ -0,0 +1,54 @@ +package main + +import ( + "context" + "flag" + "fmt" + + "github.com/google/subcommands" + "github.com/jhillyerd/inbucket/rest/client" +) + +type listCmd struct { + mailbox string +} + +func (*listCmd) Name() string { + return "list" +} + +func (*listCmd) Synopsis() string { + return "list contents of mailbox" +} + +func (*listCmd) Usage() string { + return `list : + list message IDs in mailbox +` +} + +func (l *listCmd) SetFlags(f *flag.FlagSet) { +} + +func (l *listCmd) Execute( + _ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus { + mailbox := f.Arg(0) + if mailbox == "" { + return usage("mailbox required") + } + // Setup rest client + c, err := client.New(baseURL()) + if err != nil { + return fatal("Couldn't build client", err) + } + // Get list + headers, err := c.ListMailbox(mailbox) + if err != nil { + return fatal("REST call failed", err) + } + for _, h := range headers { + fmt.Println(h.ID) + } + + return subcommands.ExitSuccess +} diff --git a/cmd/client/main.go b/cmd/client/main.go new file mode 100644 index 0000000..48828fe --- /dev/null +++ b/cmd/client/main.go @@ -0,0 +1,79 @@ +// Package main implements a command line client for the Inbucket REST API +package main + +import ( + "context" + "flag" + "fmt" + "os" + "regexp" + + "github.com/google/subcommands" +) + +var host = flag.String("host", "localhost", "host/IP of Inbucket server") +var port = flag.Uint("port", 9000, "HTTP port of Inbucket server") + +// Allow subcommands to accept regular expressions as flags +type regexFlag struct { + *regexp.Regexp +} + +func (r *regexFlag) Defined() bool { + return r.Regexp != nil +} + +func (r *regexFlag) Set(pattern string) error { + if pattern == "" { + r.Regexp = nil + return nil + } + re, err := regexp.Compile(pattern) + if err != nil { + return err + } + r.Regexp = re + return nil +} + +func (r *regexFlag) String() string { + if r.Regexp == nil { + return "" + } + return r.Regexp.String() +} + +// regexFlag must implement flag.Value +var _ flag.Value = ®exFlag{} + +func main() { + // Important top-level flags + subcommands.ImportantFlag("host") + subcommands.ImportantFlag("port") + // Setup standard helpers + subcommands.Register(subcommands.HelpCommand(), "") + subcommands.Register(subcommands.FlagsCommand(), "") + subcommands.Register(subcommands.CommandsCommand(), "") + // Setup my commands + subcommands.Register(&listCmd{}, "") + subcommands.Register(&matchCmd{}, "") + subcommands.Register(&mboxCmd{}, "") + // Parse and execute + flag.Parse() + ctx := context.Background() + os.Exit(int(subcommands.Execute(ctx))) +} + +func baseURL() string { + return fmt.Sprintf("http://%s:%v", *host, *port) +} + +func fatal(msg string, err error) subcommands.ExitStatus { + fmt.Fprintf(os.Stderr, "%s: %v\n", msg, err) + return subcommands.ExitFailure +} + +func usage(msg string) subcommands.ExitStatus { + fmt.Fprintln(os.Stderr, msg) + return subcommands.ExitUsageError +} diff --git a/cmd/client/match.go b/cmd/client/match.go new file mode 100644 index 0000000..df49a87 --- /dev/null +++ b/cmd/client/match.go @@ -0,0 +1,164 @@ +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "net/mail" + "os" + "time" + + "github.com/google/subcommands" + "github.com/jhillyerd/inbucket/rest/client" +) + +type matchCmd struct { + mailbox string + output string + outFunc func(headers []*client.MessageHeader) error + delete bool + // match criteria + from regexFlag + subject regexFlag + to regexFlag + maxAge time.Duration +} + +func (*matchCmd) Name() string { + return "match" +} + +func (*matchCmd) Synopsis() string { + return "output messages matching criteria" +} + +func (*matchCmd) Usage() string { + return `match [flags] : + output messages matching all specified criteria + exit status will be 1 if no matches were found, otherwise 0 +` +} + +func (m *matchCmd) SetFlags(f *flag.FlagSet) { + f.StringVar(&m.output, "output", "id", "output format: id, json, or mbox") + f.BoolVar(&m.delete, "delete", false, "delete matched messages after output") + f.Var(&m.from, "from", "From header matching regexp (address, not name)") + f.Var(&m.subject, "subject", "Subject header matching regexp") + f.Var(&m.to, "to", "To header matching regexp (must match 1+ to address)") + f.DurationVar( + &m.maxAge, "maxage", 0, + "Matches must have been received in this time frame (ex: \"10s\", \"5m\")") +} + +func (m *matchCmd) Execute( + _ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus { + mailbox := f.Arg(0) + if mailbox == "" { + return usage("mailbox required") + } + // Select output function + switch m.output { + case "id": + m.outFunc = outputID + case "json": + m.outFunc = outputJSON + case "mbox": + m.outFunc = outputMbox + default: + return usage("unknown output type: " + m.output) + } + // Setup REST client + c, err := client.New(baseURL()) + if err != nil { + return fatal("Couldn't build client", err) + } + // Get list + headers, err := c.ListMailbox(mailbox) + if err != nil { + return fatal("List REST call failed", err) + } + // Find matches + matches := make([]*client.MessageHeader, 0, len(headers)) + for _, h := range headers { + if m.match(h) { + matches = append(matches, h) + } + } + // Return error status if no matches + if len(matches) == 0 { + return subcommands.ExitFailure + } + // Output matches + err = m.outFunc(matches) + if err != nil { + return fatal("Error", err) + } + if m.delete { + // Delete matches + for _, h := range matches { + err = h.Delete() + if err != nil { + return fatal("Delete REST call failed", err) + } + } + } + return subcommands.ExitSuccess +} + +// match returns true if header matches all defined criteria +func (m *matchCmd) match(header *client.MessageHeader) bool { + if m.maxAge > 0 { + if time.Since(header.Date) > m.maxAge { + return false + } + } + if m.subject.Defined() { + if !m.subject.MatchString(header.Subject) { + return false + } + } + if m.from.Defined() { + from := header.From + addr, err := mail.ParseAddress(from) + if err == nil { + // Parsed successfully + from = addr.Address + } + if !m.from.MatchString(from) { + return false + } + } + if m.to.Defined() { + match := false + for _, to := range header.To { + addr, err := mail.ParseAddress(to) + if err == nil { + // Parsed successfully + to = addr.Address + } + if m.to.MatchString(to) { + match = true + break + } + } + if !match { + return false + } + } + return true +} + +func outputID(headers []*client.MessageHeader) error { + for _, h := range headers { + fmt.Println(h.ID) + } + return nil +} + +func outputJSON(headers []*client.MessageHeader) error { + jsonEncoder := json.NewEncoder(os.Stdout) + jsonEncoder.SetEscapeHTML(false) + jsonEncoder.SetIndent("", " ") + return jsonEncoder.Encode(headers) +} diff --git a/cmd/client/mbox.go b/cmd/client/mbox.go new file mode 100644 index 0000000..cf2b46e --- /dev/null +++ b/cmd/client/mbox.go @@ -0,0 +1,82 @@ +package main + +import ( + "context" + "flag" + "fmt" + "os" + + "github.com/google/subcommands" + "github.com/jhillyerd/inbucket/rest/client" +) + +type mboxCmd struct { + mailbox string + delete bool +} + +func (*mboxCmd) Name() string { + return "mbox" +} + +func (*mboxCmd) Synopsis() string { + return "output mailbox in mbox format" +} + +func (*mboxCmd) Usage() string { + return `mbox [flags] : + output mailbox in mbox format +` +} + +func (m *mboxCmd) SetFlags(f *flag.FlagSet) { + f.BoolVar(&m.delete, "delete", false, "delete messages after output") +} + +func (m *mboxCmd) Execute( + _ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus { + mailbox := f.Arg(0) + if mailbox == "" { + return usage("mailbox required") + } + // Setup REST client + c, err := client.New(baseURL()) + if err != nil { + return fatal("Couldn't build client", err) + } + // Get list + headers, err := c.ListMailbox(mailbox) + if err != nil { + return fatal("List REST call failed", err) + } + err = outputMbox(headers) + if err != nil { + return fatal("Error", err) + } + if m.delete { + // Delete matches + for _, h := range headers { + err = h.Delete() + if err != nil { + return fatal("Delete REST call failed", err) + } + } + } + return subcommands.ExitSuccess +} + +// outputMbox renders messages in mbox format +// also used by match subcommand +func outputMbox(headers []*client.MessageHeader) error { + for _, h := range headers { + source, err := h.GetSource() + if err != nil { + return fmt.Errorf("Get source REST failed: %v", err) + } + fmt.Printf("From %s\n", h.From) + // TODO Escape "From " in message bodies with > + source.WriteTo(os.Stdout) + fmt.Println() + } + return nil +}