mirror of
https://github.com/jhillyerd/inbucket.git
synced 2025-12-17 09:37:02 +00:00
Merge branch 'feature/read-58' for #58
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -25,6 +26,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 +126,13 @@ 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 {
|
||||
log.Debug().Str("module", "manager").Str("mailbox", mailbox).Str("id", id).
|
||||
Msg("Marking as seen")
|
||||
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)
|
||||
@@ -158,5 +167,6 @@ func makeMetadata(m storage.Message) *Metadata {
|
||||
Date: m.Date(),
|
||||
Subject: m.Subject(),
|
||||
Size: m.Size(),
|
||||
Seen: m.Seen(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", "<from2@host>")
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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, "/")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -29,6 +29,10 @@ body {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.message-list-entry .unseen {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.message-list-scroll {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@@ -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<messageListData.length; i++) {
|
||||
messageListData[i].seenClass = messageListData[i].seen ? '' : 'unseen'
|
||||
}
|
||||
// Render list
|
||||
$('#message-list').loadTemplate($('#list-entry-template'), data);
|
||||
$('#message-list').loadTemplate($('#list-entry-template'), messageListData);
|
||||
$('.message-list-entry').click(onMessageListClick);
|
||||
// Reveal and select current message
|
||||
$("#message-list").slideDown();
|
||||
@@ -115,6 +119,7 @@ function onDocumentChange() {
|
||||
|
||||
// onDocumentReady is called by mailbox/index.html to initialize
|
||||
function onDocumentReady() {
|
||||
seenDelay = makeDelay(1500);
|
||||
// Prevent search and resize handlers being called too often
|
||||
var searchDelay = makeDelay(200);
|
||||
var resizeDelay = makeDelay(100);
|
||||
@@ -142,10 +147,12 @@ function onDocumentReady() {
|
||||
|
||||
// onMessageListClick is triggered by clicks on the message list
|
||||
function onMessageListClick() {
|
||||
var id = this.id;
|
||||
$('.message-list-entry').removeClass("disabled");
|
||||
$(this).addClass("disabled");
|
||||
$('#message-content').load('/mailbox/' + mailbox + '/' + this.id, onMessageLoaded);
|
||||
selected = this.id;
|
||||
$('#message-content').load('/mailbox/' + mailbox + '/' + id, onMessageLoaded);
|
||||
selected = id;
|
||||
seenDelay(function() { markSeen(id); });
|
||||
}
|
||||
|
||||
// onMessageLoaded is called each time a new message is shown
|
||||
@@ -199,3 +206,26 @@ function updateMessageSearch() {
|
||||
}
|
||||
}
|
||||
|
||||
// markSeen notifies the server that this message has been seen by the user.
|
||||
function markSeen(id) {
|
||||
for (i=0; i<messageListData.length; i++) {
|
||||
if (messageListData[i].id == id) {
|
||||
if (messageListData[i].seen) {
|
||||
return;
|
||||
} else {
|
||||
messageListData[i].seen = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
var updateClass = function() {
|
||||
$('#' + id).find('.unseen').removeClass('unseen');
|
||||
}
|
||||
$.ajax({
|
||||
type: 'PATCH',
|
||||
url: '/api/v1/mailbox/' + mailbox + '/' + id,
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify({'seen': true}),
|
||||
processData: false,
|
||||
success: updateClass
|
||||
});
|
||||
}
|
||||
|
||||
@@ -15,8 +15,9 @@ $(document).ready(function() {
|
||||
<script type="text/html" id="list-entry-template">
|
||||
<button data-id="id" type="button" class="message-list-entry list-group-item">
|
||||
<div class="row">
|
||||
<div class="col-sm-4 col-md-12 text-primary" data-content-text="subject"
|
||||
data-format="subject"/>
|
||||
<div class="col-sm-4 col-md-12 text-primary">
|
||||
<span data-class="seenClass" data-content-text="subject" data-format="subject"/>
|
||||
</div>
|
||||
<div class="col-sm-4 col-md-12 small" data-content-text="from"/>
|
||||
<div class="col-sm-4 col-md-12 small" data-content="date" data-format="date"/>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user