From a1e35009e0c2e527b4c121b80d4a88fc2792198f Mon Sep 17 00:00:00 2001 From: James Hillyerd Date: Sat, 4 Feb 2017 16:14:40 -0800 Subject: [PATCH 01/16] Add convenience methods to rest/client types --- rest/client/apiv1_client.go | 68 +++++++++-- rest/client/apiv1_client_test.go | 200 +++++++++++++++++++++++++++++-- rest/client/rest.go | 5 +- rest/client/rest_test.go | 13 +- 4 files changed, 258 insertions(+), 28 deletions(-) diff --git a/rest/client/apiv1_client.go b/rest/client/apiv1_client.go index 5ad5245..d75bfa3 100644 --- a/rest/client/apiv1_client.go +++ b/rest/client/apiv1_client.go @@ -1,3 +1,4 @@ +// Package client provides a basic REST client for Inbucket package client import ( @@ -10,19 +11,19 @@ import ( "github.com/jhillyerd/inbucket/rest/model" ) -// ClientV1 accesses the Inbucket REST API v1 -type ClientV1 struct { +// Client accesses the Inbucket REST API v1 +type Client struct { restClient } -// NewV1 creates a new v1 REST API client given the base URL of an Inbucket server, ex: +// New creates a new v1 REST API client given the base URL of an Inbucket server, ex: // "http://localhost:9000" -func NewV1(baseURL string) (*ClientV1, error) { +func New(baseURL string) (*Client, error) { parsedURL, err := url.Parse(baseURL) if err != nil { return nil, err } - c := &ClientV1{ + c := &Client{ restClient{ client: &http.Client{ Timeout: 30 * time.Second, @@ -34,21 +35,31 @@ func NewV1(baseURL string) (*ClientV1, error) { } // ListMailbox returns a list of messages for the requested mailbox -func (c *ClientV1) ListMailbox(name string) (headers []*model.JSONMessageHeaderV1, err error) { +func (c *Client) ListMailbox(name string) (headers []*MessageHeader, err error) { uri := "/api/v1/mailbox/" + url.QueryEscape(name) err = c.doJSON("GET", uri, &headers) + if err != nil { + return nil, err + } + for _, h := range headers { + h.client = c + } return } // GetMessage returns the message details given a mailbox name and message ID. -func (c *ClientV1) GetMessage(name, id string) (message *model.JSONMessageV1, err error) { +func (c *Client) GetMessage(name, id string) (message *Message, err error) { uri := "/api/v1/mailbox/" + url.QueryEscape(name) + "/" + id err = c.doJSON("GET", uri, &message) + if err != nil { + return nil, err + } + message.client = c return } // GetMessageSource returns the message source given a mailbox name and message ID. -func (c *ClientV1) GetMessageSource(name, id string) (*bytes.Buffer, error) { +func (c *Client) GetMessageSource(name, id string) (*bytes.Buffer, error) { uri := "/api/v1/mailbox/" + url.QueryEscape(name) + "/" + id + "/source" resp, err := c.do("GET", uri) if err != nil { @@ -68,7 +79,7 @@ func (c *ClientV1) GetMessageSource(name, id string) (*bytes.Buffer, error) { } // DeleteMessage deletes a single message given the mailbox name and message ID. -func (c *ClientV1) DeleteMessage(name, id string) error { +func (c *Client) DeleteMessage(name, id string) error { uri := "/api/v1/mailbox/" + url.QueryEscape(name) + "/" + id resp, err := c.do("DELETE", uri) if err != nil { @@ -82,7 +93,7 @@ func (c *ClientV1) DeleteMessage(name, id string) error { } // PurgeMailbox deletes all messages in the given mailbox -func (c *ClientV1) PurgeMailbox(name string) error { +func (c *Client) PurgeMailbox(name string) error { uri := "/api/v1/mailbox/" + url.QueryEscape(name) resp, err := c.do("DELETE", uri) if err != nil { @@ -94,3 +105,40 @@ func (c *ClientV1) PurgeMailbox(name string) error { } return nil } + +// MessageHeader represents an Inbucket message sans content +type MessageHeader struct { + *model.JSONMessageHeaderV1 + client *Client +} + +// GetMessage returns this message with content +func (h *MessageHeader) GetMessage() (message *Message, err error) { + return h.client.GetMessage(h.Mailbox, h.ID) +} + +// GetSource returns the source for this message +func (h *MessageHeader) GetSource() (*bytes.Buffer, error) { + return h.client.GetMessageSource(h.Mailbox, h.ID) +} + +// Delete deletes this message from the mailbox +func (h *MessageHeader) Delete() error { + return h.client.DeleteMessage(h.Mailbox, h.ID) +} + +// Message represents an Inbucket message including content +type Message struct { + *model.JSONMessageV1 + client *Client +} + +// GetSource returns the source for this message +func (m *Message) GetSource() (*bytes.Buffer, error) { + return m.client.GetMessageSource(m.Mailbox, m.ID) +} + +// Delete deletes this message from the mailbox +func (m *Message) Delete() error { + return m.client.DeleteMessage(m.Mailbox, m.ID) +} diff --git a/rest/client/apiv1_client_test.go b/rest/client/apiv1_client_test.go index 1e8ddc7..abce5ca 100644 --- a/rest/client/apiv1_client_test.go +++ b/rest/client/apiv1_client_test.go @@ -5,7 +5,7 @@ import "testing" func TestClientV1ListMailbox(t *testing.T) { var want, got string - c, err := NewV1(baseURLStr) + c, err := New(baseURLStr) if err != nil { t.Fatal(err) } @@ -13,7 +13,7 @@ func TestClientV1ListMailbox(t *testing.T) { c.client = mth // Method under test - c.ListMailbox("testbox") + _, _ = c.ListMailbox("testbox") want = "GET" got = mth.req.Method @@ -31,7 +31,7 @@ func TestClientV1ListMailbox(t *testing.T) { func TestClientV1GetMessage(t *testing.T) { var want, got string - c, err := NewV1(baseURLStr) + c, err := New(baseURLStr) if err != nil { t.Fatal(err) } @@ -39,7 +39,7 @@ func TestClientV1GetMessage(t *testing.T) { c.client = mth // Method under test - c.GetMessage("testbox", "20170107T224128-0000") + _, _ = c.GetMessage("testbox", "20170107T224128-0000") want = "GET" got = mth.req.Method @@ -57,13 +57,12 @@ func TestClientV1GetMessage(t *testing.T) { func TestClientV1GetMessageSource(t *testing.T) { var want, got string - c, err := NewV1(baseURLStr) + c, err := New(baseURLStr) if err != nil { t.Fatal(err) } mth := &mockHTTPClient{ - statusCode: 200, - body: "message source", + body: "message source", } c.client = mth @@ -95,7 +94,7 @@ func TestClientV1GetMessageSource(t *testing.T) { func TestClientV1DeleteMessage(t *testing.T) { var want, got string - c, err := NewV1(baseURLStr) + c, err := New(baseURLStr) if err != nil { t.Fatal(err) } @@ -103,7 +102,10 @@ func TestClientV1DeleteMessage(t *testing.T) { c.client = mth // Method under test - c.DeleteMessage("testbox", "20170107T224128-0000") + err = c.DeleteMessage("testbox", "20170107T224128-0000") + if err != nil { + t.Fatal(err) + } want = "DELETE" got = mth.req.Method @@ -121,7 +123,7 @@ func TestClientV1DeleteMessage(t *testing.T) { func TestClientV1PurgeMailbox(t *testing.T) { var want, got string - c, err := NewV1(baseURLStr) + c, err := New(baseURLStr) if err != nil { t.Fatal(err) } @@ -129,7 +131,10 @@ func TestClientV1PurgeMailbox(t *testing.T) { c.client = mth // Method under test - c.PurgeMailbox("testbox") + err = c.PurgeMailbox("testbox") + if err != nil { + t.Fatal(err) + } want = "DELETE" got = mth.req.Method @@ -143,3 +148,176 @@ func TestClientV1PurgeMailbox(t *testing.T) { t.Errorf("req.URL == %q, want %q", got, want) } } + +func TestClientV1MessageHeader(t *testing.T) { + var want, got string + response := `[ + { + "mailbox":"mailbox1", + "id":"id1", + "from":"from1", + "subject":"subject1", + "date":"2017-01-01T00:00:00.000-07:00", + "size":100 + } + ]` + + c, err := New(baseURLStr) + if err != nil { + t.Fatal(err) + } + mth := &mockHTTPClient{body: response} + c.client = mth + + // Method under test + headers, err := c.ListMailbox("testbox") + if err != nil { + t.Fatal(err) + } + + want = "GET" + got = mth.req.Method + if got != want { + t.Errorf("req.Method == %q, want %q", got, want) + } + + want = baseURLStr + "/api/v1/mailbox/testbox" + got = mth.req.URL.String() + if got != want { + t.Errorf("req.URL == %q, want %q", got, want) + } + + if len(headers) != 1 { + t.Fatalf("len(headers) == %v, want 1", len(headers)) + } + header := headers[0] + + want = "mailbox1" + got = header.Mailbox + if got != want { + t.Errorf("Mailbox == %q, want %q", got, want) + } + + want = "id1" + got = header.ID + if got != want { + t.Errorf("ID == %q, want %q", got, want) + } + + want = "from1" + got = header.From + if got != want { + t.Errorf("From == %q, want %q", got, want) + } + + want = "subject1" + got = header.Subject + if got != want { + t.Errorf("Subject == %q, want %q", got, want) + } + + // Test MessageHeader.Delete() + mth.body = "" + err = header.Delete() + if err != nil { + t.Fatal(err) + } + + want = "DELETE" + got = mth.req.Method + if got != want { + t.Errorf("req.Method == %q, want %q", got, want) + } + + want = baseURLStr + "/api/v1/mailbox/mailbox1/id1" + got = mth.req.URL.String() + if got != want { + t.Errorf("req.URL == %q, want %q", got, want) + } + + // Test MessageHeader.GetSource() + mth.body = "source1" + _, err = header.GetSource() + if err != nil { + t.Fatal(err) + } + + want = "GET" + got = mth.req.Method + if got != want { + t.Errorf("req.Method == %q, want %q", got, want) + } + + want = baseURLStr + "/api/v1/mailbox/mailbox1/id1/source" + got = mth.req.URL.String() + if got != want { + t.Errorf("req.URL == %q, want %q", got, want) + } + + // Test MessageHeader.GetMessage() + mth.body = `{ + "mailbox":"mailbox1", + "id":"id1", + "from":"from1", + "subject":"subject1", + "date":"2017-01-01T00:00:00.000-07:00", + "size":100 + }` + message, err := header.GetMessage() + if err != nil { + t.Fatal(err) + } + if message == nil { + t.Fatalf("message was nil, wanted a value") + } + + want = "GET" + got = mth.req.Method + if got != want { + t.Errorf("req.Method == %q, want %q", got, want) + } + + want = baseURLStr + "/api/v1/mailbox/mailbox1/id1" + got = mth.req.URL.String() + if got != want { + t.Errorf("req.URL == %q, want %q", got, want) + } + + // Test Message.Delete() + mth.body = "" + err = message.Delete() + if err != nil { + t.Fatal(err) + } + + want = "DELETE" + got = mth.req.Method + if got != want { + t.Errorf("req.Method == %q, want %q", got, want) + } + + want = baseURLStr + "/api/v1/mailbox/mailbox1/id1" + got = mth.req.URL.String() + if got != want { + t.Errorf("req.URL == %q, want %q", got, want) + } + + // Test MessageHeader.GetSource() + mth.body = "source1" + _, err = message.GetSource() + if err != nil { + t.Fatal(err) + } + + want = "GET" + got = mth.req.Method + if got != want { + t.Errorf("req.Method == %q, want %q", got, want) + } + + want = baseURLStr + "/api/v1/mailbox/mailbox1/id1/source" + got = mth.req.URL.String() + if got != want { + t.Errorf("req.URL == %q, want %q", got, want) + } +} diff --git a/rest/client/rest.go b/rest/client/rest.go index 1878052..718749d 100644 --- a/rest/client/rest.go +++ b/rest/client/rest.go @@ -50,10 +50,9 @@ func (c *restClient) doJSON(method string, uri string, v interface{}) error { if resp.StatusCode == http.StatusOK { if v == nil { return nil - } else { - // Decode response body - return json.NewDecoder(resp.Body).Decode(v) } + // Decode response body + return json.NewDecoder(resp.Body).Decode(v) } return fmt.Errorf("Unexpected HTTP response status %v: %s", resp.StatusCode, resp.Status) diff --git a/rest/client/rest_test.go b/rest/client/rest_test.go index 05eb6a7..3668578 100644 --- a/rest/client/rest_test.go +++ b/rest/client/rest_test.go @@ -28,6 +28,9 @@ type mockHTTPClient struct { func (m *mockHTTPClient) Do(req *http.Request) (resp *http.Response, err error) { m.req = req + if m.statusCode == 0 { + m.statusCode = 200 + } resp = &http.Response{ StatusCode: m.statusCode, Body: ioutil.NopCloser(bytes.NewBufferString(m.body)), @@ -64,13 +67,15 @@ func TestDoJSON(t *testing.T) { var want, got string mth := &mockHTTPClient{ - statusCode: 200, - body: `{"foo": "bar"}`, + body: `{"foo": "bar"}`, } c := &restClient{mth, baseURL} var v map[string]interface{} - c.doJSON("GET", "/doget", &v) + err := c.doJSON("GET", "/doget", &v) + if err != nil { + t.Fatal(err) + } want = "GET" got = mth.req.Method @@ -98,7 +103,7 @@ func TestDoJSON(t *testing.T) { func TestDoJSONNilV(t *testing.T) { var want, got string - mth := &mockHTTPClient{statusCode: 200} + mth := &mockHTTPClient{} c := &restClient{mth, baseURL} err := c.doJSON("GET", "/doget", nil) From 56cff6296a9034e761369dc59da41e99ae907ace Mon Sep 17 00:00:00 2001 From: James Hillyerd Date: Sat, 4 Feb 2017 18:20:27 -0800 Subject: [PATCH 02/16] Update changelog --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5746e30..0a7a9ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ Change Log All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). +[Unreleased] +------------ + +### Added +- `rest/client` types `MessageHeader` and `Message` with convenience methods; + provides a more natural API + +### Changed +- `rest/client.NewV1` renamed to `New` +- `rest/client` package now embeds the shared `rest/model` structs into its own + types + [1.2.0-rc1] - 2017-01-29 ------------------------ From 67228114256a5e8d9a8d9457c022caa50674a345 Mon Sep 17 00:00:00 2001 From: James Hillyerd Date: Sat, 4 Feb 2017 18:07:25 -0800 Subject: [PATCH 03/16] Beginnings of a command line REST client --- .gitignore | 4 ++- cmd/client/list.go | 54 +++++++++++++++++++++++++++++++++++ cmd/client/main.go | 45 +++++++++++++++++++++++++++++ cmd/client/mbox.go | 70 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 172 insertions(+), 1 deletion(-) create mode 100644 cmd/client/list.go create mode 100644 cmd/client/main.go create mode 100644 cmd/client/mbox.go 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/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..b56b63b --- /dev/null +++ b/cmd/client/main.go @@ -0,0 +1,45 @@ +// Package main implements a command line client for the Inbucket REST API +package main + +import ( + "context" + "flag" + "fmt" + "os" + + "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") + +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(&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/mbox.go b/cmd/client/mbox.go new file mode 100644 index 0000000..6943796 --- /dev/null +++ b/cmd/client/mbox.go @@ -0,0 +1,70 @@ +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 [options] : + 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) + } + for _, h := range headers { + source, err := h.GetSource() + if err != nil { + return fatal("Source REST call failed", err) + } + fmt.Printf("From %s\n", h.From) + // TODO Escape "From " in message bodies with > + source.WriteTo(os.Stdout) + fmt.Println() + if m.delete { + err = h.Delete() + if err != nil { + return fatal("Delete REST call failed", err) + } + } + } + + return subcommands.ExitSuccess +} From be4675b3745e0fa9620d1f4831f081415e15b763 Mon Sep 17 00:00:00 2001 From: James Hillyerd Date: Sun, 5 Feb 2017 14:04:34 -0800 Subject: [PATCH 04/16] Add powerful `match` subcommand to cmdline client - Multiple output formats - Signals matches via exit status for shell scripts - Match against To, From, Subject via regular expressions - Can optionally delete matched messages --- CHANGELOG.md | 2 + cmd/client/main.go | 34 +++++++++++ cmd/client/match.go | 141 ++++++++++++++++++++++++++++++++++++++++++++ cmd/client/mbox.go | 36 +++++++---- 4 files changed, 201 insertions(+), 12 deletions(-) create mode 100644 cmd/client/match.go 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/main.go b/cmd/client/main.go index b56b63b..48828fe 100644 --- a/cmd/client/main.go +++ b/cmd/client/main.go @@ -6,6 +6,7 @@ import ( "flag" "fmt" "os" + "regexp" "github.com/google/subcommands" ) @@ -13,6 +14,38 @@ import ( 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") @@ -23,6 +56,7 @@ func main() { subcommands.Register(subcommands.CommandsCommand(), "") // Setup my commands subcommands.Register(&listCmd{}, "") + subcommands.Register(&matchCmd{}, "") subcommands.Register(&mboxCmd{}, "") // Parse and execute flag.Parse() diff --git a/cmd/client/match.go b/cmd/client/match.go new file mode 100644 index 0000000..f6ab45d --- /dev/null +++ b/cmd/client/match.go @@ -0,0 +1,141 @@ +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "os" + + "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 + from regexFlag + subject regexFlag + to regexFlag +} + +func (*matchCmd) Name() string { + return "match" +} + +func (*matchCmd) Synopsis() string { + return "output messages matching criteria" +} + +func (*matchCmd) Usage() string { + return `match [options] : + 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") + f.Var(&m.subject, "subject", "Subject header matching regexp") + f.Var(&m.to, "to", "To header matching regexp (must match one)") +} + +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.subject.Defined() { + if !m.subject.MatchString(header.Subject) { + return false + } + } + if m.from.Defined() { + if !m.from.MatchString(header.From) { + return false + } + } + if m.to.Defined() { + match := false + for _, to := range header.To { + 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 index 6943796..71e7aaf 100644 --- a/cmd/client/mbox.go +++ b/cmd/client/mbox.go @@ -39,7 +39,7 @@ func (m *mboxCmd) Execute( if mailbox == "" { return usage("mailbox required") } - // Setup rest client + // Setup REST client c, err := client.New(baseURL()) if err != nil { return fatal("Couldn't build client", err) @@ -49,22 +49,34 @@ func (m *mboxCmd) Execute( if err != nil { return fatal("List REST call failed", err) } - for _, h := range headers { - source, err := h.GetSource() - if err != nil { - return fatal("Source REST call failed", err) - } - fmt.Printf("From %s\n", h.From) - // TODO Escape "From " in message bodies with > - source.WriteTo(os.Stdout) - fmt.Println() - if m.delete { + 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 +} From 64e75face8fd224ad6ca4b3bbab035dc4351194e Mon Sep 17 00:00:00 2001 From: James Hillyerd Date: Sun, 5 Feb 2017 15:15:28 -0800 Subject: [PATCH 05/16] Add maxage flag to match subcommand --- cmd/client/match.go | 13 ++++++++++++- cmd/client/mbox.go | 2 +- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/cmd/client/match.go b/cmd/client/match.go index f6ab45d..44c0830 100644 --- a/cmd/client/match.go +++ b/cmd/client/match.go @@ -6,6 +6,7 @@ import ( "flag" "fmt" "os" + "time" "github.com/google/subcommands" "github.com/jhillyerd/inbucket/rest/client" @@ -16,9 +17,11 @@ type matchCmd struct { 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 { @@ -30,7 +33,7 @@ func (*matchCmd) Synopsis() string { } func (*matchCmd) Usage() string { - return `match [options] : + return `match [flags] : output messages matching all specified criteria exit status will be 1 if no matches were found, otherwise 0 ` @@ -42,6 +45,9 @@ func (m *matchCmd) SetFlags(f *flag.FlagSet) { f.Var(&m.from, "from", "From header matching regexp") f.Var(&m.subject, "subject", "Subject header matching regexp") f.Var(&m.to, "to", "To header matching regexp (must match one)") + f.DurationVar( + &m.maxAge, "maxage", 0, + "Matches must have been received in this time frame (ex: \"10s\", \"5m\")") } func (m *matchCmd) Execute( @@ -101,6 +107,11 @@ func (m *matchCmd) Execute( // 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 diff --git a/cmd/client/mbox.go b/cmd/client/mbox.go index 71e7aaf..cf2b46e 100644 --- a/cmd/client/mbox.go +++ b/cmd/client/mbox.go @@ -24,7 +24,7 @@ func (*mboxCmd) Synopsis() string { } func (*mboxCmd) Usage() string { - return `mbox [options] : + return `mbox [flags] : output mailbox in mbox format ` } From 5e94f7b750e4198f843436778d2db25e64e25ff5 Mon Sep 17 00:00:00 2001 From: James Hillyerd Date: Sun, 5 Feb 2017 15:31:31 -0800 Subject: [PATCH 06/16] Address matching should only apply to address, not name --- cmd/client/match.go | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/cmd/client/match.go b/cmd/client/match.go index 44c0830..df49a87 100644 --- a/cmd/client/match.go +++ b/cmd/client/match.go @@ -5,6 +5,7 @@ import ( "encoding/json" "flag" "fmt" + "net/mail" "os" "time" @@ -42,9 +43,9 @@ func (*matchCmd) Usage() string { 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") + 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 one)") + 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\")") @@ -118,13 +119,24 @@ func (m *matchCmd) match(header *client.MessageHeader) bool { } } if m.from.Defined() { - if !m.from.MatchString(header.From) { + 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 From 9fc9a333a6b5a67fd371a632169827553bbca6f2 Mon Sep 17 00:00:00 2001 From: James Hillyerd Date: Sun, 12 Feb 2017 14:53:42 -0800 Subject: [PATCH 07/16] Run travis tests with race detector enabled --- .travis.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.travis.yml b/.travis.yml index 7caab9a..249ad60 100644 --- a/.travis.yml +++ b/.travis.yml @@ -7,3 +7,5 @@ before_script: go: - 1.7.5 - master + +script: go test -race -v ./... From 304a2260e8945d2fa54438bcb7b293d23d9a90f0 Mon Sep 17 00:00:00 2001 From: James Hillyerd Date: Sun, 12 Feb 2017 16:21:44 -0800 Subject: [PATCH 08/16] Don't close writers with defer --- smtpd/filestore.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/smtpd/filestore.go b/smtpd/filestore.go index d59699a..361acc2 100644 --- a/smtpd/filestore.go +++ b/smtpd/filestore.go @@ -254,22 +254,22 @@ func (mb *FileMailbox) writeIndex() error { if err != nil { return err } - defer func() { - if err := file.Close(); err != nil { - log.Errorf("Failed to close %q: %v", mb.indexPath, err) - } - }() writer := bufio.NewWriter(file) - // Write each message and then flush enc := gob.NewEncoder(writer) for _, m := range mb.messages { err = enc.Encode(m) if err != nil { + _ = file.Close() return err } } if err := writer.Flush(); err != nil { + _ = file.Close() + return err + } + if err := file.Close(); err != nil { + log.Errorf("Failed to close %q: %v", mb.indexPath, err) return err } } else { From 0a967f0f21e9c1074a5322a2dda2a77d32598c37 Mon Sep 17 00:00:00 2001 From: James Hillyerd Date: Tue, 12 Dec 2017 19:57:20 -0800 Subject: [PATCH 09/16] Update golang versions --- .travis.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index 249ad60..59308ff 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,7 +5,7 @@ before_script: - go vet ./... go: - - 1.7.5 - - master + - 1.8.5 + - 1.9.2 script: go test -race -v ./... From dc0b9b325e9d8f8bf67294fa6bdce54b1e42d928 Mon Sep 17 00:00:00 2001 From: James Hillyerd Date: Tue, 12 Dec 2017 19:59:33 -0800 Subject: [PATCH 10/16] Use bash for swaks tests, no pipefail in sh --- swaks-tests/run-tests.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/swaks-tests/run-tests.sh b/swaks-tests/run-tests.sh index e747dde..0784a11 100755 --- a/swaks-tests/run-tests.sh +++ b/swaks-tests/run-tests.sh @@ -1,4 +1,4 @@ -#!/bin/sh +#!/bin/bash # run-tests.sh # description: Generate test emails for Inbucket From a9b174bcb6849d9f219ce9ce5425cff6240862d0 Mon Sep 17 00:00:00 2001 From: James Hillyerd Date: Thu, 14 Dec 2017 18:32:55 -0800 Subject: [PATCH 11/16] Add tl;dr to CONTRIBUTING.md --- CONTRIBUTING.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index aeaf852..c34dc94 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,6 +4,8 @@ How to Contribute Inbucket encourages third-party patches. It's valuable to know how other developers are using the product. +**tl;dr:** File pull requests against the `develop` branch, not `master`! + ## Getting Started From 7908e412123daf91fc07e8ce6f04ed6e26950fb7 Mon Sep 17 00:00:00 2001 From: James Hillyerd Date: Thu, 14 Dec 2017 18:51:35 -0800 Subject: [PATCH 12/16] Fixes #61 - monitor.history=0 panic --- CHANGELOG.md | 1 + msghub/hub.go | 16 +++++++++------- msghub/hub_test.go | 11 +++++++++++ 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5817b71..7d6e8e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). - `rest/client.NewV1` renamed to `New` - `rest/client` package now embeds the shared `rest/model` structs into its own types +- Fixed panic when `monitor.history` set to 0 [1.2.0-rc1] - 2017-01-29 ------------------------ diff --git a/msghub/hub.go b/msghub/hub.go index afc32b5..77e06ee 100644 --- a/msghub/hub.go +++ b/msghub/hub.go @@ -63,13 +63,15 @@ func New(ctx context.Context, historyLen int) *Hub { // history buffer and then relayed to all registered listeners. func (hub *Hub) Dispatch(msg Message) { hub.opChan <- func(h *Hub) { - // Add to history buffer - h.history.Value = msg - h.history = h.history.Next() - // Deliver message to all listeners, removing listeners if they return an error - for l := range h.listeners { - if err := l.Receive(msg); err != nil { - delete(h.listeners, l) + if h.history != nil { + // Add to history buffer + h.history.Value = msg + h.history = h.history.Next() + // Deliver message to all listeners, removing listeners if they return an error + for l := range h.listeners { + if err := l.Receive(msg); err != nil { + delete(h.listeners, l) + } } } } diff --git a/msghub/hub_test.go b/msghub/hub_test.go index f5da3ad..db77c7f 100644 --- a/msghub/hub_test.go +++ b/msghub/hub_test.go @@ -60,6 +60,17 @@ func TestHubNew(t *testing.T) { } } +func TestHubZeroLen(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + hub := New(ctx, 0) + m := Message{} + for i := 0; i < 100; i++ { + hub.Dispatch(m) + } + // Just making sure Hub doesn't panic +} + func TestHubZeroListeners(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() From ef17ad9074d59521c70874cc0d8970e16529a9be Mon Sep 17 00:00:00 2001 From: James Hillyerd Date: Thu, 14 Dec 2017 22:16:24 -0800 Subject: [PATCH 13/16] Update Docker base to go 1.9 --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 27b3ce8..cebab97 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ # Docker build file for Inbucket, see https://www.docker.io/ # Inbucket website: http://www.inbucket.org/ -FROM golang:1.7-alpine +FROM golang:1.9-alpine MAINTAINER James Hillyerd, @jameshillyerd # Configuration (WORKDIR doesn't support env vars) From 6368e3a83bf66ff293d6dc7d43f67676bd8c25a8 Mon Sep 17 00:00:00 2001 From: Carlos Tadeu Panato Junior Date: Sat, 16 Dec 2017 02:00:09 +0100 Subject: [PATCH 14/16] Add option to get the latest message using `latest` as request parameter (#63) --- smtpd/filestore.go | 10 ++++++--- smtpd/filestore_test.go | 49 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/smtpd/filestore.go b/smtpd/filestore.go index 361acc2..9f8f615 100644 --- a/smtpd/filestore.go +++ b/smtpd/filestore.go @@ -181,9 +181,13 @@ func (mb *FileMailbox) GetMessage(id string) (Message, error) { } } - for _, m := range mb.messages { - if m.Fid == id { - return m, nil + if id == "latest" && len(mb.messages) != 0 { + return mb.messages[len(mb.messages)-1], nil + } else { + for _, m := range mb.messages { + if m.Fid == id { + return m, nil + } } } diff --git a/smtpd/filestore_test.go b/smtpd/filestore_test.go index e783e17..2ca3104 100644 --- a/smtpd/filestore_test.go +++ b/smtpd/filestore_test.go @@ -458,6 +458,55 @@ func TestFSNoMessageCap(t *testing.T) { } } +// Test Get the latest message +func TestGetLatestMessage(t *testing.T) { + ds, logbuf := setupDataStore(config.DataStoreConfig{}) + defer teardownDataStore(ds) + + // james hashes to 474ba67bdb289c6263b36dfd8a7bed6c85b04943 + mbName := "james" + + // Test empty mailbox + mb, err := ds.MailboxFor(mbName) + assert.Nil(t, err) + msg, err := mb.GetMessage("latest") + assert.Error(t, err) + fmt.Println(msg) + + // Deliver test message + deliverMessage(ds, mbName, "test", time.Now()) + + // Deliver test message 2 + id2, _ := deliverMessage(ds, mbName, "test 2", time.Now()) + + // Test get the latest message + mb, err = ds.MailboxFor(mbName) + assert.Nil(t, err) + msg, err = mb.GetMessage("latest") + assert.Nil(t, err) + assert.True(t, msg.ID() == id2, "Expected %q to be equal to %q", msg.ID(), id2) + + // Deliver test message 3 + id3, _ := deliverMessage(ds, mbName, "test 3", time.Now()) + + mb, err = ds.MailboxFor(mbName) + assert.Nil(t, err) + msg, err = mb.GetMessage("latest") + assert.Nil(t, err) + assert.True(t, msg.ID() == id3, "Expected %q to be equal to %q", msg.ID(), id3) + + // Test wrong id + msg, err = mb.GetMessage("wrongid") + assert.Error(t, err) + + if t.Failed() { + // Wait for handler to finish logging + time.Sleep(2 * time.Second) + // Dump buffered log data if there was a failure + _, _ = io.Copy(os.Stderr, logbuf) + } +} + // setupDataStore creates a new FileDataStore in a temporary directory func setupDataStore(cfg config.DataStoreConfig) (*FileDataStore, *bytes.Buffer) { path, err := ioutil.TempDir("", "inbucket") From f597687aa3084c6b8588f740d941973fe276f40b Mon Sep 17 00:00:00 2001 From: James Hillyerd Date: Fri, 15 Dec 2017 20:20:27 -0800 Subject: [PATCH 15/16] Update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d6e8e5..c10ee25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). provides a more natural API - Powerful command line REST [client](https://github.com/jhillyerd/inbucket/wiki/cmd-client) +- Allow use of `latest` as a message ID in REST calls ### Changed - `rest/client.NewV1` renamed to `New` From 1efe2ba48fe1e628d164e35581fafb8c5034fc00 Mon Sep 17 00:00:00 2001 From: James Hillyerd Date: Fri, 15 Dec 2017 20:34:27 -0800 Subject: [PATCH 16/16] Prepare release 1.2.0-rc2 --- .goxc.json | 4 ++-- CHANGELOG.md | 7 ++++--- inbucket.go | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/.goxc.json b/.goxc.json index 270dcdd..b78010a 100644 --- a/.goxc.json +++ b/.goxc.json @@ -7,7 +7,7 @@ "Os": "darwin freebsd linux windows", "ResourcesInclude": "README*,LICENSE*,CHANGELOG*,inbucket.bat,etc,themes", "PackageVersion": "1.2.0", - "PrereleaseInfo": "rc1", + "PrereleaseInfo": "rc2", "ConfigVersion": "0.9", "BuildSettings": { "LdFlagsXVars": { @@ -15,4 +15,4 @@ "Version": "main.VERSION" } } -} +} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index c10ee25..2f2972f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,8 @@ Change Log All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). -[Unreleased] ------------- +[1.2.0-rc2] - 2017-12-15 +------------------------ ### Added - `rest/client` types `MessageHeader` and `Message` with convenience methods; @@ -92,6 +92,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). specific message. [Unreleased]: https://github.com/jhillyerd/inbucket/compare/master...develop +[1.2.0-rc2]: https://github.com/jhillyerd/inbucket/compare/1.2.0-rc1...1.2.0-rc2 [1.2.0-rc1]: https://github.com/jhillyerd/inbucket/compare/1.1.0...1.2.0-rc1 [1.1.0]: https://github.com/jhillyerd/inbucket/compare/1.1.0-rc2...1.1.0 [1.1.0-rc2]: https://github.com/jhillyerd/inbucket/compare/1.1.0-rc1...1.1.0-rc2 @@ -107,7 +108,7 @@ Release Checklist - Ensure *Unreleased* section is up to date - Rename *Unreleased* section to release name and date. - Add new GitHub `/compare` link -3. Update goxc version info: `goxc -wc -pv=1.x.0 -pr=snapshot` +3. Update goxc version info: `goxc -wc -pv=1.x.0 -pr=rc1` 4. Run: `goxc interpolate-source` to update VERSION var 5. Run tests 6. Test cross-compile: `goxc` diff --git a/inbucket.go b/inbucket.go index c8f17ce..303e090 100644 --- a/inbucket.go +++ b/inbucket.go @@ -24,7 +24,7 @@ import ( var ( // VERSION contains the build version number, populated during linking by goxc - VERSION = "1.2.0-rc1" + VERSION = "1.2.0-rc2" // BUILDDATE contains the build date, populated during linking by goxc BUILDDATE = "undefined"