1
0
mirror of https://github.com/jhillyerd/inbucket.git synced 2025-12-17 17:47:03 +00:00

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.
This commit is contained in:
James Hillyerd
2018-04-01 11:13:33 -07:00
parent cc5cd7f9c3
commit dc02092cf6
11 changed files with 247 additions and 17 deletions

View File

@@ -25,6 +25,7 @@ type Manager interface {
) (id string, err error) ) (id string, err error)
GetMetadata(mailbox string) ([]*Metadata, error) GetMetadata(mailbox string) ([]*Metadata, error)
GetMessage(mailbox, id string) (*Message, error) GetMessage(mailbox, id string) (*Message, error)
MarkSeen(mailbox, id string) error
PurgeMessages(mailbox string) error PurgeMessages(mailbox string) error
RemoveMessage(mailbox, id string) error RemoveMessage(mailbox, id string) error
SourceReader(mailbox, id string) (io.ReadCloser, 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 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. // PurgeMessages removes all messages from the specified mailbox.
func (s *StoreManager) PurgeMessages(mailbox string) error { func (s *StoreManager) PurgeMessages(mailbox string) error {
return s.Store.PurgeMessages(mailbox) return s.Store.PurgeMessages(mailbox)

View File

@@ -7,6 +7,7 @@ import (
"crypto/md5" "crypto/md5"
"encoding/hex" "encoding/hex"
"encoding/json"
"strconv" "strconv"
"github.com/jhillyerd/inbucket/pkg/rest/model" "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, Subject: msg.Subject,
Date: msg.Date, Date: msg.Date,
Size: msg.Size, Size: msg.Size,
Seen: msg.Seen,
} }
} }
return web.RenderJSON(w, jmessages) return web.RenderJSON(w, jmessages)
@@ -83,6 +85,7 @@ func MailboxShowV1(w http.ResponseWriter, req *http.Request, ctx *web.Context) (
Subject: msg.Subject, Subject: msg.Subject,
Date: msg.Date, Date: msg.Date,
Size: msg.Size, Size: msg.Size,
Seen: msg.Seen,
Header: msg.Header(), Header: msg.Header(),
Body: &model.JSONMessageBodyV1{ Body: &model.JSONMessageBodyV1{
Text: msg.Text(), 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 // MailboxPurgeV1 deletes all messages from a mailbox
func MailboxPurgeV1(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) { 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 // Don't have to validate these aren't empty, Gorilla returns 404

View File

@@ -115,6 +115,7 @@ func TestRestMailboxList(t *testing.T) {
decodedStringEquals(t, result, "[0]/subject", "subject 1") decodedStringEquals(t, result, "[0]/subject", "subject 1")
decodedStringEquals(t, result, "[0]/date", "2012-02-01T10:11:12.000000253-08:00") decodedStringEquals(t, result, "[0]/date", "2012-02-01T10:11:12.000000253-08:00")
decodedNumberEquals(t, result, "[0]/size", 0) decodedNumberEquals(t, result, "[0]/size", 0)
decodedBoolEquals(t, result, "[0]/seen", false)
decodedStringEquals(t, result, "[1]/mailbox", "good") decodedStringEquals(t, result, "[1]/mailbox", "good")
decodedStringEquals(t, result, "[1]/id", "0002") decodedStringEquals(t, result, "[1]/id", "0002")
decodedStringEquals(t, result, "[1]/from", "<from2@host>") decodedStringEquals(t, result, "[1]/from", "<from2@host>")
@@ -122,6 +123,7 @@ func TestRestMailboxList(t *testing.T) {
decodedStringEquals(t, result, "[1]/subject", "subject 2") decodedStringEquals(t, result, "[1]/subject", "subject 2")
decodedStringEquals(t, result, "[1]/date", "2012-07-01T10:11:12.000000253-07:00") decodedStringEquals(t, result, "[1]/date", "2012-07-01T10:11:12.000000253-07:00")
decodedNumberEquals(t, result, "[1]/size", 0) decodedNumberEquals(t, result, "[1]/size", 0)
decodedBoolEquals(t, result, "[1]/seen", false)
if t.Failed() { if t.Failed() {
// Wait for handler to finish logging // Wait for handler to finish logging
@@ -183,6 +185,7 @@ func TestRestMessage(t *testing.T) {
To: []*mail.Address{{Name: "", Address: "to1@host"}}, To: []*mail.Address{{Name: "", Address: "to1@host"}},
Subject: "subject 1", Subject: "subject 1",
Date: time.Date(2012, 2, 1, 10, 11, 12, 253, tzPST), Date: time.Date(2012, 2, 1, 10, 11, 12, 253, tzPST),
Seen: true,
}, },
&enmime.Envelope{ &enmime.Envelope{
Text: "This is some text", Text: "This is some text",
@@ -221,6 +224,7 @@ func TestRestMessage(t *testing.T) {
decodedStringEquals(t, result, "subject", "subject 1") decodedStringEquals(t, result, "subject", "subject 1")
decodedStringEquals(t, result, "date", "2012-02-01T10:11:12.000000253-08:00") decodedStringEquals(t, result, "date", "2012-02-01T10:11:12.000000253-08:00")
decodedNumberEquals(t, result, "size", 0) decodedNumberEquals(t, result, "size", 0)
decodedBoolEquals(t, result, "seen", true)
decodedStringEquals(t, result, "body/text", "This is some text") decodedStringEquals(t, result, "body/text", "This is some text")
decodedStringEquals(t, result, "body/html", "This is some HTML") decodedStringEquals(t, result, "body/html", "This is some HTML")
decodedStringEquals(t, result, "header/To/[0]", "fred@fish.com") decodedStringEquals(t, result, "header/To/[0]", "fred@fish.com")
@@ -234,3 +238,67 @@ func TestRestMessage(t *testing.T) {
_, _ = io.Copy(os.Stderr, logbuf) _, _ = 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)
}
}

View File

@@ -58,10 +58,20 @@ func (c *Client) GetMessage(name, id string) (message *Message, err error) {
return 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. // GetMessageSource returns the message source given a mailbox name and message ID.
func (c *Client) 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" 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 { if err != nil {
return nil, err 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. // DeleteMessage deletes a single message given the mailbox name and message ID.
func (c *Client) DeleteMessage(name, id string) error { func (c *Client) DeleteMessage(name, id string) error {
uri := "/api/v1/mailbox/" + url.QueryEscape(name) + "/" + id uri := "/api/v1/mailbox/" + url.QueryEscape(name) + "/" + id
resp, err := c.do("DELETE", uri) resp, err := c.do("DELETE", uri, nil)
if err != nil { if err != nil {
return err return err
} }
@@ -95,7 +105,7 @@ func (c *Client) DeleteMessage(name, id string) error {
// PurgeMailbox deletes all messages in the given mailbox // PurgeMailbox deletes all messages in the given mailbox
func (c *Client) PurgeMailbox(name string) error { func (c *Client) PurgeMailbox(name string) error {
uri := "/api/v1/mailbox/" + url.QueryEscape(name) uri := "/api/v1/mailbox/" + url.QueryEscape(name)
resp, err := c.do("DELETE", uri) resp, err := c.do("DELETE", uri, nil)
if err != nil { if err != nil {
return err return err
} }

View File

@@ -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) { func TestClientV1GetMessageSource(t *testing.T) {
var want, got string var want, got string
@@ -158,7 +184,8 @@ func TestClientV1MessageHeader(t *testing.T) {
"from":"from1", "from":"from1",
"subject":"subject1", "subject":"subject1",
"date":"2017-01-01T00:00:00.000-07:00", "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) 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() // Test MessageHeader.Delete()
mth.body = "" mth.body = ""
err = header.Delete() err = header.Delete()

View File

@@ -1,8 +1,10 @@
package client package client
import ( import (
"bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"net/http" "net/http"
"net/url" "net/url"
) )
@@ -18,28 +20,48 @@ type restClient struct {
baseURL *url.URL baseURL *url.URL
} }
// do performs an HTTP request with this client and returns the response // do performs an HTTP request with this client and returns the response.
func (c *restClient) do(method, uri string) (*http.Response, error) { func (c *restClient) do(method, uri string, body []byte) (*http.Response, error) {
rel, err := url.Parse(uri) rel, err := url.Parse(uri)
if err != nil { if err != nil {
return nil, err return nil, err
} }
url := c.baseURL.ResolveReference(rel) url := c.baseURL.ResolveReference(rel)
var r io.Reader
// Build the request if body != nil {
req, err := http.NewRequest(method, url.String(), nil) r = bytes.NewReader(body)
}
req, err := http.NewRequest(method, url.String(), r)
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Send the request
return c.client.Do(req) 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 { 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 { if err != nil {
return err return err
} }

View File

@@ -35,17 +35,29 @@ func (m *mockHTTPClient) Do(req *http.Request) (resp *http.Response, err error)
StatusCode: m.statusCode, StatusCode: m.statusCode,
Body: ioutil.NopCloser(bytes.NewBufferString(m.body)), Body: ioutil.NopCloser(bytes.NewBufferString(m.body)),
} }
return 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) { func TestDo(t *testing.T) {
var want, got string var want, got string
mth := &mockHTTPClient{} mth := &mockHTTPClient{}
c := &restClient{mth, baseURL} c := &restClient{mth, baseURL}
body := []byte("Test body")
_, err := c.do("POST", "/dopost") _, err := c.do("POST", "/dopost", body)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -61,6 +73,11 @@ func TestDo(t *testing.T) {
if got != want { if got != want {
t.Errorf("req.URL == %q, want %q", 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) { func TestDoJSON(t *testing.T) {

View File

@@ -13,6 +13,7 @@ type JSONMessageHeaderV1 struct {
Subject string `json:"subject"` Subject string `json:"subject"`
Date time.Time `json:"date"` Date time.Time `json:"date"`
Size int64 `json:"size"` Size int64 `json:"size"`
Seen bool `json:"seen"`
} }
// JSONMessageV1 contains the same data as the header plus a JSONMessageBody // JSONMessageV1 contains the same data as the header plus a JSONMessageBody
@@ -24,6 +25,7 @@ type JSONMessageV1 struct {
Subject string `json:"subject"` Subject string `json:"subject"`
Date time.Time `json:"date"` Date time.Time `json:"date"`
Size int64 `json:"size"` Size int64 `json:"size"`
Seen bool `json:"seen"`
Body *JSONMessageBodyV1 `json:"body"` Body *JSONMessageBodyV1 `json:"body"`
Header map[string][]string `json:"header"` Header map[string][]string `json:"header"`
Attachments []*JSONMessageAttachmentV1 `json:"attachments"` Attachments []*JSONMessageAttachmentV1 `json:"attachments"`

View File

@@ -12,6 +12,8 @@ func SetupRoutes(r *mux.Router) {
web.Handler(MailboxPurgeV1)).Name("MailboxPurgeV1").Methods("DELETE") web.Handler(MailboxPurgeV1)).Name("MailboxPurgeV1").Methods("DELETE")
r.Path("/api/v1/mailbox/{name}/{id}").Handler( r.Path("/api/v1/mailbox/{name}/{id}").Handler(
web.Handler(MailboxShowV1)).Name("MailboxShowV1").Methods("GET") 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( r.Path("/api/v1/mailbox/{name}/{id}").Handler(
web.Handler(MailboxDeleteV1)).Name("MailboxDeleteV1").Methods("DELETE") web.Handler(MailboxDeleteV1)).Name("MailboxDeleteV1").Methods("DELETE")
r.Path("/api/v1/mailbox/{name}/{id}/source").Handler( r.Path("/api/v1/mailbox/{name}/{id}/source").Handler(

View File

@@ -21,7 +21,17 @@ func testRestGet(url string) (*httptest.ResponseRecorder, error) {
if err != nil { if err != nil {
return nil, err 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() w := httptest.NewRecorder()
web.Router.ServeHTTP(w, req) web.Router.ServeHTTP(w, req)
return w, nil return w, nil
@@ -46,6 +56,22 @@ func setupWebServer(mm message.Manager) *bytes.Buffer {
return buf 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) { func decodedNumberEquals(t *testing.T, json interface{}, path string, want float64) {
t.Helper() t.Helper()
els := strings.Split(path, "/") els := strings.Split(path, "/")

View File

@@ -57,3 +57,17 @@ func (m *ManagerStub) GetMetadata(mailbox string) ([]*message.Metadata, error) {
func (m *ManagerStub) MailboxForAddress(address string) (string, error) { func (m *ManagerStub) MailboxForAddress(address string) (string, error) {
return policy.ParseMailboxName(address) 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
}