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

Merge branch 'feature/read-58' for #58

This commit is contained in:
James Hillyerd
2018-04-01 15:32:36 -07:00
23 changed files with 392 additions and 23 deletions

View File

@@ -16,10 +16,14 @@ This project adheres to [Semantic Versioning](http://semver.org/).
page. page.
- Debian `.deb` package generation to release process. - Debian `.deb` package generation to release process.
- RedHat `.rpm` 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 ### Changed
- Massive refactor of back-end code. Inbucket should now be both easier and - Massive refactor of back-end code. Inbucket should now be both easier and
more enjoyable to work on. 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` - Renamed `themes` directory to `ui` and eliminated the intermediate `bootstrap`
directory. directory.
- Docker build: - Docker build:

View File

@@ -8,7 +8,7 @@ export INBUCKET_WEB_TEMPLATECACHE="false"
export INBUCKET_WEB_COOKIEAUTHKEY="not-secret" export INBUCKET_WEB_COOKIEAUTHKEY="not-secret"
export INBUCKET_STORAGE_TYPE="file" export INBUCKET_STORAGE_TYPE="file"
export INBUCKET_STORAGE_PARAMS="path:/tmp/inbucket" export INBUCKET_STORAGE_PARAMS="path:/tmp/inbucket"
export INBUCKET_STORAGE_RETENTIONPERIOD="5m" export INBUCKET_STORAGE_RETENTIONPERIOD="15m"
if ! test -x ./inbucket; then if ! test -x ./inbucket; then
echo "$PWD/inbucket not found/executable!" >&2 echo "$PWD/inbucket not found/executable!" >&2

View File

@@ -12,6 +12,7 @@ import (
"github.com/jhillyerd/inbucket/pkg/policy" "github.com/jhillyerd/inbucket/pkg/policy"
"github.com/jhillyerd/inbucket/pkg/storage" "github.com/jhillyerd/inbucket/pkg/storage"
"github.com/jhillyerd/inbucket/pkg/stringutil" "github.com/jhillyerd/inbucket/pkg/stringutil"
"github.com/rs/zerolog/log"
) )
// Manager is the interface controllers use to interact with messages. // Manager is the interface controllers use to interact with messages.
@@ -25,6 +26,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 +126,13 @@ 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 {
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. // 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)
@@ -158,5 +167,6 @@ func makeMetadata(m storage.Message) *Metadata {
Date: m.Date(), Date: m.Date(),
Subject: m.Subject(), Subject: m.Subject(),
Size: m.Size(), Size: m.Size(),
Seen: m.Seen(),
} }
} }

View File

@@ -21,6 +21,7 @@ type Metadata struct {
Date time.Time Date time.Time
Subject string Subject string
Size int64 Size int64
Seen bool
} }
// Message holds both the metadata and content of a message. // 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) { func (d *Delivery) Source() (io.ReadCloser, error) {
return ioutil.NopCloser(d.Reader), nil return ioutil.NopCloser(d.Reader), nil
} }
// Seen getter.
func (d *Delivery) Seen() bool {
return d.Meta.Seen
}

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

@@ -21,6 +21,7 @@ type Message struct {
Fto []*mail.Address Fto []*mail.Address
Fsubject string Fsubject string
Fsize int64 Fsize int64
Fseen bool
} }
// newMessage creates a new FileMessage object and sets the Date and ID fields. // 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 return file, nil
} }
// Seen returns the seen flag value.
func (m *Message) Seen() bool {
return m.Fseen
}

View File

@@ -147,6 +147,32 @@ func (fs *Store) GetMessages(mailbox string) ([]storage.Message, error) {
return mb.getMessages() 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. // RemoveMessage deletes a message by ID from the specified mailbox.
func (fs *Store) RemoveMessage(mailbox, id string) error { func (fs *Store) RemoveMessage(mailbox, id string) error {
mb, err := fs.mbox(mailbox) mb, err := fs.mbox(mailbox)

View File

@@ -21,6 +21,7 @@ type Message struct {
date time.Time date time.Time
subject string subject string
source []byte source []byte
seen bool
el *list.Element // This message in Store.messages 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. // Size returns the message size in bytes.
func (m *Message) Size() int64 { return int64(len(m.source)) } func (m *Message) Size() int64 { return int64(len(m.source)) }
// Seen returns the message seen flag.
func (m *Message) Seen() bool { return m.seen }

View File

@@ -112,6 +112,17 @@ func (s *Store) GetMessages(mailbox string) (ms []storage.Message, err error) {
return ms, err 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. // PurgeMessages deletes the contents of a mailbox.
func (s *Store) PurgeMessages(mailbox string) error { func (s *Store) PurgeMessages(mailbox string) error {
var messages map[string]*Message var messages map[string]*Message

View File

@@ -28,6 +28,7 @@ type Store interface {
AddMessage(message Message) (id string, err error) AddMessage(message Message) (id string, err error)
GetMessage(mailbox, id string) (Message, error) GetMessage(mailbox, id string) (Message, error)
GetMessages(mailbox string) ([]Message, error) GetMessages(mailbox 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
VisitMailboxes(f func([]Message) (cont bool)) error VisitMailboxes(f func([]Message) (cont bool)) error
@@ -43,6 +44,7 @@ type Message interface {
Subject() string Subject() string
Source() (io.ReadCloser, error) Source() (io.ReadCloser, error)
Size() int64 Size() int64
Seen() bool
} }
// FromConfig creates an instance of the Store based on the provided configuration. // FromConfig creates an instance of the Store based on the provided configuration.

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
}

View File

@@ -28,6 +28,7 @@ func StoreSuite(t *testing.T, factory StoreFactory) {
{"content", testContent, config.Storage{}}, {"content", testContent, config.Storage{}},
{"delivery order", testDeliveryOrder, config.Storage{}}, {"delivery order", testDeliveryOrder, config.Storage{}},
{"size", testSize, config.Storage{}}, {"size", testSize, config.Storage{}},
{"seen", testSeen, config.Storage{}},
{"delete", testDelete, config.Storage{}}, {"delete", testDelete, config.Storage{}},
{"purge", testPurge, config.Storage{}}, {"purge", testPurge, config.Storage{}},
{"cap=10", testMsgCap, config.Storage{MailboxMsgCap: 10}}, {"cap=10", testMsgCap, config.Storage{MailboxMsgCap: 10}},
@@ -65,6 +66,7 @@ func testMetadata(t *testing.T, store storage.Store) {
To: to, To: to,
Date: date, Date: date,
Subject: subject, Subject: subject,
Seen: false,
}, },
Reader: strings.NewReader(content), Reader: strings.NewReader(content),
} }
@@ -107,6 +109,9 @@ func testMetadata(t *testing.T, store storage.Store) {
if sm.Size() != int64(len(content)) { if sm.Size() != int64(len(content)) {
t.Errorf("got size %v, want: %v", sm.Size(), 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. // 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. // testDelete creates and deletes some messages.
func testDelete(t *testing.T, store storage.Store) { func testDelete(t *testing.T, store storage.Store) {
mailbox := "fred" mailbox := "fred"

View File

@@ -29,6 +29,10 @@ body {
font-size: 18px; font-size: 18px;
} }
.message-list-entry .unseen {
font-weight: bold;
}
.message-list-scroll { .message-list-scroll {
overflow-y: auto; overflow-y: auto;
} }

View File

@@ -5,6 +5,7 @@ var messageListMargin = 275;
var clipboard = null; var clipboard = null;
var messageListScroll = false; var messageListScroll = false;
var messageListData = null; var messageListData = null;
var seenDelay = null;
// clearMessageSearch resets the message list search // clearMessageSearch resets the message list search
function clearMessageSearch() { function clearMessageSearch() {
@@ -55,8 +56,11 @@ function loadList() {
url: '/api/v1/mailbox/' + mailbox, url: '/api/v1/mailbox/' + mailbox,
success: function(data) { success: function(data) {
messageListData = data.reverse(); messageListData = data.reverse();
for (i=0; i<messageListData.length; i++) {
messageListData[i].seenClass = messageListData[i].seen ? '' : 'unseen'
}
// Render list // Render list
$('#message-list').loadTemplate($('#list-entry-template'), data); $('#message-list').loadTemplate($('#list-entry-template'), messageListData);
$('.message-list-entry').click(onMessageListClick); $('.message-list-entry').click(onMessageListClick);
// Reveal and select current message // Reveal and select current message
$("#message-list").slideDown(); $("#message-list").slideDown();
@@ -115,6 +119,7 @@ function onDocumentChange() {
// onDocumentReady is called by mailbox/index.html to initialize // onDocumentReady is called by mailbox/index.html to initialize
function onDocumentReady() { function onDocumentReady() {
seenDelay = makeDelay(1500);
// Prevent search and resize handlers being called too often // Prevent search and resize handlers being called too often
var searchDelay = makeDelay(200); var searchDelay = makeDelay(200);
var resizeDelay = makeDelay(100); var resizeDelay = makeDelay(100);
@@ -142,10 +147,12 @@ function onDocumentReady() {
// onMessageListClick is triggered by clicks on the message list // onMessageListClick is triggered by clicks on the message list
function onMessageListClick() { function onMessageListClick() {
var id = this.id;
$('.message-list-entry').removeClass("disabled"); $('.message-list-entry').removeClass("disabled");
$(this).addClass("disabled"); $(this).addClass("disabled");
$('#message-content').load('/mailbox/' + mailbox + '/' + this.id, onMessageLoaded); $('#message-content').load('/mailbox/' + mailbox + '/' + id, onMessageLoaded);
selected = this.id; selected = id;
seenDelay(function() { markSeen(id); });
} }
// onMessageLoaded is called each time a new message is shown // 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
});
}

View File

@@ -15,8 +15,9 @@ $(document).ready(function() {
<script type="text/html" id="list-entry-template"> <script type="text/html" id="list-entry-template">
<button data-id="id" type="button" class="message-list-entry list-group-item"> <button data-id="id" type="button" class="message-list-entry list-group-item">
<div class="row"> <div class="row">
<div class="col-sm-4 col-md-12 text-primary" data-content-text="subject" <div class="col-sm-4 col-md-12 text-primary">
data-format="subject"/> <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-text="from"/>
<div class="col-sm-4 col-md-12 small" data-content="date" data-format="date"/> <div class="col-sm-4 col-md-12 small" data-content="date" data-format="date"/>
</div> </div>