diff --git a/.gitignore b/.gitignore index 8a701bd..52aeb50 100644 --- a/.gitignore +++ b/.gitignore @@ -29,9 +29,5 @@ _testmain.go /inbucket /inbucket.exe /dist/** -/target/** /cmd/client/client /cmd/client/client.exe - -# local goxc config -.goxc.local.json diff --git a/.goxc.json b/.goxc.json deleted file mode 100644 index 78f24d2..0000000 --- a/.goxc.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "ArtifactsDest": "target", - "TasksExclude": [ - "pkg-build" - ], - "Arch": "amd64", - "Os": "darwin freebsd linux windows", - "ResourcesInclude": "README*,LICENSE*,CHANGELOG*,inbucket.bat,etc,themes", - "PackageVersion": "1.2.0", - "ConfigVersion": "0.9", - "BuildSettings": { - "LdFlagsXVars": { - "TimeNow": "main.BUILDDATE", - "Version": "main.VERSION" - } - } -} diff --git a/.travis.yml b/.travis.yml index 59d36e9..b9ea160 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,13 +5,11 @@ env: - DEPLOY_WITH_MAJOR="1.9" before_script: - - go vet ./... + - go get github.com/golang/lint/golint go: - - 1.8.x - 1.9.x - -script: go test -race -v ./... + - "1.10" deploy: provider: script diff --git a/CHANGELOG.md b/CHANGELOG.md index d50e215..df7e53f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,16 +4,20 @@ Change Log All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). -[1.2.0] - 2017-12-27 --------------------- +## [v1.3.0] - 2018-02-28 + +### Added +- Button to purge mailbox contents from the UI. +- Simple HTML/CSS sanitization; `Safe HTML` and `Plain Text` UI tabs. ### Changed +- Reverse message display sort order in the UI; now newest first. +## [v1.2.0] - 2017-12-27 + +### Changed - No significant code changes from rc2 -[1.2.0-rc2] - 2017-12-15 ------------------------- - ### Added - `rest/client` types `MessageHeader` and `Message` with convenience methods; provides a more natural API @@ -27,8 +31,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). types - Fixed panic when `monitor.history` set to 0 -[1.2.0-rc1] - 2017-01-29 ------------------------- +## [v1.2.0-rc1] - 2017-01-29 ### Added - Storage of `To:` header in messages (likely breaks existing datastores) @@ -54,8 +57,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). - Allow increased local-part length of 128 chars for Mailgun - RedHat and Ubuntu now use systemd instead of legacy init systems -[1.1.0] - 2016-09-03 --------------------- +## [v1.1.0] - 2016-09-03 ### Added - Homebrew inbucket.conf and formula (see README) @@ -63,8 +65,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). ### Fixed - Log and continue when unable to delete oldest message during cap enforcement -[1.1.0-rc2] - 2016-03-06 ------------------------- +## [v1.1.0-rc2] - 2016-03-06 ### Added - Message Cap to status page @@ -74,8 +75,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). - Shutdown hang in retention scanner - Display empty subject as `(No Subject)` -[1.1.0-rc1] - 2016-03-04 ------------------------- +## [v1.1.0-rc1] - 2016-03-04 ### Added - Inbucket now builds with Go 1.5 or 1.6 @@ -89,8 +89,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). - RESTful API moved to `/api/v1` base URI - More graceful shutdown on Ctrl-C or when errors encountered -[1.0] - 2014-04-14 ------------------- +## [v1.0] - 2014-04-14 ### Added - Add new configuration option `mailbox.message.cap` to prevent individual @@ -98,30 +97,29 @@ This project adheres to [Semantic Versioning](http://semver.org/). - Add Link button to messages, allows for directing another person to a specific message. -[Unreleased]: https://github.com/jhillyerd/inbucket/compare/master...develop -[1.2.0]: https://github.com/jhillyerd/inbucket/compare/1.2.0-rc2...1.2.0 -[1.2.0-rc2]: https://github.com/jhillyerd/inbucket/compare/1.2.0-rc1...1.2.0-rc2 -[1.2.0-rc1]: https://github.com/jhillyerd/inbucket/compare/1.1.0...1.2.0-rc1 -[1.1.0]: https://github.com/jhillyerd/inbucket/compare/1.1.0-rc2...1.1.0 -[1.1.0-rc2]: https://github.com/jhillyerd/inbucket/compare/1.1.0-rc1...1.1.0-rc2 -[1.1.0-rc1]: https://github.com/jhillyerd/inbucket/compare/1.0...1.1.0-rc1 -[1.0]: https://github.com/jhillyerd/inbucket/compare/1.0-rc1...1.0 +[Unreleased]: https://github.com/jhillyerd/inbucket/compare/master...develop +[v1.3.0]: https://github.com/jhillyerd/inbucket/compare/1.2.0...1.3.0 +[v1.2.0]: https://github.com/jhillyerd/inbucket/compare/1.2.0-rc2...1.2.0 +[v1.2.0-rc2]: https://github.com/jhillyerd/inbucket/compare/1.2.0-rc1...1.2.0-rc2 +[v1.2.0-rc1]: https://github.com/jhillyerd/inbucket/compare/1.1.0...1.2.0-rc1 +[v1.1.0]: https://github.com/jhillyerd/inbucket/compare/1.1.0-rc2...1.1.0 +[v1.1.0-rc2]: https://github.com/jhillyerd/inbucket/compare/1.1.0-rc1...1.1.0-rc2 +[v1.1.0-rc1]: https://github.com/jhillyerd/inbucket/compare/1.0...1.1.0-rc1 +[v1.0]: https://github.com/jhillyerd/inbucket/compare/1.0-rc1...1.0 -Release Checklist ------------------ +## Release Checklist 1. Create release branch: `git flow release start 1.x.0` 2. Update CHANGELOG.md: - - Ensure *Unreleased* section is up to date - - Rename *Unreleased* section to release name and date. - - Add new GitHub `/compare` link -3. Update goxc version info: `goxc -wc -pv=1.x.0 -pr=rc1` -4. Run: `goxc interpolate-source` to update VERSION var -5. Run tests -6. Test cross-compile: `goxc` -7. Commit changes and merge release: `git flow release finish 1.x.0` -8. Upload to bintray: `goxc bintray` -9. Update `binary_versions` option in `inbucket-site/_config.yml` + - Ensure *Unreleased* section is up to date + - Rename *Unreleased* section to release name and date. + - Add new GitHub `/compare` link +3. Run tests +4. Test cross-compile: `goreleaser --snapshot` +5. Commit changes and merge release: `git flow release finish` +6. Push tags and wait for https://travis-ci.org/jhillyerd/inbucket build to + complete +7. Update `binary_versions` option in `inbucket-site/_config.yml` See http://keepachangelog.com/ for additional instructions on how to update this file. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8101866 --- /dev/null +++ b/Makefile @@ -0,0 +1,35 @@ +PKG := inbucket +SHELL := /bin/sh + +SRC := $(shell find . -type f -name '*.go' -not -path "./vendor/*") +PKGS := $$(go list ./... | grep -v /vendor/) + +.PHONY: all build clean fmt install lint simplify test + +all: test lint build + +clean: + go clean + +deps: + go get -t ./... + +build: clean deps + go build + +install: build + go install + +test: clean deps + go test -race ./... + +fmt: + @gofmt -l -w $(SRC) + +simplify: + @gofmt -s -l -w $(SRC) + +lint: + @test -z "$(shell gofmt -l . | tee /dev/stderr)" || echo "[WARN] Fix formatting issues with 'make fmt'" + @golint -set_exit_status $${PKGS} + @go vet $${PKGS} 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 eea845f..23c646a 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" @@ -24,7 +25,7 @@ import ( var ( // version contains the build version number, populated during linking - version = "1.2.0" + version = "undefined" // date contains the build date, populated during linking date = "undefined" @@ -85,7 +86,7 @@ func main() { } // Setup signal handler - sigChan := make(chan os.Signal) + sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, syscall.SIGHUP, syscall.SIGTERM, syscall.SIGINT) // Initialize logging @@ -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) @@ -124,8 +125,7 @@ func main() { go httpd.Start(rootCtx) // Start POP3 server - // TODO pass datastore - pop3Server = pop3d.New(shutdownChan) + pop3Server = pop3d.New(config.GetPOP3Config(), shutdownChan, ds) go pop3Server.Start(rootCtx) // Startup SMTP server @@ -150,7 +150,7 @@ signalLoop: log.Infof("Received SIGTERM, shutting down") close(shutdownChan) } - case _ = <-shutdownChan: + case <-shutdownChan: rootCancel() break signalLoop } 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 8536881..58b0d82 100644 --- a/pop3d/listener.go +++ b/pop3d/listener.go @@ -8,29 +8,25 @@ 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 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(shutdownChan chan bool) *Server { - // Get a new instance of the the FileDataStore - the locking and counting - // mechanisms are both global variables in the smtpd package. If that - // changes in the future, this should be modified to use the same DataStore - // instance. - ds := smtpd.DefaultFileDataStore() - cfg := config.GetPOP3Config() +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, dataStore: ds, maxIdleSeconds: cfg.MaxIdleSeconds, @@ -41,9 +37,7 @@ func New(shutdownChan chan bool) *Server { // Start the server and listen for connections func (s *Server) Start(ctx context.Context) { - cfg := config.GetPOP3Config() - addr, err := net.ResolveTCPAddr("tcp4", fmt.Sprintf("%v:%v", - cfg.IP4address, cfg.IP4port)) + addr, err := net.ResolveTCPAddr("tcp4", s.host) if err != nil { log.Errorf("POP3 Failed to build tcp4 address: %v", err) s.emergencyShutdown() 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/sanitize/css.go b/sanitize/css.go new file mode 100644 index 0000000..e37d04e --- /dev/null +++ b/sanitize/css.go @@ -0,0 +1,110 @@ +package sanitize + +import ( + "bytes" + "strings" + + "github.com/gorilla/css/scanner" +) + +// propertyRule may someday allow control of what values are valid for a particular property. +type propertyRule struct{} + +var allowedProperties = map[string]propertyRule{ + "align": {}, + "background-color": {}, + "border": {}, + "border-bottom": {}, + "border-left": {}, + "border-radius": {}, + "border-right": {}, + "border-top": {}, + "box-sizing": {}, + "clear": {}, + "color": {}, + "content": {}, + "display": {}, + "font-family": {}, + "font-size": {}, + "font-weight": {}, + "height": {}, + "line-height": {}, + "margin": {}, + "margin-bottom": {}, + "margin-left": {}, + "margin-right": {}, + "margin-top": {}, + "max-height": {}, + "max-width": {}, + "overflow": {}, + "padding": {}, + "padding-bottom": {}, + "padding-left": {}, + "padding-right": {}, + "padding-top": {}, + "table-layout": {}, + "text-align": {}, + "text-decoration": {}, + "text-shadow": {}, + "vertical-align": {}, + "width": {}, + "word-break": {}, +} + +// Handler Token, return next state. +type stateHandler func(b *bytes.Buffer, t *scanner.Token) stateHandler + +func sanitizeStyle(input string) string { + b := &bytes.Buffer{} + scan := scanner.New(input) + state := stateStart + for { + t := scan.Next() + if t.Type == scanner.TokenEOF { + return b.String() + } + if t.Type == scanner.TokenError { + return "" + } + state = state(b, t) + if state == nil { + return "" + } + } +} + +func stateStart(b *bytes.Buffer, t *scanner.Token) stateHandler { + switch t.Type { + case scanner.TokenIdent: + _, ok := allowedProperties[strings.ToLower(t.Value)] + if !ok { + return stateEat + } + b.WriteString(t.Value) + return stateValid + case scanner.TokenS: + return stateStart + } + // Unexpected type. + b.WriteString("/*" + t.Type.String() + "*/") + return stateEat +} + +func stateEat(b *bytes.Buffer, t *scanner.Token) stateHandler { + if t.Type == scanner.TokenChar && t.Value == ";" { + // Done eating. + return stateStart + } + // Throw away this token. + return stateEat +} + +func stateValid(b *bytes.Buffer, t *scanner.Token) stateHandler { + state := stateValid + if t.Type == scanner.TokenChar && t.Value == ";" { + // End of property. + state = stateStart + } + b.WriteString(t.Value) + return state +} diff --git a/sanitize/css_test.go b/sanitize/css_test.go new file mode 100644 index 0000000..dc72a82 --- /dev/null +++ b/sanitize/css_test.go @@ -0,0 +1,34 @@ +package sanitize + +import ( + "testing" +) + +func TestSanitizeStyle(t *testing.T) { + testCases := []struct { + input, want string + }{ + {"", ""}, + { + "color: red;", + "color: red;", + }, + { + "background-color: black; color: white", + "background-color: black;color: white", + }, + { + "background-color: black; invalid: true; color: white", + "background-color: black;color: white", + }, + } + for _, tc := range testCases { + t.Run(tc.input, func(t *testing.T) { + got := sanitizeStyle(tc.input) + if got != tc.want { + t.Errorf("got: %q, want: %q, input: %q", got, tc.want, tc.input) + } + }) + } + +} diff --git a/sanitize/html.go b/sanitize/html.go new file mode 100644 index 0000000..475880f --- /dev/null +++ b/sanitize/html.go @@ -0,0 +1,88 @@ +package sanitize + +import ( + "bufio" + "bytes" + "io" + "regexp" + "strings" + + "github.com/microcosm-cc/bluemonday" + "golang.org/x/net/html" +) + +var ( + cssSafe = regexp.MustCompile(".*") + policy = bluemonday.UGCPolicy(). + AllowElements("center"). + AllowAttrs("style").Matching(cssSafe).Globally() +) + +func HTML(html string) (output string, err error) { + output, err = sanitizeStyleTags(html) + if err != nil { + return "", err + } + output = policy.Sanitize(output) + return +} + +func sanitizeStyleTags(input string) (string, error) { + r := strings.NewReader(input) + b := &bytes.Buffer{} + if err := styleTagFilter(b, r); err != nil { + return "", err + } + return b.String(), nil +} + +func styleTagFilter(w io.Writer, r io.Reader) error { + bw := bufio.NewWriter(w) + b := make([]byte, 256) + z := html.NewTokenizer(r) + for { + b = b[:0] + tt := z.Next() + switch tt { + case html.ErrorToken: + err := z.Err() + if err == io.EOF { + return bw.Flush() + } + return err + case html.StartTagToken, html.SelfClosingTagToken: + name, hasAttr := z.TagName() + if !hasAttr { + bw.Write(z.Raw()) + continue + } + b = append(b, '<') + b = append(b, name...) + for { + key, val, more := z.TagAttr() + strval := string(val) + style := false + if strings.ToLower(string(key)) == "style" { + style = true + strval = sanitizeStyle(strval) + } + if !style || strval != "" { + b = append(b, ' ') + b = append(b, key...) + b = append(b, '=', '"') + b = append(b, []byte(html.EscapeString(strval))...) + b = append(b, '"') + } + if !more { + break + } + } + if tt == html.SelfClosingTagToken { + b = append(b, '/') + } + bw.Write(append(b, '>')) + default: + bw.Write(z.Raw()) + } + } +} diff --git a/sanitize/html_test.go b/sanitize/html_test.go new file mode 100644 index 0000000..c6acf09 --- /dev/null +++ b/sanitize/html_test.go @@ -0,0 +1,171 @@ +package sanitize_test + +import ( + "testing" + + "github.com/jhillyerd/inbucket/sanitize" +) + +// TestHTMLPlainStrings test plain text passthrough +func TestHTMLPlainStrings(t *testing.T) { + testStrings := []string{ + "", + "plain string", + "one < two", + } + for _, ts := range testStrings { + t.Run(ts, func(t *testing.T) { + got, err := sanitize.HTML(ts) + if err != nil { + t.Fatal(err) + } + if got != ts { + t.Errorf("Got: %q, want: %q", got, ts) + } + }) + } +} + +// TestHTMLSimpleFormatting tests basic tags we should allow +func TestHTMLSimpleFormatting(t *testing.T) { + testStrings := []string{ + "
paragraph
", + "bold", + "italic", + "emphasis", + "strong", + "some text
`, + `some text
`, + }, + { + "invalid styles", + `
+
+ |
+ |||||||||
+
|
+ |||||||||
|
+
+
+
+
+
+
+
+
|
+ |||||||||
|
+
+
+
+
+
+
+
+
+
+
+
|
+ |||||||||
|
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
|
+ |||||||||
|
+
+
+
+
+
+
+
+
|
+ |||||||||
|
+
+
+
+
+
+
+
+
|
+
+ This is a test of HTML at the top level. +
diff --git a/swaks-tests/run-tests.sh b/swaks-tests/run-tests.sh index 0784a11..c9a93b8 100755 --- a/swaks-tests/run-tests.sh +++ b/swaks-tests/run-tests.sh @@ -53,5 +53,6 @@ swaks $* --data gmail.raw # Outlook test swaks $* --data outlook.raw -# Nonemime responsive HTML test +# Non-mime responsive HTML test swaks $* --data nonmime-html-responsive.raw +swaks $* --data nonmime-html-inlined.raw diff --git a/themes/bootstrap/public/inbucket.css b/themes/bootstrap/public/inbucket.css index c3c9f49..c895626 100644 --- a/themes/bootstrap/public/inbucket.css +++ b/themes/bootstrap/public/inbucket.css @@ -51,12 +51,17 @@ body { } } +#body-tabs > li > a { + padding-top: 6px; + padding-bottom: 6px; +} + .message-body { - padding: 0 5px; + padding: 10px 4px; } .message-attachments { - margin-top: 20px; + margin-top: 5px; padding: 10px 10px 0 0; } diff --git a/themes/bootstrap/public/mailbox.js b/themes/bootstrap/public/mailbox.js index f7e229a..5a624c6 100644 --- a/themes/bootstrap/public/mailbox.js +++ b/themes/bootstrap/public/mailbox.js @@ -22,6 +22,17 @@ function deleteMessage(id) { }) } +// deleteMailbox clears the mailbox +function deleteMailbox() { + if (confirm("Are you sure you want delete this mailbox?")) { + $.ajax({ + type: 'DELETE', + url: '/api/v1/mailbox/' + mailbox, + success: loadList + }) + } +} + // flashTooltip temporarily changes the text of a tooltip function flashTooltip(el, text) { var prevText = $(el).attr('data-original-title'); @@ -43,7 +54,7 @@ function loadList() { dataType: "json", url: '/api/v1/mailbox/' + mailbox, success: function(data) { - messageListData = data; + messageListData = data.reverse(); // Render list $('#message-list').loadTemplate($('#list-entry-template'), data); $('.message-list-entry').click(onMessageListClick); @@ -144,6 +155,7 @@ function onMessageLoaded(responseText, textStatus, XMLHttpRequest) { return; } onDocumentChange(); + $('#body-tabs a:first').tab('show') var top = $('#message-container').offset().top - navBarOffset; $(window).scrollTop(top); } diff --git a/themes/bootstrap/public/monitor.js b/themes/bootstrap/public/monitor.js index e670621..3a03ed6 100644 --- a/themes/bootstrap/public/monitor.js +++ b/themes/bootstrap/public/monitor.js @@ -30,7 +30,7 @@ function startMonitor(mailbox) { $('#monitor-message-list').loadTemplate( $('#message-template'), msg, - { append: true }); + { prepend: true }); }); ws.addEventListener('close', function (e) { $('#conn-status').text('Disconnected!'); diff --git a/themes/bootstrap/templates/mailbox/_show.html b/themes/bootstrap/templates/mailbox/_show.html index 33a9975..49621f0 100644 --- a/themes/bootstrap/templates/mailbox/_show.html +++ b/themes/bootstrap/templates/mailbox/_show.html @@ -24,7 +24,7 @@ class="btn btn-primary" onClick="htmlView('{{.message.ID}}');"> - HTML + Raw HTML {{end}}