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/.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/.travis.yml b/.travis.yml index 7caab9a..59308ff 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,5 +5,7 @@ before_script: - go vet ./... go: - - 1.7.5 - - master + - 1.8.5 + - 1.9.2 + +script: go test -race -v ./... diff --git a/CHANGELOG.md b/CHANGELOG.md index 5746e30..2f2972f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,22 @@ Change Log All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). +[1.2.0-rc2] - 2017-12-15 +------------------------ + +### 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) +- Allow use of `latest` as a message ID in REST calls + +### Changed +- `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 ------------------------ @@ -76,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 @@ -91,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/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 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) 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 +} 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" 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() 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) diff --git a/smtpd/filestore.go b/smtpd/filestore.go index d59699a..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 + } } } @@ -254,22 +258,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 { 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") 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