diff --git a/smtpd/datastore.go b/datastore/datastore.go similarity index 92% rename from smtpd/datastore.go rename to datastore/datastore.go index 180a3ec..67aebf6 100644 --- a/smtpd/datastore.go +++ b/datastore/datastore.go @@ -1,4 +1,5 @@ -package smtpd +// Package datastore contains implementation independent datastore logic +package datastore import ( "errors" diff --git a/smtpd/retention.go b/datastore/retention.go similarity index 93% rename from smtpd/retention.go rename to datastore/retention.go index 79c28b5..7d52c27 100644 --- a/smtpd/retention.go +++ b/datastore/retention.go @@ -1,4 +1,4 @@ -package smtpd +package datastore import ( "container/list" @@ -36,6 +36,11 @@ func init() { rm.Set("Period", expRetentionPeriod) rm.Set("RetainedHist", expRetainedHist) rm.Set("RetainedCurrent", expRetainedCurrent) + + log.AddTickerFunc(func() { + expRetentionDeletesHist.Set(log.PushMetric(retentionDeletesHist, expRetentionDeletesTotal)) + expRetainedHist.Set(log.PushMetric(retainedHist, expRetainedCurrent)) + }) } // RetentionScanner looks for messages older than the configured retention period and deletes them. @@ -85,9 +90,9 @@ retentionLoop: dur := time.Minute - since log.Tracef("Retention scanner sleeping for %v", dur) select { - case _ = <-rs.globalShutdown: + case <-rs.globalShutdown: break retentionLoop - case _ = <-time.After(dur): + case <-time.After(dur): } } // Kickoff scan @@ -97,7 +102,7 @@ retentionLoop: } // Check for global shutdown select { - case _ = <-rs.globalShutdown: + case <-rs.globalShutdown: break retentionLoop default: } @@ -154,9 +159,7 @@ func (rs *RetentionScanner) doScan() error { // Join does not retun until the retention scanner has shut down func (rs *RetentionScanner) Join() { if rs.retentionShutdown != nil { - select { - case <-rs.retentionShutdown: - } + <-rs.retentionShutdown } } diff --git a/datastore/retention_test.go b/datastore/retention_test.go new file mode 100644 index 0000000..c357f7e --- /dev/null +++ b/datastore/retention_test.go @@ -0,0 +1,67 @@ +package datastore + +import ( + "fmt" + "testing" + "time" +) + +func TestDoRetentionScan(t *testing.T) { + // Create mock objects + mds := &MockDataStore{} + + mb1 := &MockMailbox{} + mb2 := &MockMailbox{} + mb3 := &MockMailbox{} + + // Mockup some different aged messages (num is in hours) + new1 := mockMessage(0) + new2 := mockMessage(1) + new3 := mockMessage(2) + old1 := mockMessage(4) + old2 := mockMessage(12) + old3 := mockMessage(24) + + // First it should ask for all mailboxes + mds.On("AllMailboxes").Return([]Mailbox{mb1, mb2, mb3}, nil) + + // Then for all messages on each box + mb1.On("GetMessages").Return([]Message{new1, old1, old2}, nil) + mb2.On("GetMessages").Return([]Message{old3, new2}, nil) + mb3.On("GetMessages").Return([]Message{new3}, nil) + + // Test 4 hour retention + rs := &RetentionScanner{ + ds: mds, + retentionPeriod: 4*time.Hour - time.Minute, + retentionSleep: 0, + } + if err := rs.doScan(); err != nil { + t.Error(err) + } + + // Check our assertions + mds.AssertExpectations(t) + mb1.AssertExpectations(t) + mb2.AssertExpectations(t) + mb3.AssertExpectations(t) + + // Delete should not have been called on new messages + new1.AssertNotCalled(t, "Delete") + new2.AssertNotCalled(t, "Delete") + new3.AssertNotCalled(t, "Delete") + + // Delete should have been called once on old messages + old1.AssertNumberOfCalls(t, "Delete", 1) + old2.AssertNumberOfCalls(t, "Delete", 1) + old3.AssertNumberOfCalls(t, "Delete", 1) +} + +// Make a MockMessage of a specific age +func mockMessage(ageHours int) *MockMessage { + msg := &MockMessage{} + msg.On("ID").Return(fmt.Sprintf("MSG[age=%vh]", ageHours)) + msg.On("Date").Return(time.Now().Add(time.Duration(ageHours*-1) * time.Hour)) + msg.On("Delete").Return(nil) + return msg +} diff --git a/rest/testmocks_test.go b/datastore/testing.go similarity index 62% rename from rest/testmocks_test.go rename to datastore/testing.go index bc720fc..23474f6 100644 --- a/rest/testmocks_test.go +++ b/datastore/testing.go @@ -1,4 +1,4 @@ -package rest +package datastore import ( "io" @@ -6,130 +6,151 @@ import ( "time" "github.com/jhillyerd/enmime" - "github.com/jhillyerd/inbucket/smtpd" "github.com/stretchr/testify/mock" ) -// Mock DataStore object +// MockDataStore is a shared mock for unit testing type MockDataStore struct { mock.Mock } -func (m *MockDataStore) MailboxFor(name string) (smtpd.Mailbox, error) { +// MailboxFor mock function +func (m *MockDataStore) MailboxFor(name string) (Mailbox, error) { args := m.Called(name) - return args.Get(0).(smtpd.Mailbox), args.Error(1) + return args.Get(0).(Mailbox), args.Error(1) } -func (m *MockDataStore) AllMailboxes() ([]smtpd.Mailbox, error) { +// AllMailboxes mock function +func (m *MockDataStore) AllMailboxes() ([]Mailbox, error) { args := m.Called() - return args.Get(0).([]smtpd.Mailbox), args.Error(1) + return args.Get(0).([]Mailbox), args.Error(1) } -// Mock Mailbox object +// MockMailbox is a shared mock for unit testing type MockMailbox struct { mock.Mock } -func (m *MockMailbox) GetMessages() ([]smtpd.Message, error) { +// GetMessages mock function +func (m *MockMailbox) GetMessages() ([]Message, error) { args := m.Called() - return args.Get(0).([]smtpd.Message), args.Error(1) + return args.Get(0).([]Message), args.Error(1) } -func (m *MockMailbox) GetMessage(id string) (smtpd.Message, error) { +// GetMessage mock function +func (m *MockMailbox) GetMessage(id string) (Message, error) { args := m.Called(id) - return args.Get(0).(smtpd.Message), args.Error(1) + return args.Get(0).(Message), args.Error(1) } +// Purge mock function func (m *MockMailbox) Purge() error { args := m.Called() return args.Error(0) } -func (m *MockMailbox) NewMessage() (smtpd.Message, error) { +// NewMessage mock function +func (m *MockMailbox) NewMessage() (Message, error) { args := m.Called() - return args.Get(0).(smtpd.Message), args.Error(1) + return args.Get(0).(Message), args.Error(1) } +// Name mock function func (m *MockMailbox) Name() string { args := m.Called() return args.String(0) } +// String mock function func (m *MockMailbox) String() string { args := m.Called() return args.String(0) } -// Mock Message object +// MockMessage is a shared mock for unit testing type MockMessage struct { mock.Mock } +// ID mock function func (m *MockMessage) ID() string { args := m.Called() return args.String(0) } +// From mock function func (m *MockMessage) From() string { args := m.Called() return args.String(0) } +// To mock function func (m *MockMessage) To() []string { args := m.Called() return args.Get(0).([]string) } +// Date mock function func (m *MockMessage) Date() time.Time { args := m.Called() return args.Get(0).(time.Time) } +// Subject mock function func (m *MockMessage) Subject() string { args := m.Called() return args.String(0) } +// ReadHeader mock function func (m *MockMessage) ReadHeader() (msg *mail.Message, err error) { args := m.Called() return args.Get(0).(*mail.Message), args.Error(1) } +// ReadBody mock function func (m *MockMessage) ReadBody() (body *enmime.Envelope, err error) { args := m.Called() return args.Get(0).(*enmime.Envelope), args.Error(1) } +// ReadRaw mock function func (m *MockMessage) ReadRaw() (raw *string, err error) { args := m.Called() return args.Get(0).(*string), args.Error(1) } +// RawReader mock function func (m *MockMessage) RawReader() (reader io.ReadCloser, err error) { args := m.Called() return args.Get(0).(io.ReadCloser), args.Error(1) } +// Size mock function func (m *MockMessage) Size() int64 { args := m.Called() return int64(args.Int(0)) } +// Append mock function func (m *MockMessage) Append(data []byte) error { // []byte arg seems to mess up testify/mock return nil } +// Close mock function func (m *MockMessage) Close() error { args := m.Called() return args.Error(0) } +// Delete mock function func (m *MockMessage) Delete() error { args := m.Called() return args.Error(0) } +// String mock function func (m *MockMessage) String() string { args := m.Called() return args.String(0) diff --git a/smtpd/filemsg.go b/filestore/fmessage.go similarity index 96% rename from smtpd/filemsg.go rename to filestore/fmessage.go index edf3ec9..54da127 100644 --- a/smtpd/filemsg.go +++ b/filestore/fmessage.go @@ -1,4 +1,4 @@ -package smtpd +package filestore import ( "bufio" @@ -11,6 +11,7 @@ import ( "time" "github.com/jhillyerd/enmime" + "github.com/jhillyerd/inbucket/datastore" "github.com/jhillyerd/inbucket/log" ) @@ -33,7 +34,7 @@ type FileMessage struct { // NewMessage creates a new FileMessage object and sets the Date and Id fields. // It will also delete messages over messageCap if configured. -func (mb *FileMailbox) NewMessage() (Message, error) { +func (mb *FileMailbox) NewMessage() (datastore.Message, error) { // Load index if !mb.indexLoaded { if err := mb.readIndex(); err != nil { @@ -71,7 +72,7 @@ func (m *FileMessage) From() string { return m.Ffrom } -// From returns the value of the Message To header +// To returns the value of the Message To header func (m *FileMessage) To() []string { return m.Fto } @@ -165,7 +166,7 @@ func (m *FileMessage) ReadRaw() (raw *string, err error) { func (m *FileMessage) Append(data []byte) error { // Prevent Appending to a pre-existing Message if !m.writable { - return ErrNotWritable + return datastore.ErrNotWritable } // Open file for writing if we haven't yet if m.writer == nil { diff --git a/smtpd/filestore.go b/filestore/fstore.go similarity index 89% rename from smtpd/filestore.go rename to filestore/fstore.go index 9f8f615..4ac7bc1 100644 --- a/smtpd/filestore.go +++ b/filestore/fstore.go @@ -1,4 +1,4 @@ -package smtpd +package filestore import ( "bufio" @@ -12,7 +12,9 @@ import ( "time" "github.com/jhillyerd/inbucket/config" + "github.com/jhillyerd/inbucket/datastore" "github.com/jhillyerd/inbucket/log" + "github.com/jhillyerd/inbucket/stringutil" ) // Name of index file in each mailbox @@ -55,7 +57,7 @@ type FileDataStore struct { } // NewFileDataStore creates a new DataStore object using the specified path -func NewFileDataStore(cfg config.DataStoreConfig) DataStore { +func NewFileDataStore(cfg config.DataStoreConfig) datastore.DataStore { path := cfg.Path if path == "" { log.Errorf("No value configured for datastore path") @@ -73,19 +75,19 @@ func NewFileDataStore(cfg config.DataStoreConfig) DataStore { // DefaultFileDataStore creates a new DataStore object. It uses the inbucket.Config object to // construct it's path. -func DefaultFileDataStore() DataStore { +func DefaultFileDataStore() datastore.DataStore { cfg := config.GetDataStoreConfig() return NewFileDataStore(cfg) } // MailboxFor retrieves the Mailbox object for a specified email address, if the mailbox // does not exist, it will attempt to create it. -func (ds *FileDataStore) MailboxFor(emailAddress string) (Mailbox, error) { - name, err := ParseMailboxName(emailAddress) +func (ds *FileDataStore) MailboxFor(emailAddress string) (datastore.Mailbox, error) { + name, err := stringutil.ParseMailboxName(emailAddress) if err != nil { return nil, err } - dir := HashMailboxName(name) + dir := stringutil.HashMailboxName(name) s1 := dir[0:3] s2 := dir[0:6] path := filepath.Join(ds.mailPath, s1, s2, dir) @@ -96,8 +98,8 @@ func (ds *FileDataStore) MailboxFor(emailAddress string) (Mailbox, error) { } // AllMailboxes returns a slice with all Mailboxes -func (ds *FileDataStore) AllMailboxes() ([]Mailbox, error) { - mailboxes := make([]Mailbox, 0, 100) +func (ds *FileDataStore) AllMailboxes() ([]datastore.Mailbox, error) { + mailboxes := make([]datastore.Mailbox, 0, 100) infos1, err := ioutil.ReadDir(ds.mailPath) if err != nil { return nil, err @@ -149,24 +151,26 @@ type FileMailbox struct { messages []*FileMessage } +// Name of the mailbox func (mb *FileMailbox) Name() string { return mb.name } +// String renders the name and directory path of the mailbox func (mb *FileMailbox) String() string { return mb.name + "[" + mb.dirName + "]" } // GetMessages scans the mailbox directory for .gob files and decodes them into // a slice of Message objects. -func (mb *FileMailbox) GetMessages() ([]Message, error) { +func (mb *FileMailbox) GetMessages() ([]datastore.Message, error) { if !mb.indexLoaded { if err := mb.readIndex(); err != nil { return nil, err } } - messages := make([]Message, len(mb.messages)) + messages := make([]datastore.Message, len(mb.messages)) for i, m := range mb.messages { messages[i] = m } @@ -174,7 +178,7 @@ func (mb *FileMailbox) GetMessages() ([]Message, error) { } // GetMessage decodes a single message by Id and returns a Message object -func (mb *FileMailbox) GetMessage(id string) (Message, error) { +func (mb *FileMailbox) GetMessage(id string) (datastore.Message, error) { if !mb.indexLoaded { if err := mb.readIndex(); err != nil { return nil, err @@ -183,15 +187,15 @@ func (mb *FileMailbox) GetMessage(id string) (Message, error) { if id == "latest" && len(mb.messages) != 0 { return mb.messages[len(mb.messages)-1], nil - } else { - for _, m := range mb.messages { - if m.Fid == id { - return m, nil - } + } + + for _, m := range mb.messages { + if m.Fid == id { + return m, nil } } - return nil, ErrNotExist + return nil, datastore.ErrNotExist } // Purge deletes all messages in this mailbox diff --git a/smtpd/filestore_test.go b/filestore/fstore_test.go similarity index 99% rename from smtpd/filestore_test.go rename to filestore/fstore_test.go index 2ca3104..9cac985 100644 --- a/smtpd/filestore_test.go +++ b/filestore/fstore_test.go @@ -1,4 +1,4 @@ -package smtpd +package filestore import ( "bytes" @@ -470,8 +470,8 @@ func TestGetLatestMessage(t *testing.T) { mb, err := ds.MailboxFor(mbName) assert.Nil(t, err) msg, err := mb.GetMessage("latest") + assert.Nil(t, msg) assert.Error(t, err) - fmt.Println(msg) // Deliver test message deliverMessage(ds, mbName, "test", time.Now()) @@ -496,7 +496,7 @@ func TestGetLatestMessage(t *testing.T) { assert.True(t, msg.ID() == id3, "Expected %q to be equal to %q", msg.ID(), id3) // Test wrong id - msg, err = mb.GetMessage("wrongid") + _, err = mb.GetMessage("wrongid") assert.Error(t, err) if t.Failed() { diff --git a/httpd/context.go b/httpd/context.go index 52abfc6..6a54541 100644 --- a/httpd/context.go +++ b/httpd/context.go @@ -7,15 +7,15 @@ import ( "github.com/gorilla/mux" "github.com/gorilla/sessions" "github.com/jhillyerd/inbucket/config" + "github.com/jhillyerd/inbucket/datastore" "github.com/jhillyerd/inbucket/msghub" - "github.com/jhillyerd/inbucket/smtpd" ) // Context is passed into every request handler function type Context struct { Vars map[string]string Session *sessions.Session - DataStore smtpd.DataStore + DataStore datastore.DataStore MsgHub *msghub.Hub WebConfig config.WebConfig IsJSON bool diff --git a/httpd/server.go b/httpd/server.go index 103e0f1..f89b9ff 100644 --- a/httpd/server.go +++ b/httpd/server.go @@ -13,9 +13,9 @@ import ( "github.com/gorilla/securecookie" "github.com/gorilla/sessions" "github.com/jhillyerd/inbucket/config" + "github.com/jhillyerd/inbucket/datastore" "github.com/jhillyerd/inbucket/log" "github.com/jhillyerd/inbucket/msghub" - "github.com/jhillyerd/inbucket/smtpd" ) // Handler is a function type that handles an HTTP request in Inbucket @@ -23,7 +23,7 @@ type Handler func(http.ResponseWriter, *http.Request, *Context) error var ( // DataStore is where all the mailboxes and messages live - DataStore smtpd.DataStore + DataStore datastore.DataStore // msgHub holds a reference to the message pub/sub system msgHub *msghub.Hub @@ -51,7 +51,7 @@ func init() { func Initialize( cfg config.WebConfig, shutdownChan chan bool, - ds smtpd.DataStore, + ds datastore.DataStore, mh *msghub.Hub) { webConfig = cfg diff --git a/inbucket.go b/inbucket.go index 7593000..83c39f4 100644 --- a/inbucket.go +++ b/inbucket.go @@ -13,6 +13,7 @@ import ( "time" "github.com/jhillyerd/inbucket/config" + "github.com/jhillyerd/inbucket/filestore" "github.com/jhillyerd/inbucket/httpd" "github.com/jhillyerd/inbucket/log" "github.com/jhillyerd/inbucket/msghub" @@ -115,7 +116,7 @@ func main() { msgHub := msghub.New(rootCtx, config.GetWebConfig().MonitorHistory) // Grab our datastore - ds := smtpd.DefaultFileDataStore() + ds := filestore.DefaultFileDataStore() // Start HTTP server httpd.Initialize(config.GetWebConfig(), shutdownChan, ds, msgHub) diff --git a/log/metrics.go b/log/metrics.go new file mode 100644 index 0000000..c16f1e8 --- /dev/null +++ b/log/metrics.go @@ -0,0 +1,62 @@ +package log + +import ( + "container/list" + "expvar" + "strings" + "time" +) + +// TickerFunc is the type of metrics function accepted by AddTickerFunc +type TickerFunc func() + +var tickerFuncChan = make(chan TickerFunc) + +func init() { + go metricsTicker() +} + +// AddTickerFunc adds a new function callback to the list of metrics TickerFuncs that get +// called each minute. +func AddTickerFunc(f TickerFunc) { + tickerFuncChan <- f +} + +// PushMetric adds the metric to the end of the list and returns a comma separated string of the +// previous 61 entries. We return 61 instead of 60 (an hour) because the chart on the client +// tracks deltas between these values - there is nothing to compare the first value against. +func PushMetric(history *list.List, ev expvar.Var) string { + history.PushBack(ev.String()) + if history.Len() > 61 { + history.Remove(history.Front()) + } + return joinStringList(history) +} + +// joinStringList joins a List containing strings by commas +func joinStringList(listOfStrings *list.List) string { + if listOfStrings.Len() == 0 { + return "" + } + s := make([]string, 0, listOfStrings.Len()) + for e := listOfStrings.Front(); e != nil; e = e.Next() { + s = append(s, e.Value.(string)) + } + return strings.Join(s, ",") +} + +func metricsTicker() { + funcs := make([]TickerFunc, 0) + ticker := time.NewTicker(time.Minute) + + for { + select { + case <-ticker.C: + for _, f := range funcs { + f() + } + case f := <-tickerFuncChan: + funcs = append(funcs, f) + } + } +} diff --git a/pop3d/handler.go b/pop3d/handler.go index 782cf8e..7f5967e 100644 --- a/pop3d/handler.go +++ b/pop3d/handler.go @@ -11,8 +11,8 @@ import ( "strings" "time" + "github.com/jhillyerd/inbucket/datastore" "github.com/jhillyerd/inbucket/log" - "github.com/jhillyerd/inbucket/smtpd" ) // State tracks the current mode of our POP3 state machine @@ -57,18 +57,18 @@ var commands = map[string]bool{ // Session defines an active POP3 session type Session struct { - server *Server // Reference to the server we belong to - id int // Session ID number - conn net.Conn // Our network connection - remoteHost string // IP address of client - sendError error // Used to bail out of read loop on send error - state State // Current session state - reader *bufio.Reader // Buffered reader for our net conn - user string // Mailbox name - mailbox smtpd.Mailbox // Mailbox instance - messages []smtpd.Message // Slice of messages in mailbox - retain []bool // Messages to retain upon UPDATE (true=retain) - msgCount int // Number of undeleted messages + server *Server // Reference to the server we belong to + id int // Session ID number + conn net.Conn // Our network connection + remoteHost string // IP address of client + sendError error // Used to bail out of read loop on send error + state State // Current session state + reader *bufio.Reader // Buffered reader for our net conn + user string // Mailbox name + mailbox datastore.Mailbox // Mailbox instance + messages []datastore.Message // Slice of messages in mailbox + retain []bool // Messages to retain upon UPDATE (true=retain) + msgCount int // Number of undeleted messages } // NewSession creates a new POP3 session @@ -432,7 +432,7 @@ func (ses *Session) transactionHandler(cmd string, args []string) { } // Send the contents of the message to the client -func (ses *Session) sendMessage(msg smtpd.Message) { +func (ses *Session) sendMessage(msg datastore.Message) { reader, err := msg.RawReader() if err != nil { ses.logError("Failed to read message for RETR command") @@ -465,7 +465,7 @@ func (ses *Session) sendMessage(msg smtpd.Message) { } // Send the headers plus the top N lines to the client -func (ses *Session) sendMessageTop(msg smtpd.Message, lineCount int) { +func (ses *Session) sendMessageTop(msg datastore.Message, lineCount int) { reader, err := msg.RawReader() if err != nil { ses.logError("Failed to read message for RETR command") diff --git a/pop3d/listener.go b/pop3d/listener.go index d1b11d5..58b0d82 100644 --- a/pop3d/listener.go +++ b/pop3d/listener.go @@ -8,8 +8,8 @@ import ( "time" "github.com/jhillyerd/inbucket/config" + "github.com/jhillyerd/inbucket/datastore" "github.com/jhillyerd/inbucket/log" - "github.com/jhillyerd/inbucket/smtpd" ) // Server defines an instance of our POP3 server @@ -17,14 +17,14 @@ type Server struct { host string domain string maxIdleSeconds int - dataStore smtpd.DataStore + dataStore datastore.DataStore listener net.Listener globalShutdown chan bool waitgroup *sync.WaitGroup } // New creates a new Server struct -func New(cfg config.POP3Config, shutdownChan chan bool, ds smtpd.DataStore) *Server { +func New(cfg config.POP3Config, shutdownChan chan bool, ds datastore.DataStore) *Server { return &Server{ host: fmt.Sprintf("%v:%v", cfg.IP4address, cfg.IP4port), domain: cfg.Domain, diff --git a/rest/apiv1_controller.go b/rest/apiv1_controller.go index 67a3528..9037eb1 100644 --- a/rest/apiv1_controller.go +++ b/rest/apiv1_controller.go @@ -10,16 +10,17 @@ import ( "io/ioutil" "strconv" + "github.com/jhillyerd/inbucket/datastore" "github.com/jhillyerd/inbucket/httpd" "github.com/jhillyerd/inbucket/log" "github.com/jhillyerd/inbucket/rest/model" - "github.com/jhillyerd/inbucket/smtpd" + "github.com/jhillyerd/inbucket/stringutil" ) // MailboxListV1 renders a list of messages in a mailbox func MailboxListV1(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) (err error) { // Don't have to validate these aren't empty, Gorilla returns 404 - name, err := smtpd.ParseMailboxName(ctx.Vars["name"]) + name, err := stringutil.ParseMailboxName(ctx.Vars["name"]) if err != nil { return err } @@ -54,7 +55,7 @@ func MailboxListV1(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) func MailboxShowV1(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) (err error) { // Don't have to validate these aren't empty, Gorilla returns 404 id := ctx.Vars["id"] - name, err := smtpd.ParseMailboxName(ctx.Vars["name"]) + name, err := stringutil.ParseMailboxName(ctx.Vars["name"]) if err != nil { return err } @@ -64,7 +65,7 @@ func MailboxShowV1(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) return fmt.Errorf("Failed to get mailbox for %q: %v", name, err) } msg, err := mb.GetMessage(id) - if err == smtpd.ErrNotExist { + if err == datastore.ErrNotExist { http.NotFound(w, req) return nil } @@ -116,7 +117,7 @@ func MailboxShowV1(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) // MailboxPurgeV1 deletes all messages from a mailbox func MailboxPurgeV1(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) (err error) { // Don't have to validate these aren't empty, Gorilla returns 404 - name, err := smtpd.ParseMailboxName(ctx.Vars["name"]) + name, err := stringutil.ParseMailboxName(ctx.Vars["name"]) if err != nil { return err } @@ -139,7 +140,7 @@ func MailboxPurgeV1(w http.ResponseWriter, req *http.Request, ctx *httpd.Context func MailboxSourceV1(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) (err error) { // Don't have to validate these aren't empty, Gorilla returns 404 id := ctx.Vars["id"] - name, err := smtpd.ParseMailboxName(ctx.Vars["name"]) + name, err := stringutil.ParseMailboxName(ctx.Vars["name"]) if err != nil { return err } @@ -149,7 +150,7 @@ func MailboxSourceV1(w http.ResponseWriter, req *http.Request, ctx *httpd.Contex return fmt.Errorf("Failed to get mailbox for %q: %v", name, err) } message, err := mb.GetMessage(id) - if err == smtpd.ErrNotExist { + if err == datastore.ErrNotExist { http.NotFound(w, req) return nil } @@ -173,7 +174,7 @@ func MailboxSourceV1(w http.ResponseWriter, req *http.Request, ctx *httpd.Contex func MailboxDeleteV1(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) (err error) { // Don't have to validate these aren't empty, Gorilla returns 404 id := ctx.Vars["id"] - name, err := smtpd.ParseMailboxName(ctx.Vars["name"]) + name, err := stringutil.ParseMailboxName(ctx.Vars["name"]) if err != nil { return err } @@ -183,7 +184,7 @@ func MailboxDeleteV1(w http.ResponseWriter, req *http.Request, ctx *httpd.Contex return fmt.Errorf("Failed to get mailbox for %q: %v", name, err) } message, err := mb.GetMessage(id) - if err == smtpd.ErrNotExist { + if err == datastore.ErrNotExist { http.NotFound(w, req) return nil } diff --git a/rest/apiv1_controller_test.go b/rest/apiv1_controller_test.go index 9297f88..b1c9a00 100644 --- a/rest/apiv1_controller_test.go +++ b/rest/apiv1_controller_test.go @@ -9,7 +9,7 @@ import ( "testing" "time" - "github.com/jhillyerd/inbucket/smtpd" + "github.com/jhillyerd/inbucket/datastore" ) const ( @@ -31,7 +31,7 @@ const ( func TestRestMailboxList(t *testing.T) { // Setup - ds := &MockDataStore{} + ds := &datastore.MockDataStore{} logbuf := setupWebServer(ds) // Test invalid mailbox name @@ -45,9 +45,9 @@ func TestRestMailboxList(t *testing.T) { } // Test empty mailbox - emptybox := &MockMailbox{} + emptybox := &datastore.MockMailbox{} ds.On("MailboxFor", "empty").Return(emptybox, nil) - emptybox.On("GetMessages").Return([]smtpd.Message{}, nil) + emptybox.On("GetMessages").Return([]datastore.Message{}, nil) w, err = testRestGet(baseURL + "/mailbox/empty") expectCode = 200 @@ -59,7 +59,7 @@ func TestRestMailboxList(t *testing.T) { } // Test MailboxFor error - ds.On("MailboxFor", "error").Return(&MockMailbox{}, fmt.Errorf("Internal error")) + ds.On("MailboxFor", "error").Return(&datastore.MockMailbox{}, fmt.Errorf("Internal error")) w, err = testRestGet(baseURL + "/mailbox/error") expectCode = 500 if err != nil { @@ -77,9 +77,9 @@ func TestRestMailboxList(t *testing.T) { } // Test MailboxFor error - error2box := &MockMailbox{} + error2box := &datastore.MockMailbox{} ds.On("MailboxFor", "error2").Return(error2box, nil) - error2box.On("GetMessages").Return([]smtpd.Message{}, fmt.Errorf("Internal error 2")) + error2box.On("GetMessages").Return([]datastore.Message{}, fmt.Errorf("Internal error 2")) w, err = testRestGet(baseURL + "/mailbox/error2") expectCode = 500 @@ -107,11 +107,11 @@ func TestRestMailboxList(t *testing.T) { Subject: "subject 2", Date: time.Date(2012, 7, 1, 10, 11, 12, 253, time.FixedZone("PDT", -700)), } - goodbox := &MockMailbox{} + goodbox := &datastore.MockMailbox{} ds.On("MailboxFor", "good").Return(goodbox, nil) msg1 := data1.MockMessage() msg2 := data2.MockMessage() - goodbox.On("GetMessages").Return([]smtpd.Message{msg1, msg2}, nil) + goodbox.On("GetMessages").Return([]datastore.Message{msg1, msg2}, nil) // Check return code w, err = testRestGet(baseURL + "/mailbox/good") @@ -155,7 +155,7 @@ func TestRestMailboxList(t *testing.T) { func TestRestMessage(t *testing.T) { // Setup - ds := &MockDataStore{} + ds := &datastore.MockDataStore{} logbuf := setupWebServer(ds) // Test invalid mailbox name @@ -169,9 +169,9 @@ func TestRestMessage(t *testing.T) { } // Test requesting a message that does not exist - emptybox := &MockMailbox{} + emptybox := &datastore.MockMailbox{} ds.On("MailboxFor", "empty").Return(emptybox, nil) - emptybox.On("GetMessage", "0001").Return(&MockMessage{}, smtpd.ErrNotExist) + emptybox.On("GetMessage", "0001").Return(&datastore.MockMessage{}, datastore.ErrNotExist) w, err = testRestGet(baseURL + "/mailbox/empty/0001") expectCode = 404 @@ -183,7 +183,7 @@ func TestRestMessage(t *testing.T) { } // Test MailboxFor error - ds.On("MailboxFor", "error").Return(&MockMailbox{}, fmt.Errorf("Internal error")) + ds.On("MailboxFor", "error").Return(&datastore.MockMailbox{}, fmt.Errorf("Internal error")) w, err = testRestGet(baseURL + "/mailbox/error/0001") expectCode = 500 if err != nil { @@ -201,9 +201,9 @@ func TestRestMessage(t *testing.T) { } // Test GetMessage error - error2box := &MockMailbox{} + error2box := &datastore.MockMailbox{} ds.On("MailboxFor", "error2").Return(error2box, nil) - error2box.On("GetMessage", "0001").Return(&MockMessage{}, fmt.Errorf("Internal error 2")) + error2box.On("GetMessage", "0001").Return(&datastore.MockMessage{}, fmt.Errorf("Internal error 2")) w, err = testRestGet(baseURL + "/mailbox/error2/0001") expectCode = 500 @@ -228,7 +228,7 @@ func TestRestMessage(t *testing.T) { Text: "This is some text", HTML: "This is some HTML", } - goodbox := &MockMailbox{} + goodbox := &datastore.MockMailbox{} ds.On("MailboxFor", "good").Return(goodbox, nil) msg1 := data1.MockMessage() goodbox.On("GetMessage", "0001").Return(msg1, nil) diff --git a/rest/socketv1_controller.go b/rest/socketv1_controller.go index 764519a..78bec2d 100644 --- a/rest/socketv1_controller.go +++ b/rest/socketv1_controller.go @@ -9,7 +9,7 @@ import ( "github.com/jhillyerd/inbucket/log" "github.com/jhillyerd/inbucket/msghub" "github.com/jhillyerd/inbucket/rest/model" - "github.com/jhillyerd/inbucket/smtpd" + "github.com/jhillyerd/inbucket/stringutil" ) const ( @@ -169,7 +169,7 @@ func MonitorAllMessagesV1( func MonitorMailboxMessagesV1( w http.ResponseWriter, req *http.Request, ctx *httpd.Context) (err error) { - name, err := smtpd.ParseMailboxName(ctx.Vars["name"]) + name, err := stringutil.ParseMailboxName(ctx.Vars["name"]) if err != nil { return err } diff --git a/rest/testutils_test.go b/rest/testutils_test.go index 7cfd223..6ef4182 100644 --- a/rest/testutils_test.go +++ b/rest/testutils_test.go @@ -11,9 +11,9 @@ import ( "github.com/jhillyerd/enmime" "github.com/jhillyerd/inbucket/config" + "github.com/jhillyerd/inbucket/datastore" "github.com/jhillyerd/inbucket/httpd" "github.com/jhillyerd/inbucket/msghub" - "github.com/jhillyerd/inbucket/smtpd" ) type InputMessageData struct { @@ -25,8 +25,8 @@ type InputMessageData struct { HTML, Text string } -func (d *InputMessageData) MockMessage() *MockMessage { - msg := &MockMessage{} +func (d *InputMessageData) MockMessage() *datastore.MockMessage { + msg := &datastore.MockMessage{} msg.On("ID").Return(d.ID) msg.On("From").Return(d.From) msg.On("To").Return(d.To) @@ -188,7 +188,7 @@ func testRestGet(url string) (*httptest.ResponseRecorder, error) { return w, nil } -func setupWebServer(ds smtpd.DataStore) *bytes.Buffer { +func setupWebServer(ds datastore.DataStore) *bytes.Buffer { // Capture log output buf := new(bytes.Buffer) log.SetOutput(buf) diff --git a/smtpd/handler.go b/smtpd/handler.go index 7353194..f6c4397 100644 --- a/smtpd/handler.go +++ b/smtpd/handler.go @@ -12,8 +12,10 @@ import ( "strings" "time" + "github.com/jhillyerd/inbucket/datastore" "github.com/jhillyerd/inbucket/log" "github.com/jhillyerd/inbucket/msghub" + "github.com/jhillyerd/inbucket/stringutil" ) // State tracks the current mode of our SMTP state machine @@ -71,7 +73,7 @@ var commands = map[string]bool{ // recipientDetails for message delivery type recipientDetails struct { address, localPart, domainPart string - mailbox Mailbox + mailbox datastore.Mailbox } // Session holds the state of an SMTP session @@ -265,7 +267,7 @@ func (ss *Session) readyHandler(cmd string, arg string) { return } from := m[1] - if _, _, err := ParseEmailAddress(from); err != nil { + if _, _, err := stringutil.ParseEmailAddress(from); err != nil { ss.send("501 Bad sender address syntax") ss.logWarn("Bad address as MAIL arg: %q, %s", from, err) return @@ -314,7 +316,7 @@ func (ss *Session) mailHandler(cmd string, arg string) { } // This trim is probably too forgiving recip := strings.Trim(arg[3:], "<> ") - if _, _, err := ParseEmailAddress(recip); err != nil { + if _, _, err := stringutil.ParseEmailAddress(recip); err != nil { ss.send("501 Bad recipient address syntax") ss.logWarn("Bad address as RCPT arg: %q, %s", recip, err) return @@ -354,7 +356,7 @@ func (ss *Session) dataHandler() { if ss.server.storeMessages { for e := ss.recipients.Front(); e != nil; e = e.Next() { recip := e.Value.(string) - local, domain, err := ParseEmailAddress(recip) + local, domain, err := stringutil.ParseEmailAddress(recip) if err != nil { ss.logError("Failed to parse address for %q", recip) ss.send(fmt.Sprintf("451 Failed to open mailbox for %v", recip)) @@ -510,20 +512,16 @@ func (ss *Session) send(msg string) { // readByteLine reads a line of input into the provided buffer. Does // not reset the Buffer - please do so prior to calling. -func (ss *Session) readByteLine(buf *bytes.Buffer) error { +func (ss *Session) readByteLine(buf io.Writer) error { if err := ss.conn.SetReadDeadline(ss.nextDeadline()); err != nil { return err } - for { - line, err := ss.reader.ReadBytes('\n') - if err != nil { - return err - } - if _, err = buf.Write(line); err != nil { - return err - } - return nil + line, err := ss.reader.ReadBytes('\n') + if err != nil { + return err } + _, err = buf.Write(line) + return err } // Reads a line of input @@ -572,7 +570,7 @@ func (ss *Session) parseCmd(line string) (cmd string, arg string, ok bool) { // The leading space is mandatory. func (ss *Session) parseArgs(arg string) (args map[string]string, ok bool) { args = make(map[string]string) - re := regexp.MustCompile(" (\\w+)=(\\w+)") + re := regexp.MustCompile(` (\w+)=(\w+)`) pm := re.FindAllStringSubmatch(arg, -1) if pm == nil { ss.logWarn("Failed to parse arg string: %q") diff --git a/smtpd/handler_test.go b/smtpd/handler_test.go index 61d4f69..e541f4e 100644 --- a/smtpd/handler_test.go +++ b/smtpd/handler_test.go @@ -14,6 +14,7 @@ import ( "time" "github.com/jhillyerd/inbucket/config" + "github.com/jhillyerd/inbucket/datastore" "github.com/jhillyerd/inbucket/msghub" ) @@ -25,17 +26,13 @@ type scriptStep struct { // Test commands in GREET state func TestGreetState(t *testing.T) { // Setup mock objects - mds := &MockDataStore{} - mb1 := &MockMailbox{} - mds.On("MailboxFor").Return(mb1, nil) + mds := &datastore.MockDataStore{} server, logbuf, teardown := setupSMTPServer(mds) defer teardown() - var script []scriptStep - // Test out some mangled HELOs - script = []scriptStep{ + script := []scriptStep{ {"HELO", 501}, {"EHLO", 501}, {"HELLO", 500}, @@ -86,17 +83,13 @@ func TestGreetState(t *testing.T) { // Test commands in READY state func TestReadyState(t *testing.T) { // Setup mock objects - mds := &MockDataStore{} - mb1 := &MockMailbox{} - mds.On("MailboxFor").Return(mb1, nil) + mds := &datastore.MockDataStore{} server, logbuf, teardown := setupSMTPServer(mds) defer teardown() - var script []scriptStep - // Test out some mangled READY commands - script = []scriptStep{ + script := []scriptStep{ {"HELO localhost", 250}, {"FOOB", 500}, {"HELO", 503}, @@ -151,10 +144,10 @@ func TestReadyState(t *testing.T) { // Test commands in MAIL state func TestMailState(t *testing.T) { // Setup mock objects - mds := &MockDataStore{} - mb1 := &MockMailbox{} - msg1 := &MockMessage{} - mds.On("MailboxFor").Return(mb1, nil) + mds := &datastore.MockDataStore{} + mb1 := &datastore.MockMailbox{} + msg1 := &datastore.MockMessage{} + mds.On("MailboxFor", "u1").Return(mb1, nil) mb1.On("NewMessage").Return(msg1, nil) mb1.On("Name").Return("u1") msg1.On("ID").Return("") @@ -168,10 +161,8 @@ func TestMailState(t *testing.T) { server, logbuf, teardown := setupSMTPServer(mds) defer teardown() - var script []scriptStep - // Test out some mangled READY commands - script = []scriptStep{ + script := []scriptStep{ {"HELO localhost", 250}, {"MAIL FROM:", 250}, {"FOOB", 500}, @@ -268,10 +259,10 @@ func TestMailState(t *testing.T) { // Test commands in DATA state func TestDataState(t *testing.T) { // Setup mock objects - mds := &MockDataStore{} - mb1 := &MockMailbox{} - msg1 := &MockMessage{} - mds.On("MailboxFor").Return(mb1, nil) + mds := &datastore.MockDataStore{} + mb1 := &datastore.MockMailbox{} + msg1 := &datastore.MockMessage{} + mds.On("MailboxFor", "u1").Return(mb1, nil) mb1.On("NewMessage").Return(msg1, nil) mb1.On("Name").Return("u1") msg1.On("ID").Return("") @@ -376,7 +367,7 @@ func (m *mockConn) SetDeadline(t time.Time) error { return nil } func (m *mockConn) SetReadDeadline(t time.Time) error { return nil } func (m *mockConn) SetWriteDeadline(t time.Time) error { return nil } -func setupSMTPServer(ds DataStore) (s *Server, buf *bytes.Buffer, teardown func()) { +func setupSMTPServer(ds datastore.DataStore) (s *Server, buf *bytes.Buffer, teardown func()) { // Test Server Config cfg := config.SMTPConfig{ IP4address: net.IPv4(127, 0, 0, 1), diff --git a/smtpd/listener.go b/smtpd/listener.go index 11c4632..60953b6 100644 --- a/smtpd/listener.go +++ b/smtpd/listener.go @@ -11,10 +11,31 @@ import ( "time" "github.com/jhillyerd/inbucket/config" + "github.com/jhillyerd/inbucket/datastore" "github.com/jhillyerd/inbucket/log" "github.com/jhillyerd/inbucket/msghub" ) +func init() { + m := expvar.NewMap("smtp") + m.Set("ConnectsTotal", expConnectsTotal) + m.Set("ConnectsHist", expConnectsHist) + m.Set("ConnectsCurrent", expConnectsCurrent) + m.Set("ReceivedTotal", expReceivedTotal) + m.Set("ReceivedHist", expReceivedHist) + m.Set("ErrorsTotal", expErrorsTotal) + m.Set("ErrorsHist", expErrorsHist) + m.Set("WarnsTotal", expWarnsTotal) + m.Set("WarnsHist", expWarnsHist) + + log.AddTickerFunc(func() { + expReceivedHist.Set(log.PushMetric(deliveredHist, expReceivedTotal)) + expConnectsHist.Set(log.PushMetric(connectsHist, expConnectsTotal)) + expErrorsHist.Set(log.PushMetric(errorsHist, expErrorsTotal)) + expWarnsHist.Set(log.PushMetric(warnsHist, expWarnsTotal)) + }) +} + // Server holds the configuration and state of our SMTP server type Server struct { // Configuration @@ -27,10 +48,10 @@ type Server struct { storeMessages bool // Dependencies - dataStore DataStore // Mailbox/message store - globalShutdown chan bool // Shuts down Inbucket - msgHub *msghub.Hub // Pub/sub for message info - retentionScanner *RetentionScanner // Deletes expired messages + dataStore datastore.DataStore // Mailbox/message store + globalShutdown chan bool // Shuts down Inbucket + msgHub *msghub.Hub // Pub/sub for message info + retentionScanner *datastore.RetentionScanner // Deletes expired messages // State listener net.Listener // Incoming network connections @@ -62,7 +83,7 @@ var ( func NewServer( cfg config.SMTPConfig, globalShutdown chan bool, - ds DataStore, + ds datastore.DataStore, msgHub *msghub.Hub) *Server { return &Server{ host: fmt.Sprintf("%v:%v", cfg.IP4address, cfg.IP4port), @@ -75,7 +96,7 @@ func NewServer( globalShutdown: globalShutdown, dataStore: ds, msgHub: msgHub, - retentionScanner: NewRetentionScanner(ds, globalShutdown), + retentionScanner: datastore.NewRetentionScanner(ds, globalShutdown), waitgroup: new(sync.WaitGroup), } } @@ -110,10 +131,8 @@ func (s *Server) Start(ctx context.Context) { go s.serve(ctx) // Wait for shutdown - select { - case <-ctx.Done(): - log.Tracef("SMTP shutdown requested, connections will be drained") - } + <-ctx.Done() + log.Tracef("SMTP shutdown requested, connections will be drained") // Closing the listener will cause the serve() go routine to exit if err := s.listener.Close(); err != nil { @@ -165,7 +184,7 @@ func (s *Server) serve(ctx context.Context) { func (s *Server) emergencyShutdown() { // Shutdown Inbucket select { - case _ = <-s.globalShutdown: + case <-s.globalShutdown: default: close(s.globalShutdown) } @@ -178,44 +197,3 @@ func (s *Server) Drain() { log.Tracef("SMTP connections have drained") s.retentionScanner.Join() } - -// When the provided Ticker ticks, we update our metrics history -func metricsTicker(t *time.Ticker) { - ok := true - for ok { - _, ok = <-t.C - expReceivedHist.Set(pushMetric(deliveredHist, expReceivedTotal)) - expConnectsHist.Set(pushMetric(connectsHist, expConnectsTotal)) - expErrorsHist.Set(pushMetric(errorsHist, expErrorsTotal)) - expWarnsHist.Set(pushMetric(warnsHist, expWarnsTotal)) - expRetentionDeletesHist.Set(pushMetric(retentionDeletesHist, expRetentionDeletesTotal)) - expRetainedHist.Set(pushMetric(retainedHist, expRetainedCurrent)) - } -} - -// pushMetric adds the metric to the end of the list and returns a comma separated string of the -// previous 61 entries. We return 61 instead of 60 (an hour) because the chart on the client -// tracks deltas between these values - there is nothing to compare the first value against. -func pushMetric(history *list.List, ev expvar.Var) string { - history.PushBack(ev.String()) - if history.Len() > 61 { - history.Remove(history.Front()) - } - return JoinStringList(history) -} - -func init() { - m := expvar.NewMap("smtp") - m.Set("ConnectsTotal", expConnectsTotal) - m.Set("ConnectsHist", expConnectsHist) - m.Set("ConnectsCurrent", expConnectsCurrent) - m.Set("ReceivedTotal", expReceivedTotal) - m.Set("ReceivedHist", expReceivedHist) - m.Set("ErrorsTotal", expErrorsTotal) - m.Set("ErrorsHist", expErrorsHist) - m.Set("WarnsTotal", expWarnsTotal) - m.Set("WarnsHist", expWarnsHist) - - t := time.NewTicker(time.Minute) - go metricsTicker(t) -} diff --git a/smtpd/retention_test.go b/smtpd/retention_test.go deleted file mode 100644 index 5e76b42..0000000 --- a/smtpd/retention_test.go +++ /dev/null @@ -1,197 +0,0 @@ -package smtpd - -import ( - "fmt" - "io" - "net/mail" - "testing" - "time" - - "github.com/jhillyerd/enmime" - "github.com/stretchr/testify/mock" -) - -func TestDoRetentionScan(t *testing.T) { - // Create mock objects - mds := &MockDataStore{} - - mb1 := &MockMailbox{} - mb2 := &MockMailbox{} - mb3 := &MockMailbox{} - - // Mockup some different aged messages (num is in hours) - new1 := mockMessage(0) - new2 := mockMessage(1) - new3 := mockMessage(2) - old1 := mockMessage(4) - old2 := mockMessage(12) - old3 := mockMessage(24) - - // First it should ask for all mailboxes - mds.On("AllMailboxes").Return([]Mailbox{mb1, mb2, mb3}, nil) - - // Then for all messages on each box - mb1.On("GetMessages").Return([]Message{new1, old1, old2}, nil) - mb2.On("GetMessages").Return([]Message{old3, new2}, nil) - mb3.On("GetMessages").Return([]Message{new3}, nil) - - // Test 4 hour retention - rs := &RetentionScanner{ - ds: mds, - retentionPeriod: 4*time.Hour - time.Minute, - retentionSleep: 0, - } - if err := rs.doScan(); err != nil { - t.Error(err) - } - - // Check our assertions - mds.AssertExpectations(t) - mb1.AssertExpectations(t) - mb2.AssertExpectations(t) - mb3.AssertExpectations(t) - - // Delete should not have been called on new messages - new1.AssertNotCalled(t, "Delete") - new2.AssertNotCalled(t, "Delete") - new3.AssertNotCalled(t, "Delete") - - // Delete should have been called once on old messages - old1.AssertNumberOfCalls(t, "Delete", 1) - old2.AssertNumberOfCalls(t, "Delete", 1) - old3.AssertNumberOfCalls(t, "Delete", 1) -} - -// Make a MockMessage of a specific age -func mockMessage(ageHours int) *MockMessage { - msg := &MockMessage{} - msg.On("ID").Return(fmt.Sprintf("MSG[age=%vh]", ageHours)) - msg.On("Date").Return(time.Now().Add(time.Duration(ageHours*-1) * time.Hour)) - msg.On("Delete").Return(nil) - return msg -} - -// Mock DataStore object -type MockDataStore struct { - mock.Mock -} - -func (m *MockDataStore) MailboxFor(name string) (Mailbox, error) { - args := m.Called() - return args.Get(0).(Mailbox), args.Error(1) -} - -func (m *MockDataStore) AllMailboxes() ([]Mailbox, error) { - args := m.Called() - return args.Get(0).([]Mailbox), args.Error(1) -} - -// Mock Mailbox object -type MockMailbox struct { - mock.Mock -} - -func (m *MockMailbox) GetMessages() ([]Message, error) { - args := m.Called() - return args.Get(0).([]Message), args.Error(1) -} - -func (m *MockMailbox) GetMessage(id string) (Message, error) { - args := m.Called(id) - return args.Get(0).(Message), args.Error(1) -} - -func (m *MockMailbox) Purge() error { - args := m.Called() - return args.Error(0) -} - -func (m *MockMailbox) NewMessage() (Message, error) { - args := m.Called() - return args.Get(0).(Message), args.Error(1) -} - -func (m *MockMailbox) Name() string { - args := m.Called() - return args.String(0) -} - -func (m *MockMailbox) String() string { - args := m.Called() - return args.String(0) -} - -// Mock Message object -type MockMessage struct { - mock.Mock -} - -func (m *MockMessage) ID() string { - args := m.Called() - return args.String(0) -} - -func (m *MockMessage) From() string { - args := m.Called() - return args.String(0) -} - -func (m *MockMessage) To() []string { - args := m.Called() - return args.Get(0).([]string) -} - -func (m *MockMessage) Date() time.Time { - args := m.Called() - return args.Get(0).(time.Time) -} - -func (m *MockMessage) Subject() string { - args := m.Called() - return args.String(0) -} - -func (m *MockMessage) ReadHeader() (msg *mail.Message, err error) { - args := m.Called() - return args.Get(0).(*mail.Message), args.Error(1) -} - -func (m *MockMessage) ReadBody() (body *enmime.Envelope, err error) { - args := m.Called() - return args.Get(0).(*enmime.Envelope), args.Error(1) -} - -func (m *MockMessage) ReadRaw() (raw *string, err error) { - args := m.Called() - return args.Get(0).(*string), args.Error(1) -} - -func (m *MockMessage) RawReader() (reader io.ReadCloser, err error) { - args := m.Called() - return args.Get(0).(io.ReadCloser), args.Error(1) -} - -func (m *MockMessage) Size() int64 { - args := m.Called() - return int64(args.Int(0)) -} - -func (m *MockMessage) Append(data []byte) error { - // []byte arg seems to mess up testify/mock - return nil -} - -func (m *MockMessage) Close() error { - args := m.Called() - return args.Error(0) -} - -func (m *MockMessage) Delete() error { - args := m.Called() - return args.Error(0) -} - -func (m *MockMessage) String() string { - args := m.Called() - return args.String(0) -} diff --git a/smtpd/utils.go b/stringutil/utils.go similarity index 88% rename from smtpd/utils.go rename to stringutil/utils.go index a8dcfb7..450ae1f 100644 --- a/smtpd/utils.go +++ b/stringutil/utils.go @@ -1,8 +1,7 @@ -package smtpd +package stringutil import ( "bytes" - "container/list" "crypto/sha1" "fmt" "io" @@ -42,7 +41,7 @@ func ParseMailboxName(localPart string) (result string, err error) { return result, nil } -// HashMailboxName accepts a mailbox name and hashes it. Inbucket uses this as +// HashMailboxName accepts a mailbox name and hashes it. filestore uses this as // the directory to house the mailbox func HashMailboxName(mailbox string) string { h := sha1.New() @@ -53,18 +52,6 @@ func HashMailboxName(mailbox string) string { return fmt.Sprintf("%x", h.Sum(nil)) } -// JoinStringList joins a List containing strings by commas -func JoinStringList(listOfStrings *list.List) string { - if listOfStrings.Len() == 0 { - return "" - } - s := make([]string, 0, listOfStrings.Len()) - for e := listOfStrings.Front(); e != nil; e = e.Next() { - s = append(s, e.Value.(string)) - } - return strings.Join(s, ",") -} - // ValidateDomainPart returns true if the domain part complies to RFC3696, RFC1035 func ValidateDomainPart(domain string) bool { if len(domain) == 0 { @@ -143,15 +130,24 @@ LOOP: switch { case ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z'): // Letters are OK - _ = buf.WriteByte(c) + err = buf.WriteByte(c) + if err != nil { + return + } inCharQuote = false case '0' <= c && c <= '9': // Numbers are OK - _ = buf.WriteByte(c) + err = buf.WriteByte(c) + if err != nil { + return + } inCharQuote = false case bytes.IndexByte([]byte("!#$%&'*+-/=?^_`{|}~"), c) >= 0: // These specials can be used unquoted - _ = buf.WriteByte(c) + err = buf.WriteByte(c) + if err != nil { + return + } inCharQuote = false case c == '.': // A single period is OK @@ -159,13 +155,19 @@ LOOP: // Sequence of periods is not permitted return "", "", fmt.Errorf("Sequence of periods is not permitted") } - _ = buf.WriteByte(c) + err = buf.WriteByte(c) + if err != nil { + return + } inCharQuote = false case c == '\\': inCharQuote = true case c == '"': if inCharQuote { - _ = buf.WriteByte(c) + err = buf.WriteByte(c) + if err != nil { + return + } inCharQuote = false } else if inStringQuote { inStringQuote = false @@ -178,7 +180,10 @@ LOOP: } case c == '@': if inCharQuote || inStringQuote { - _ = buf.WriteByte(c) + err = buf.WriteByte(c) + if err != nil { + return + } inCharQuote = false } else { // End of local-part @@ -195,7 +200,10 @@ LOOP: return "", "", fmt.Errorf("Characters outside of US-ASCII range not permitted") default: if inCharQuote || inStringQuote { - _ = buf.WriteByte(c) + err = buf.WriteByte(c) + if err != nil { + return + } inCharQuote = false } else { return "", "", fmt.Errorf("Character %q must be quoted", c) diff --git a/smtpd/utils_test.go b/stringutil/utils_test.go similarity index 99% rename from smtpd/utils_test.go rename to stringutil/utils_test.go index ae4fda3..330bfbd 100644 --- a/smtpd/utils_test.go +++ b/stringutil/utils_test.go @@ -1,4 +1,4 @@ -package smtpd +package stringutil import ( "strings" diff --git a/webui/mailbox_controller.go b/webui/mailbox_controller.go index 35126db..b7c8a4e 100644 --- a/webui/mailbox_controller.go +++ b/webui/mailbox_controller.go @@ -7,9 +7,10 @@ import ( "net/http" "strconv" + "github.com/jhillyerd/inbucket/datastore" "github.com/jhillyerd/inbucket/httpd" "github.com/jhillyerd/inbucket/log" - "github.com/jhillyerd/inbucket/smtpd" + "github.com/jhillyerd/inbucket/stringutil" ) // MailboxIndex renders the index page for a particular mailbox @@ -23,7 +24,7 @@ func MailboxIndex(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) http.Redirect(w, req, httpd.Reverse("RootIndex"), http.StatusSeeOther) return nil } - name, err = smtpd.ParseMailboxName(name) + name, err = stringutil.ParseMailboxName(name) if err != nil { ctx.Session.AddFlash(err.Error(), "errors") _ = ctx.Session.Save(req, w) @@ -50,7 +51,7 @@ func MailboxIndex(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) func MailboxLink(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) (err error) { // Don't have to validate these aren't empty, Gorilla returns 404 id := ctx.Vars["id"] - name, err := smtpd.ParseMailboxName(ctx.Vars["name"]) + name, err := stringutil.ParseMailboxName(ctx.Vars["name"]) if err != nil { ctx.Session.AddFlash(err.Error(), "errors") _ = ctx.Session.Save(req, w) @@ -66,7 +67,7 @@ func MailboxLink(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) ( // MailboxList renders a list of messages in a mailbox. Renders a partial func MailboxList(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) (err error) { // Don't have to validate these aren't empty, Gorilla returns 404 - name, err := smtpd.ParseMailboxName(ctx.Vars["name"]) + name, err := stringutil.ParseMailboxName(ctx.Vars["name"]) if err != nil { return err } @@ -93,7 +94,7 @@ func MailboxList(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) ( func MailboxShow(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) (err error) { // Don't have to validate these aren't empty, Gorilla returns 404 id := ctx.Vars["id"] - name, err := smtpd.ParseMailboxName(ctx.Vars["name"]) + name, err := stringutil.ParseMailboxName(ctx.Vars["name"]) if err != nil { return err } @@ -103,7 +104,7 @@ func MailboxShow(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) ( return fmt.Errorf("Failed to get mailbox for %q: %v", name, err) } msg, err := mb.GetMessage(id) - if err == smtpd.ErrNotExist { + if err == datastore.ErrNotExist { http.NotFound(w, req) return nil } @@ -133,7 +134,7 @@ func MailboxShow(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) ( func MailboxHTML(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) (err error) { // Don't have to validate these aren't empty, Gorilla returns 404 id := ctx.Vars["id"] - name, err := smtpd.ParseMailboxName(ctx.Vars["name"]) + name, err := stringutil.ParseMailboxName(ctx.Vars["name"]) if err != nil { return err } @@ -143,7 +144,7 @@ func MailboxHTML(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) ( return fmt.Errorf("Failed to get mailbox for %q: %v", name, err) } message, err := mb.GetMessage(id) - if err == smtpd.ErrNotExist { + if err == datastore.ErrNotExist { http.NotFound(w, req) return nil } @@ -170,7 +171,7 @@ func MailboxHTML(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) ( func MailboxSource(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) (err error) { // Don't have to validate these aren't empty, Gorilla returns 404 id := ctx.Vars["id"] - name, err := smtpd.ParseMailboxName(ctx.Vars["name"]) + name, err := stringutil.ParseMailboxName(ctx.Vars["name"]) if err != nil { return err } @@ -180,7 +181,7 @@ func MailboxSource(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) return fmt.Errorf("Failed to get mailbox for %q: %v", name, err) } message, err := mb.GetMessage(id) - if err == smtpd.ErrNotExist { + if err == datastore.ErrNotExist { http.NotFound(w, req) return nil } @@ -205,7 +206,7 @@ func MailboxSource(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) func MailboxDownloadAttach(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) (err error) { // Don't have to validate these aren't empty, Gorilla returns 404 id := ctx.Vars["id"] - name, err := smtpd.ParseMailboxName(ctx.Vars["name"]) + name, err := stringutil.ParseMailboxName(ctx.Vars["name"]) if err != nil { ctx.Session.AddFlash(err.Error(), "errors") _ = ctx.Session.Save(req, w) @@ -226,7 +227,7 @@ func MailboxDownloadAttach(w http.ResponseWriter, req *http.Request, ctx *httpd. return fmt.Errorf("Failed to get mailbox for %q: %v", name, err) } message, err := mb.GetMessage(id) - if err == smtpd.ErrNotExist { + if err == datastore.ErrNotExist { http.NotFound(w, req) return nil } @@ -257,7 +258,7 @@ func MailboxDownloadAttach(w http.ResponseWriter, req *http.Request, ctx *httpd. // MailboxViewAttach sends the attachment to the client for online viewing func MailboxViewAttach(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) (err error) { // Don't have to validate these aren't empty, Gorilla returns 404 - name, err := smtpd.ParseMailboxName(ctx.Vars["name"]) + name, err := stringutil.ParseMailboxName(ctx.Vars["name"]) if err != nil { ctx.Session.AddFlash(err.Error(), "errors") _ = ctx.Session.Save(req, w) @@ -279,7 +280,7 @@ func MailboxViewAttach(w http.ResponseWriter, req *http.Request, ctx *httpd.Cont return fmt.Errorf("Failed to get mailbox for %q: %v", name, err) } message, err := mb.GetMessage(id) - if err == smtpd.ErrNotExist { + if err == datastore.ErrNotExist { http.NotFound(w, req) return nil } diff --git a/webui/root_controller.go b/webui/root_controller.go index 651e686..9a64bc5 100644 --- a/webui/root_controller.go +++ b/webui/root_controller.go @@ -8,7 +8,7 @@ import ( "github.com/jhillyerd/inbucket/config" "github.com/jhillyerd/inbucket/httpd" - "github.com/jhillyerd/inbucket/smtpd" + "github.com/jhillyerd/inbucket/stringutil" ) // RootIndex serves the Inbucket landing page @@ -58,7 +58,7 @@ func RootMonitorMailbox(w http.ResponseWriter, req *http.Request, ctx *httpd.Con http.Redirect(w, req, httpd.Reverse("RootIndex"), http.StatusSeeOther) return nil } - name, err := smtpd.ParseMailboxName(ctx.Vars["name"]) + name, err := stringutil.ParseMailboxName(ctx.Vars["name"]) if err != nil { ctx.Session.AddFlash(err.Error(), "errors") _ = ctx.Session.Save(req, w)