From cc5cd7f9c322e353588f4b761a9abfce44d6f0c3 Mon Sep 17 00:00:00 2001 From: James Hillyerd Date: Sat, 31 Mar 2018 21:46:10 -0700 Subject: [PATCH 1/3] storage: Add Seen flag, tests for #58 --- CHANGELOG.md | 4 ++++ pkg/message/message.go | 6 ++++++ pkg/storage/file/fmessage.go | 6 ++++++ pkg/storage/file/fstore.go | 26 +++++++++++++++++++++++ pkg/storage/mem/message.go | 4 ++++ pkg/storage/mem/store.go | 11 ++++++++++ pkg/storage/storage.go | 2 ++ pkg/test/storage_suite.go | 41 ++++++++++++++++++++++++++++++++++++ 8 files changed, 100 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f1872bd..127330b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,10 +16,14 @@ This project adheres to [Semantic Versioning](http://semver.org/). page. - Debian `.deb` package generation to release process. - RedHat `.rpm` package generation to release process. +- Message seen flag in REST and Web UI so you can see which messages have + already been read. ### Changed - Massive refactor of back-end code. Inbucket should now be both easier and more enjoyable to work on. +- Changes to file storage format, will require pre-2.0 mail store directories to + be deleted. - Renamed `themes` directory to `ui` and eliminated the intermediate `bootstrap` directory. - Docker build: diff --git a/pkg/message/message.go b/pkg/message/message.go index 8bdd460..8c97cda 100644 --- a/pkg/message/message.go +++ b/pkg/message/message.go @@ -21,6 +21,7 @@ type Metadata struct { Date time.Time Subject string Size int64 + Seen bool } // Message holds both the metadata and content of a message. @@ -109,3 +110,8 @@ func (d *Delivery) Size() int64 { func (d *Delivery) Source() (io.ReadCloser, error) { return ioutil.NopCloser(d.Reader), nil } + +// Seen getter. +func (d *Delivery) Seen() bool { + return d.Meta.Seen +} diff --git a/pkg/storage/file/fmessage.go b/pkg/storage/file/fmessage.go index 961df62..a7ed1e2 100644 --- a/pkg/storage/file/fmessage.go +++ b/pkg/storage/file/fmessage.go @@ -21,6 +21,7 @@ type Message struct { Fto []*mail.Address Fsubject string Fsize int64 + Fseen bool } // newMessage creates a new FileMessage object and sets the Date and ID fields. @@ -96,3 +97,8 @@ func (m *Message) Source() (reader io.ReadCloser, err error) { } return file, nil } + +// Seen returns the seen flag value. +func (m *Message) Seen() bool { + return m.Fseen +} diff --git a/pkg/storage/file/fstore.go b/pkg/storage/file/fstore.go index 75ec3ba..b31ea0f 100644 --- a/pkg/storage/file/fstore.go +++ b/pkg/storage/file/fstore.go @@ -147,6 +147,32 @@ func (fs *Store) GetMessages(mailbox string) ([]storage.Message, error) { return mb.getMessages() } +// MarkSeen flags the message as having been read. +func (fs *Store) MarkSeen(mailbox, id string) error { + mb, err := fs.mbox(mailbox) + if err != nil { + return err + } + mb.Lock() + defer mb.Unlock() + if !mb.indexLoaded { + if err := mb.readIndex(); err != nil { + return err + } + } + for _, m := range mb.messages { + if m.Fid == id { + if m.Fseen { + // Already marked seen. + return nil + } + m.Fseen = true + break + } + } + return mb.writeIndex() +} + // RemoveMessage deletes a message by ID from the specified mailbox. func (fs *Store) RemoveMessage(mailbox, id string) error { mb, err := fs.mbox(mailbox) diff --git a/pkg/storage/mem/message.go b/pkg/storage/mem/message.go index b5ca498..02ae503 100644 --- a/pkg/storage/mem/message.go +++ b/pkg/storage/mem/message.go @@ -21,6 +21,7 @@ type Message struct { date time.Time subject string source []byte + seen bool el *list.Element // This message in Store.messages } @@ -51,3 +52,6 @@ func (m *Message) Source() (io.ReadCloser, error) { // Size returns the message size in bytes. func (m *Message) Size() int64 { return int64(len(m.source)) } + +// Seen returns the message seen flag. +func (m *Message) Seen() bool { return m.seen } diff --git a/pkg/storage/mem/store.go b/pkg/storage/mem/store.go index c37b241..16b094e 100644 --- a/pkg/storage/mem/store.go +++ b/pkg/storage/mem/store.go @@ -112,6 +112,17 @@ func (s *Store) GetMessages(mailbox string) (ms []storage.Message, err error) { return ms, err } +// MarkSeen marks a message as having been read. +func (s *Store) MarkSeen(mailbox, id string) error { + s.withMailbox(mailbox, true, func(mb *mbox) { + m := mb.messages[id] + if m != nil { + m.seen = true + } + }) + return nil +} + // PurgeMessages deletes the contents of a mailbox. func (s *Store) PurgeMessages(mailbox string) error { var messages map[string]*Message diff --git a/pkg/storage/storage.go b/pkg/storage/storage.go index 67ef9fc..7cd40b3 100644 --- a/pkg/storage/storage.go +++ b/pkg/storage/storage.go @@ -28,6 +28,7 @@ type Store interface { AddMessage(message Message) (id string, err error) GetMessage(mailbox, id string) (Message, error) GetMessages(mailbox string) ([]Message, error) + MarkSeen(mailbox, id string) error PurgeMessages(mailbox string) error RemoveMessage(mailbox, id string) error VisitMailboxes(f func([]Message) (cont bool)) error @@ -43,6 +44,7 @@ type Message interface { Subject() string Source() (io.ReadCloser, error) Size() int64 + Seen() bool } // FromConfig creates an instance of the Store based on the provided configuration. diff --git a/pkg/test/storage_suite.go b/pkg/test/storage_suite.go index 9936145..b4135b0 100644 --- a/pkg/test/storage_suite.go +++ b/pkg/test/storage_suite.go @@ -28,6 +28,7 @@ func StoreSuite(t *testing.T, factory StoreFactory) { {"content", testContent, config.Storage{}}, {"delivery order", testDeliveryOrder, config.Storage{}}, {"size", testSize, config.Storage{}}, + {"seen", testSeen, config.Storage{}}, {"delete", testDelete, config.Storage{}}, {"purge", testPurge, config.Storage{}}, {"cap=10", testMsgCap, config.Storage{MailboxMsgCap: 10}}, @@ -65,6 +66,7 @@ func testMetadata(t *testing.T, store storage.Store) { To: to, Date: date, Subject: subject, + Seen: false, }, Reader: strings.NewReader(content), } @@ -107,6 +109,9 @@ func testMetadata(t *testing.T, store storage.Store) { if sm.Size() != int64(len(content)) { t.Errorf("got size %v, want: %v", sm.Size(), len(content)) } + if sm.Seen() { + t.Errorf("got seen %v, want: false", sm.Seen()) + } } // testContent generates some binary content and makes sure it is correctly retrieved. @@ -210,6 +215,42 @@ func testSize(t *testing.T, store storage.Store) { } } +// testSeen verifies a message can be marked as seen. +func testSeen(t *testing.T, store storage.Store) { + mailbox := "lisa" + id1, _ := DeliverToStore(t, store, mailbox, "whatever", time.Now()) + id2, _ := DeliverToStore(t, store, mailbox, "hello?", time.Now()) + // Confirm unseen. + msg, err := store.GetMessage(mailbox, id1) + if err != nil { + t.Fatal(err) + } + if msg.Seen() { + t.Errorf("got seen %v, want: false", msg.Seen()) + } + // Mark id1 seen. + err = store.MarkSeen(mailbox, id1) + if err != nil { + t.Fatal(err) + } + // Verify id1 seen. + msg, err = store.GetMessage(mailbox, id1) + if err != nil { + t.Fatal(err) + } + if !msg.Seen() { + t.Errorf("id1 got seen %v, want: true", msg.Seen()) + } + // Verify id2 still unseen. + msg, err = store.GetMessage(mailbox, id2) + if err != nil { + t.Fatal(err) + } + if msg.Seen() { + t.Errorf("id2 got seen %v, want: false", msg.Seen()) + } +} + // testDelete creates and deletes some messages. func testDelete(t *testing.T, store storage.Store) { mailbox := "fred" From dc02092cf6490580b530d2814bc62ce2484606bb Mon Sep 17 00:00:00 2001 From: James Hillyerd Date: Sun, 1 Apr 2018 11:13:33 -0700 Subject: [PATCH 2/3] 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 +} From c695a2690de142e385be0f047ac8923dc4fa15db Mon Sep 17 00:00:00 2001 From: James Hillyerd Date: Sun, 1 Apr 2018 15:16:48 -0700 Subject: [PATCH 3/3] ui: Mark messages as seen after 1.5s for #58 Embolden subject font for unseen messages. --- etc/dev-start.sh | 2 +- pkg/message/manager.go | 4 ++++ ui/static/inbucket.css | 4 ++++ ui/static/mailbox.js | 36 ++++++++++++++++++++++++++++++--- ui/templates/mailbox/index.html | 5 +++-- 5 files changed, 45 insertions(+), 6 deletions(-) diff --git a/etc/dev-start.sh b/etc/dev-start.sh index 4efd440..82b5cc9 100755 --- a/etc/dev-start.sh +++ b/etc/dev-start.sh @@ -8,7 +8,7 @@ export INBUCKET_WEB_TEMPLATECACHE="false" export INBUCKET_WEB_COOKIEAUTHKEY="not-secret" export INBUCKET_STORAGE_TYPE="file" export INBUCKET_STORAGE_PARAMS="path:/tmp/inbucket" -export INBUCKET_STORAGE_RETENTIONPERIOD="5m" +export INBUCKET_STORAGE_RETENTIONPERIOD="15m" if ! test -x ./inbucket; then echo "$PWD/inbucket not found/executable!" >&2 diff --git a/pkg/message/manager.go b/pkg/message/manager.go index ff0a8a9..35d0fcd 100644 --- a/pkg/message/manager.go +++ b/pkg/message/manager.go @@ -12,6 +12,7 @@ import ( "github.com/jhillyerd/inbucket/pkg/policy" "github.com/jhillyerd/inbucket/pkg/storage" "github.com/jhillyerd/inbucket/pkg/stringutil" + "github.com/rs/zerolog/log" ) // Manager is the interface controllers use to interact with messages. @@ -127,6 +128,8 @@ func (s *StoreManager) GetMessage(mailbox, id string) (*Message, error) { // MarkSeen marks the message as having been read. func (s *StoreManager) MarkSeen(mailbox, id string) error { + log.Debug().Str("module", "manager").Str("mailbox", mailbox).Str("id", id). + Msg("Marking as seen") return s.Store.MarkSeen(mailbox, id) } @@ -164,5 +167,6 @@ func makeMetadata(m storage.Message) *Metadata { Date: m.Date(), Subject: m.Subject(), Size: m.Size(), + Seen: m.Seen(), } } diff --git a/ui/static/inbucket.css b/ui/static/inbucket.css index c895626..ece379e 100644 --- a/ui/static/inbucket.css +++ b/ui/static/inbucket.css @@ -29,6 +29,10 @@ body { font-size: 18px; } +.message-list-entry .unseen { + font-weight: bold; +} + .message-list-scroll { overflow-y: auto; } diff --git a/ui/static/mailbox.js b/ui/static/mailbox.js index 5a624c6..efe52d4 100644 --- a/ui/static/mailbox.js +++ b/ui/static/mailbox.js @@ -5,6 +5,7 @@ var messageListMargin = 275; var clipboard = null; var messageListScroll = false; var messageListData = null; +var seenDelay = null; // clearMessageSearch resets the message list search function clearMessageSearch() { @@ -55,8 +56,11 @@ function loadList() { url: '/api/v1/mailbox/' + mailbox, success: function(data) { messageListData = data.reverse(); + for (i=0; i