From dc02092cf6490580b530d2814bc62ce2484606bb Mon Sep 17 00:00:00 2001 From: James Hillyerd Date: Sun, 1 Apr 2018 11:13:33 -0700 Subject: [PATCH] rest: Implement MarkSeen for #58 - message: Add MarkSeen to Manager, StoreManager. - rest: Add PATCH for /mailbox/name/id. - rest: Add MailboxMarkSeenV1 handler. - rest: Add Seen to model. - rest: Update handlers to set Seen. - rest: Add doJSONBody func. --- pkg/message/manager.go | 6 +++ pkg/rest/apiv1_controller.go | 30 ++++++++++++ pkg/rest/apiv1_controller_test.go | 68 ++++++++++++++++++++++++++++ pkg/rest/client/apiv1_client.go | 16 +++++-- pkg/rest/client/apiv1_client_test.go | 35 +++++++++++++- pkg/rest/client/rest.go | 42 +++++++++++++---- pkg/rest/client/rest_test.go | 23 ++++++++-- pkg/rest/model/apiv1_model.go | 2 + pkg/rest/routes.go | 2 + pkg/rest/testutils_test.go | 26 +++++++++++ pkg/test/manager.go | 14 ++++++ 11 files changed, 247 insertions(+), 17 deletions(-) diff --git a/pkg/message/manager.go b/pkg/message/manager.go index 5e428e6..ff0a8a9 100644 --- a/pkg/message/manager.go +++ b/pkg/message/manager.go @@ -25,6 +25,7 @@ type Manager interface { ) (id string, err error) GetMetadata(mailbox string) ([]*Metadata, error) GetMessage(mailbox, id string) (*Message, error) + MarkSeen(mailbox, id string) error PurgeMessages(mailbox string) error RemoveMessage(mailbox, id string) error SourceReader(mailbox, id string) (io.ReadCloser, error) @@ -124,6 +125,11 @@ func (s *StoreManager) GetMessage(mailbox, id string) (*Message, error) { return &Message{Metadata: *header, env: env}, nil } +// MarkSeen marks the message as having been read. +func (s *StoreManager) MarkSeen(mailbox, id string) error { + return s.Store.MarkSeen(mailbox, id) +} + // PurgeMessages removes all messages from the specified mailbox. func (s *StoreManager) PurgeMessages(mailbox string) error { return s.Store.PurgeMessages(mailbox) diff --git a/pkg/rest/apiv1_controller.go b/pkg/rest/apiv1_controller.go index adef527..ea71e43 100644 --- a/pkg/rest/apiv1_controller.go +++ b/pkg/rest/apiv1_controller.go @@ -7,6 +7,7 @@ import ( "crypto/md5" "encoding/hex" + "encoding/json" "strconv" "github.com/jhillyerd/inbucket/pkg/rest/model" @@ -37,6 +38,7 @@ func MailboxListV1(w http.ResponseWriter, req *http.Request, ctx *web.Context) ( Subject: msg.Subject, Date: msg.Date, Size: msg.Size, + Seen: msg.Seen, } } return web.RenderJSON(w, jmessages) @@ -83,6 +85,7 @@ func MailboxShowV1(w http.ResponseWriter, req *http.Request, ctx *web.Context) ( Subject: msg.Subject, Date: msg.Date, Size: msg.Size, + Seen: msg.Seen, Header: msg.Header(), Body: &model.JSONMessageBodyV1{ Text: msg.Text(), @@ -92,6 +95,33 @@ func MailboxShowV1(w http.ResponseWriter, req *http.Request, ctx *web.Context) ( }) } +// MailboxMarkSeenV1 marks a message as read. +func MailboxMarkSeenV1(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) { + // Don't have to validate these aren't empty, Gorilla returns 404 + id := ctx.Vars["id"] + name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"]) + if err != nil { + return err + } + dec := json.NewDecoder(req.Body) + dm := model.JSONMessageHeaderV1{} + if err := dec.Decode(&dm); err != nil { + return fmt.Errorf("Failed to decode JSON: %v", err) + } + if dm.Seen { + err = ctx.Manager.MarkSeen(name, id) + if err == storage.ErrNotExist { + http.NotFound(w, req) + return nil + } + if err != nil { + // This doesn't indicate empty, likely an IO error + return fmt.Errorf("MarkSeen(%q) failed: %v", id, err) + } + } + return web.RenderJSON(w, "OK") +} + // MailboxPurgeV1 deletes all messages from a mailbox func MailboxPurgeV1(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) { // Don't have to validate these aren't empty, Gorilla returns 404 diff --git a/pkg/rest/apiv1_controller_test.go b/pkg/rest/apiv1_controller_test.go index 734879e..cf64234 100644 --- a/pkg/rest/apiv1_controller_test.go +++ b/pkg/rest/apiv1_controller_test.go @@ -115,6 +115,7 @@ func TestRestMailboxList(t *testing.T) { decodedStringEquals(t, result, "[0]/subject", "subject 1") decodedStringEquals(t, result, "[0]/date", "2012-02-01T10:11:12.000000253-08:00") decodedNumberEquals(t, result, "[0]/size", 0) + decodedBoolEquals(t, result, "[0]/seen", false) decodedStringEquals(t, result, "[1]/mailbox", "good") decodedStringEquals(t, result, "[1]/id", "0002") decodedStringEquals(t, result, "[1]/from", "") @@ -122,6 +123,7 @@ func TestRestMailboxList(t *testing.T) { decodedStringEquals(t, result, "[1]/subject", "subject 2") decodedStringEquals(t, result, "[1]/date", "2012-07-01T10:11:12.000000253-07:00") decodedNumberEquals(t, result, "[1]/size", 0) + decodedBoolEquals(t, result, "[1]/seen", false) if t.Failed() { // Wait for handler to finish logging @@ -183,6 +185,7 @@ func TestRestMessage(t *testing.T) { To: []*mail.Address{{Name: "", Address: "to1@host"}}, Subject: "subject 1", Date: time.Date(2012, 2, 1, 10, 11, 12, 253, tzPST), + Seen: true, }, &enmime.Envelope{ Text: "This is some text", @@ -221,6 +224,7 @@ func TestRestMessage(t *testing.T) { decodedStringEquals(t, result, "subject", "subject 1") decodedStringEquals(t, result, "date", "2012-02-01T10:11:12.000000253-08:00") decodedNumberEquals(t, result, "size", 0) + decodedBoolEquals(t, result, "seen", true) decodedStringEquals(t, result, "body/text", "This is some text") decodedStringEquals(t, result, "body/html", "This is some HTML") decodedStringEquals(t, result, "header/To/[0]", "fred@fish.com") @@ -234,3 +238,67 @@ func TestRestMessage(t *testing.T) { _, _ = io.Copy(os.Stderr, logbuf) } } + +func TestRestMarkSeen(t *testing.T) { + mm := test.NewManager() + logbuf := setupWebServer(mm) + // Create some messages. + tzPDT := time.FixedZone("PDT", -7*3600) + tzPST := time.FixedZone("PST", -8*3600) + meta1 := message.Metadata{ + Mailbox: "good", + ID: "0001", + From: &mail.Address{Name: "", Address: "from1@host"}, + To: []*mail.Address{{Name: "", Address: "to1@host"}}, + Subject: "subject 1", + Date: time.Date(2012, 2, 1, 10, 11, 12, 253, tzPST), + } + meta2 := message.Metadata{ + Mailbox: "good", + ID: "0002", + From: &mail.Address{Name: "", Address: "from2@host"}, + To: []*mail.Address{{Name: "", Address: "to1@host"}}, + Subject: "subject 2", + Date: time.Date(2012, 7, 1, 10, 11, 12, 253, tzPDT), + } + mm.AddMessage("good", &message.Message{Metadata: meta1}) + mm.AddMessage("good", &message.Message{Metadata: meta2}) + // Mark one read. + w, err := testRestPatch(baseURL+"/mailbox/good/0002", `{"seen":true}`) + expectCode := 200 + if err != nil { + t.Fatal(err) + } + if w.Code != expectCode { + t.Fatalf("Expected code %v, got %v", expectCode, w.Code) + } + // Get mailbox. + w, err = testRestGet(baseURL + "/mailbox/good") + expectCode = 200 + if err != nil { + t.Fatal(err) + } + if w.Code != expectCode { + t.Fatalf("Expected code %v, got %v", expectCode, w.Code) + } + // Check JSON. + dec := json.NewDecoder(w.Body) + var result []interface{} + if err := dec.Decode(&result); err != nil { + t.Errorf("Failed to decode JSON: %v", err) + } + if len(result) != 2 { + t.Fatalf("Expected 2 results, got %v", len(result)) + } + decodedStringEquals(t, result, "[0]/id", "0001") + decodedBoolEquals(t, result, "[0]/seen", false) + decodedStringEquals(t, result, "[1]/id", "0002") + decodedBoolEquals(t, result, "[1]/seen", true) + + 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) + } +} diff --git a/pkg/rest/client/apiv1_client.go b/pkg/rest/client/apiv1_client.go index ad62dcb..d88afce 100644 --- a/pkg/rest/client/apiv1_client.go +++ b/pkg/rest/client/apiv1_client.go @@ -58,10 +58,20 @@ func (c *Client) GetMessage(name, id string) (message *Message, err error) { return } +// MarkSeen marks the specified message as having been read. +func (c *Client) MarkSeen(name, id string) error { + uri := "/api/v1/mailbox/" + url.QueryEscape(name) + "/" + id + err := c.doJSON("PATCH", uri, nil) + if err != nil { + return err + } + return nil +} + // GetMessageSource returns the message source given a mailbox name and message ID. 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) + resp, err := c.do("GET", uri, nil) if err != nil { return nil, err } @@ -81,7 +91,7 @@ func (c *Client) GetMessageSource(name, id string) (*bytes.Buffer, error) { // DeleteMessage deletes a single message given the mailbox name and message ID. func (c *Client) DeleteMessage(name, id string) error { uri := "/api/v1/mailbox/" + url.QueryEscape(name) + "/" + id - resp, err := c.do("DELETE", uri) + resp, err := c.do("DELETE", uri, nil) if err != nil { return err } @@ -95,7 +105,7 @@ func (c *Client) DeleteMessage(name, id string) error { // PurgeMailbox deletes all messages in the given mailbox func (c *Client) PurgeMailbox(name string) error { uri := "/api/v1/mailbox/" + url.QueryEscape(name) - resp, err := c.do("DELETE", uri) + resp, err := c.do("DELETE", uri, nil) if err != nil { return err } diff --git a/pkg/rest/client/apiv1_client_test.go b/pkg/rest/client/apiv1_client_test.go index abce5ca..d3c8ca7 100644 --- a/pkg/rest/client/apiv1_client_test.go +++ b/pkg/rest/client/apiv1_client_test.go @@ -54,6 +54,32 @@ func TestClientV1GetMessage(t *testing.T) { } } +func TestClientV1MarkSeen(t *testing.T) { + var want, got string + + c, err := New(baseURLStr) + if err != nil { + t.Fatal(err) + } + mth := &mockHTTPClient{} + c.client = mth + + // Method under test + _ = c.MarkSeen("testbox", "20170107T224128-0000") + + want = "PATCH" + got = mth.req.Method + if got != want { + t.Errorf("req.Method == %q, want %q", got, want) + } + + want = baseURLStr + "/api/v1/mailbox/testbox/20170107T224128-0000" + got = mth.req.URL.String() + if got != want { + t.Errorf("req.URL == %q, want %q", got, want) + } +} + func TestClientV1GetMessageSource(t *testing.T) { var want, got string @@ -158,7 +184,8 @@ func TestClientV1MessageHeader(t *testing.T) { "from":"from1", "subject":"subject1", "date":"2017-01-01T00:00:00.000-07:00", - "size":100 + "size":100, + "seen":true } ]` @@ -216,6 +243,12 @@ func TestClientV1MessageHeader(t *testing.T) { t.Errorf("Subject == %q, want %q", got, want) } + wantb := true + gotb := header.Seen + if gotb != wantb { + t.Errorf("Seen == %v, want %v", gotb, wantb) + } + // Test MessageHeader.Delete() mth.body = "" err = header.Delete() diff --git a/pkg/rest/client/rest.go b/pkg/rest/client/rest.go index 718749d..1cefd55 100644 --- a/pkg/rest/client/rest.go +++ b/pkg/rest/client/rest.go @@ -1,8 +1,10 @@ package client import ( + "bytes" "encoding/json" "fmt" + "io" "net/http" "net/url" ) @@ -18,28 +20,48 @@ type restClient struct { baseURL *url.URL } -// do performs an HTTP request with this client and returns the response -func (c *restClient) do(method, uri string) (*http.Response, error) { +// do performs an HTTP request with this client and returns the response. +func (c *restClient) do(method, uri string, body []byte) (*http.Response, error) { rel, err := url.Parse(uri) if err != nil { return nil, err } - url := c.baseURL.ResolveReference(rel) - - // Build the request - req, err := http.NewRequest(method, url.String(), nil) + var r io.Reader + if body != nil { + r = bytes.NewReader(body) + } + req, err := http.NewRequest(method, url.String(), r) if err != nil { return nil, err } - - // Send the request return c.client.Do(req) } -// doGet performs an HTTP request with this client and marshalls the JSON response into v +// doJSON performs an HTTP request with this client and marshalls the JSON response into v. func (c *restClient) doJSON(method string, uri string, v interface{}) error { - resp, err := c.do(method, uri) + resp, err := c.do(method, uri, nil) + if err != nil { + return err + } + + defer func() { + _ = resp.Body.Close() + }() + if resp.StatusCode == http.StatusOK { + if v == nil { + return nil + } + // Decode response body + return json.NewDecoder(resp.Body).Decode(v) + } + + return fmt.Errorf("Unexpected HTTP response status %v: %s", resp.StatusCode, resp.Status) +} + +// doJSONBody performs an HTTP request with this client and marshalls the JSON response into v. +func (c *restClient) doJSONBody(method string, uri string, body []byte, v interface{}) error { + resp, err := c.do(method, uri, body) if err != nil { return err } diff --git a/pkg/rest/client/rest_test.go b/pkg/rest/client/rest_test.go index 3668578..c4d8f6e 100644 --- a/pkg/rest/client/rest_test.go +++ b/pkg/rest/client/rest_test.go @@ -35,17 +35,29 @@ func (m *mockHTTPClient) Do(req *http.Request) (resp *http.Response, err error) StatusCode: m.statusCode, Body: ioutil.NopCloser(bytes.NewBufferString(m.body)), } - return } +func (m *mockHTTPClient) ReqBody() []byte { + r, err := m.req.GetBody() + if err != nil { + return nil + } + body, err := ioutil.ReadAll(r) + if err != nil { + return nil + } + _ = r.Close() + return body +} + func TestDo(t *testing.T) { var want, got string - mth := &mockHTTPClient{} c := &restClient{mth, baseURL} + body := []byte("Test body") - _, err := c.do("POST", "/dopost") + _, err := c.do("POST", "/dopost", body) if err != nil { t.Fatal(err) } @@ -61,6 +73,11 @@ func TestDo(t *testing.T) { if got != want { t.Errorf("req.URL == %q, want %q", got, want) } + + b := mth.ReqBody() + if !bytes.Equal(b, body) { + t.Errorf("req.Body == %q, want %q", b, body) + } } func TestDoJSON(t *testing.T) { diff --git a/pkg/rest/model/apiv1_model.go b/pkg/rest/model/apiv1_model.go index 7e1e083..2b18e13 100644 --- a/pkg/rest/model/apiv1_model.go +++ b/pkg/rest/model/apiv1_model.go @@ -13,6 +13,7 @@ type JSONMessageHeaderV1 struct { Subject string `json:"subject"` Date time.Time `json:"date"` Size int64 `json:"size"` + Seen bool `json:"seen"` } // JSONMessageV1 contains the same data as the header plus a JSONMessageBody @@ -24,6 +25,7 @@ type JSONMessageV1 struct { Subject string `json:"subject"` Date time.Time `json:"date"` Size int64 `json:"size"` + Seen bool `json:"seen"` Body *JSONMessageBodyV1 `json:"body"` Header map[string][]string `json:"header"` Attachments []*JSONMessageAttachmentV1 `json:"attachments"` diff --git a/pkg/rest/routes.go b/pkg/rest/routes.go index fad722d..d622f3d 100644 --- a/pkg/rest/routes.go +++ b/pkg/rest/routes.go @@ -12,6 +12,8 @@ func SetupRoutes(r *mux.Router) { web.Handler(MailboxPurgeV1)).Name("MailboxPurgeV1").Methods("DELETE") r.Path("/api/v1/mailbox/{name}/{id}").Handler( web.Handler(MailboxShowV1)).Name("MailboxShowV1").Methods("GET") + r.Path("/api/v1/mailbox/{name}/{id}").Handler( + web.Handler(MailboxMarkSeenV1)).Name("MailboxMarkSeenV1").Methods("PATCH") r.Path("/api/v1/mailbox/{name}/{id}").Handler( web.Handler(MailboxDeleteV1)).Name("MailboxDeleteV1").Methods("DELETE") r.Path("/api/v1/mailbox/{name}/{id}/source").Handler( diff --git a/pkg/rest/testutils_test.go b/pkg/rest/testutils_test.go index b062083..67b7075 100644 --- a/pkg/rest/testutils_test.go +++ b/pkg/rest/testutils_test.go @@ -21,7 +21,17 @@ func testRestGet(url string) (*httptest.ResponseRecorder, error) { if err != nil { return nil, err } + w := httptest.NewRecorder() + web.Router.ServeHTTP(w, req) + return w, nil +} +func testRestPatch(url string, body string) (*httptest.ResponseRecorder, error) { + req, err := http.NewRequest("PATCH", url, strings.NewReader(body)) + req.Header.Add("Accept", "application/json") + if err != nil { + return nil, err + } w := httptest.NewRecorder() web.Router.ServeHTTP(w, req) return w, nil @@ -46,6 +56,22 @@ func setupWebServer(mm message.Manager) *bytes.Buffer { return buf } +func decodedBoolEquals(t *testing.T, json interface{}, path string, want bool) { + t.Helper() + els := strings.Split(path, "/") + val, msg := getDecodedPath(json, els...) + if msg != "" { + t.Errorf("JSON result%s", msg) + return + } + if got, ok := val.(bool); ok { + if got == want { + return + } + } + t.Errorf("JSON result/%s == %v (%T), want: %v", path, val, val, want) +} + func decodedNumberEquals(t *testing.T, json interface{}, path string, want float64) { t.Helper() els := strings.Split(path, "/") diff --git a/pkg/test/manager.go b/pkg/test/manager.go index bf8d9ad..f5053df 100644 --- a/pkg/test/manager.go +++ b/pkg/test/manager.go @@ -57,3 +57,17 @@ func (m *ManagerStub) GetMetadata(mailbox string) ([]*message.Metadata, error) { func (m *ManagerStub) MailboxForAddress(address string) (string, error) { return policy.ParseMailboxName(address) } + +// MarkSeen marks a message as having been read. +func (m *ManagerStub) MarkSeen(mailbox, id string) error { + if mailbox == "messageerr" { + return errors.New("internal error") + } + for _, msg := range m.mailboxes[mailbox] { + if msg.ID == id { + msg.Metadata.Seen = true + return nil + } + } + return storage.ErrNotExist +}