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

Compare commits

...

43 Commits

Author SHA1 Message Date
James Hillyerd
f58e51d921 Fix change log tag format 2018-02-28 14:21:39 -08:00
James Hillyerd
5f5a7eecd3 Release v1.3.0 2018-02-28 14:18:04 -08:00
James Hillyerd
1ff8ffe9bd Release prep for 1.3.0 2018-02-28 12:50:39 -08:00
James Hillyerd
b4abdb6675 Change to trash glyph for delete mailbox 2018-02-28 12:37:56 -08:00
James Hillyerd
ffa756d895 gcloud: removed
- Dockerized and moved to https://github.com/jhillyerd/demo.inbucket.org
- Merge master changelog entry
2018-02-27 21:20:23 -08:00
James Hillyerd
d5aea4d635 Merge branch 'feature/sanitize'
- Closes #5
- Closes #70
2018-02-27 20:53:38 -08:00
James Hillyerd
3c19e0820b Add Makefile for developer convenience. 2018-02-27 20:50:09 -08:00
James Hillyerd
3b9af85924 sanitize: naive CSS sanitizer implementation
- CSS sanitizer allows a limited set of properties in a style attribute.
- Added a CSS inlined version of the tutsplus responsive test mail.
- Linter fixes in inbucket.go
2018-02-27 20:37:24 -08:00
James Hillyerd
26c38b1148 Simple HTML sanitizer implementation 2018-01-06 16:45:12 -08:00
James Hillyerd
3062b70ea0 Merge branch 'release/1.2.0' 2017-12-27 13:29:06 -08:00
James Hillyerd
01d51302c4 Prepare release 1.2.0 2017-12-27 13:18:11 -08:00
James Hillyerd
dedd0eacff Merge branch 'feature/filestore' into develop #67 2017-12-26 23:17:01 -08:00
James Hillyerd
6431b71cfe Refactor filestore into a dedicated pkg, closes #67 2017-12-26 23:04:39 -08:00
James Hillyerd
25815767a7 Move smtpd/utils.go into dedicated stringutil pkg 2017-12-26 22:55:20 -08:00
James Hillyerd
06165cb3d3 Many linter fixes for smtpd pkg 2017-12-26 22:16:47 -08:00
James Hillyerd
ac21675bd7 Clean up datastore related linter findings 2017-12-26 18:54:02 -08:00
James Hillyerd
f62eaa31b9 Move retention scanner into datastore pkg for #67 2017-12-26 18:33:56 -08:00
James Hillyerd
fcc0848bc0 Move metrics ticker to log pkg for #67 2017-12-26 18:25:11 -08:00
James Hillyerd
dec67622ba Move handler tests to shared datastore mocks for #48 2017-12-26 16:42:25 -08:00
James Hillyerd
11033a5359 Move datastore mocks into correct package
- Start of work for #48
- Continues #67
2017-12-26 15:45:18 -08:00
James Hillyerd
3a4fd3f093 Refactor datastore into it's own package for #67 2017-12-26 14:54:49 -08:00
James Hillyerd
cc47895d71 Pass cfg and ds as params, helps #26 #67 2017-12-26 13:57:04 -08:00
adrium
76a77beca9 Reverse message display sort order (#59)
Closes #60
2017-12-24 13:59:04 -08:00
James Hillyerd
81eba8f51a Only deploy with one version of Go 2017-12-24 13:43:22 -08:00
James Hillyerd
c750dcff81 Merge branch 'hotfix/build' to prevent dup deploys 2017-12-24 13:40:30 -08:00
James Hillyerd
de75b778c0 Only deploy with one version of Go 2017-12-24 13:37:47 -08:00
James Hillyerd
0e72b414c4 Add fauxmailer to gcloud, custom greeting 2017-12-24 13:22:51 -08:00
James Hillyerd
52de1b2bfe Initial gcloud setup.sh, not yet tested as metadata 2017-12-23 23:22:51 -08:00
James Hillyerd
b28e1d86d8 Include version for final goxc release 2017-12-18 19:15:51 -08:00
James Hillyerd
f4fadd7e44 Docker version will now fall back to commit if no tag 2017-12-18 19:12:47 -08:00
James Hillyerd
28b40eb94d Fetch tags during docker build 2017-12-18 19:12:32 -08:00
James Hillyerd
0f67e51e56 Fix version & date in Docker containers for #64 2017-12-18 19:11:08 -08:00
James Hillyerd
9d68e2c0a5 Docker version will now fall back to commit if no tag 2017-12-17 21:44:20 -08:00
James Hillyerd
5bca2ae738 Fetch tags during docker build 2017-12-17 21:29:57 -08:00
James Hillyerd
10cce5c751 Fix version & date in Docker containers for #64 2017-12-17 21:05:48 -08:00
Carlos Tadeu Panato Junior
8040b07c28 Button to delete the mailbox from the UI (#65), closes #55 2017-12-17 20:36:14 -08:00
James Hillyerd
4e8c287608 Migrate from goxc to goreleaser, closes #64 2017-12-17 20:18:51 -08:00
James Hillyerd
6f57c51934 Update release procedures, cleanup goxc config 2017-12-17 20:13:14 -08:00
James Hillyerd
a457b65603 Add cmd/client to release builds 2017-12-17 20:05:07 -08:00
James Hillyerd
890d8e0202 Rename link variables, setup travis tag releases 2017-12-17 19:32:05 -08:00
James Hillyerd
9f6dee640e Customize goreleaser to get a working build 2017-12-17 19:10:59 -08:00
James Hillyerd
095796c8a1 Default config from goreleaser init 2017-12-17 12:33:09 -08:00
James Hillyerd
db358fea8c Merge tag '1.2.0-rc2' into develop 2017-12-15 20:41:02 -08:00
46 changed files with 1403 additions and 520 deletions

5
.gitignore vendored
View File

@@ -28,9 +28,6 @@ _testmain.go
# our binaries # our binaries
/inbucket /inbucket
/inbucket.exe /inbucket.exe
/target/** /dist/**
/cmd/client/client /cmd/client/client
/cmd/client/client.exe /cmd/client/client.exe
# local goxc config
.goxc.local.json

60
.goreleaser.yml Normal file
View File

@@ -0,0 +1,60 @@
project_name: inbucket
release:
github:
owner: jhillyerd
name: inbucket
name_template: '{{.Tag}}'
brew:
commit_author:
name: goreleaserbot
email: goreleaser@carlosbecker.com
install: bin.install ""
builds:
- binary: inbucket
goos:
- darwin
- freebsd
- linux
- windows
goarch:
- amd64
goarm:
- "6"
main: .
ldflags: -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}
- binary: client
goos:
- darwin
- freebsd
- linux
- windows
goarch:
- amd64
goarm:
- "6"
main: ./cmd/client
ldflags: -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}
archive:
format: tar.gz
wrap_in_directory: true
name_template: '{{ .Binary }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{
.Arm }}{{ end }}'
format_overrides:
- goos: windows
format: zip
files:
- LICENSE*
- README*
- CHANGELOG*
- inbucket.bat
- etc/**/*
- themes/**/*
fpm:
bindir: /usr/local/bin
snapshot:
name_template: SNAPSHOT-{{ .Commit }}
checksum:
name_template: '{{ .ProjectName }}_{{ .Version }}_checksums.txt'
dist: dist
sign:
artifacts: none

View File

@@ -1,18 +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",
"PrereleaseInfo": "rc2",
"ConfigVersion": "0.9",
"BuildSettings": {
"LdFlagsXVars": {
"TimeNow": "main.BUILDDATE",
"Version": "main.VERSION"
}
}
}

View File

@@ -1,11 +1,19 @@
language: go language: go
sudo: false sudo: false
env:
- DEPLOY_WITH_MAJOR="1.9"
before_script: before_script:
- go vet ./... - go get github.com/golang/lint/golint
go: go:
- 1.8.5 - 1.9.x
- 1.9.2 - "1.10"
script: go test -race -v ./... deploy:
provider: script
script: etc/travis-deploy.sh
on:
tags: true
branch: master

View File

@@ -4,8 +4,19 @@ Change Log
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
This project adheres to [Semantic Versioning](http://semver.org/). This project adheres to [Semantic Versioning](http://semver.org/).
[1.2.0-rc2] - 2017-12-15 ## [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
### Added ### Added
- `rest/client` types `MessageHeader` and `Message` with convenience methods; - `rest/client` types `MessageHeader` and `Message` with convenience methods;
@@ -20,8 +31,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
types types
- Fixed panic when `monitor.history` set to 0 - Fixed panic when `monitor.history` set to 0
[1.2.0-rc1] - 2017-01-29 ## [v1.2.0-rc1] - 2017-01-29
------------------------
### Added ### Added
- Storage of `To:` header in messages (likely breaks existing datastores) - Storage of `To:` header in messages (likely breaks existing datastores)
@@ -47,8 +57,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
- Allow increased local-part length of 128 chars for Mailgun - Allow increased local-part length of 128 chars for Mailgun
- RedHat and Ubuntu now use systemd instead of legacy init systems - RedHat and Ubuntu now use systemd instead of legacy init systems
[1.1.0] - 2016-09-03 ## [v1.1.0] - 2016-09-03
--------------------
### Added ### Added
- Homebrew inbucket.conf and formula (see README) - Homebrew inbucket.conf and formula (see README)
@@ -56,8 +65,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
### Fixed ### Fixed
- Log and continue when unable to delete oldest message during cap enforcement - 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 ### Added
- Message Cap to status page - Message Cap to status page
@@ -67,8 +75,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
- Shutdown hang in retention scanner - Shutdown hang in retention scanner
- Display empty subject as `(No Subject)` - Display empty subject as `(No Subject)`
[1.1.0-rc1] - 2016-03-04 ## [v1.1.0-rc1] - 2016-03-04
------------------------
### Added ### Added
- Inbucket now builds with Go 1.5 or 1.6 - Inbucket now builds with Go 1.5 or 1.6
@@ -82,8 +89,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
- RESTful API moved to `/api/v1` base URI - RESTful API moved to `/api/v1` base URI
- More graceful shutdown on Ctrl-C or when errors encountered - More graceful shutdown on Ctrl-C or when errors encountered
[1.0] - 2014-04-14 ## [v1.0] - 2014-04-14
------------------
### Added ### Added
- Add new configuration option `mailbox.message.cap` to prevent individual - Add new configuration option `mailbox.message.cap` to prevent individual
@@ -91,29 +97,29 @@ This project adheres to [Semantic Versioning](http://semver.org/).
- Add Link button to messages, allows for directing another person to a - Add Link button to messages, allows for directing another person to a
specific message. specific message.
[Unreleased]: https://github.com/jhillyerd/inbucket/compare/master...develop [Unreleased]: https://github.com/jhillyerd/inbucket/compare/master...develop
[1.2.0-rc2]: https://github.com/jhillyerd/inbucket/compare/1.2.0-rc1...1.2.0-rc2 [v1.3.0]: https://github.com/jhillyerd/inbucket/compare/v1.2.0...v1.3.0
[1.2.0-rc1]: https://github.com/jhillyerd/inbucket/compare/1.1.0...1.2.0-rc1 [v1.2.0]: https://github.com/jhillyerd/inbucket/compare/1.2.0-rc2...1.2.0
[1.1.0]: https://github.com/jhillyerd/inbucket/compare/1.1.0-rc2...1.1.0 [v1.2.0-rc2]: https://github.com/jhillyerd/inbucket/compare/1.2.0-rc1...1.2.0-rc2
[1.1.0-rc2]: https://github.com/jhillyerd/inbucket/compare/1.1.0-rc1...1.1.0-rc2 [v1.2.0-rc1]: https://github.com/jhillyerd/inbucket/compare/1.1.0...1.2.0-rc1
[1.1.0-rc1]: https://github.com/jhillyerd/inbucket/compare/1.0...1.1.0-rc1 [v1.1.0]: https://github.com/jhillyerd/inbucket/compare/1.1.0-rc2...1.1.0
[1.0]: https://github.com/jhillyerd/inbucket/compare/1.0-rc1...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` 1. Create release branch: `git flow release start 1.x.0`
2. Update CHANGELOG.md: 2. Update CHANGELOG.md:
- Ensure *Unreleased* section is up to date - Ensure *Unreleased* section is up to date
- Rename *Unreleased* section to release name and date. - Rename *Unreleased* section to release name and date.
- Add new GitHub `/compare` link - Add new GitHub `/compare` link
3. Update goxc version info: `goxc -wc -pv=1.x.0 -pr=rc1` 3. Run tests
4. Run: `goxc interpolate-source` to update VERSION var 4. Test cross-compile: `goreleaser --snapshot`
5. Run tests 5. Commit changes and merge release: `git flow release finish`
6. Test cross-compile: `goxc` 6. Push tags and wait for https://travis-ci.org/jhillyerd/inbucket build to
7. Commit changes and merge release: `git flow release finish 1.x.0` complete
8. Upload to bintray: `goxc bintray` 7. Update `binary_versions` option in `inbucket-site/_config.yml`
9. Update `binary_versions` option in `inbucket-site/_config.yml`
See http://keepachangelog.com/ for additional instructions on how to update this file. See http://keepachangelog.com/ for additional instructions on how to update this file.

35
Makefile Normal file
View File

@@ -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}

View File

@@ -1,4 +1,5 @@
package smtpd // Package datastore contains implementation independent datastore logic
package datastore
import ( import (
"errors" "errors"

View File

@@ -1,4 +1,4 @@
package smtpd package datastore
import ( import (
"container/list" "container/list"
@@ -36,6 +36,11 @@ func init() {
rm.Set("Period", expRetentionPeriod) rm.Set("Period", expRetentionPeriod)
rm.Set("RetainedHist", expRetainedHist) rm.Set("RetainedHist", expRetainedHist)
rm.Set("RetainedCurrent", expRetainedCurrent) 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. // RetentionScanner looks for messages older than the configured retention period and deletes them.
@@ -85,9 +90,9 @@ retentionLoop:
dur := time.Minute - since dur := time.Minute - since
log.Tracef("Retention scanner sleeping for %v", dur) log.Tracef("Retention scanner sleeping for %v", dur)
select { select {
case _ = <-rs.globalShutdown: case <-rs.globalShutdown:
break retentionLoop break retentionLoop
case _ = <-time.After(dur): case <-time.After(dur):
} }
} }
// Kickoff scan // Kickoff scan
@@ -97,7 +102,7 @@ retentionLoop:
} }
// Check for global shutdown // Check for global shutdown
select { select {
case _ = <-rs.globalShutdown: case <-rs.globalShutdown:
break retentionLoop break retentionLoop
default: default:
} }
@@ -154,9 +159,7 @@ func (rs *RetentionScanner) doScan() error {
// Join does not retun until the retention scanner has shut down // Join does not retun until the retention scanner has shut down
func (rs *RetentionScanner) Join() { func (rs *RetentionScanner) Join() {
if rs.retentionShutdown != nil { if rs.retentionShutdown != nil {
select { <-rs.retentionShutdown
case <-rs.retentionShutdown:
}
} }
} }

View File

@@ -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
}

View File

@@ -1,4 +1,4 @@
package rest package datastore
import ( import (
"io" "io"
@@ -6,130 +6,151 @@ import (
"time" "time"
"github.com/jhillyerd/enmime" "github.com/jhillyerd/enmime"
"github.com/jhillyerd/inbucket/smtpd"
"github.com/stretchr/testify/mock" "github.com/stretchr/testify/mock"
) )
// Mock DataStore object // MockDataStore is a shared mock for unit testing
type MockDataStore struct { type MockDataStore struct {
mock.Mock 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) 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() 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 { type MockMailbox struct {
mock.Mock mock.Mock
} }
func (m *MockMailbox) GetMessages() ([]smtpd.Message, error) { // GetMessages mock function
func (m *MockMailbox) GetMessages() ([]Message, error) {
args := m.Called() 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) 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 { func (m *MockMailbox) Purge() error {
args := m.Called() args := m.Called()
return args.Error(0) return args.Error(0)
} }
func (m *MockMailbox) NewMessage() (smtpd.Message, error) { // NewMessage mock function
func (m *MockMailbox) NewMessage() (Message, error) {
args := m.Called() 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 { func (m *MockMailbox) Name() string {
args := m.Called() args := m.Called()
return args.String(0) return args.String(0)
} }
// String mock function
func (m *MockMailbox) String() string { func (m *MockMailbox) String() string {
args := m.Called() args := m.Called()
return args.String(0) return args.String(0)
} }
// Mock Message object // MockMessage is a shared mock for unit testing
type MockMessage struct { type MockMessage struct {
mock.Mock mock.Mock
} }
// ID mock function
func (m *MockMessage) ID() string { func (m *MockMessage) ID() string {
args := m.Called() args := m.Called()
return args.String(0) return args.String(0)
} }
// From mock function
func (m *MockMessage) From() string { func (m *MockMessage) From() string {
args := m.Called() args := m.Called()
return args.String(0) return args.String(0)
} }
// To mock function
func (m *MockMessage) To() []string { func (m *MockMessage) To() []string {
args := m.Called() args := m.Called()
return args.Get(0).([]string) return args.Get(0).([]string)
} }
// Date mock function
func (m *MockMessage) Date() time.Time { func (m *MockMessage) Date() time.Time {
args := m.Called() args := m.Called()
return args.Get(0).(time.Time) return args.Get(0).(time.Time)
} }
// Subject mock function
func (m *MockMessage) Subject() string { func (m *MockMessage) Subject() string {
args := m.Called() args := m.Called()
return args.String(0) return args.String(0)
} }
// ReadHeader mock function
func (m *MockMessage) ReadHeader() (msg *mail.Message, err error) { func (m *MockMessage) ReadHeader() (msg *mail.Message, err error) {
args := m.Called() args := m.Called()
return args.Get(0).(*mail.Message), args.Error(1) return args.Get(0).(*mail.Message), args.Error(1)
} }
// ReadBody mock function
func (m *MockMessage) ReadBody() (body *enmime.Envelope, err error) { func (m *MockMessage) ReadBody() (body *enmime.Envelope, err error) {
args := m.Called() args := m.Called()
return args.Get(0).(*enmime.Envelope), args.Error(1) return args.Get(0).(*enmime.Envelope), args.Error(1)
} }
// ReadRaw mock function
func (m *MockMessage) ReadRaw() (raw *string, err error) { func (m *MockMessage) ReadRaw() (raw *string, err error) {
args := m.Called() args := m.Called()
return args.Get(0).(*string), args.Error(1) return args.Get(0).(*string), args.Error(1)
} }
// RawReader mock function
func (m *MockMessage) RawReader() (reader io.ReadCloser, err error) { func (m *MockMessage) RawReader() (reader io.ReadCloser, err error) {
args := m.Called() args := m.Called()
return args.Get(0).(io.ReadCloser), args.Error(1) return args.Get(0).(io.ReadCloser), args.Error(1)
} }
// Size mock function
func (m *MockMessage) Size() int64 { func (m *MockMessage) Size() int64 {
args := m.Called() args := m.Called()
return int64(args.Int(0)) return int64(args.Int(0))
} }
// Append mock function
func (m *MockMessage) Append(data []byte) error { func (m *MockMessage) Append(data []byte) error {
// []byte arg seems to mess up testify/mock // []byte arg seems to mess up testify/mock
return nil return nil
} }
// Close mock function
func (m *MockMessage) Close() error { func (m *MockMessage) Close() error {
args := m.Called() args := m.Called()
return args.Error(0) return args.Error(0)
} }
// Delete mock function
func (m *MockMessage) Delete() error { func (m *MockMessage) Delete() error {
args := m.Called() args := m.Called()
return args.Error(0) return args.Error(0)
} }
// String mock function
func (m *MockMessage) String() string { func (m *MockMessage) String() string {
args := m.Called() args := m.Called()
return args.String(0) return args.String(0)

View File

@@ -15,11 +15,14 @@ apk add --no-cache --virtual .build-deps git
# Setup # Setup
export GOBIN="$bindir" export GOBIN="$bindir"
builddate="$(date -Iseconds)"
cd "$srcdir" cd "$srcdir"
go clean # Fetch tags for describe
git fetch -t
builddate="$(date -Iseconds)"
buildver="$(git describe --tags --always)"
# Build # Build
go clean
echo "### Fetching Dependencies" echo "### Fetching Dependencies"
go get -t -v ./... go get -t -v ./...
@@ -27,7 +30,7 @@ echo "### Testing Inbucket"
go test ./... go test ./...
echo "### Building Inbucket" echo "### Building Inbucket"
go build -o inbucket -ldflags "-X 'main.BUILDDATE=$builddate'" -v . go build -o inbucket -ldflags "-X 'main.version=$buildver' -X 'main.date=$builddate'" -v .
echo "### Installing Inbucket" echo "### Installing Inbucket"
set -x set -x

10
etc/travis-deploy.sh Executable file
View File

@@ -0,0 +1,10 @@
#!/bin/bash
# travis-deploy.sh
# description: Trigger goreleaser deployment in correct build scenarios
set -eo pipefail
set -x
if [[ "$TRAVIS_GO_VERSION" == "$DEPLOY_WITH_MAJOR."* ]]; then
curl -sL https://git.io/goreleaser | bash
fi

View File

@@ -1,4 +1,4 @@
package smtpd package filestore
import ( import (
"bufio" "bufio"
@@ -11,6 +11,7 @@ import (
"time" "time"
"github.com/jhillyerd/enmime" "github.com/jhillyerd/enmime"
"github.com/jhillyerd/inbucket/datastore"
"github.com/jhillyerd/inbucket/log" "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. // NewMessage creates a new FileMessage object and sets the Date and Id fields.
// It will also delete messages over messageCap if configured. // It will also delete messages over messageCap if configured.
func (mb *FileMailbox) NewMessage() (Message, error) { func (mb *FileMailbox) NewMessage() (datastore.Message, error) {
// Load index // Load index
if !mb.indexLoaded { if !mb.indexLoaded {
if err := mb.readIndex(); err != nil { if err := mb.readIndex(); err != nil {
@@ -71,7 +72,7 @@ func (m *FileMessage) From() string {
return m.Ffrom 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 { func (m *FileMessage) To() []string {
return m.Fto return m.Fto
} }
@@ -165,7 +166,7 @@ func (m *FileMessage) ReadRaw() (raw *string, err error) {
func (m *FileMessage) Append(data []byte) error { func (m *FileMessage) Append(data []byte) error {
// Prevent Appending to a pre-existing Message // Prevent Appending to a pre-existing Message
if !m.writable { if !m.writable {
return ErrNotWritable return datastore.ErrNotWritable
} }
// Open file for writing if we haven't yet // Open file for writing if we haven't yet
if m.writer == nil { if m.writer == nil {

View File

@@ -1,4 +1,4 @@
package smtpd package filestore
import ( import (
"bufio" "bufio"
@@ -12,7 +12,9 @@ import (
"time" "time"
"github.com/jhillyerd/inbucket/config" "github.com/jhillyerd/inbucket/config"
"github.com/jhillyerd/inbucket/datastore"
"github.com/jhillyerd/inbucket/log" "github.com/jhillyerd/inbucket/log"
"github.com/jhillyerd/inbucket/stringutil"
) )
// Name of index file in each mailbox // Name of index file in each mailbox
@@ -55,7 +57,7 @@ type FileDataStore struct {
} }
// NewFileDataStore creates a new DataStore object using the specified path // 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 path := cfg.Path
if path == "" { if path == "" {
log.Errorf("No value configured for datastore 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 // DefaultFileDataStore creates a new DataStore object. It uses the inbucket.Config object to
// construct it's path. // construct it's path.
func DefaultFileDataStore() DataStore { func DefaultFileDataStore() datastore.DataStore {
cfg := config.GetDataStoreConfig() cfg := config.GetDataStoreConfig()
return NewFileDataStore(cfg) return NewFileDataStore(cfg)
} }
// MailboxFor retrieves the Mailbox object for a specified email address, if the mailbox // MailboxFor retrieves the Mailbox object for a specified email address, if the mailbox
// does not exist, it will attempt to create it. // does not exist, it will attempt to create it.
func (ds *FileDataStore) MailboxFor(emailAddress string) (Mailbox, error) { func (ds *FileDataStore) MailboxFor(emailAddress string) (datastore.Mailbox, error) {
name, err := ParseMailboxName(emailAddress) name, err := stringutil.ParseMailboxName(emailAddress)
if err != nil { if err != nil {
return nil, err return nil, err
} }
dir := HashMailboxName(name) dir := stringutil.HashMailboxName(name)
s1 := dir[0:3] s1 := dir[0:3]
s2 := dir[0:6] s2 := dir[0:6]
path := filepath.Join(ds.mailPath, s1, s2, dir) 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 // AllMailboxes returns a slice with all Mailboxes
func (ds *FileDataStore) AllMailboxes() ([]Mailbox, error) { func (ds *FileDataStore) AllMailboxes() ([]datastore.Mailbox, error) {
mailboxes := make([]Mailbox, 0, 100) mailboxes := make([]datastore.Mailbox, 0, 100)
infos1, err := ioutil.ReadDir(ds.mailPath) infos1, err := ioutil.ReadDir(ds.mailPath)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -149,24 +151,26 @@ type FileMailbox struct {
messages []*FileMessage messages []*FileMessage
} }
// Name of the mailbox
func (mb *FileMailbox) Name() string { func (mb *FileMailbox) Name() string {
return mb.name return mb.name
} }
// String renders the name and directory path of the mailbox
func (mb *FileMailbox) String() string { func (mb *FileMailbox) String() string {
return mb.name + "[" + mb.dirName + "]" return mb.name + "[" + mb.dirName + "]"
} }
// GetMessages scans the mailbox directory for .gob files and decodes them into // GetMessages scans the mailbox directory for .gob files and decodes them into
// a slice of Message objects. // a slice of Message objects.
func (mb *FileMailbox) GetMessages() ([]Message, error) { func (mb *FileMailbox) GetMessages() ([]datastore.Message, error) {
if !mb.indexLoaded { if !mb.indexLoaded {
if err := mb.readIndex(); err != nil { if err := mb.readIndex(); err != nil {
return nil, err return nil, err
} }
} }
messages := make([]Message, len(mb.messages)) messages := make([]datastore.Message, len(mb.messages))
for i, m := range mb.messages { for i, m := range mb.messages {
messages[i] = m 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 // 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 !mb.indexLoaded {
if err := mb.readIndex(); err != nil { if err := mb.readIndex(); err != nil {
return nil, err return nil, err
@@ -183,15 +187,15 @@ func (mb *FileMailbox) GetMessage(id string) (Message, error) {
if id == "latest" && len(mb.messages) != 0 { if id == "latest" && len(mb.messages) != 0 {
return mb.messages[len(mb.messages)-1], nil return mb.messages[len(mb.messages)-1], nil
} else { }
for _, m := range mb.messages {
if m.Fid == id { for _, m := range mb.messages {
return m, nil if m.Fid == id {
} return m, nil
} }
} }
return nil, ErrNotExist return nil, datastore.ErrNotExist
} }
// Purge deletes all messages in this mailbox // Purge deletes all messages in this mailbox

View File

@@ -1,4 +1,4 @@
package smtpd package filestore
import ( import (
"bytes" "bytes"
@@ -470,8 +470,8 @@ func TestGetLatestMessage(t *testing.T) {
mb, err := ds.MailboxFor(mbName) mb, err := ds.MailboxFor(mbName)
assert.Nil(t, err) assert.Nil(t, err)
msg, err := mb.GetMessage("latest") msg, err := mb.GetMessage("latest")
assert.Nil(t, msg)
assert.Error(t, err) assert.Error(t, err)
fmt.Println(msg)
// Deliver test message // Deliver test message
deliverMessage(ds, mbName, "test", time.Now()) 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) assert.True(t, msg.ID() == id3, "Expected %q to be equal to %q", msg.ID(), id3)
// Test wrong id // Test wrong id
msg, err = mb.GetMessage("wrongid") _, err = mb.GetMessage("wrongid")
assert.Error(t, err) assert.Error(t, err)
if t.Failed() { if t.Failed() {

View File

@@ -7,15 +7,15 @@ import (
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/gorilla/sessions" "github.com/gorilla/sessions"
"github.com/jhillyerd/inbucket/config" "github.com/jhillyerd/inbucket/config"
"github.com/jhillyerd/inbucket/datastore"
"github.com/jhillyerd/inbucket/msghub" "github.com/jhillyerd/inbucket/msghub"
"github.com/jhillyerd/inbucket/smtpd"
) )
// Context is passed into every request handler function // Context is passed into every request handler function
type Context struct { type Context struct {
Vars map[string]string Vars map[string]string
Session *sessions.Session Session *sessions.Session
DataStore smtpd.DataStore DataStore datastore.DataStore
MsgHub *msghub.Hub MsgHub *msghub.Hub
WebConfig config.WebConfig WebConfig config.WebConfig
IsJSON bool IsJSON bool

View File

@@ -13,9 +13,9 @@ import (
"github.com/gorilla/securecookie" "github.com/gorilla/securecookie"
"github.com/gorilla/sessions" "github.com/gorilla/sessions"
"github.com/jhillyerd/inbucket/config" "github.com/jhillyerd/inbucket/config"
"github.com/jhillyerd/inbucket/datastore"
"github.com/jhillyerd/inbucket/log" "github.com/jhillyerd/inbucket/log"
"github.com/jhillyerd/inbucket/msghub" "github.com/jhillyerd/inbucket/msghub"
"github.com/jhillyerd/inbucket/smtpd"
) )
// Handler is a function type that handles an HTTP request in Inbucket // 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 ( var (
// DataStore is where all the mailboxes and messages live // 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 holds a reference to the message pub/sub system
msgHub *msghub.Hub msgHub *msghub.Hub
@@ -51,7 +51,7 @@ func init() {
func Initialize( func Initialize(
cfg config.WebConfig, cfg config.WebConfig,
shutdownChan chan bool, shutdownChan chan bool,
ds smtpd.DataStore, ds datastore.DataStore,
mh *msghub.Hub) { mh *msghub.Hub) {
webConfig = cfg webConfig = cfg

View File

@@ -13,6 +13,7 @@ import (
"time" "time"
"github.com/jhillyerd/inbucket/config" "github.com/jhillyerd/inbucket/config"
"github.com/jhillyerd/inbucket/filestore"
"github.com/jhillyerd/inbucket/httpd" "github.com/jhillyerd/inbucket/httpd"
"github.com/jhillyerd/inbucket/log" "github.com/jhillyerd/inbucket/log"
"github.com/jhillyerd/inbucket/msghub" "github.com/jhillyerd/inbucket/msghub"
@@ -23,11 +24,11 @@ import (
) )
var ( var (
// VERSION contains the build version number, populated during linking by goxc // version contains the build version number, populated during linking
VERSION = "1.2.0-rc2" version = "undefined"
// BUILDDATE contains the build date, populated during linking by goxc // date contains the build date, populated during linking
BUILDDATE = "undefined" date = "undefined"
// Command line flags // Command line flags
help = flag.Bool("help", false, "Displays this help") help = flag.Bool("help", false, "Displays this help")
@@ -61,8 +62,8 @@ func init() {
} }
func main() { func main() {
config.Version = VERSION config.Version = version
config.BuildDate = BUILDDATE config.BuildDate = date
flag.Parse() flag.Parse()
if *help { if *help {
@@ -85,7 +86,7 @@ func main() {
} }
// Setup signal handler // Setup signal handler
sigChan := make(chan os.Signal) sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGHUP, syscall.SIGTERM, syscall.SIGINT) signal.Notify(sigChan, syscall.SIGHUP, syscall.SIGTERM, syscall.SIGINT)
// Initialize logging // Initialize logging
@@ -115,7 +116,7 @@ func main() {
msgHub := msghub.New(rootCtx, config.GetWebConfig().MonitorHistory) msgHub := msghub.New(rootCtx, config.GetWebConfig().MonitorHistory)
// Grab our datastore // Grab our datastore
ds := smtpd.DefaultFileDataStore() ds := filestore.DefaultFileDataStore()
// Start HTTP server // Start HTTP server
httpd.Initialize(config.GetWebConfig(), shutdownChan, ds, msgHub) httpd.Initialize(config.GetWebConfig(), shutdownChan, ds, msgHub)
@@ -124,8 +125,7 @@ func main() {
go httpd.Start(rootCtx) go httpd.Start(rootCtx)
// Start POP3 server // Start POP3 server
// TODO pass datastore pop3Server = pop3d.New(config.GetPOP3Config(), shutdownChan, ds)
pop3Server = pop3d.New(shutdownChan)
go pop3Server.Start(rootCtx) go pop3Server.Start(rootCtx)
// Startup SMTP server // Startup SMTP server
@@ -150,7 +150,7 @@ signalLoop:
log.Infof("Received SIGTERM, shutting down") log.Infof("Received SIGTERM, shutting down")
close(shutdownChan) close(shutdownChan)
} }
case _ = <-shutdownChan: case <-shutdownChan:
rootCancel() rootCancel()
break signalLoop break signalLoop
} }

62
log/metrics.go Normal file
View File

@@ -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)
}
}
}

View File

@@ -11,8 +11,8 @@ import (
"strings" "strings"
"time" "time"
"github.com/jhillyerd/inbucket/datastore"
"github.com/jhillyerd/inbucket/log" "github.com/jhillyerd/inbucket/log"
"github.com/jhillyerd/inbucket/smtpd"
) )
// State tracks the current mode of our POP3 state machine // 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 // Session defines an active POP3 session
type Session struct { type Session struct {
server *Server // Reference to the server we belong to server *Server // Reference to the server we belong to
id int // Session ID number id int // Session ID number
conn net.Conn // Our network connection conn net.Conn // Our network connection
remoteHost string // IP address of client remoteHost string // IP address of client
sendError error // Used to bail out of read loop on send error sendError error // Used to bail out of read loop on send error
state State // Current session state state State // Current session state
reader *bufio.Reader // Buffered reader for our net conn reader *bufio.Reader // Buffered reader for our net conn
user string // Mailbox name user string // Mailbox name
mailbox smtpd.Mailbox // Mailbox instance mailbox datastore.Mailbox // Mailbox instance
messages []smtpd.Message // Slice of messages in mailbox messages []datastore.Message // Slice of messages in mailbox
retain []bool // Messages to retain upon UPDATE (true=retain) retain []bool // Messages to retain upon UPDATE (true=retain)
msgCount int // Number of undeleted messages msgCount int // Number of undeleted messages
} }
// NewSession creates a new POP3 session // 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 // 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() reader, err := msg.RawReader()
if err != nil { if err != nil {
ses.logError("Failed to read message for RETR command") 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 // 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() reader, err := msg.RawReader()
if err != nil { if err != nil {
ses.logError("Failed to read message for RETR command") ses.logError("Failed to read message for RETR command")

View File

@@ -8,29 +8,25 @@ import (
"time" "time"
"github.com/jhillyerd/inbucket/config" "github.com/jhillyerd/inbucket/config"
"github.com/jhillyerd/inbucket/datastore"
"github.com/jhillyerd/inbucket/log" "github.com/jhillyerd/inbucket/log"
"github.com/jhillyerd/inbucket/smtpd"
) )
// Server defines an instance of our POP3 server // Server defines an instance of our POP3 server
type Server struct { type Server struct {
host string
domain string domain string
maxIdleSeconds int maxIdleSeconds int
dataStore smtpd.DataStore dataStore datastore.DataStore
listener net.Listener listener net.Listener
globalShutdown chan bool globalShutdown chan bool
waitgroup *sync.WaitGroup waitgroup *sync.WaitGroup
} }
// New creates a new Server struct // New creates a new Server struct
func New(shutdownChan chan bool) *Server { func New(cfg config.POP3Config, shutdownChan chan bool, ds datastore.DataStore) *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()
return &Server{ return &Server{
host: fmt.Sprintf("%v:%v", cfg.IP4address, cfg.IP4port),
domain: cfg.Domain, domain: cfg.Domain,
dataStore: ds, dataStore: ds,
maxIdleSeconds: cfg.MaxIdleSeconds, maxIdleSeconds: cfg.MaxIdleSeconds,
@@ -41,9 +37,7 @@ func New(shutdownChan chan bool) *Server {
// Start the server and listen for connections // Start the server and listen for connections
func (s *Server) Start(ctx context.Context) { func (s *Server) Start(ctx context.Context) {
cfg := config.GetPOP3Config() addr, err := net.ResolveTCPAddr("tcp4", s.host)
addr, err := net.ResolveTCPAddr("tcp4", fmt.Sprintf("%v:%v",
cfg.IP4address, cfg.IP4port))
if err != nil { if err != nil {
log.Errorf("POP3 Failed to build tcp4 address: %v", err) log.Errorf("POP3 Failed to build tcp4 address: %v", err)
s.emergencyShutdown() s.emergencyShutdown()

View File

@@ -10,16 +10,17 @@ import (
"io/ioutil" "io/ioutil"
"strconv" "strconv"
"github.com/jhillyerd/inbucket/datastore"
"github.com/jhillyerd/inbucket/httpd" "github.com/jhillyerd/inbucket/httpd"
"github.com/jhillyerd/inbucket/log" "github.com/jhillyerd/inbucket/log"
"github.com/jhillyerd/inbucket/rest/model" "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 // MailboxListV1 renders a list of messages in a mailbox
func MailboxListV1(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) (err error) { 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 // 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 { if err != nil {
return err 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) { 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 // Don't have to validate these aren't empty, Gorilla returns 404
id := ctx.Vars["id"] id := ctx.Vars["id"]
name, err := smtpd.ParseMailboxName(ctx.Vars["name"]) name, err := stringutil.ParseMailboxName(ctx.Vars["name"])
if err != nil { if err != nil {
return err 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) return fmt.Errorf("Failed to get mailbox for %q: %v", name, err)
} }
msg, err := mb.GetMessage(id) msg, err := mb.GetMessage(id)
if err == smtpd.ErrNotExist { if err == datastore.ErrNotExist {
http.NotFound(w, req) http.NotFound(w, req)
return nil return nil
} }
@@ -116,7 +117,7 @@ func MailboxShowV1(w http.ResponseWriter, req *http.Request, ctx *httpd.Context)
// MailboxPurgeV1 deletes all messages from a mailbox // MailboxPurgeV1 deletes all messages from a mailbox
func MailboxPurgeV1(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) (err error) { 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 // 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 { if err != nil {
return err 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) { 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 // Don't have to validate these aren't empty, Gorilla returns 404
id := ctx.Vars["id"] id := ctx.Vars["id"]
name, err := smtpd.ParseMailboxName(ctx.Vars["name"]) name, err := stringutil.ParseMailboxName(ctx.Vars["name"])
if err != nil { if err != nil {
return err 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) return fmt.Errorf("Failed to get mailbox for %q: %v", name, err)
} }
message, err := mb.GetMessage(id) message, err := mb.GetMessage(id)
if err == smtpd.ErrNotExist { if err == datastore.ErrNotExist {
http.NotFound(w, req) http.NotFound(w, req)
return nil 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) { 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 // Don't have to validate these aren't empty, Gorilla returns 404
id := ctx.Vars["id"] id := ctx.Vars["id"]
name, err := smtpd.ParseMailboxName(ctx.Vars["name"]) name, err := stringutil.ParseMailboxName(ctx.Vars["name"])
if err != nil { if err != nil {
return err 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) return fmt.Errorf("Failed to get mailbox for %q: %v", name, err)
} }
message, err := mb.GetMessage(id) message, err := mb.GetMessage(id)
if err == smtpd.ErrNotExist { if err == datastore.ErrNotExist {
http.NotFound(w, req) http.NotFound(w, req)
return nil return nil
} }

View File

@@ -9,7 +9,7 @@ import (
"testing" "testing"
"time" "time"
"github.com/jhillyerd/inbucket/smtpd" "github.com/jhillyerd/inbucket/datastore"
) )
const ( const (
@@ -31,7 +31,7 @@ const (
func TestRestMailboxList(t *testing.T) { func TestRestMailboxList(t *testing.T) {
// Setup // Setup
ds := &MockDataStore{} ds := &datastore.MockDataStore{}
logbuf := setupWebServer(ds) logbuf := setupWebServer(ds)
// Test invalid mailbox name // Test invalid mailbox name
@@ -45,9 +45,9 @@ func TestRestMailboxList(t *testing.T) {
} }
// Test empty mailbox // Test empty mailbox
emptybox := &MockMailbox{} emptybox := &datastore.MockMailbox{}
ds.On("MailboxFor", "empty").Return(emptybox, nil) 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") w, err = testRestGet(baseURL + "/mailbox/empty")
expectCode = 200 expectCode = 200
@@ -59,7 +59,7 @@ func TestRestMailboxList(t *testing.T) {
} }
// Test MailboxFor error // 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") w, err = testRestGet(baseURL + "/mailbox/error")
expectCode = 500 expectCode = 500
if err != nil { if err != nil {
@@ -77,9 +77,9 @@ func TestRestMailboxList(t *testing.T) {
} }
// Test MailboxFor error // Test MailboxFor error
error2box := &MockMailbox{} error2box := &datastore.MockMailbox{}
ds.On("MailboxFor", "error2").Return(error2box, nil) 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") w, err = testRestGet(baseURL + "/mailbox/error2")
expectCode = 500 expectCode = 500
@@ -107,11 +107,11 @@ func TestRestMailboxList(t *testing.T) {
Subject: "subject 2", Subject: "subject 2",
Date: time.Date(2012, 7, 1, 10, 11, 12, 253, time.FixedZone("PDT", -700)), 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) ds.On("MailboxFor", "good").Return(goodbox, nil)
msg1 := data1.MockMessage() msg1 := data1.MockMessage()
msg2 := data2.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 // Check return code
w, err = testRestGet(baseURL + "/mailbox/good") w, err = testRestGet(baseURL + "/mailbox/good")
@@ -155,7 +155,7 @@ func TestRestMailboxList(t *testing.T) {
func TestRestMessage(t *testing.T) { func TestRestMessage(t *testing.T) {
// Setup // Setup
ds := &MockDataStore{} ds := &datastore.MockDataStore{}
logbuf := setupWebServer(ds) logbuf := setupWebServer(ds)
// Test invalid mailbox name // Test invalid mailbox name
@@ -169,9 +169,9 @@ func TestRestMessage(t *testing.T) {
} }
// Test requesting a message that does not exist // Test requesting a message that does not exist
emptybox := &MockMailbox{} emptybox := &datastore.MockMailbox{}
ds.On("MailboxFor", "empty").Return(emptybox, nil) 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") w, err = testRestGet(baseURL + "/mailbox/empty/0001")
expectCode = 404 expectCode = 404
@@ -183,7 +183,7 @@ func TestRestMessage(t *testing.T) {
} }
// Test MailboxFor error // 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") w, err = testRestGet(baseURL + "/mailbox/error/0001")
expectCode = 500 expectCode = 500
if err != nil { if err != nil {
@@ -201,9 +201,9 @@ func TestRestMessage(t *testing.T) {
} }
// Test GetMessage error // Test GetMessage error
error2box := &MockMailbox{} error2box := &datastore.MockMailbox{}
ds.On("MailboxFor", "error2").Return(error2box, nil) 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") w, err = testRestGet(baseURL + "/mailbox/error2/0001")
expectCode = 500 expectCode = 500
@@ -228,7 +228,7 @@ func TestRestMessage(t *testing.T) {
Text: "This is some text", Text: "This is some text",
HTML: "This is some HTML", HTML: "This is some HTML",
} }
goodbox := &MockMailbox{} goodbox := &datastore.MockMailbox{}
ds.On("MailboxFor", "good").Return(goodbox, nil) ds.On("MailboxFor", "good").Return(goodbox, nil)
msg1 := data1.MockMessage() msg1 := data1.MockMessage()
goodbox.On("GetMessage", "0001").Return(msg1, nil) goodbox.On("GetMessage", "0001").Return(msg1, nil)

View File

@@ -9,7 +9,7 @@ import (
"github.com/jhillyerd/inbucket/log" "github.com/jhillyerd/inbucket/log"
"github.com/jhillyerd/inbucket/msghub" "github.com/jhillyerd/inbucket/msghub"
"github.com/jhillyerd/inbucket/rest/model" "github.com/jhillyerd/inbucket/rest/model"
"github.com/jhillyerd/inbucket/smtpd" "github.com/jhillyerd/inbucket/stringutil"
) )
const ( const (
@@ -169,7 +169,7 @@ func MonitorAllMessagesV1(
func MonitorMailboxMessagesV1( func MonitorMailboxMessagesV1(
w http.ResponseWriter, req *http.Request, ctx *httpd.Context) (err error) { 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 { if err != nil {
return err return err
} }

View File

@@ -11,9 +11,9 @@ import (
"github.com/jhillyerd/enmime" "github.com/jhillyerd/enmime"
"github.com/jhillyerd/inbucket/config" "github.com/jhillyerd/inbucket/config"
"github.com/jhillyerd/inbucket/datastore"
"github.com/jhillyerd/inbucket/httpd" "github.com/jhillyerd/inbucket/httpd"
"github.com/jhillyerd/inbucket/msghub" "github.com/jhillyerd/inbucket/msghub"
"github.com/jhillyerd/inbucket/smtpd"
) )
type InputMessageData struct { type InputMessageData struct {
@@ -25,8 +25,8 @@ type InputMessageData struct {
HTML, Text string HTML, Text string
} }
func (d *InputMessageData) MockMessage() *MockMessage { func (d *InputMessageData) MockMessage() *datastore.MockMessage {
msg := &MockMessage{} msg := &datastore.MockMessage{}
msg.On("ID").Return(d.ID) msg.On("ID").Return(d.ID)
msg.On("From").Return(d.From) msg.On("From").Return(d.From)
msg.On("To").Return(d.To) msg.On("To").Return(d.To)
@@ -188,7 +188,7 @@ func testRestGet(url string) (*httptest.ResponseRecorder, error) {
return w, nil return w, nil
} }
func setupWebServer(ds smtpd.DataStore) *bytes.Buffer { func setupWebServer(ds datastore.DataStore) *bytes.Buffer {
// Capture log output // Capture log output
buf := new(bytes.Buffer) buf := new(bytes.Buffer)
log.SetOutput(buf) log.SetOutput(buf)

110
sanitize/css.go Normal file
View File

@@ -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
}

34
sanitize/css_test.go Normal file
View File

@@ -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)
}
})
}
}

88
sanitize/html.go Normal file
View File

@@ -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())
}
}
}

171
sanitize/html_test.go Normal file
View File

@@ -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 &lt; 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{
"<p>paragraph</p>",
"<b>bold</b>",
"<i>italic</b>",
"<em>emphasis</em>",
"<strong>strong</strong>",
"<div><span>text</span></div>",
"<center>text</center>",
}
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)
}
})
}
}
// TestHTMLScriptTags tests some strings with JavaScript
func TestHTMLScriptTags(t *testing.T) {
testCases := []struct {
input, want string
}{
{
`safe<script>nope</script>`,
`safe`,
},
{
`<a onblur="alert(something)" href="http://mysite.com">mysite</a>`,
`<a href="http://mysite.com" rel="nofollow">mysite</a>`,
},
}
for _, tc := range testCases {
t.Run(tc.input, func(t *testing.T) {
got, err := sanitize.HTML(tc.input)
if err != nil {
t.Fatal(err)
}
if got != tc.want {
t.Errorf("Got: %q, want: %q", got, tc.want)
}
})
}
}
func TestSanitizeStyleTags(t *testing.T) {
testCases := []struct {
name, input, want string
}{
{
"empty",
``,
``,
},
{
"open",
`<div>`,
`<div>`,
},
{
"open close",
`<div></div>`,
`<div></div>`,
},
{
"inner text",
`<div>foo bar</div>`,
`<div>foo bar</div>`,
},
{
"self close",
`<br/>`,
`<br/>`,
},
{
"open params",
`<div id="me">`,
`<div id="me">`,
},
{
"open params squote",
`<div id="me" title='best'>`,
`<div id="me" title="best">`,
},
{
"open style",
`<div id="me" style="color: red;">`,
`<div id="me" style="color: red;">`,
},
{
"open style squote",
`<div id="me" style='color: red;'>`,
`<div id="me" style="color: red;">`,
},
{
"open style mixed case",
`<div id="me" StYlE="color: red;">`,
`<div id="me" style="color: red;">`,
},
{
"closed style",
`<br style="border: 1px solid red;"/>`,
`<br style="border: 1px solid red;"/>`,
},
{
"mixed case style",
`<br StYlE="border: 1px solid red;"/>`,
`<br style="border: 1px solid red;"/>`,
},
{
"mixed case invalid style",
`<br StYlE="position: fixed;"/>`,
`<br/>`,
},
{
"mixed",
`<p id='i' title="cla'zz" style="font-size: 25px;"><b>some text</b></p>`,
`<p id="i" title="cla&#39;zz" style="font-size: 25px;"><b>some text</b></p>`,
},
{
"invalid styles",
`<div id="me" style='position: absolute;'>`,
`<div id="me">`,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
got, err := sanitize.HTML(tc.input)
if err != nil {
t.Fatal(err)
}
if got != tc.want {
t.Errorf("input: %s\ngot : %s\nwant: %s", tc.input, got, tc.want)
}
})
}
}

View File

@@ -12,8 +12,10 @@ import (
"strings" "strings"
"time" "time"
"github.com/jhillyerd/inbucket/datastore"
"github.com/jhillyerd/inbucket/log" "github.com/jhillyerd/inbucket/log"
"github.com/jhillyerd/inbucket/msghub" "github.com/jhillyerd/inbucket/msghub"
"github.com/jhillyerd/inbucket/stringutil"
) )
// State tracks the current mode of our SMTP state machine // State tracks the current mode of our SMTP state machine
@@ -71,7 +73,7 @@ var commands = map[string]bool{
// recipientDetails for message delivery // recipientDetails for message delivery
type recipientDetails struct { type recipientDetails struct {
address, localPart, domainPart string address, localPart, domainPart string
mailbox Mailbox mailbox datastore.Mailbox
} }
// Session holds the state of an SMTP session // Session holds the state of an SMTP session
@@ -265,7 +267,7 @@ func (ss *Session) readyHandler(cmd string, arg string) {
return return
} }
from := m[1] from := m[1]
if _, _, err := ParseEmailAddress(from); err != nil { if _, _, err := stringutil.ParseEmailAddress(from); err != nil {
ss.send("501 Bad sender address syntax") ss.send("501 Bad sender address syntax")
ss.logWarn("Bad address as MAIL arg: %q, %s", from, err) ss.logWarn("Bad address as MAIL arg: %q, %s", from, err)
return return
@@ -314,7 +316,7 @@ func (ss *Session) mailHandler(cmd string, arg string) {
} }
// This trim is probably too forgiving // This trim is probably too forgiving
recip := strings.Trim(arg[3:], "<> ") 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.send("501 Bad recipient address syntax")
ss.logWarn("Bad address as RCPT arg: %q, %s", recip, err) ss.logWarn("Bad address as RCPT arg: %q, %s", recip, err)
return return
@@ -354,7 +356,7 @@ func (ss *Session) dataHandler() {
if ss.server.storeMessages { if ss.server.storeMessages {
for e := ss.recipients.Front(); e != nil; e = e.Next() { for e := ss.recipients.Front(); e != nil; e = e.Next() {
recip := e.Value.(string) recip := e.Value.(string)
local, domain, err := ParseEmailAddress(recip) local, domain, err := stringutil.ParseEmailAddress(recip)
if err != nil { if err != nil {
ss.logError("Failed to parse address for %q", recip) ss.logError("Failed to parse address for %q", recip)
ss.send(fmt.Sprintf("451 Failed to open mailbox for %v", 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 // readByteLine reads a line of input into the provided buffer. Does
// not reset the Buffer - please do so prior to calling. // 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 { if err := ss.conn.SetReadDeadline(ss.nextDeadline()); err != nil {
return err return err
} }
for { line, err := ss.reader.ReadBytes('\n')
line, err := ss.reader.ReadBytes('\n') if err != nil {
if err != nil { return err
return err
}
if _, err = buf.Write(line); err != nil {
return err
}
return nil
} }
_, err = buf.Write(line)
return err
} }
// Reads a line of input // 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. // The leading space is mandatory.
func (ss *Session) parseArgs(arg string) (args map[string]string, ok bool) { func (ss *Session) parseArgs(arg string) (args map[string]string, ok bool) {
args = make(map[string]string) args = make(map[string]string)
re := regexp.MustCompile(" (\\w+)=(\\w+)") re := regexp.MustCompile(` (\w+)=(\w+)`)
pm := re.FindAllStringSubmatch(arg, -1) pm := re.FindAllStringSubmatch(arg, -1)
if pm == nil { if pm == nil {
ss.logWarn("Failed to parse arg string: %q") ss.logWarn("Failed to parse arg string: %q")

View File

@@ -14,6 +14,7 @@ import (
"time" "time"
"github.com/jhillyerd/inbucket/config" "github.com/jhillyerd/inbucket/config"
"github.com/jhillyerd/inbucket/datastore"
"github.com/jhillyerd/inbucket/msghub" "github.com/jhillyerd/inbucket/msghub"
) )
@@ -25,17 +26,13 @@ type scriptStep struct {
// Test commands in GREET state // Test commands in GREET state
func TestGreetState(t *testing.T) { func TestGreetState(t *testing.T) {
// Setup mock objects // Setup mock objects
mds := &MockDataStore{} mds := &datastore.MockDataStore{}
mb1 := &MockMailbox{}
mds.On("MailboxFor").Return(mb1, nil)
server, logbuf, teardown := setupSMTPServer(mds) server, logbuf, teardown := setupSMTPServer(mds)
defer teardown() defer teardown()
var script []scriptStep
// Test out some mangled HELOs // Test out some mangled HELOs
script = []scriptStep{ script := []scriptStep{
{"HELO", 501}, {"HELO", 501},
{"EHLO", 501}, {"EHLO", 501},
{"HELLO", 500}, {"HELLO", 500},
@@ -86,17 +83,13 @@ func TestGreetState(t *testing.T) {
// Test commands in READY state // Test commands in READY state
func TestReadyState(t *testing.T) { func TestReadyState(t *testing.T) {
// Setup mock objects // Setup mock objects
mds := &MockDataStore{} mds := &datastore.MockDataStore{}
mb1 := &MockMailbox{}
mds.On("MailboxFor").Return(mb1, nil)
server, logbuf, teardown := setupSMTPServer(mds) server, logbuf, teardown := setupSMTPServer(mds)
defer teardown() defer teardown()
var script []scriptStep
// Test out some mangled READY commands // Test out some mangled READY commands
script = []scriptStep{ script := []scriptStep{
{"HELO localhost", 250}, {"HELO localhost", 250},
{"FOOB", 500}, {"FOOB", 500},
{"HELO", 503}, {"HELO", 503},
@@ -151,10 +144,10 @@ func TestReadyState(t *testing.T) {
// Test commands in MAIL state // Test commands in MAIL state
func TestMailState(t *testing.T) { func TestMailState(t *testing.T) {
// Setup mock objects // Setup mock objects
mds := &MockDataStore{} mds := &datastore.MockDataStore{}
mb1 := &MockMailbox{} mb1 := &datastore.MockMailbox{}
msg1 := &MockMessage{} msg1 := &datastore.MockMessage{}
mds.On("MailboxFor").Return(mb1, nil) mds.On("MailboxFor", "u1").Return(mb1, nil)
mb1.On("NewMessage").Return(msg1, nil) mb1.On("NewMessage").Return(msg1, nil)
mb1.On("Name").Return("u1") mb1.On("Name").Return("u1")
msg1.On("ID").Return("") msg1.On("ID").Return("")
@@ -168,10 +161,8 @@ func TestMailState(t *testing.T) {
server, logbuf, teardown := setupSMTPServer(mds) server, logbuf, teardown := setupSMTPServer(mds)
defer teardown() defer teardown()
var script []scriptStep
// Test out some mangled READY commands // Test out some mangled READY commands
script = []scriptStep{ script := []scriptStep{
{"HELO localhost", 250}, {"HELO localhost", 250},
{"MAIL FROM:<john@gmail.com>", 250}, {"MAIL FROM:<john@gmail.com>", 250},
{"FOOB", 500}, {"FOOB", 500},
@@ -268,10 +259,10 @@ func TestMailState(t *testing.T) {
// Test commands in DATA state // Test commands in DATA state
func TestDataState(t *testing.T) { func TestDataState(t *testing.T) {
// Setup mock objects // Setup mock objects
mds := &MockDataStore{} mds := &datastore.MockDataStore{}
mb1 := &MockMailbox{} mb1 := &datastore.MockMailbox{}
msg1 := &MockMessage{} msg1 := &datastore.MockMessage{}
mds.On("MailboxFor").Return(mb1, nil) mds.On("MailboxFor", "u1").Return(mb1, nil)
mb1.On("NewMessage").Return(msg1, nil) mb1.On("NewMessage").Return(msg1, nil)
mb1.On("Name").Return("u1") mb1.On("Name").Return("u1")
msg1.On("ID").Return("") 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) SetReadDeadline(t time.Time) error { return nil }
func (m *mockConn) SetWriteDeadline(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 // Test Server Config
cfg := config.SMTPConfig{ cfg := config.SMTPConfig{
IP4address: net.IPv4(127, 0, 0, 1), IP4address: net.IPv4(127, 0, 0, 1),

View File

@@ -11,13 +11,35 @@ import (
"time" "time"
"github.com/jhillyerd/inbucket/config" "github.com/jhillyerd/inbucket/config"
"github.com/jhillyerd/inbucket/datastore"
"github.com/jhillyerd/inbucket/log" "github.com/jhillyerd/inbucket/log"
"github.com/jhillyerd/inbucket/msghub" "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 // Server holds the configuration and state of our SMTP server
type Server struct { type Server struct {
// Configuration // Configuration
host string
domain string domain string
domainNoStore string domainNoStore string
maxRecips int maxRecips int
@@ -26,10 +48,10 @@ type Server struct {
storeMessages bool storeMessages bool
// Dependencies // Dependencies
dataStore DataStore // Mailbox/message store dataStore datastore.DataStore // Mailbox/message store
globalShutdown chan bool // Shuts down Inbucket globalShutdown chan bool // Shuts down Inbucket
msgHub *msghub.Hub // Pub/sub for message info msgHub *msghub.Hub // Pub/sub for message info
retentionScanner *RetentionScanner // Deletes expired messages retentionScanner *datastore.RetentionScanner // Deletes expired messages
// State // State
listener net.Listener // Incoming network connections listener net.Listener // Incoming network connections
@@ -61,9 +83,10 @@ var (
func NewServer( func NewServer(
cfg config.SMTPConfig, cfg config.SMTPConfig,
globalShutdown chan bool, globalShutdown chan bool,
ds DataStore, ds datastore.DataStore,
msgHub *msghub.Hub) *Server { msgHub *msghub.Hub) *Server {
return &Server{ return &Server{
host: fmt.Sprintf("%v:%v", cfg.IP4address, cfg.IP4port),
domain: cfg.Domain, domain: cfg.Domain,
domainNoStore: strings.ToLower(cfg.DomainNoStore), domainNoStore: strings.ToLower(cfg.DomainNoStore),
maxRecips: cfg.MaxRecipients, maxRecips: cfg.MaxRecipients,
@@ -73,16 +96,14 @@ func NewServer(
globalShutdown: globalShutdown, globalShutdown: globalShutdown,
dataStore: ds, dataStore: ds,
msgHub: msgHub, msgHub: msgHub,
retentionScanner: NewRetentionScanner(ds, globalShutdown), retentionScanner: datastore.NewRetentionScanner(ds, globalShutdown),
waitgroup: new(sync.WaitGroup), waitgroup: new(sync.WaitGroup),
} }
} }
// Start the listener and handle incoming connections // Start the listener and handle incoming connections
func (s *Server) Start(ctx context.Context) { func (s *Server) Start(ctx context.Context) {
cfg := config.GetSMTPConfig() addr, err := net.ResolveTCPAddr("tcp4", s.host)
addr, err := net.ResolveTCPAddr("tcp4", fmt.Sprintf("%v:%v",
cfg.IP4address, cfg.IP4port))
if err != nil { if err != nil {
log.Errorf("Failed to build tcp4 address: %v", err) log.Errorf("Failed to build tcp4 address: %v", err)
s.emergencyShutdown() s.emergencyShutdown()
@@ -110,10 +131,8 @@ func (s *Server) Start(ctx context.Context) {
go s.serve(ctx) go s.serve(ctx)
// Wait for shutdown // Wait for shutdown
select { <-ctx.Done()
case <-ctx.Done(): log.Tracef("SMTP shutdown requested, connections will be drained")
log.Tracef("SMTP shutdown requested, connections will be drained")
}
// Closing the listener will cause the serve() go routine to exit // Closing the listener will cause the serve() go routine to exit
if err := s.listener.Close(); err != nil { if err := s.listener.Close(); err != nil {
@@ -165,7 +184,7 @@ func (s *Server) serve(ctx context.Context) {
func (s *Server) emergencyShutdown() { func (s *Server) emergencyShutdown() {
// Shutdown Inbucket // Shutdown Inbucket
select { select {
case _ = <-s.globalShutdown: case <-s.globalShutdown:
default: default:
close(s.globalShutdown) close(s.globalShutdown)
} }
@@ -178,44 +197,3 @@ func (s *Server) Drain() {
log.Tracef("SMTP connections have drained") log.Tracef("SMTP connections have drained")
s.retentionScanner.Join() 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)
}

View File

@@ -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)
}

View File

@@ -1,8 +1,7 @@
package smtpd package stringutil
import ( import (
"bytes" "bytes"
"container/list"
"crypto/sha1" "crypto/sha1"
"fmt" "fmt"
"io" "io"
@@ -42,7 +41,7 @@ func ParseMailboxName(localPart string) (result string, err error) {
return result, nil 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 // the directory to house the mailbox
func HashMailboxName(mailbox string) string { func HashMailboxName(mailbox string) string {
h := sha1.New() h := sha1.New()
@@ -53,18 +52,6 @@ func HashMailboxName(mailbox string) string {
return fmt.Sprintf("%x", h.Sum(nil)) 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 // ValidateDomainPart returns true if the domain part complies to RFC3696, RFC1035
func ValidateDomainPart(domain string) bool { func ValidateDomainPart(domain string) bool {
if len(domain) == 0 { if len(domain) == 0 {
@@ -143,15 +130,24 @@ LOOP:
switch { switch {
case ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z'): case ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z'):
// Letters are OK // Letters are OK
_ = buf.WriteByte(c) err = buf.WriteByte(c)
if err != nil {
return
}
inCharQuote = false inCharQuote = false
case '0' <= c && c <= '9': case '0' <= c && c <= '9':
// Numbers are OK // Numbers are OK
_ = buf.WriteByte(c) err = buf.WriteByte(c)
if err != nil {
return
}
inCharQuote = false inCharQuote = false
case bytes.IndexByte([]byte("!#$%&'*+-/=?^_`{|}~"), c) >= 0: case bytes.IndexByte([]byte("!#$%&'*+-/=?^_`{|}~"), c) >= 0:
// These specials can be used unquoted // These specials can be used unquoted
_ = buf.WriteByte(c) err = buf.WriteByte(c)
if err != nil {
return
}
inCharQuote = false inCharQuote = false
case c == '.': case c == '.':
// A single period is OK // A single period is OK
@@ -159,13 +155,19 @@ LOOP:
// Sequence of periods is not permitted // Sequence of periods is not permitted
return "", "", fmt.Errorf("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 inCharQuote = false
case c == '\\': case c == '\\':
inCharQuote = true inCharQuote = true
case c == '"': case c == '"':
if inCharQuote { if inCharQuote {
_ = buf.WriteByte(c) err = buf.WriteByte(c)
if err != nil {
return
}
inCharQuote = false inCharQuote = false
} else if inStringQuote { } else if inStringQuote {
inStringQuote = false inStringQuote = false
@@ -178,7 +180,10 @@ LOOP:
} }
case c == '@': case c == '@':
if inCharQuote || inStringQuote { if inCharQuote || inStringQuote {
_ = buf.WriteByte(c) err = buf.WriteByte(c)
if err != nil {
return
}
inCharQuote = false inCharQuote = false
} else { } else {
// End of local-part // End of local-part
@@ -195,7 +200,10 @@ LOOP:
return "", "", fmt.Errorf("Characters outside of US-ASCII range not permitted") return "", "", fmt.Errorf("Characters outside of US-ASCII range not permitted")
default: default:
if inCharQuote || inStringQuote { if inCharQuote || inStringQuote {
_ = buf.WriteByte(c) err = buf.WriteByte(c)
if err != nil {
return
}
inCharQuote = false inCharQuote = false
} else { } else {
return "", "", fmt.Errorf("Character %q must be quoted", c) return "", "", fmt.Errorf("Character %q must be quoted", c)

View File

@@ -1,4 +1,4 @@
package smtpd package stringutil
import ( import (
"strings" "strings"

View File

@@ -0,0 +1,393 @@
Date: %DATE%
To: %TO_ADDRESS%
From: %FROM_ADDRESS%
Subject: tutsplus responsive inlined CSS
MIME-Version: 1.0
Content-Type: text/html; charset="UTF-8"
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<!--[if !mso]><!-->
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<!--<![endif]-->
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title></title>
<!--[if (gte mso 9)|(IE)]>
<style type="text/css">
table {border-collapse: collapse !important;}
</style>
<![endif]-->
</head>
<body style="margin-top:0 !important;margin-bottom:0 !important;margin-right:0 !important;margin-left:0 !important;padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;background-color:#ffffff;" >
<center class="wrapper" style="width:100%;table-layout:fixed;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;" >
<div class="webkit" style="max-width:600px;margin-top:0;margin-bottom:0;margin-right:auto;margin-left:auto;" >
<!--[if (gte mso 9)|(IE)]>
<table width="600" align="center" style="border-spacing:0;font-family:sans-serif;color:#333333;" >
<tr>
<td style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" >
<![endif]-->
<table class="outer" align="center" style="border-spacing:0;font-family:sans-serif;color:#333333;Margin:0 auto;width:100%;max-width:600px;" >
<tr>
<td class="full-width-image" style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" >
<img src="http://www.inbucket.org/email-assets/responsive/header.jpg" width="600" alt="" style="border-width:0;width:100%;max-width:600px;height:auto;" />
</td>
</tr>
<tr>
<td class="one-column" style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" >
<table width="100%" style="border-spacing:0;font-family:sans-serif;color:#333333;" >
<tr>
<td class="inner contents" style="padding-top:10px;padding-bottom:10px;padding-right:10px;padding-left:10px;width:100%;text-align:left;" >
<p class="h1" style="Margin:0;font-weight:bold;font-size:14px;Margin-bottom:10px;" >Lorem ipsum dolor sit amet</p>
<p style="Margin:0;font-size:14px;Margin-bottom:10px;" >
Compare to:
<a href="http://tutsplus.github.io/creating-a-future-proof-responsive-email-without-media-queries/index.html" style="color:#ee6a56;text-decoration:underline;" >
tutsplus sample</a>
</p>
<p style="Margin:0;font-size:14px;Margin-bottom:10px;" >Copyright (c) 2015, Envato Tuts+<br/>
All rights reserved.</p>
<p style="Margin:0;font-size:14px;Margin-bottom:10px;" >Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:</p>
<ul>
<li>Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.</li>
<li>Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.</li>
</ul>
<p style="Margin:0;font-size:14px;Margin-bottom:10px;" >THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.</p>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td class="two-column" style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;text-align:center;font-size:0;" >
<!--[if (gte mso 9)|(IE)]>
<table width="100%" style="border-spacing:0;font-family:sans-serif;color:#333333;" >
<tr>
<td width="50%" valign="top" style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" >
<![endif]-->
<div class="column" style="width:100%;max-width:300px;display:inline-block;vertical-align:top;" >
<table width="100%" style="border-spacing:0;font-family:sans-serif;color:#333333;" >
<tr>
<td class="inner" style="padding-top:10px;padding-bottom:10px;padding-right:10px;padding-left:10px;" >
<table class="contents" style="border-spacing:0;font-family:sans-serif;color:#333333;width:100%;font-size:14px;text-align:left;" >
<tr>
<td style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" >
<img src="http://www.inbucket.org/email-assets/responsive/two-column-01.jpg" width="280" alt="" style="border-width:0;width:100%;max-width:280px;height:auto;" />
</td>
</tr>
<tr>
<td class="text" style="padding-bottom:0;padding-right:0;padding-left:0;padding-top:10px;" >
Maecenas sed ante pellentesque, posuere leo id, eleifend dolor. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos.
</td>
</tr>
</table>
</td>
</tr>
</table>
</div>
<!--[if (gte mso 9)|(IE)]>
</td><td width="50%" valign="top" style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" >
<![endif]-->
<div class="column" style="width:100%;max-width:300px;display:inline-block;vertical-align:top;" >
<table width="100%" style="border-spacing:0;font-family:sans-serif;color:#333333;" >
<tr>
<td class="inner" style="padding-top:10px;padding-bottom:10px;padding-right:10px;padding-left:10px;" >
<table class="contents" style="border-spacing:0;font-family:sans-serif;color:#333333;width:100%;font-size:14px;text-align:left;" >
<tr>
<td style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" >
<img src="http://www.inbucket.org/email-assets/responsive/two-column-02.jpg" width="280" alt="" style="border-width:0;width:100%;max-width:280px;height:auto;" />
</td>
</tr>
<tr>
<td class="text" style="padding-bottom:0;padding-right:0;padding-left:0;padding-top:10px;" >
Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Maecenas sed ante pellentesque, posuere leo id, eleifend dolor.
</td>
</tr>
</table>
</td>
</tr>
</table>
</div>
<!--[if (gte mso 9)|(IE)]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
<tr>
<td class="three-column" style="padding-right:0;padding-left:0;text-align:center;font-size:0;padding-top:10px;padding-bottom:10px;" >
<!--[if (gte mso 9)|(IE)]>
<table width="100%" style="border-spacing:0;font-family:sans-serif;color:#333333;" >
<tr>
<td width="200" valign="top" style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" >
<![endif]-->
<div class="column" style="width:100%;max-width:200px;display:inline-block;vertical-align:top;" >
<table width="100%" style="border-spacing:0;font-family:sans-serif;color:#333333;" >
<tr>
<td class="inner" style="padding-top:10px;padding-bottom:10px;padding-right:10px;padding-left:10px;" >
<table class="contents" style="border-spacing:0;font-family:sans-serif;color:#333333;width:100%;font-size:14px;text-align:center;" >
<tr>
<td style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" >
<img src="http://www.inbucket.org/email-assets/responsive/three-column-01.jpg" width="180" alt="" style="border-width:0;width:100%;max-width:180px;height:auto;" />
</td>
</tr>
<tr>
<td class="text" style="padding-bottom:0;padding-right:0;padding-left:0;padding-top:10px;" >
Scelerisque congue eros eu posuere. Praesent in felis ut velit pretium lobortis rhoncus ut erat.
</td>
</tr>
</table>
</td>
</tr>
</table>
</div>
<!--[if (gte mso 9)|(IE)]>
</td><td width="200" valign="top" style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" >
<![endif]-->
<div class="column" style="width:100%;max-width:200px;display:inline-block;vertical-align:top;" >
<table width="100%" style="border-spacing:0;font-family:sans-serif;color:#333333;" >
<tr>
<td class="inner" style="padding-top:10px;padding-bottom:10px;padding-right:10px;padding-left:10px;" >
<table class="contents" style="border-spacing:0;font-family:sans-serif;color:#333333;width:100%;font-size:14px;text-align:center;" >
<tr>
<td style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" >
<img src="http://www.inbucket.org/email-assets/responsive/three-column-02.jpg" width="180" alt="" style="border-width:0;width:100%;max-width:180px;height:auto;" />
</td>
</tr>
<tr>
<td class="text" style="padding-bottom:0;padding-right:0;padding-left:0;padding-top:10px;" >
Maecenas sed ante pellentesque, posuere leo id, eleifend dolor.
</td>
</tr>
</table>
</td>
</tr>
</table>
</div>
<!--[if (gte mso 9)|(IE)]>
</td><td width="200" valign="top" style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" >
<![endif]-->
<div class="column" style="width:100%;max-width:200px;display:inline-block;vertical-align:top;" >
<table width="100%" style="border-spacing:0;font-family:sans-serif;color:#333333;" >
<tr>
<td class="inner" style="padding-top:10px;padding-bottom:10px;padding-right:10px;padding-left:10px;" >
<table class="contents" style="border-spacing:0;font-family:sans-serif;color:#333333;width:100%;font-size:14px;text-align:center;" >
<tr>
<td style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" >
<img src="http://www.inbucket.org/email-assets/responsive/three-column-03.jpg" width="180" alt="" style="border-width:0;width:100%;max-width:180px;height:auto;" />
</td>
</tr>
<tr>
<td class="text" style="padding-bottom:0;padding-right:0;padding-left:0;padding-top:10px;" >
Praesent laoreet malesuada cursus. Maecenas scelerisque congue eros eu posuere.
</td>
</tr>
</table>
</td>
</tr>
</table>
</div>
<!--[if (gte mso 9)|(IE)]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
<tr>
<td class="three-column" style="padding-right:0;padding-left:0;text-align:center;font-size:0;padding-top:10px;padding-bottom:10px;" >
<!--[if (gte mso 9)|(IE)]>
<table width="100%" style="border-spacing:0;font-family:sans-serif;color:#333333;" >
<tr>
<td width="200" valign="top" style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" >
<![endif]-->
<div class="column" style="width:100%;max-width:200px;display:inline-block;vertical-align:top;" >
<table width="100%" style="border-spacing:0;font-family:sans-serif;color:#333333;" >
<tr>
<td class="inner contents" style="padding-top:10px;padding-bottom:10px;padding-right:10px;padding-left:10px;width:100%;font-size:14px;text-align:center;" >
<p class="h2" style="Margin:0;font-size:18px;font-weight:bold;Margin-bottom:12px;" >Fashion</p>
<p style="Margin:0;" >Class eleifend aptent taciti sociosqu ad litora torquent conubia</p>
<p style="Margin:0;" ><a href="#" style="color:#ee6a56;text-decoration:underline;" >Read requirements</a></p>
</td>
</tr>
</table>
</div>
<!--[if (gte mso 9)|(IE)]>
</td><td width="200" valign="top" style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" >
<![endif]-->
<div class="column" style="width:100%;max-width:200px;display:inline-block;vertical-align:top;" >
<table width="100%" style="border-spacing:0;font-family:sans-serif;color:#333333;" >
<tr>
<td class="inner contents" style="padding-top:10px;padding-bottom:10px;padding-right:10px;padding-left:10px;width:100%;font-size:14px;text-align:center;" >
<p class="h2" style="Margin:0;font-size:18px;font-weight:bold;Margin-bottom:12px;" >Photography</p>
<p style="Margin:0;" >Maecenas sed ante pellentesque, posuere leo id, eleifend dolor</p>
<p style="Margin:0;" ><a href="#" style="color:#ee6a56;text-decoration:underline;" >See examples</a></p>
</td>
</tr>
</table>
</div>
<!--[if (gte mso 9)|(IE)]>
</td><td width="200" valign="top" style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" >
<![endif]-->
<div class="column" style="width:100%;max-width:200px;display:inline-block;vertical-align:top;" >
<table width="100%" style="border-spacing:0;font-family:sans-serif;color:#333333;" >
<tr>
<td class="inner contents" style="padding-top:10px;padding-bottom:10px;padding-right:10px;padding-left:10px;width:100%;font-size:14px;text-align:center;" >
<p class="h2" style="Margin:0;font-size:18px;font-weight:bold;Margin-bottom:12px;" >Design</p>
<p style="Margin:0;" >Class aptent taciti sociosqu eleifend ad litora per conubia nostra</p>
<p style="Margin:0;" ><a href="#" style="color:#ee6a56;text-decoration:underline;" >See the winners</a></p>
</td>
</tr>
</table>
</div>
<!--[if (gte mso 9)|(IE)]>
</td>
</tr>
<tr>
<td width="200" valign="top" style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" >
<![endif]-->
<div class="column" style="width:100%;max-width:200px;display:inline-block;vertical-align:top;" >
<table width="100%" style="border-spacing:0;font-family:sans-serif;color:#333333;" >
<tr>
<td class="inner contents" style="padding-top:10px;padding-bottom:10px;padding-right:10px;padding-left:10px;width:100%;font-size:14px;text-align:center;" >
<p class="h2" style="Margin:0;font-size:18px;font-weight:bold;Margin-bottom:12px;" >Cooking</p>
<p style="Margin:0;" >Class aptent taciti eleifend sociosqu ad litora torquent conubia</p>
<p style="Margin:0;" ><a href="#" style="color:#ee6a56;text-decoration:underline;" >Read recipes</a></p>
</td>
</tr>
</table>
</div>
<!--[if (gte mso 9)|(IE)]>
</td><td width="200" valign="top" style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" >
<![endif]-->
<div class="column" style="width:100%;max-width:200px;display:inline-block;vertical-align:top;" >
<table width="100%" style="border-spacing:0;font-family:sans-serif;color:#333333;" >
<tr>
<td class="inner contents" style="padding-top:10px;padding-bottom:10px;padding-right:10px;padding-left:10px;width:100%;font-size:14px;text-align:center;" >
<p class="h2" style="Margin:0;font-size:18px;font-weight:bold;Margin-bottom:12px;" >Woodworking</p>
<p style="Margin:0;" >Maecenas sed ante pellentesque, posuere leo id, eleifend dolor</p>
<p style="Margin:0;" ><a href="#" style="color:#ee6a56;text-decoration:underline;" >See examples</a></p>
</td>
</tr>
</table>
</div>
<!--[if (gte mso 9)|(IE)]>
</td><td width="200" valign="top" style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" >
<![endif]-->
<div class="column" style="width:100%;max-width:200px;display:inline-block;vertical-align:top;" >
<table width="100%" style="border-spacing:0;font-family:sans-serif;color:#333333;" >
<tr>
<td class="inner contents" style="padding-top:10px;padding-bottom:10px;padding-right:10px;padding-left:10px;width:100%;font-size:14px;text-align:center;" >
<p class="h2" style="Margin:0;font-size:18px;font-weight:bold;Margin-bottom:12px;" >Craft</p>
<p style="Margin:0;" >Class aptent taciti sociosqu ad eleifend litora per conubia nostra</p>
<p style="Margin:0;" ><a href="#" style="color:#ee6a56;text-decoration:underline;" >Vote now</a></p>
</td>
</tr>
</table>
</div>
<!--[if (gte mso 9)|(IE)]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
<tr>
<td class="left-sidebar" style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;text-align:center;font-size:0;" >
<!--[if (gte mso 9)|(IE)]>
<table width="100%" style="border-spacing:0;font-family:sans-serif;color:#333333;" >
<tr>
<td width="100" style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" >
<![endif]-->
<div class="column left" style="width:100%;display:inline-block;vertical-align:middle;max-width:100px;" >
<table width="100%" style="border-spacing:0;font-family:sans-serif;color:#333333;" >
<tr>
<td class="inner" style="padding-top:10px;padding-bottom:10px;padding-right:10px;padding-left:10px;" >
<img src="http://www.inbucket.org/email-assets/responsive/sidebar-01.jpg" width="80" alt="" style="border-width:0;" />
</td>
</tr>
</table>
</div>
<!--[if (gte mso 9)|(IE)]>
</td><td width="500" style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" >
<![endif]-->
<div class="column right" style="width:100%;display:inline-block;vertical-align:middle;max-width:500px;" >
<table width="100%" style="border-spacing:0;font-family:sans-serif;color:#333333;" >
<tr>
<td class="inner contents" style="padding-top:10px;padding-bottom:10px;padding-right:10px;padding-left:10px;width:100%;font-size:14px;text-align:center;" >
Praesent laoreet malesuada cursus. Maecenas scelerisque congue eros eu posuere. Praesent in felis ut velit pretium lobortis rhoncus ut erat. <a href="#" style="text-decoration:underline;color:#85ab70;" >Read&nbsp;on</a>
</td>
</tr>
</table>
</div>
<!--[if (gte mso 9)|(IE)]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
<tr>
<td class="right-sidebar" dir="rtl" style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;text-align:center;font-size:0;" >
<!--[if (gte mso 9)|(IE)]>
<table width="100%" dir="rtl" style="border-spacing:0;font-family:sans-serif;color:#333333;" >
<tr>
<td width="100" style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" >
<![endif]-->
<div class="column left" dir="ltr" style="width:100%;display:inline-block;vertical-align:middle;max-width:100px;" >
<table width="100%" style="border-spacing:0;font-family:sans-serif;color:#333333;" >
<tr>
<td class="inner contents" style="padding-top:10px;padding-bottom:10px;padding-right:10px;padding-left:10px;width:100%;font-size:14px;text-align:center;" >
<img src="http://www.inbucket.org/email-assets/responsive/sidebar-02.jpg" width="80" alt="" style="border-width:0;" />
</td>
</tr>
</table>
</div>
<!--[if (gte mso 9)|(IE)]>
</td><td width="500" style="padding-top:0;padding-bottom:0;padding-right:0;padding-left:0;" >
<![endif]-->
<div class="column right" dir="ltr" style="width:100%;display:inline-block;vertical-align:middle;max-width:500px;" >
<table width="100%" style="border-spacing:0;font-family:sans-serif;color:#333333;" >
<tr>
<td class="inner contents" style="padding-top:10px;padding-bottom:10px;padding-right:10px;padding-left:10px;width:100%;font-size:14px;text-align:center;" >
Maecenas sed ante pellentesque, posuere leo id, eleifend dolor. Class aptent taciti sociosqu ad litora torquent per conubia nostra. <a href="#" style="text-decoration:underline;color:#70bbd9;" >Per&nbsp;inceptos</a>
</td>
</tr>
</table>
</div>
<!--[if (gte mso 9)|(IE)]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
</table>
<!--[if (gte mso 9)|(IE)]>
</td>
</tr>
</table>
<![endif]-->
</div>
</center>
</body>
</html>

View File

@@ -1,7 +1,7 @@
Date: %DATE% Date: %DATE%
To: %TO_ADDRESS% To: %TO_ADDRESS%
From: %FROM_ADDRESS% From: %FROM_ADDRESS%
Subject: tutsplus responsive Subject: tutsplus responsive external CSS
MIME-Version: 1.0 MIME-Version: 1.0
Content-Type: text/html; charset="UTF-8" Content-Type: text/html; charset="UTF-8"

View File

@@ -5,4 +5,6 @@ Subject: Swaks HTML
MIME-Version: 1.0 MIME-Version: 1.0
Content-Type: text/html; charset="UTF-8" Content-Type: text/html; charset="UTF-8"
This is a test of <b>HTML</b> at the <i>top</i> level. <p style="font-family: 'Courier New', Courier, monospace;">
This is a test of <b>HTML</b> at the <i>top</i> level.
</p>

View File

@@ -53,5 +53,6 @@ swaks $* --data gmail.raw
# Outlook test # Outlook test
swaks $* --data outlook.raw swaks $* --data outlook.raw
# Nonemime responsive HTML test # Non-mime responsive HTML test
swaks $* --data nonmime-html-responsive.raw swaks $* --data nonmime-html-responsive.raw
swaks $* --data nonmime-html-inlined.raw

View File

@@ -51,12 +51,17 @@ body {
} }
} }
#body-tabs > li > a {
padding-top: 6px;
padding-bottom: 6px;
}
.message-body { .message-body {
padding: 0 5px; padding: 10px 4px;
} }
.message-attachments { .message-attachments {
margin-top: 20px; margin-top: 5px;
padding: 10px 10px 0 0; padding: 10px 10px 0 0;
} }

View File

@@ -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 // flashTooltip temporarily changes the text of a tooltip
function flashTooltip(el, text) { function flashTooltip(el, text) {
var prevText = $(el).attr('data-original-title'); var prevText = $(el).attr('data-original-title');
@@ -43,7 +54,7 @@ function loadList() {
dataType: "json", dataType: "json",
url: '/api/v1/mailbox/' + mailbox, url: '/api/v1/mailbox/' + mailbox,
success: function(data) { success: function(data) {
messageListData = data; messageListData = data.reverse();
// Render list // Render list
$('#message-list').loadTemplate($('#list-entry-template'), data); $('#message-list').loadTemplate($('#list-entry-template'), data);
$('.message-list-entry').click(onMessageListClick); $('.message-list-entry').click(onMessageListClick);
@@ -144,6 +155,7 @@ function onMessageLoaded(responseText, textStatus, XMLHttpRequest) {
return; return;
} }
onDocumentChange(); onDocumentChange();
$('#body-tabs a:first').tab('show')
var top = $('#message-container').offset().top - navBarOffset; var top = $('#message-container').offset().top - navBarOffset;
$(window).scrollTop(top); $(window).scrollTop(top);
} }

View File

@@ -30,7 +30,7 @@ function startMonitor(mailbox) {
$('#monitor-message-list').loadTemplate( $('#monitor-message-list').loadTemplate(
$('#message-template'), $('#message-template'),
msg, msg,
{ append: true }); { prepend: true });
}); });
ws.addEventListener('close', function (e) { ws.addEventListener('close', function (e) {
$('#conn-status').text('Disconnected!'); $('#conn-status').text('Disconnected!');

View File

@@ -24,7 +24,7 @@
class="btn btn-primary" class="btn btn-primary"
onClick="htmlView('{{.message.ID}}');"> onClick="htmlView('{{.message.ID}}');">
<span class="glyphicon glyphicon-new-window" aria-hidden="true"></span> <span class="glyphicon glyphicon-new-window" aria-hidden="true"></span>
HTML Raw HTML
</button> </button>
{{end}} {{end}}
</div> </div>
@@ -78,7 +78,22 @@
</div> </div>
{{end}} {{end}}
<div class="message-body">{{.body}}</div> <nav>
<ul id="body-tabs" class="nav nav-tabs" role="tablist">
{{if .htmlAvailable}}
<li role="presentation">
<a href="#body-html" aria-controls="body-html" role="tab" data-toggle="tab">Safe HTML</a>
</li>
{{end}}
<li role="presentation">
<a href="#body-text" aria-controls="body-text" role="tab" data-toggle="tab">Plain Text</a>
</li>
</ul>
</nav>
<div class="tab-content">
<div role="tabpanel" class="tab-pane message-body" id="body-html">{{.htmlBody}}</div>
<div role="tabpanel" class="tab-pane message-body" id="body-text">{{.body}}</div>
</div>
{{with .attachments}} {{with .attachments}}
<div class="well message-attachments"> <div class="well message-attachments">

View File

@@ -57,6 +57,14 @@ $(document).ready(function() {
onclick="loadList()"> onclick="loadList()">
<span class="glyphicon glyphicon-refresh" aria-hidden="true"></span> <span class="glyphicon glyphicon-refresh" aria-hidden="true"></span>
</button> </button>
<button class="btn btn-default"
type="button"
data-toggle="tooltip"
data-placement="top"
title="Delete&nbsp;Mailbox"
onclick="deleteMailbox()">
<span class="glyphicon glyphicon-trash"></span>
</button>
</div> </div>
</div> </div>
<div id="message-list-wrapper"> <div id="message-list-wrapper">

View File

@@ -7,9 +7,11 @@ import (
"net/http" "net/http"
"strconv" "strconv"
"github.com/jhillyerd/inbucket/datastore"
"github.com/jhillyerd/inbucket/httpd" "github.com/jhillyerd/inbucket/httpd"
"github.com/jhillyerd/inbucket/log" "github.com/jhillyerd/inbucket/log"
"github.com/jhillyerd/inbucket/smtpd" "github.com/jhillyerd/inbucket/sanitize"
"github.com/jhillyerd/inbucket/stringutil"
) )
// MailboxIndex renders the index page for a particular mailbox // MailboxIndex renders the index page for a particular mailbox
@@ -23,7 +25,7 @@ func MailboxIndex(w http.ResponseWriter, req *http.Request, ctx *httpd.Context)
http.Redirect(w, req, httpd.Reverse("RootIndex"), http.StatusSeeOther) http.Redirect(w, req, httpd.Reverse("RootIndex"), http.StatusSeeOther)
return nil return nil
} }
name, err = smtpd.ParseMailboxName(name) name, err = stringutil.ParseMailboxName(name)
if err != nil { if err != nil {
ctx.Session.AddFlash(err.Error(), "errors") ctx.Session.AddFlash(err.Error(), "errors")
_ = ctx.Session.Save(req, w) _ = ctx.Session.Save(req, w)
@@ -50,7 +52,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) { 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 // Don't have to validate these aren't empty, Gorilla returns 404
id := ctx.Vars["id"] id := ctx.Vars["id"]
name, err := smtpd.ParseMailboxName(ctx.Vars["name"]) name, err := stringutil.ParseMailboxName(ctx.Vars["name"])
if err != nil { if err != nil {
ctx.Session.AddFlash(err.Error(), "errors") ctx.Session.AddFlash(err.Error(), "errors")
_ = ctx.Session.Save(req, w) _ = ctx.Session.Save(req, w)
@@ -66,7 +68,7 @@ func MailboxLink(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) (
// MailboxList renders a list of messages in a mailbox. Renders a partial // 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) { 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 // 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 { if err != nil {
return err return err
} }
@@ -93,7 +95,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) { 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 // Don't have to validate these aren't empty, Gorilla returns 404
id := ctx.Vars["id"] id := ctx.Vars["id"]
name, err := smtpd.ParseMailboxName(ctx.Vars["name"]) name, err := stringutil.ParseMailboxName(ctx.Vars["name"])
if err != nil { if err != nil {
return err return err
} }
@@ -103,7 +105,7 @@ func MailboxShow(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) (
return fmt.Errorf("Failed to get mailbox for %q: %v", name, err) return fmt.Errorf("Failed to get mailbox for %q: %v", name, err)
} }
msg, err := mb.GetMessage(id) msg, err := mb.GetMessage(id)
if err == smtpd.ErrNotExist { if err == datastore.ErrNotExist {
http.NotFound(w, req) http.NotFound(w, req)
return nil return nil
} }
@@ -117,6 +119,14 @@ func MailboxShow(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) (
} }
body := template.HTML(httpd.TextToHTML(mime.Text)) body := template.HTML(httpd.TextToHTML(mime.Text))
htmlAvailable := mime.HTML != "" htmlAvailable := mime.HTML != ""
var htmlBody template.HTML
if htmlAvailable {
if str, err := sanitize.HTML(mime.HTML); err == nil {
htmlBody = template.HTML(str)
} else {
log.Warnf("HTML sanitizer failed: %s", err)
}
}
// Render partial template // Render partial template
return httpd.RenderPartial("mailbox/_show.html", w, map[string]interface{}{ return httpd.RenderPartial("mailbox/_show.html", w, map[string]interface{}{
"ctx": ctx, "ctx": ctx,
@@ -124,6 +134,7 @@ func MailboxShow(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) (
"message": msg, "message": msg,
"body": body, "body": body,
"htmlAvailable": htmlAvailable, "htmlAvailable": htmlAvailable,
"htmlBody": htmlBody,
"mimeErrors": mime.Errors, "mimeErrors": mime.Errors,
"attachments": mime.Attachments, "attachments": mime.Attachments,
}) })
@@ -133,7 +144,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) { 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 // Don't have to validate these aren't empty, Gorilla returns 404
id := ctx.Vars["id"] id := ctx.Vars["id"]
name, err := smtpd.ParseMailboxName(ctx.Vars["name"]) name, err := stringutil.ParseMailboxName(ctx.Vars["name"])
if err != nil { if err != nil {
return err return err
} }
@@ -143,7 +154,7 @@ func MailboxHTML(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) (
return fmt.Errorf("Failed to get mailbox for %q: %v", name, err) return fmt.Errorf("Failed to get mailbox for %q: %v", name, err)
} }
message, err := mb.GetMessage(id) message, err := mb.GetMessage(id)
if err == smtpd.ErrNotExist { if err == datastore.ErrNotExist {
http.NotFound(w, req) http.NotFound(w, req)
return nil return nil
} }
@@ -170,7 +181,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) { 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 // Don't have to validate these aren't empty, Gorilla returns 404
id := ctx.Vars["id"] id := ctx.Vars["id"]
name, err := smtpd.ParseMailboxName(ctx.Vars["name"]) name, err := stringutil.ParseMailboxName(ctx.Vars["name"])
if err != nil { if err != nil {
return err return err
} }
@@ -180,7 +191,7 @@ func MailboxSource(w http.ResponseWriter, req *http.Request, ctx *httpd.Context)
return fmt.Errorf("Failed to get mailbox for %q: %v", name, err) return fmt.Errorf("Failed to get mailbox for %q: %v", name, err)
} }
message, err := mb.GetMessage(id) message, err := mb.GetMessage(id)
if err == smtpd.ErrNotExist { if err == datastore.ErrNotExist {
http.NotFound(w, req) http.NotFound(w, req)
return nil return nil
} }
@@ -205,7 +216,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) { 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 // Don't have to validate these aren't empty, Gorilla returns 404
id := ctx.Vars["id"] id := ctx.Vars["id"]
name, err := smtpd.ParseMailboxName(ctx.Vars["name"]) name, err := stringutil.ParseMailboxName(ctx.Vars["name"])
if err != nil { if err != nil {
ctx.Session.AddFlash(err.Error(), "errors") ctx.Session.AddFlash(err.Error(), "errors")
_ = ctx.Session.Save(req, w) _ = ctx.Session.Save(req, w)
@@ -226,7 +237,7 @@ func MailboxDownloadAttach(w http.ResponseWriter, req *http.Request, ctx *httpd.
return fmt.Errorf("Failed to get mailbox for %q: %v", name, err) return fmt.Errorf("Failed to get mailbox for %q: %v", name, err)
} }
message, err := mb.GetMessage(id) message, err := mb.GetMessage(id)
if err == smtpd.ErrNotExist { if err == datastore.ErrNotExist {
http.NotFound(w, req) http.NotFound(w, req)
return nil return nil
} }
@@ -257,7 +268,7 @@ func MailboxDownloadAttach(w http.ResponseWriter, req *http.Request, ctx *httpd.
// MailboxViewAttach sends the attachment to the client for online viewing // MailboxViewAttach sends the attachment to the client for online viewing
func MailboxViewAttach(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) (err error) { 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 // 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 { if err != nil {
ctx.Session.AddFlash(err.Error(), "errors") ctx.Session.AddFlash(err.Error(), "errors")
_ = ctx.Session.Save(req, w) _ = ctx.Session.Save(req, w)
@@ -279,7 +290,7 @@ func MailboxViewAttach(w http.ResponseWriter, req *http.Request, ctx *httpd.Cont
return fmt.Errorf("Failed to get mailbox for %q: %v", name, err) return fmt.Errorf("Failed to get mailbox for %q: %v", name, err)
} }
message, err := mb.GetMessage(id) message, err := mb.GetMessage(id)
if err == smtpd.ErrNotExist { if err == datastore.ErrNotExist {
http.NotFound(w, req) http.NotFound(w, req)
return nil return nil
} }

View File

@@ -8,7 +8,7 @@ import (
"github.com/jhillyerd/inbucket/config" "github.com/jhillyerd/inbucket/config"
"github.com/jhillyerd/inbucket/httpd" "github.com/jhillyerd/inbucket/httpd"
"github.com/jhillyerd/inbucket/smtpd" "github.com/jhillyerd/inbucket/stringutil"
) )
// RootIndex serves the Inbucket landing page // 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) http.Redirect(w, req, httpd.Reverse("RootIndex"), http.StatusSeeOther)
return nil return nil
} }
name, err := smtpd.ParseMailboxName(ctx.Vars["name"]) name, err := stringutil.ParseMailboxName(ctx.Vars["name"])
if err != nil { if err != nil {
ctx.Session.AddFlash(err.Error(), "errors") ctx.Session.AddFlash(err.Error(), "errors")
_ = ctx.Session.Save(req, w) _ = ctx.Session.Save(req, w)