1
0
mirror of https://github.com/jhillyerd/inbucket.git synced 2025-12-18 10:07:02 +00:00

Compare commits

..

125 Commits

Author SHA1 Message Date
James Hillyerd
f00b9ddef0 Merge branch 'release/1.3.1' 2018-03-10 10:11:50 -08:00
James Hillyerd
019e66d798 Update change log for 1.3.1 2018-03-10 10:06:09 -08:00
James Hillyerd
a3877e4f4b datastore: Concurrency fix, closes #77 2018-03-09 14:02:15 -08:00
James Hillyerd
a89b6bbca2 Fix change log tag format 2018-02-28 14:22:47 -08:00
James Hillyerd
f58e51d921 Fix change log tag format 2018-02-28 14:21:39 -08:00
James Hillyerd
c39d5ded3f Merge tag 'v1.3.0' into develop
release 1.3.0
2018-02-28 14:19:31 -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
James Hillyerd
86554a63b8 Merge branch 'release/1.2.0-rc2' 2017-12-15 20:39:37 -08:00
James Hillyerd
1efe2ba48f Prepare release 1.2.0-rc2 2017-12-15 20:34:27 -08:00
James Hillyerd
f597687aa3 Update CHANGELOG.md 2017-12-15 20:20:27 -08:00
Carlos Tadeu Panato Junior
6368e3a83b Add option to get the latest message using latest as request parameter (#63) 2017-12-15 17:00:09 -08:00
James Hillyerd
ef17ad9074 Update Docker base to go 1.9 2017-12-14 22:16:24 -08:00
James Hillyerd
7908e41212 Fixes #61 - monitor.history=0 panic 2017-12-14 18:54:22 -08:00
James Hillyerd
a9b174bcb6 Add tl;dr to CONTRIBUTING.md 2017-12-14 18:32:55 -08:00
James Hillyerd
dc0b9b325e Use bash for swaks tests, no pipefail in sh 2017-12-12 19:59:33 -08:00
James Hillyerd
0a967f0f21 Update golang versions 2017-12-12 19:57:20 -08:00
James Hillyerd
304a2260e8 Don't close writers with defer 2017-02-12 16:21:44 -08:00
James Hillyerd
9fc9a333a6 Run travis tests with race detector enabled 2017-02-12 14:53:42 -08:00
James Hillyerd
3e8b914f89 Merge branch 'feature/cmdline' into develop 2017-02-05 15:47:40 -08:00
James Hillyerd
5e94f7b750 Address matching should only apply to address, not name 2017-02-05 15:31:31 -08:00
James Hillyerd
64e75face8 Add maxage flag to match subcommand 2017-02-05 15:15:28 -08:00
James Hillyerd
be4675b374 Add powerful match subcommand to cmdline client
- Multiple output formats
- Signals matches via exit status for shell scripts
- Match against To, From, Subject via regular expressions
- Can optionally delete matched messages
2017-02-05 14:17:47 -08:00
James Hillyerd
6722811425 Beginnings of a command line REST client 2017-02-04 18:21:55 -08:00
James Hillyerd
56cff6296a Update changelog 2017-02-04 18:20:27 -08:00
James Hillyerd
a1e35009e0 Add convenience methods to rest/client types 2017-02-04 16:14:40 -08:00
James Hillyerd
cc0428ab9b Merge branch 'release/1.2.0-rc1' into develop 2017-01-29 13:18:19 -08:00
James Hillyerd
68e35b5eca Merge branch 'release/1.2.0-rc1' 2017-01-29 13:14:55 -08:00
James Hillyerd
5147865e55 rc1 prep 2017-01-29 13:14:27 -08:00
James Hillyerd
a3727ee436 Finalize changelog 2017-01-29 12:41:19 -08:00
James Hillyerd
9e49480482 Update bootstrap theme javascript dependencies 2017-01-28 20:20:58 -08:00
James Hillyerd
958f5a44d9 travis to Go 1.7.5 2017-01-28 19:24:01 -08:00
James Hillyerd
9b1d28fc7d Reimplement msghub as an actor 2017-01-28 19:20:06 -08:00
James Hillyerd
e6f95c9367 Make @inbucket prompt configurable, closes #31 2017-01-28 17:27:50 -08:00
James Hillyerd
de5b9a824b Remove empty intermediate directories, closes #12 2017-01-28 16:17:25 -08:00
James Hillyerd
9ac3c90036 Add mutex to protect directory operations 2017-01-22 22:03:56 -08:00
James Hillyerd
85e3a77fe5 Extract FileMessage into filemsg.go 2017-01-22 21:26:47 -08:00
James Hillyerd
32631daeae Refactor retention scanner prior to starting #12 2017-01-22 20:59:59 -08:00
James Hillyerd
62b77dfe5e Refactor configuration parser to use tables 2017-01-22 18:57:03 -08:00
James Hillyerd
fa28fa57f8 Note Golang 1.7 requirement in CHANGELOG 2017-01-22 14:02:31 -08:00
James Hillyerd
00e4d3791c Allow monitoring of a particular mailbox for #44
- No UI to access, just append /mailbox to /monitor URL
- Changed API URLs:
  - /api/v1/monitor/messages - all
  - /api/v1/monitor/messages/{name} - specific
2017-01-22 13:51:55 -08:00
James Hillyerd
cf7bdee925 Add goroutine count to metrics 2017-01-22 12:45:53 -08:00
James Hillyerd
83b71334c2 Add screenshot to README 2017-01-22 12:02:28 -08:00
James Hillyerd
aa0edff398 Update CHANGELOG with Monitor feature 2017-01-21 21:34:01 -08:00
James Hillyerd
f09a4558a9 Bump go versions for Docker and TravisCI 2017-01-21 21:22:29 -08:00
James Hillyerd
1137912e1d Merge in branch for msg monitor feature, closes #44 2017-01-21 21:19:44 -08:00
James Hillyerd
e14e97919f Add connection status indicator for #44 2017-01-21 20:47:10 -08:00
James Hillyerd
9ae428ca44 Make monitor configurable for #44 2017-01-21 20:23:04 -08:00
James Hillyerd
c346372c85 Fix for flashes not clearing bug 2017-01-21 19:31:39 -08:00
James Hillyerd
63a76696bf Add user interface for monitor, #44 2017-01-16 21:30:40 -08:00
James Hillyerd
86365a047c Add open WebSockets to metrics 2017-01-16 18:53:24 -08:00
James Hillyerd
e5aad9f5d0 Implement server side of message monitor for #44 2017-01-16 18:12:27 -08:00
James Hillyerd
e32e6d00d6 Remove httpbuf, manually save sessions
- httpbuf prevents connections being upgraded to websockets
2017-01-16 16:14:01 -08:00
James Hillyerd
b3db619db9 Broadcast deliveries into msghub for #44 2017-01-16 13:09:50 -08:00
James Hillyerd
6ca2c27747 Pull message delivery into its own method 2017-01-16 10:50:28 -08:00
James Hillyerd
88ccb19360 Implement pub/sub message hub as a base for #44 2017-01-15 22:06:39 -08:00
James Hillyerd
a222b7c428 Make use of pkg context
- Use context inside of servers for shutdown
- Remove unnecessary localShutdown related code
2017-01-15 22:00:58 -08:00
James Hillyerd
0e02061c4a Merge branch 'feature/rest-client' into develop 2017-01-08 22:16:26 +00:00
James Hillyerd
c8fd56ca90 Complete REST client for #43
- Add source, delete and purge
2017-01-08 22:11:22 +00:00
James Hillyerd
d8255382da Start of V1 REST client for #43
- List mailbox contents
- Get message
2017-01-08 04:25:50 +00:00
James Hillyerd
61e9b91637 Generic REST client (HTTP GET only) for #43 2017-01-08 04:11:06 +00:00
James Hillyerd
fa6b0a3227 Move REST JSON model into its own package for #43 2017-01-05 05:59:12 +00:00
James Hillyerd
61c6e7c2e9 Create systemd service unit file for #47
- Switch ubuntu from upstart to systemd
- Switch redhat from init.d to systemd
2017-01-05 05:29:21 +00:00
James Hillyerd
dcc0f36f48 Increase max local-part length to 128
- Improves compatibility with Mailgun
- Closes #41
- Update CHANGELOG
2016-12-31 05:09:12 +00:00
James Hillyerd
c1e7de5e14 Remove old REST API, closes #28 2016-12-31 03:39:32 +00:00
James Hillyerd
493efb04cd Remove legacy theme 'integral' 2016-12-31 01:41:13 +00:00
James Hillyerd
ff481c56c6 Add contributing guide 2016-12-31 00:35:59 +00:00
WiszniewskiMateusz
2f5d80a521 Added data about message attachments to REST API (#46) 2016-12-29 10:13:27 -08:00
James Hillyerd
364e7a0b80 Track enmime API changes
- Part accessors: 196b2ad725
- IsTextFromHTML: 2bd44ac6cc
2016-11-21 22:32:29 -08:00
James Hillyerd
26a9903492 Track enmime API change
0af1249adf
2016-11-21 20:29:45 -08:00
James Hillyerd
264fa9e11d Update travis config for go master 2016-11-17 18:37:37 -08:00
James Hillyerd
1906a147f0 Migrate from pkg go.enmime to enmime 2016-11-17 18:35:01 -08:00
James Hillyerd
145e71dc43 Merge branch 'feature/to-header' into develop
- Messages now store To information
2016-09-22 20:12:09 -07:00
James Hillyerd
017a097588 Switch to storing To addresses as a slice
- Changes on-disk storage format
- Changes JSON API
- To and From values are now parsed/formatted by Go's mail.ParseAddress
  function
- Fixed bug in list-entry-template, was not escaping HTML characters
- Updated tests
2016-09-21 22:12:20 -07:00
James Hillyerd
01ea89e7e2 Multi-recipient swaks test
- Add a multi-recipient test to run-tests.sh
- Removal accidental output of jq binary location when pretty-printing REST JSON
- Add To: change to CHANGELOG.md
- Fix comment typo
2016-09-18 17:48:21 -07:00
Tomasz Wojtuń
8f14ba8359 corrected webui/rest_test.go 2016-09-18 17:48:21 -07:00
Tomasz Wojtuń
8d36aa9750 revert webui stuff 2016-09-18 17:48:21 -07:00
Tomasz Wojtuń
02eee0a608 corrected tests 2016-09-18 17:48:21 -07:00
Tomasz Wojtuń
124f830478 Added "To:" header 2016-09-18 17:48:21 -07:00
James Hillyerd
1856deae46 SMTP handler is now more forgiving of line endings, a la Postfix 2016-09-18 17:45:03 -07:00
James Hillyerd
a939605d4a Cache message in memory during receipt, closes #23 2016-09-18 16:35:13 -07:00
James Hillyerd
f84b36039e Merge branch 'release/1.1.0' into develop 2016-09-03 11:29:39 -07:00
James Hillyerd
5ef3adc88e Merge branch 'release/1.1.0' 2016-09-03 11:27:24 -07:00
James Hillyerd
1742bf9a34 Merge branch 'release/1.1.0-rc2' 2016-03-06 16:47:45 -08:00
James Hillyerd
c2779ff054 Merge branch 'release/1.1.0-rc1' 2016-03-03 21:41:09 -08:00
490 changed files with 117230 additions and 82804 deletions

9
.gitignore vendored
View File

@@ -25,10 +25,9 @@ _testmain.go
*.swp
*.swo
# our binary
# our binaries
/inbucket
/inbucket.exe
/target/**
# local goxc config
.goxc.local.json
/dist/**
/cmd/client/client
/cmd/client/client.exe

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,17 +0,0 @@
{
"ArtifactsDest": "target",
"TasksExclude": [
"pkg-build"
],
"Arch": "amd64",
"Os": "darwin freebsd linux windows",
"ResourcesInclude": "README*,LICENSE*,CHANGELOG*,inbucket.bat,etc,themes",
"PackageVersion": "1.1.0",
"ConfigVersion": "0.9",
"BuildSettings": {
"LdFlagsXVars": {
"TimeNow": "main.BUILDDATE",
"Version": "main.VERSION"
}
}
}

View File

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

View File

@@ -4,8 +4,71 @@ Change Log
All notable changes to this project will be documented in this file.
This project adheres to [Semantic Versioning](http://semver.org/).
[1.1.0] - 2016-09-03
------------
## [v1.3.1] - 2018-03-10
### Fixed
- Adding additional locking during message delivery to prevent race condition
that could lose messages.
## [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
- `rest/client` types `MessageHeader` and `Message` with convenience methods;
provides a more natural API
- Powerful command line REST
[client](https://github.com/jhillyerd/inbucket/wiki/cmd-client)
- Allow use of `latest` as a message ID in REST calls
### Changed
- `rest/client.NewV1` renamed to `New`
- `rest/client` package now embeds the shared `rest/model` structs into its own
types
- Fixed panic when `monitor.history` set to 0
## [v1.2.0-rc1] - 2017-01-29
### Added
- Storage of `To:` header in messages (likely breaks existing datastores)
- Attachment list to [GET message
JSON](https://github.com/jhillyerd/inbucket/wiki/REST-GET-message)
- [Go client for REST
API](https://godoc.org/github.com/jhillyerd/inbucket/rest/client)
- Monitor feature: lists messages as they arrive, regardless of their
destination mailbox
- Make `@inbucket` mailbox prompt configurable
- Warnings and errors from MIME parser are displayed with message
### Fixed
- No longer run out of file handles when dealing with a large number of
recipients for a single message.
- Empty intermediate directories are now removed when a mailbox is deleted,
leaving less junk on your filesystem.
### Changed
- Build now requires Go 1.7 or later
- Removed legacy `integral` theme, as most new features only in `bootstrap`
- Removed old RESTful APIs, must use `/api/v1` base URI now
- Allow increased local-part length of 128 chars for Mailgun
- RedHat and Ubuntu now use systemd instead of legacy init systems
## [v1.1.0] - 2016-09-03
### Added
- Homebrew inbucket.conf and formula (see README)
@@ -13,8 +76,8 @@ This project adheres to [Semantic Versioning](http://semver.org/).
### Fixed
- Log and continue when unable to delete oldest message during cap enforcement
[1.1.0-rc2] - 2016-03-06
------------------------
## [v1.1.0-rc2] - 2016-03-06
### Added
- Message Cap to status page
@@ -24,8 +87,8 @@ This project adheres to [Semantic Versioning](http://semver.org/).
- Shutdown hang in retention scanner
- Display empty subject as `(No Subject)`
[1.1.0-rc1] - 2016-03-04
------------------------
## [v1.1.0-rc1] - 2016-03-04
### Added
- Inbucket now builds with Go 1.5 or 1.6
@@ -39,8 +102,8 @@ This project adheres to [Semantic Versioning](http://semver.org/).
- RESTful API moved to `/api/v1` base URI
- More graceful shutdown on Ctrl-C or when errors encountered
[1.0] - 2014-04-14
------------------
## [v1.0] - 2014-04-14
### Added
- Add new configuration option `mailbox.message.cap` to prevent individual
@@ -48,27 +111,30 @@ This project adheres to [Semantic Versioning](http://semver.org/).
- Add Link button to messages, allows for directing another person to a
specific message.
[Unreleased]: https://github.com/jhillyerd/inbucket/compare/master...develop
[1.1.0]: https://github.com/jhillyerd/inbucket/compare/1.1.0-rc2...1.1.0
[1.1.0-rc2]: https://github.com/jhillyerd/inbucket/compare/1.1.0-rc1...1.1.0-rc2
[1.1.0-rc1]: https://github.com/jhillyerd/inbucket/compare/1.0...1.1.0-rc1
[1.0]: https://github.com/jhillyerd/inbucket/compare/1.0-rc1...1.0
[Unreleased]: https://github.com/jhillyerd/inbucket/compare/master...develop
[v1.3.1]: https://github.com/jhillyerd/inbucket/compare/v1.3.0...v1.3.1
[v1.3.0]: https://github.com/jhillyerd/inbucket/compare/v1.2.0...v1.3.0
[v1.2.0]: https://github.com/jhillyerd/inbucket/compare/1.2.0-rc2...1.2.0
[v1.2.0-rc2]: https://github.com/jhillyerd/inbucket/compare/1.2.0-rc1...1.2.0-rc2
[v1.2.0-rc1]: https://github.com/jhillyerd/inbucket/compare/1.1.0...1.2.0-rc1
[v1.1.0]: https://github.com/jhillyerd/inbucket/compare/1.1.0-rc2...1.1.0
[v1.1.0-rc2]: https://github.com/jhillyerd/inbucket/compare/1.1.0-rc1...1.1.0-rc2
[v1.1.0-rc1]: https://github.com/jhillyerd/inbucket/compare/1.0...1.1.0-rc1
[v1.0]: https://github.com/jhillyerd/inbucket/compare/1.0-rc1...1.0
Release Checklist
-----------------
## Release Checklist
1. Create release branch: `git flow release start 1.x.0`
2. Update CHANGELOG.md:
- Ensure *Unreleased* section is up to date
- Rename *Unreleased* section to release name and date.
- Add new GitHub `/compare` link
3. Update goxc version info: `goxc -wc -pv=1.x.0 -pr=snapshot`
4. Run: `goxc interpolate-source` to update VERSION var
5. Run tests
6. Test cross-compile: `goxc`
7. Commit changes and merge release: `git flow release finish 1.x.0`
8. Upload to bintray: `goxc bintray`
9. Update `binary_versions` option in `inbucket-site/_config.yml`
- Ensure *Unreleased* section is up to date
- Rename *Unreleased* section to release name and date.
- Add new GitHub `/compare` link
3. Run tests
4. Test cross-compile: `goreleaser --snapshot`
5. Commit changes and merge release: `git flow release finish`
6. Push tags and wait for https://travis-ci.org/jhillyerd/inbucket build to
complete
7. Update `binary_versions` option in `inbucket-site/_config.yml`
See http://keepachangelog.com/ for additional instructions on how to update this file.

44
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,44 @@
How to Contribute
=================
Inbucket encourages third-party patches. It's valuable to know how other
developers are using the product.
**tl;dr:** File pull requests against the `develop` branch, not `master`!
## Getting Started
If you anticipate your issue requiring a large patch, please first submit a
GitHub issue describing the problem or feature. You are also encouraged to
outline the process you would like to use to resolve the issue. I will attempt
to provide validation and/or guidance on your suggested approach.
## Making Changes
Inbucket uses [git-flow] with default options. If you have git-flow installed,
you can run `git flow feature start <topic branch name>`.
Without git-flow, create a topic branch from where you want to base your work:
- This is usually the `develop` branch, example command:
`git checkout origin/develop -b <topic branch name>`
- Only target the `master` branch if the issue is already resolved in
`develop`.
Once you are on your topic branch:
1. Make commits of logical units.
2. Add unit tests to exercise your changes.
3. Run the updated code through `go fmt` and `go vet`.
4. Ensure the code builds and tests with the following commands:
- `go clean ./...`
- `go build ./...`
- `go test ./...`
## Thanks
Thank you for contributing to Inbucket!
[git-flow]: https://github.com/nvie/gitflow

View File

@@ -1,7 +1,7 @@
# Docker build file for Inbucket, see https://www.docker.io/
# Inbucket website: http://www.inbucket.org/
FROM golang:1.6-alpine
FROM golang:1.9-alpine
MAINTAINER James Hillyerd, @jameshillyerd
# Configuration (WORKDIR doesn't support env vars)

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,28 +1,31 @@
Inbucket [![Build Status](https://travis-ci.org/jhillyerd/inbucket.png?branch=master)][Build Status]
========
Inbucket
=============================================================================
[![Build Status](https://travis-ci.org/jhillyerd/inbucket.png?branch=master)][Build Status]
Inbucket is an email testing service; it will accept messages for any email
address and make them available via web, REST and POP3. Once compiled,
Inbucket does not have an external dependencies (HTTP, SMTP, POP3 and storage
Inbucket does not have any external dependencies (HTTP, SMTP, POP3 and storage
are all built in).
Read more at the [Inbucket Website]
Development Status
------------------
![Screenshot](http://www.inbucket.org/images/inbucket-ss1.png "Viewing a message")
## Development Status
Inbucket is currently production quality: it is being used for real work.
Please see the [Change Log] and [Issues List] for more details.
Please see the [Change Log] and [Issues List] for more details. If you'd like
to contribute code to the project check out [CONTRIBUTING.md].
Homebrew Tap
------------
## Homebrew Tap
Inbucket has an OS X [Homebrew] tap available as [jhillyerd/inbucket][Homebrew Tap],
see the `README.md` there for installation instructions.
Building from Source
--------------------
## Building from Source
You will need a functioning [Go installation][Google Go] for this to work.
@@ -41,8 +44,8 @@ the web interface will be available at [localhost:9000](http://localhost:9000/).
The Inbucket website has a more complete guide to
[installing from source][From Source]
About
-----
## About
Inbucket is written in [Google Go]
@@ -51,6 +54,7 @@ version can be found at https://github.com/jhillyerd/inbucket
[Build Status]: https://travis-ci.org/jhillyerd/inbucket
[Change Log]: https://github.com/jhillyerd/inbucket/blob/master/CHANGELOG.md
[CONTRIBUTING.md]: https://github.com/jhillyerd/inbucket/blob/develop/CONTRIBUTING.md
[From Source]: http://www.inbucket.org/installation/from-source.html
[Google Go]: http://golang.org/
[Homebrew]: http://brew.sh/

54
cmd/client/list.go Normal file
View File

@@ -0,0 +1,54 @@
package main
import (
"context"
"flag"
"fmt"
"github.com/google/subcommands"
"github.com/jhillyerd/inbucket/rest/client"
)
type listCmd struct {
mailbox string
}
func (*listCmd) Name() string {
return "list"
}
func (*listCmd) Synopsis() string {
return "list contents of mailbox"
}
func (*listCmd) Usage() string {
return `list <mailbox>:
list message IDs in mailbox
`
}
func (l *listCmd) SetFlags(f *flag.FlagSet) {
}
func (l *listCmd) Execute(
_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
mailbox := f.Arg(0)
if mailbox == "" {
return usage("mailbox required")
}
// Setup rest client
c, err := client.New(baseURL())
if err != nil {
return fatal("Couldn't build client", err)
}
// Get list
headers, err := c.ListMailbox(mailbox)
if err != nil {
return fatal("REST call failed", err)
}
for _, h := range headers {
fmt.Println(h.ID)
}
return subcommands.ExitSuccess
}

79
cmd/client/main.go Normal file
View File

@@ -0,0 +1,79 @@
// Package main implements a command line client for the Inbucket REST API
package main
import (
"context"
"flag"
"fmt"
"os"
"regexp"
"github.com/google/subcommands"
)
var host = flag.String("host", "localhost", "host/IP of Inbucket server")
var port = flag.Uint("port", 9000, "HTTP port of Inbucket server")
// Allow subcommands to accept regular expressions as flags
type regexFlag struct {
*regexp.Regexp
}
func (r *regexFlag) Defined() bool {
return r.Regexp != nil
}
func (r *regexFlag) Set(pattern string) error {
if pattern == "" {
r.Regexp = nil
return nil
}
re, err := regexp.Compile(pattern)
if err != nil {
return err
}
r.Regexp = re
return nil
}
func (r *regexFlag) String() string {
if r.Regexp == nil {
return ""
}
return r.Regexp.String()
}
// regexFlag must implement flag.Value
var _ flag.Value = &regexFlag{}
func main() {
// Important top-level flags
subcommands.ImportantFlag("host")
subcommands.ImportantFlag("port")
// Setup standard helpers
subcommands.Register(subcommands.HelpCommand(), "")
subcommands.Register(subcommands.FlagsCommand(), "")
subcommands.Register(subcommands.CommandsCommand(), "")
// Setup my commands
subcommands.Register(&listCmd{}, "")
subcommands.Register(&matchCmd{}, "")
subcommands.Register(&mboxCmd{}, "")
// Parse and execute
flag.Parse()
ctx := context.Background()
os.Exit(int(subcommands.Execute(ctx)))
}
func baseURL() string {
return fmt.Sprintf("http://%s:%v", *host, *port)
}
func fatal(msg string, err error) subcommands.ExitStatus {
fmt.Fprintf(os.Stderr, "%s: %v\n", msg, err)
return subcommands.ExitFailure
}
func usage(msg string) subcommands.ExitStatus {
fmt.Fprintln(os.Stderr, msg)
return subcommands.ExitUsageError
}

164
cmd/client/match.go Normal file
View File

@@ -0,0 +1,164 @@
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"net/mail"
"os"
"time"
"github.com/google/subcommands"
"github.com/jhillyerd/inbucket/rest/client"
)
type matchCmd struct {
mailbox string
output string
outFunc func(headers []*client.MessageHeader) error
delete bool
// match criteria
from regexFlag
subject regexFlag
to regexFlag
maxAge time.Duration
}
func (*matchCmd) Name() string {
return "match"
}
func (*matchCmd) Synopsis() string {
return "output messages matching criteria"
}
func (*matchCmd) Usage() string {
return `match [flags] <mailbox>:
output messages matching all specified criteria
exit status will be 1 if no matches were found, otherwise 0
`
}
func (m *matchCmd) SetFlags(f *flag.FlagSet) {
f.StringVar(&m.output, "output", "id", "output format: id, json, or mbox")
f.BoolVar(&m.delete, "delete", false, "delete matched messages after output")
f.Var(&m.from, "from", "From header matching regexp (address, not name)")
f.Var(&m.subject, "subject", "Subject header matching regexp")
f.Var(&m.to, "to", "To header matching regexp (must match 1+ to address)")
f.DurationVar(
&m.maxAge, "maxage", 0,
"Matches must have been received in this time frame (ex: \"10s\", \"5m\")")
}
func (m *matchCmd) Execute(
_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
mailbox := f.Arg(0)
if mailbox == "" {
return usage("mailbox required")
}
// Select output function
switch m.output {
case "id":
m.outFunc = outputID
case "json":
m.outFunc = outputJSON
case "mbox":
m.outFunc = outputMbox
default:
return usage("unknown output type: " + m.output)
}
// Setup REST client
c, err := client.New(baseURL())
if err != nil {
return fatal("Couldn't build client", err)
}
// Get list
headers, err := c.ListMailbox(mailbox)
if err != nil {
return fatal("List REST call failed", err)
}
// Find matches
matches := make([]*client.MessageHeader, 0, len(headers))
for _, h := range headers {
if m.match(h) {
matches = append(matches, h)
}
}
// Return error status if no matches
if len(matches) == 0 {
return subcommands.ExitFailure
}
// Output matches
err = m.outFunc(matches)
if err != nil {
return fatal("Error", err)
}
if m.delete {
// Delete matches
for _, h := range matches {
err = h.Delete()
if err != nil {
return fatal("Delete REST call failed", err)
}
}
}
return subcommands.ExitSuccess
}
// match returns true if header matches all defined criteria
func (m *matchCmd) match(header *client.MessageHeader) bool {
if m.maxAge > 0 {
if time.Since(header.Date) > m.maxAge {
return false
}
}
if m.subject.Defined() {
if !m.subject.MatchString(header.Subject) {
return false
}
}
if m.from.Defined() {
from := header.From
addr, err := mail.ParseAddress(from)
if err == nil {
// Parsed successfully
from = addr.Address
}
if !m.from.MatchString(from) {
return false
}
}
if m.to.Defined() {
match := false
for _, to := range header.To {
addr, err := mail.ParseAddress(to)
if err == nil {
// Parsed successfully
to = addr.Address
}
if m.to.MatchString(to) {
match = true
break
}
}
if !match {
return false
}
}
return true
}
func outputID(headers []*client.MessageHeader) error {
for _, h := range headers {
fmt.Println(h.ID)
}
return nil
}
func outputJSON(headers []*client.MessageHeader) error {
jsonEncoder := json.NewEncoder(os.Stdout)
jsonEncoder.SetEscapeHTML(false)
jsonEncoder.SetIndent("", " ")
return jsonEncoder.Encode(headers)
}

82
cmd/client/mbox.go Normal file
View File

@@ -0,0 +1,82 @@
package main
import (
"context"
"flag"
"fmt"
"os"
"github.com/google/subcommands"
"github.com/jhillyerd/inbucket/rest/client"
)
type mboxCmd struct {
mailbox string
delete bool
}
func (*mboxCmd) Name() string {
return "mbox"
}
func (*mboxCmd) Synopsis() string {
return "output mailbox in mbox format"
}
func (*mboxCmd) Usage() string {
return `mbox [flags] <mailbox>:
output mailbox in mbox format
`
}
func (m *mboxCmd) SetFlags(f *flag.FlagSet) {
f.BoolVar(&m.delete, "delete", false, "delete messages after output")
}
func (m *mboxCmd) Execute(
_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
mailbox := f.Arg(0)
if mailbox == "" {
return usage("mailbox required")
}
// Setup REST client
c, err := client.New(baseURL())
if err != nil {
return fatal("Couldn't build client", err)
}
// Get list
headers, err := c.ListMailbox(mailbox)
if err != nil {
return fatal("List REST call failed", err)
}
err = outputMbox(headers)
if err != nil {
return fatal("Error", err)
}
if m.delete {
// Delete matches
for _, h := range headers {
err = h.Delete()
if err != nil {
return fatal("Delete REST call failed", err)
}
}
}
return subcommands.ExitSuccess
}
// outputMbox renders messages in mbox format
// also used by match subcommand
func outputMbox(headers []*client.MessageHeader) error {
for _, h := range headers {
source, err := h.GetSource()
if err != nil {
return fmt.Errorf("Get source REST failed: %v", err)
}
fmt.Printf("From %s\n", h.From)
// TODO Escape "From " in message bodies with >
source.WriteTo(os.Stdout)
fmt.Println()
}
return nil
}

View File

@@ -1,17 +1,17 @@
package config
import (
"container/list"
"fmt"
"net"
"os"
"sort"
"strings"
"github.com/robfig/config"
)
// SMTPConfig contains the SMTP server configuration - not using pointers
// so that we can pass around copies of the object safely.
// SMTPConfig contains the SMTP server configuration - not using pointers so that we can pass around
// copies of the object safely.
type SMTPConfig struct {
IP4address net.IP
IP4port int
@@ -33,13 +33,16 @@ type POP3Config struct {
// WebConfig contains the HTTP server configuration
type WebConfig struct {
IP4address net.IP
IP4port int
TemplateDir string
TemplateCache bool
PublicDir string
GreetingFile string
CookieAuthKey string
IP4address net.IP
IP4port int
TemplateDir string
TemplateCache bool
PublicDir string
GreetingFile string
MailboxPrompt string
CookieAuthKey string
MonitorVisible bool
MonitorHistory int
}
// DataStoreConfig contains the mail store configuration
@@ -50,6 +53,11 @@ type DataStoreConfig struct {
MailboxMsgCap int
}
const (
missingErrorFmt = "[%v] missing required option %q"
parseErrorFmt = "[%v] option %q error: %v"
)
var (
// Version of this build, set by main
Version = ""
@@ -58,13 +66,14 @@ var (
BuildDate = ""
// Config is our global robfig/config object
Config *config.Config
Config *config.Config
logLevel string
// Parsed specific configs
smtpConfig *SMTPConfig
pop3Config *POP3Config
webConfig *WebConfig
dataStoreConfig *DataStoreConfig
smtpConfig = &SMTPConfig{}
pop3Config = &POP3Config{}
webConfig = &WebConfig{}
dataStoreConfig = &DataStoreConfig{}
)
// GetSMTPConfig returns a copy of the SmtpConfig object
@@ -87,323 +96,175 @@ func GetDataStoreConfig() DataStoreConfig {
return *dataStoreConfig
}
// LoadConfig loads the specified configuration file into inbucket.Config
// and performs validations on it.
// GetLogLevel returns the configured log level
func GetLogLevel() string {
return logLevel
}
// LoadConfig loads the specified configuration file into inbucket.Config and performs validations
// on it.
func LoadConfig(filename string) error {
var err error
Config, err = config.ReadDefault(filename)
if err != nil {
return err
}
messages := list.New()
// Validation error messages
messages := make([]string, 0)
// Validate sections
requireSection(messages, "logging")
requireSection(messages, "smtp")
requireSection(messages, "pop3")
requireSection(messages, "web")
requireSection(messages, "datastore")
if messages.Len() > 0 {
for _, s := range []string{"logging", "smtp", "pop3", "web", "datastore"} {
if !Config.HasSection(s) {
messages = append(messages,
fmt.Sprintf("Config section [%v] is required", s))
}
}
// Return immediately if config is missing entire sections
if len(messages) > 0 {
fmt.Fprintln(os.Stderr, "Error(s) validating configuration:")
for e := messages.Front(); e != nil; e = e.Next() {
fmt.Fprintln(os.Stderr, " -", e.Value.(string))
for _, m := range messages {
fmt.Fprintln(os.Stderr, " -", m)
}
return fmt.Errorf("Failed to validate configuration")
}
// Validate options
requireOption(messages, "logging", "level")
requireOption(messages, "smtp", "ip4.address")
requireOption(messages, "smtp", "ip4.port")
requireOption(messages, "smtp", "domain")
requireOption(messages, "smtp", "max.recipients")
requireOption(messages, "smtp", "max.idle.seconds")
requireOption(messages, "smtp", "max.message.bytes")
requireOption(messages, "smtp", "store.messages")
requireOption(messages, "pop3", "ip4.address")
requireOption(messages, "pop3", "ip4.port")
requireOption(messages, "pop3", "domain")
requireOption(messages, "pop3", "max.idle.seconds")
requireOption(messages, "web", "ip4.address")
requireOption(messages, "web", "ip4.port")
requireOption(messages, "web", "template.dir")
requireOption(messages, "web", "template.cache")
requireOption(messages, "web", "public.dir")
requireOption(messages, "datastore", "path")
requireOption(messages, "datastore", "retention.minutes")
requireOption(messages, "datastore", "retention.sleep.millis")
requireOption(messages, "datastore", "mailbox.message.cap")
// Return error if validations failed
if messages.Len() > 0 {
fmt.Fprintln(os.Stderr, "Error(s) validating configuration:")
for e := messages.Front(); e != nil; e = e.Next() {
fmt.Fprintln(os.Stderr, " -", e.Value.(string))
// Load string config options
stringOptions := []struct {
section string
name string
target *string
required bool
}{
{"logging", "level", &logLevel, true},
{"smtp", "domain", &smtpConfig.Domain, true},
{"smtp", "domain.nostore", &smtpConfig.DomainNoStore, false},
{"pop3", "domain", &pop3Config.Domain, true},
{"web", "template.dir", &webConfig.TemplateDir, true},
{"web", "public.dir", &webConfig.PublicDir, true},
{"web", "greeting.file", &webConfig.GreetingFile, true},
{"web", "mailbox.prompt", &webConfig.MailboxPrompt, false},
{"web", "cookie.auth.key", &webConfig.CookieAuthKey, false},
{"datastore", "path", &dataStoreConfig.Path, true},
}
for _, opt := range stringOptions {
str, err := Config.String(opt.section, opt.name)
if Config.HasOption(opt.section, opt.name) && err != nil {
messages = append(messages, fmt.Sprintf(parseErrorFmt, opt.section, opt.name, err))
continue
}
return fmt.Errorf("Failed to validate configuration")
if str == "" && opt.required {
messages = append(messages, fmt.Sprintf(missingErrorFmt, opt.section, opt.name))
}
*opt.target = str
}
if err = parseSMTPConfig(); err != nil {
return err
// Load boolean config options
boolOptions := []struct {
section string
name string
target *bool
required bool
}{
{"smtp", "store.messages", &smtpConfig.StoreMessages, true},
{"web", "template.cache", &webConfig.TemplateCache, true},
{"web", "monitor.visible", &webConfig.MonitorVisible, true},
}
if err = parsePOP3Config(); err != nil {
return err
for _, opt := range boolOptions {
if Config.HasOption(opt.section, opt.name) {
flag, err := Config.Bool(opt.section, opt.name)
if err != nil {
messages = append(messages, fmt.Sprintf(parseErrorFmt, opt.section, opt.name, err))
}
*opt.target = flag
} else {
if opt.required {
messages = append(messages, fmt.Sprintf(missingErrorFmt, opt.section, opt.name))
}
}
}
if err = parseWebConfig(); err != nil {
return err
// Load integer config options
intOptions := []struct {
section string
name string
target *int
required bool
}{
{"smtp", "ip4.port", &smtpConfig.IP4port, true},
{"smtp", "max.recipients", &smtpConfig.MaxRecipients, true},
{"smtp", "max.idle.seconds", &smtpConfig.MaxIdleSeconds, true},
{"smtp", "max.message.bytes", &smtpConfig.MaxMessageBytes, true},
{"pop3", "ip4.port", &pop3Config.IP4port, true},
{"pop3", "max.idle.seconds", &pop3Config.MaxIdleSeconds, true},
{"web", "ip4.port", &webConfig.IP4port, true},
{"web", "monitor.history", &webConfig.MonitorHistory, true},
{"datastore", "retention.minutes", &dataStoreConfig.RetentionMinutes, true},
{"datastore", "retention.sleep.millis", &dataStoreConfig.RetentionSleep, true},
{"datastore", "mailbox.message.cap", &dataStoreConfig.MailboxMsgCap, true},
}
if err = parseDataStoreConfig(); err != nil {
return err
for _, opt := range intOptions {
if Config.HasOption(opt.section, opt.name) {
num, err := Config.Int(opt.section, opt.name)
if err != nil {
messages = append(messages, fmt.Sprintf(parseErrorFmt, opt.section, opt.name, err))
}
*opt.target = num
} else {
if opt.required {
messages = append(messages, fmt.Sprintf(missingErrorFmt, opt.section, opt.name))
}
}
}
return nil
}
// parseLoggingConfig trying to catch config errors early
func parseLoggingConfig() error {
section := "logging"
option := "level"
str, err := Config.String(section, option)
if err != nil {
return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err)
// Load IP address config options
ipOptions := []struct {
section string
name string
target *net.IP
required bool
}{
{"smtp", "ip4.address", &smtpConfig.IP4address, true},
{"pop3", "ip4.address", &pop3Config.IP4address, true},
{"web", "ip4.address", &webConfig.IP4address, true},
}
switch strings.ToUpper(str) {
for _, opt := range ipOptions {
if Config.HasOption(opt.section, opt.name) {
str, err := Config.String(opt.section, opt.name)
if err != nil {
messages = append(messages, fmt.Sprintf(parseErrorFmt, opt.section, opt.name, err))
continue
}
addr := net.ParseIP(str)
if addr == nil {
messages = append(messages,
fmt.Sprintf("Failed to parse IP [%v]%v: %q", opt.section, opt.name, str))
continue
}
addr = addr.To4()
if addr == nil {
messages = append(messages,
fmt.Sprintf("Failed to parse IP [%v]%v: %q not IPv4!",
opt.section, opt.name, str))
}
*opt.target = addr
} else {
if opt.required {
messages = append(messages, fmt.Sprintf(missingErrorFmt, opt.section, opt.name))
}
}
}
// Validate log level
switch strings.ToUpper(logLevel) {
case "":
// Missing was already reported
case "TRACE", "INFO", "WARN", "ERROR":
default:
return fmt.Errorf("Invalid value provided for [%v]%v: '%v'", section, option, str)
messages = append(messages,
fmt.Sprintf("Invalid value provided for [logging]level: %q", logLevel))
}
return nil
}
// parseSMTPConfig trying to catch config errors early
func parseSMTPConfig() error {
smtpConfig = new(SMTPConfig)
section := "smtp"
// Parse IP4 address only, error on IP6.
option := "ip4.address"
str, err := Config.String(section, option)
if err != nil {
return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err)
}
addr := net.ParseIP(str)
if addr == nil {
return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, str)
}
addr = addr.To4()
if addr == nil {
return fmt.Errorf("Failed to parse [%v]%v: '%v' not IPv4!", section, option, str)
}
smtpConfig.IP4address = addr
option = "ip4.port"
smtpConfig.IP4port, err = Config.Int(section, option)
if err != nil {
return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err)
}
option = "domain"
str, err = Config.String(section, option)
if err != nil {
return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err)
}
smtpConfig.Domain = str
option = "domain.nostore"
if Config.HasOption(section, option) {
str, err = Config.String(section, option)
if err != nil {
return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err)
// Print messages and return error if any validations failed
if len(messages) > 0 {
fmt.Fprintln(os.Stderr, "Error(s) validating configuration:")
sort.Strings(messages)
for _, m := range messages {
fmt.Fprintln(os.Stderr, " -", m)
}
smtpConfig.DomainNoStore = str
return fmt.Errorf("Failed to validate configuration")
}
option = "max.recipients"
smtpConfig.MaxRecipients, err = Config.Int(section, option)
if err != nil {
return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err)
}
option = "max.idle.seconds"
smtpConfig.MaxIdleSeconds, err = Config.Int(section, option)
if err != nil {
return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err)
}
option = "max.message.bytes"
smtpConfig.MaxMessageBytes, err = Config.Int(section, option)
if err != nil {
return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err)
}
option = "store.messages"
flag, err := Config.Bool(section, option)
if err != nil {
return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err)
}
smtpConfig.StoreMessages = flag
return nil
}
// parsePOP3Config trying to catch config errors early
func parsePOP3Config() error {
pop3Config = new(POP3Config)
section := "pop3"
// Parse IP4 address only, error on IP6.
option := "ip4.address"
str, err := Config.String(section, option)
if err != nil {
return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err)
}
addr := net.ParseIP(str)
if addr == nil {
return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, str)
}
addr = addr.To4()
if addr == nil {
return fmt.Errorf("Failed to parse [%v]%v: '%v' not IPv4!", section, option, str)
}
pop3Config.IP4address = addr
option = "ip4.port"
pop3Config.IP4port, err = Config.Int(section, option)
if err != nil {
return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err)
}
option = "domain"
str, err = Config.String(section, option)
if err != nil {
return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err)
}
pop3Config.Domain = str
option = "max.idle.seconds"
pop3Config.MaxIdleSeconds, err = Config.Int(section, option)
if err != nil {
return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err)
}
return nil
}
// parseWebConfig trying to catch config errors early
func parseWebConfig() error {
webConfig = new(WebConfig)
section := "web"
// Parse IP4 address only, error on IP6.
option := "ip4.address"
str, err := Config.String(section, option)
if err != nil {
return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err)
}
addr := net.ParseIP(str)
if addr == nil {
return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, str)
}
addr = addr.To4()
if addr == nil {
return fmt.Errorf("Failed to parse [%v]%v: '%v' not IPv4!", section, option, str)
}
webConfig.IP4address = addr
option = "ip4.port"
webConfig.IP4port, err = Config.Int(section, option)
if err != nil {
return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err)
}
option = "template.dir"
str, err = Config.String(section, option)
if err != nil {
return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err)
}
webConfig.TemplateDir = str
option = "template.cache"
flag, err := Config.Bool(section, option)
if err != nil {
return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err)
}
webConfig.TemplateCache = flag
option = "public.dir"
str, err = Config.String(section, option)
if err != nil {
return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err)
}
webConfig.PublicDir = str
option = "greeting.file"
str, err = Config.String(section, option)
if err != nil {
return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err)
}
webConfig.GreetingFile = str
option = "cookie.auth.key"
if Config.HasOption(section, option) {
str, err = Config.String(section, option)
if err != nil {
return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err)
}
webConfig.CookieAuthKey = str
}
return nil
}
// parseDataStoreConfig trying to catch config errors early
func parseDataStoreConfig() error {
dataStoreConfig = new(DataStoreConfig)
section := "datastore"
option := "path"
str, err := Config.String(section, option)
if err != nil {
return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err)
}
dataStoreConfig.Path = str
option = "retention.minutes"
dataStoreConfig.RetentionMinutes, err = Config.Int(section, option)
if err != nil {
return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err)
}
option = "retention.sleep.millis"
dataStoreConfig.RetentionSleep, err = Config.Int(section, option)
if err != nil {
return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err)
}
option = "mailbox.message.cap"
dataStoreConfig.MailboxMsgCap, err = Config.Int(section, option)
if err != nil {
return fmt.Errorf("Failed to parse [%v]%v: '%v'", section, option, err)
}
return nil
}
// requireSection checks that a [section] is defined in the configuration file,
// appending a message if not.
func requireSection(messages *list.List, section string) {
if !Config.HasSection(section) {
messages.PushBack(fmt.Sprintf("Config section [%v] is required", section))
}
}
// requireOption checks that 'option' is defined in [section] of the config file,
// appending a message if not.
func requireOption(messages *list.List, section string, option string) {
if !Config.HasOption(section, option) {
messages.PushBack(fmt.Sprintf("Config option '%v' is required in section [%v]", option, section))
}
}

View File

@@ -1,12 +1,14 @@
package smtpd
// Package datastore contains implementation independent datastore logic
package datastore
import (
"errors"
"io"
"net/mail"
"sync"
"time"
"github.com/jhillyerd/go.enmime"
"github.com/jhillyerd/enmime"
)
var (
@@ -21,6 +23,8 @@ var (
type DataStore interface {
MailboxFor(emailAddress string) (Mailbox, error)
AllMailboxes() ([]Mailbox, error)
// LockFor is a temporary hack to fix #77 until Datastore revamp
LockFor(emailAddress string) (*sync.RWMutex, error)
}
// Mailbox is an interface to get and manipulate messages in a DataStore
@@ -29,6 +33,7 @@ type Mailbox interface {
GetMessage(id string) (Message, error)
Purge() error
NewMessage() (Message, error)
Name() string
String() string
}
@@ -36,11 +41,12 @@ type Mailbox interface {
type Message interface {
ID() string
From() string
To() []string
Date() time.Time
Subject() string
RawReader() (reader io.ReadCloser, err error)
ReadHeader() (msg *mail.Message, err error)
ReadBody() (body *enmime.MIMEBody, err error)
ReadBody() (body *enmime.Envelope, err error)
ReadRaw() (raw *string, err error)
Append(data []byte) error
Close() error

19
datastore/lock.go Normal file
View File

@@ -0,0 +1,19 @@
package datastore
import (
"strconv"
"sync"
)
type HashLock [4096]sync.RWMutex
func (h *HashLock) Get(hash string) *sync.RWMutex {
if len(hash) < 3 {
return nil
}
i, err := strconv.ParseInt(hash[0:3], 16, 0)
if err != nil {
return nil
}
return &h[i]
}

61
datastore/lock_test.go Normal file
View File

@@ -0,0 +1,61 @@
package datastore_test
import (
"testing"
"github.com/jhillyerd/inbucket/datastore"
)
func TestHashLock(t *testing.T) {
hl := &datastore.HashLock{}
// Invalid hashes
testCases := []struct {
name, input string
}{
{"empty", ""},
{"short", "a0"},
{"badhex", "zzzzzzzzzzzzzzzzzzzzzzz"},
}
for _, tc := range testCases {
t.Run(tc.input, func(t *testing.T) {
l := hl.Get(tc.input)
if l != nil {
t.Errorf("Expected nil lock for %s %q, got %v", tc.name, tc.input, l)
}
})
}
// Valid hashes
testStrings := []string{
"deadbeef",
"00000000",
"ffffffff",
}
for _, ts := range testStrings {
t.Run(ts, func(t *testing.T) {
l := hl.Get(ts)
if l == nil {
t.Errorf("Expeced non-nil lock for hex string %q", ts)
}
})
}
a := hl.Get("deadbeef")
b := hl.Get("deadbeef")
if a != b {
t.Errorf("Expected identical locks for identical hashes, got: %p != %p", a, b)
}
a = hl.Get("deadbeef")
b = hl.Get("d3adb33f")
if a == b {
t.Errorf("Expected different locks for different hashes, got: %p == %p", a, b)
}
a = hl.Get("deadbeef")
b = hl.Get("deadb33f")
if a != b {
t.Errorf("Expected identical locks for identical leading hashes, got: %p != %p", a, b)
}
}

View File

@@ -1,4 +1,4 @@
package smtpd
package datastore
import (
"container/list"
@@ -14,11 +14,6 @@ var (
retentionScanCompleted = time.Now()
retentionScanCompletedMu sync.RWMutex
// Indicates Inbucket needs to shut down
globalShutdown chan bool
// Indicates the retention scanner has shut down
retentionShutdown chan bool
// History counters
expRetentionDeletesTotal = new(expvar.Int)
expRetentionPeriod = new(expvar.Int)
@@ -33,73 +28,105 @@ var (
expRetainedHist = new(expvar.String)
)
// StartRetentionScanner launches a go-routine that scans for expired
// messages, following the configured interval
func StartRetentionScanner(ds DataStore, shutdownChannel chan bool) {
globalShutdown = shutdownChannel
retentionShutdown = make(chan bool)
cfg := config.GetDataStoreConfig()
expRetentionPeriod.Set(int64(cfg.RetentionMinutes * 60))
if cfg.RetentionMinutes > 0 {
// Retention scanning enabled
log.Infof("Retention configured for %v minutes", cfg.RetentionMinutes)
go retentionScanner(ds, time.Duration(cfg.RetentionMinutes)*time.Minute,
time.Duration(cfg.RetentionSleep)*time.Millisecond)
} else {
log.Infof("Retention scanner disabled")
close(retentionShutdown)
}
func init() {
rm := expvar.NewMap("retention")
rm.Set("SecondsSinceScanCompleted", expvar.Func(secondsSinceRetentionScanCompleted))
rm.Set("DeletesHist", expRetentionDeletesHist)
rm.Set("DeletesTotal", expRetentionDeletesTotal)
rm.Set("Period", expRetentionPeriod)
rm.Set("RetainedHist", expRetainedHist)
rm.Set("RetainedCurrent", expRetainedCurrent)
log.AddTickerFunc(func() {
expRetentionDeletesHist.Set(log.PushMetric(retentionDeletesHist, expRetentionDeletesTotal))
expRetainedHist.Set(log.PushMetric(retainedHist, expRetainedCurrent))
})
}
func retentionScanner(ds DataStore, maxAge time.Duration, sleep time.Duration) {
// RetentionScanner looks for messages older than the configured retention period and deletes them.
type RetentionScanner struct {
globalShutdown chan bool // Closes when Inbucket needs to shut down
retentionShutdown chan bool // Closed after the scanner has shut down
ds DataStore
retentionPeriod time.Duration
retentionSleep time.Duration
}
// NewRetentionScanner launches a go-routine that scans for expired
// messages, following the configured interval
func NewRetentionScanner(ds DataStore, shutdownChannel chan bool) *RetentionScanner {
cfg := config.GetDataStoreConfig()
rs := &RetentionScanner{
globalShutdown: shutdownChannel,
retentionShutdown: make(chan bool),
ds: ds,
retentionPeriod: time.Duration(cfg.RetentionMinutes) * time.Minute,
retentionSleep: time.Duration(cfg.RetentionSleep) * time.Millisecond,
}
// expRetentionPeriod is displayed on the status page
expRetentionPeriod.Set(int64(cfg.RetentionMinutes * 60))
return rs
}
// Start up the retention scanner if retention period > 0
func (rs *RetentionScanner) Start() {
if rs.retentionPeriod <= 0 {
log.Infof("Retention scanner disabled")
close(rs.retentionShutdown)
return
}
log.Infof("Retention configured for %v", rs.retentionPeriod)
go rs.run()
}
// run loops to kick off the scanner on the correct schedule
func (rs *RetentionScanner) run() {
start := time.Now()
retentionLoop:
for {
// Prevent scanner from running more than once a minute
// Prevent scanner from starting more than once a minute
since := time.Since(start)
if since < time.Minute {
dur := time.Minute - since
log.Tracef("Retention scanner sleeping for %v", dur)
select {
case _ = <-globalShutdown:
case <-rs.globalShutdown:
break retentionLoop
case _ = <-time.After(dur):
case <-time.After(dur):
}
}
// Kickoff scan
start = time.Now()
if err := doRetentionScan(ds, maxAge, sleep); err != nil {
if err := rs.doScan(); err != nil {
log.Errorf("Error during retention scan: %v", err)
}
// Check for global shutdown
select {
case _ = <-globalShutdown:
case <-rs.globalShutdown:
break retentionLoop
default:
}
}
log.Tracef("Retention scanner shut down")
close(retentionShutdown)
close(rs.retentionShutdown)
}
// doRetentionScan does a single pass of all mailboxes looking for messages that can be purged
func doRetentionScan(ds DataStore, maxAge time.Duration, sleep time.Duration) error {
// doScan does a single pass of all mailboxes looking for messages that can be purged
func (rs *RetentionScanner) doScan() error {
log.Tracef("Starting retention scan")
cutoff := time.Now().Add(-1 * maxAge)
mboxes, err := ds.AllMailboxes()
cutoff := time.Now().Add(-1 * rs.retentionPeriod)
mboxes, err := rs.ds.AllMailboxes()
if err != nil {
return err
}
retained := 0
// Loop over all mailboxes
for _, mb := range mboxes {
messages, err := mb.GetMessages()
if err != nil {
return err
}
// Loop over all messages in mailbox
for _, msg := range messages {
if msg.Date().Before(cutoff) {
log.Tracef("Purging expired message %v", msg.ID())
@@ -114,56 +141,40 @@ func doRetentionScan(ds DataStore, maxAge time.Duration, sleep time.Duration) er
retained++
}
}
// Check for shutdown
// Sleep after completing a mailbox
select {
case _ = <-globalShutdown:
case <-rs.globalShutdown:
log.Tracef("Retention scan aborted due to shutdown")
return nil
default:
case <-time.After(rs.retentionSleep):
// Reduce disk thrashing
}
// Sleep after completing a mailbox
time.Sleep(sleep)
}
// Update metrics
setRetentionScanCompleted(time.Now())
expRetainedCurrent.Set(int64(retained))
return nil
}
// RetentionJoin does not retun until the retention scanner has shut down
func RetentionJoin() {
if retentionShutdown != nil {
select {
case _ = <-retentionShutdown:
}
// Join does not retun until the retention scanner has shut down
func (rs *RetentionScanner) Join() {
if rs.retentionShutdown != nil {
<-rs.retentionShutdown
}
}
func setRetentionScanCompleted(t time.Time) {
retentionScanCompletedMu.Lock()
defer retentionScanCompletedMu.Unlock()
retentionScanCompleted = t
}
func getRetentionScanCompleted() time.Time {
retentionScanCompletedMu.RLock()
defer retentionScanCompletedMu.RUnlock()
return retentionScanCompleted
}
func secondsSinceRetentionScanCompleted() interface{} {
return time.Since(getRetentionScanCompleted()) / time.Second
}
func init() {
rm := expvar.NewMap("retention")
rm.Set("SecondsSinceScanCompleted", expvar.Func(secondsSinceRetentionScanCompleted))
rm.Set("DeletesHist", expRetentionDeletesHist)
rm.Set("DeletesTotal", expRetentionDeletesTotal)
rm.Set("Period", expRetentionPeriod)
rm.Set("RetainedHist", expRetainedHist)
rm.Set("RetainedCurrent", expRetainedCurrent)
}

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,181 +1,161 @@
package smtpd
package datastore
import (
"fmt"
"io"
"net/mail"
"testing"
"sync"
"time"
"github.com/jhillyerd/go.enmime"
"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
if err := doRetentionScan(mds, 4*time.Hour-time.Minute, 0); 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
// MockDataStore is a shared mock for unit testing
type MockDataStore struct {
mock.Mock
}
// MailboxFor mock function
func (m *MockDataStore) MailboxFor(name string) (Mailbox, error) {
args := m.Called()
args := m.Called(name)
return args.Get(0).(Mailbox), args.Error(1)
}
// AllMailboxes mock function
func (m *MockDataStore) AllMailboxes() ([]Mailbox, error) {
args := m.Called()
return args.Get(0).([]Mailbox), args.Error(1)
}
// Mock Mailbox object
func (m *MockDataStore) LockFor(name string) (*sync.RWMutex, error) {
return &sync.RWMutex{}, nil
}
// MockMailbox is a shared mock for unit testing
type MockMailbox struct {
mock.Mock
}
// GetMessages mock function
func (m *MockMailbox) GetMessages() ([]Message, error) {
args := m.Called()
return args.Get(0).([]Message), args.Error(1)
}
// GetMessage mock function
func (m *MockMailbox) GetMessage(id string) (Message, error) {
args := m.Called(id)
return args.Get(0).(Message), args.Error(1)
}
// Purge mock function
func (m *MockMailbox) Purge() error {
args := m.Called()
return args.Error(0)
}
// NewMessage mock function
func (m *MockMailbox) NewMessage() (Message, error) {
args := m.Called()
return args.Get(0).(Message), args.Error(1)
}
// Name mock function
func (m *MockMailbox) Name() string {
args := m.Called()
return args.String(0)
}
// String mock function
func (m *MockMailbox) String() string {
args := m.Called()
return args.String(0)
}
// Mock Message object
// MockMessage is a shared mock for unit testing
type MockMessage struct {
mock.Mock
}
// ID mock function
func (m *MockMessage) ID() string {
args := m.Called()
return args.String(0)
}
// From mock function
func (m *MockMessage) From() string {
args := m.Called()
return args.String(0)
}
// To mock function
func (m *MockMessage) To() []string {
args := m.Called()
return args.Get(0).([]string)
}
// Date mock function
func (m *MockMessage) Date() time.Time {
args := m.Called()
return args.Get(0).(time.Time)
}
// Subject mock function
func (m *MockMessage) Subject() string {
args := m.Called()
return args.String(0)
}
// ReadHeader mock function
func (m *MockMessage) ReadHeader() (msg *mail.Message, err error) {
args := m.Called()
return args.Get(0).(*mail.Message), args.Error(1)
}
func (m *MockMessage) ReadBody() (body *enmime.MIMEBody, err error) {
// ReadBody mock function
func (m *MockMessage) ReadBody() (body *enmime.Envelope, err error) {
args := m.Called()
return args.Get(0).(*enmime.MIMEBody), args.Error(1)
return args.Get(0).(*enmime.Envelope), args.Error(1)
}
// ReadRaw mock function
func (m *MockMessage) ReadRaw() (raw *string, err error) {
args := m.Called()
return args.Get(0).(*string), args.Error(1)
}
// RawReader mock function
func (m *MockMessage) RawReader() (reader io.ReadCloser, err error) {
args := m.Called()
return args.Get(0).(io.ReadCloser), args.Error(1)
}
// Size mock function
func (m *MockMessage) Size() int64 {
args := m.Called()
return int64(args.Int(0))
}
// Append mock function
func (m *MockMessage) Append(data []byte) error {
// []byte arg seems to mess up testify/mock
return nil
}
// Close mock function
func (m *MockMessage) Close() error {
args := m.Called()
return args.Error(0)
}
// Delete mock function
func (m *MockMessage) Delete() error {
args := m.Called()
return args.Error(0)
}
// String mock function
func (m *MockMessage) String() string {
args := m.Called()
return args.String(0)

View File

@@ -73,6 +73,10 @@ ip4.port=9000
# Name of web theme to use
theme=bootstrap
# Prompt displayed between the mailbox entry field and View button. Leave
# empty or comment out to hide the prompt.
mailbox.prompt=@inbucket
# Path to the selected themes template files
template.dir=%(install.dir)s/themes/%(theme)s/templates
@@ -91,6 +95,18 @@ greeting.file=%(install.dir)s/themes/greeting.html
# and previous sessions will be invalidated.
cookie.auth.key=secret-inbucket-session-cookie-key
# Enable or disable the live message monitor tab for the web UI. This will let
# anybody see all messages delivered to Inbucket. This setting has no impact
# on the availability of the underlying WebSocket.
monitor.visible=true
# How many historical message headers should be cached for display by new
# monitor connections. It does not limit the number of messages displayed by
# the browser once the monitor is open; all freshly received messages will be
# appended to the on screen list. This setting also affects the underlying
# API/WebSocket.
monitor.history=30
#############################################################################
[datastore]

View File

@@ -75,6 +75,10 @@ ip4.port=10080
# Name of web theme to use
theme=bootstrap
# Prompt displayed between the mailbox entry field and View button. Leave
# empty or comment out to hide the prompt.
mailbox.prompt=@inbucket
# Path to the selected themes template files
template.dir=%(install.dir)s/themes/%(theme)s/templates
@@ -93,6 +97,18 @@ greeting.file=/con/configuration/greeting.html
# and previous sessions will be invalidated.
#cookie.auth.key=secret-inbucket-session-cookie-key
# Enable or disable the live message monitor tab for the web UI. This will let
# anybody see all messages delivered to Inbucket. This setting has no impact
# on the availability of the underlying WebSocket.
monitor.visible=true
# How many historical message headers should be cached for display by new
# monitor connections. It does not limit the number of messages displayed by
# the browser once the monitor is open; all freshly received messages will be
# appended to the on screen list. This setting also affects the underlying
# API/WebSocket.
monitor.history=30
#############################################################################
[datastore]

View File

@@ -15,11 +15,14 @@ apk add --no-cache --virtual .build-deps git
# Setup
export GOBIN="$bindir"
builddate="$(date -Iseconds)"
cd "$srcdir"
go clean
# Fetch tags for describe
git fetch -t
builddate="$(date -Iseconds)"
buildver="$(git describe --tags --always)"
# Build
go clean
echo "### Fetching Dependencies"
go get -t -v ./...
@@ -27,7 +30,7 @@ echo "### Testing Inbucket"
go test ./...
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"
set -x

View File

@@ -75,6 +75,10 @@ ip4.port=9000
# Name of web theme to use
theme=bootstrap
# Prompt displayed between the mailbox entry field and View button. Leave
# empty or comment out to hide the prompt.
mailbox.prompt=@inbucket
# Path to the selected themes template files
template.dir=%(themes.dir)s/%(theme)s/templates
@@ -93,6 +97,18 @@ greeting.file=%(themes.dir)s/greeting.html
# and previous sessions will be invalidated.
cookie.auth.key=secret-inbucket-session-cookie-key
# Enable or disable the live message monitor tab for the web UI. This will let
# anybody see all messages delivered to Inbucket. This setting has no impact
# on the availability of the underlying WebSocket.
monitor.visible=true
# How many historical message headers should be cached for display by new
# monitor connections. It does not limit the number of messages displayed by
# the browser once the monitor is open; all freshly received messages will be
# appended to the on screen list. This setting also affects the underlying
# API/WebSocket.
monitor.history=30
#############################################################################
[datastore]

View File

@@ -73,6 +73,10 @@ ip4.port=9000
# Name of web theme to use
theme=bootstrap
# Prompt displayed between the mailbox entry field and View button. Leave
# empty or comment out to hide the prompt.
mailbox.prompt=@inbucket
# Path to the selected themes template files
template.dir=%(install.dir)s/themes/%(theme)s/templates
@@ -91,6 +95,18 @@ greeting.file=%(install.dir)s/themes/greeting.html
# and previous sessions will be invalidated.
#cookie.auth.key=secret-inbucket-session-cookie-key
# Enable or disable the live message monitor tab for the web UI. This will let
# anybody see all messages delivered to Inbucket. This setting has no impact
# on the availability of the underlying WebSocket.
monitor.visible=true
# How many historical message headers should be cached for display by new
# monitor connections. It does not limit the number of messages displayed by
# the browser once the monitor is open; all freshly received messages will be
# appended to the on screen list. This setting also affects the underlying
# API/WebSocket.
monitor.history=30
#############################################################################
[datastore]

View File

@@ -3,6 +3,6 @@
notifempty
create 0644 inbucket inbucket
postrotate
[ -x /sbin/reload ] && /sbin/reload inbucket >/dev/null 2>&1 || true
[ -x /bin/systemctl ] && /bin/systemctl reload inbucket >/dev/null 2>&1 || true
endscript
}

View File

@@ -0,0 +1,20 @@
[Unit]
Description=Inbucket Disposable Email Service
After=network.target
[Service]
Type=simple
User=inbucket
Group=inbucket
ExecStart=/opt/inbucket/inbucket -logfile /var/log/inbucket.log /etc/opt/inbucket.conf
# Re-open log file after rotation
ExecReload=/bin/kill -HUP $MAINPID
# Give SMTP connections time to drain
TimeoutStopSec=20
KillMode=mixed
[Install]
WantedBy=multi-user.target

View File

@@ -103,7 +103,7 @@ main() {
esac
# Use jq to pretty-print if installed and we are expecting JSON output
if [ $pretty ] && [ $is_json ] && type -P jq; then
if [ $pretty ] && [ $is_json ] && type -P jq >/dev/null; then
curl -s $curl_opts -H "Accept: application/json" --noproxy "$API_HOST" -X "$method" "$url" | jq .
else
curl -s $curl_opts -H "Accept: application/json" --noproxy "$API_HOST" -X "$method" "$url"

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

@@ -3,6 +3,6 @@
notifempty
create 0644 inbucket inbucket
postrotate
[ -e /etc/init.d/inbucket ] && /etc/init.d/inbucket reload >/dev/null 2>&1 || true
[ -x /bin/systemctl ] && /bin/systemctl reload inbucket >/dev/null 2>&1 || true
endscript
}

View File

@@ -0,0 +1,20 @@
[Unit]
Description=Inbucket Disposable Email Service
After=network.target
[Service]
Type=simple
User=inbucket
Group=inbucket
ExecStart=/opt/inbucket/inbucket -logfile /var/log/inbucket.log /etc/opt/inbucket.conf
# Re-open log file after rotation
ExecReload=/bin/kill -HUP $MAINPID
# Give SMTP connections time to drain
TimeoutStopSec=20
KillMode=mixed
[Install]
WantedBy=multi-user.target

View File

@@ -73,6 +73,10 @@ ip4.port=80
# Name of web theme to use
theme=bootstrap
# Prompt displayed between the mailbox entry field and View button. Leave
# empty or comment out to hide the prompt.
mailbox.prompt=@inbucket
# Path to the selected themes template files
template.dir=%(install.dir)s/themes/%(theme)s/templates
@@ -91,6 +95,18 @@ greeting.file=%(install.dir)s/themes/greeting.html
# and previous sessions will be invalidated.
#cookie.auth.key=secret-inbucket-session-cookie-key
# Enable or disable the live message monitor tab for the web UI. This will let
# anybody see all messages delivered to Inbucket. This setting has no impact
# on the availability of the underlying WebSocket.
monitor.visible=true
# How many historical message headers should be cached for display by new
# monitor connections. It does not limit the number of messages displayed by
# the browser once the monitor is open; all freshly received messages will be
# appended to the on screen list. This setting also affects the underlying
# API/WebSocket.
monitor.history=30
#############################################################################
[datastore]

View File

@@ -73,6 +73,10 @@ ip4.port=9000
# Name of web theme to use
theme=bootstrap
# Prompt displayed between the mailbox entry field and View button. Leave
# empty or comment out to hide the prompt.
mailbox.prompt=@inbucket
# Path to the selected themes template files
template.dir=%(install.dir)s\themes\%(theme)s\templates
@@ -91,6 +95,18 @@ greeting.file=%(install.dir)s\themes\greeting.html
# and previous sessions will be invalidated.
#cookie.auth.key=secret-inbucket-session-cookie-key
# Enable or disable the live message monitor tab for the web UI. This will let
# anybody see all messages delivered to Inbucket. This setting has no impact
# on the availability of the underlying WebSocket.
monitor.visible=true
# How many historical message headers should be cached for display by new
# monitor connections. It does not limit the number of messages displayed by
# the browser once the monitor is open; all freshly received messages will be
# appended to the on screen list. This setting also affects the underlying
# API/WebSocket.
monitor.history=30
#############################################################################
[datastore]

270
filestore/fmessage.go Normal file
View File

@@ -0,0 +1,270 @@
package filestore
import (
"bufio"
"fmt"
"io"
"io/ioutil"
"net/mail"
"os"
"path/filepath"
"time"
"github.com/jhillyerd/enmime"
"github.com/jhillyerd/inbucket/datastore"
"github.com/jhillyerd/inbucket/log"
)
// FileMessage implements Message and contains a little bit of data about a
// particular email message, and methods to retrieve the rest of it from disk.
type FileMessage struct {
mailbox *FileMailbox
// Stored in GOB
Fid string
Fdate time.Time
Ffrom string
Fto []string
Fsubject string
Fsize int64
// These are for creating new messages only
writable bool
writerFile *os.File
writer *bufio.Writer
}
// NewMessage creates a new FileMessage object and sets the Date and Id fields.
// It will also delete messages over messageCap if configured.
func (mb *FileMailbox) NewMessage() (datastore.Message, error) {
// Load index
if !mb.indexLoaded {
if err := mb.readIndex(); err != nil {
return nil, err
}
}
// Delete old messages over messageCap
if mb.store.messageCap > 0 {
for len(mb.messages) >= mb.store.messageCap {
log.Infof("Mailbox %q over configured message cap", mb.name)
if err := mb.messages[0].Delete(); err != nil {
log.Errorf("Error deleting message: %s", err)
}
}
}
date := time.Now()
id := generateID(date)
return &FileMessage{mailbox: mb, Fid: id, Fdate: date, writable: true}, nil
}
// ID gets the ID of the Message
func (m *FileMessage) ID() string {
return m.Fid
}
// Date returns the date/time this Message was received by Inbucket
func (m *FileMessage) Date() time.Time {
return m.Fdate
}
// From returns the value of the Message From header
func (m *FileMessage) From() string {
return m.Ffrom
}
// To returns the value of the Message To header
func (m *FileMessage) To() []string {
return m.Fto
}
// Subject returns the value of the Message Subject header
func (m *FileMessage) Subject() string {
return m.Fsubject
}
// String returns a string in the form: "Subject()" from From()
func (m *FileMessage) String() string {
return fmt.Sprintf("\"%v\" from %v", m.Fsubject, m.Ffrom)
}
// Size returns the size of the Message on disk in bytes
func (m *FileMessage) Size() int64 {
return m.Fsize
}
func (m *FileMessage) rawPath() string {
return filepath.Join(m.mailbox.path, m.Fid+".raw")
}
// ReadHeader opens the .raw portion of a Message and returns a standard Go mail.Message object
func (m *FileMessage) ReadHeader() (msg *mail.Message, err error) {
file, err := os.Open(m.rawPath())
if err != nil {
return nil, err
}
defer func() {
if err := file.Close(); err != nil {
log.Errorf("Failed to close %q: %v", m.rawPath(), err)
}
}()
reader := bufio.NewReader(file)
return mail.ReadMessage(reader)
}
// ReadBody opens the .raw portion of a Message and returns a MIMEBody object
func (m *FileMessage) ReadBody() (body *enmime.Envelope, err error) {
file, err := os.Open(m.rawPath())
if err != nil {
return nil, err
}
defer func() {
if err := file.Close(); err != nil {
log.Errorf("Failed to close %q: %v", m.rawPath(), err)
}
}()
reader := bufio.NewReader(file)
mime, err := enmime.ReadEnvelope(reader)
if err != nil {
return nil, err
}
return mime, nil
}
// RawReader opens the .raw portion of a Message as an io.ReadCloser
func (m *FileMessage) RawReader() (reader io.ReadCloser, err error) {
file, err := os.Open(m.rawPath())
if err != nil {
return nil, err
}
return file, nil
}
// ReadRaw opens the .raw portion of a Message and returns it as a string
func (m *FileMessage) ReadRaw() (raw *string, err error) {
reader, err := m.RawReader()
if err != nil {
return nil, err
}
defer func() {
if err := reader.Close(); err != nil {
log.Errorf("Failed to close %q: %v", m.rawPath(), err)
}
}()
bodyBytes, err := ioutil.ReadAll(bufio.NewReader(reader))
if err != nil {
return nil, err
}
bodyString := string(bodyBytes)
return &bodyString, nil
}
// Append data to a newly opened Message, this will fail on a pre-existing Message and
// after Close() is called.
func (m *FileMessage) Append(data []byte) error {
// Prevent Appending to a pre-existing Message
if !m.writable {
return datastore.ErrNotWritable
}
// Open file for writing if we haven't yet
if m.writer == nil {
// Ensure mailbox directory exists
if err := m.mailbox.createDir(); err != nil {
return err
}
file, err := os.Create(m.rawPath())
if err != nil {
// Set writable false just in case something calls me a million times
m.writable = false
return err
}
m.writerFile = file
m.writer = bufio.NewWriter(file)
}
_, err := m.writer.Write(data)
m.Fsize += int64(len(data))
return err
}
// Close this Message for writing - no more data may be Appended. Close() will also
// trigger the creation of the .gob file.
func (m *FileMessage) Close() error {
// nil out the writer fields so they can't be used
writer := m.writer
writerFile := m.writerFile
m.writer = nil
m.writerFile = nil
if writer != nil {
if err := writer.Flush(); err != nil {
return err
}
}
if writerFile != nil {
if err := writerFile.Close(); err != nil {
return err
}
}
// Fetch headers
body, err := m.ReadBody()
if err != nil {
return err
}
// Only public fields are stored in gob, hence starting with capital F
// Parse From address
if address, err := mail.ParseAddress(body.GetHeader("From")); err == nil {
m.Ffrom = address.String()
} else {
m.Ffrom = body.GetHeader("From")
}
m.Fsubject = body.GetHeader("Subject")
// Turn the To header into a slice
if addresses, err := body.AddressList("To"); err == nil {
for _, a := range addresses {
m.Fto = append(m.Fto, a.String())
}
} else {
m.Fto = []string{body.GetHeader("To")}
}
// Refresh the index before adding our message
err = m.mailbox.readIndex()
if err != nil {
return err
}
// Made it this far without errors, add it to the index
m.mailbox.messages = append(m.mailbox.messages, m)
return m.mailbox.writeIndex()
}
// Delete this Message from disk by removing it from the index and deleting the
// raw files.
func (m *FileMessage) Delete() error {
messages := m.mailbox.messages
for i, mm := range messages {
if m == mm {
// Slice around message we are deleting
m.mailbox.messages = append(messages[:i], messages[i+1:]...)
break
}
}
if err := m.mailbox.writeIndex(); err != nil {
return err
}
if len(m.mailbox.messages) == 0 {
// This was the last message, thus writeIndex() has removed the entire
// directory; we don't need to delete the raw file.
return nil
}
// There are still messages in the index
log.Tracef("Deleting %v", m.rawPath())
return os.Remove(m.rawPath())
}

View File

@@ -1,4 +1,4 @@
package smtpd
package filestore
import (
"bufio"
@@ -6,26 +6,29 @@ import (
"fmt"
"io"
"io/ioutil"
"net/mail"
"os"
"path/filepath"
"sync"
"time"
"github.com/jhillyerd/go.enmime"
"github.com/jhillyerd/inbucket/config"
"github.com/jhillyerd/inbucket/datastore"
"github.com/jhillyerd/inbucket/log"
"github.com/jhillyerd/inbucket/stringutil"
)
// Name of index file in each mailbox
const indexFileName = "index.gob"
var (
// indexLock is locked while reading/writing an index file
// indexMx is locked while reading/writing an index file
//
// NOTE: This is a bottleneck because it's a single lock even if we have a
// NOTE: This is a bottleneck because it's a single lock even if we have a
// million index files
indexLock = new(sync.RWMutex)
indexMx = new(sync.RWMutex)
// dirMx is locked while creating/removing directories
dirMx = new(sync.Mutex)
// countChannel is filled with a sequential numbers (0000..9999), which are
// used by generateID() to generate unique message IDs. It's global
@@ -48,13 +51,14 @@ func countGenerator(c chan int) {
// FileDataStore implements DataStore aand is the root of the mail storage
// hiearchy. It provides access to Mailbox objects
type FileDataStore struct {
hashLock datastore.HashLock
path string
mailPath string
messageCap int
}
// NewFileDataStore creates a new DataStore object using the specified path
func NewFileDataStore(cfg config.DataStoreConfig) DataStore {
func NewFileDataStore(cfg config.DataStoreConfig) datastore.DataStore {
path := cfg.Path
if path == "" {
log.Errorf("No value configured for datastore path")
@@ -72,19 +76,19 @@ func NewFileDataStore(cfg config.DataStoreConfig) DataStore {
// DefaultFileDataStore creates a new DataStore object. It uses the inbucket.Config object to
// construct it's path.
func DefaultFileDataStore() DataStore {
func DefaultFileDataStore() datastore.DataStore {
cfg := config.GetDataStoreConfig()
return NewFileDataStore(cfg)
}
// MailboxFor retrieves the Mailbox object for a specified email address, if the mailbox
// does not exist, it will attempt to create it.
func (ds *FileDataStore) MailboxFor(emailAddress string) (Mailbox, error) {
name, err := ParseMailboxName(emailAddress)
func (ds *FileDataStore) MailboxFor(emailAddress string) (datastore.Mailbox, error) {
name, err := stringutil.ParseMailboxName(emailAddress)
if err != nil {
return nil, err
}
dir := HashMailboxName(name)
dir := stringutil.HashMailboxName(name)
s1 := dir[0:3]
s2 := dir[0:6]
path := filepath.Join(ds.mailPath, s1, s2, dir)
@@ -95,8 +99,8 @@ func (ds *FileDataStore) MailboxFor(emailAddress string) (Mailbox, error) {
}
// AllMailboxes returns a slice with all Mailboxes
func (ds *FileDataStore) AllMailboxes() ([]Mailbox, error) {
mailboxes := make([]Mailbox, 0, 100)
func (ds *FileDataStore) AllMailboxes() ([]datastore.Mailbox, error) {
mailboxes := make([]datastore.Mailbox, 0, 100)
infos1, err := ioutil.ReadDir(ds.mailPath)
if err != nil {
return nil, err
@@ -136,6 +140,15 @@ func (ds *FileDataStore) AllMailboxes() ([]Mailbox, error) {
return mailboxes, nil
}
func (ds *FileDataStore) LockFor(emailAddress string) (*sync.RWMutex, error) {
name, err := stringutil.ParseMailboxName(emailAddress)
if err != nil {
return nil, err
}
hash := stringutil.HashMailboxName(name)
return ds.hashLock.Get(hash), nil
}
// FileMailbox implements Mailbox, manages the mail for a specific user and
// correlates to a particular directory on disk.
type FileMailbox struct {
@@ -148,20 +161,26 @@ type FileMailbox struct {
messages []*FileMessage
}
// Name of the mailbox
func (mb *FileMailbox) Name() string {
return mb.name
}
// String renders the name and directory path of the mailbox
func (mb *FileMailbox) String() string {
return mb.name + "[" + mb.dirName + "]"
}
// GetMessages scans the mailbox directory for .gob files and decodes them into
// a slice of Message objects.
func (mb *FileMailbox) GetMessages() ([]Message, error) {
func (mb *FileMailbox) GetMessages() ([]datastore.Message, error) {
if !mb.indexLoaded {
if err := mb.readIndex(); err != nil {
return nil, err
}
}
messages := make([]Message, len(mb.messages))
messages := make([]datastore.Message, len(mb.messages))
for i, m := range mb.messages {
messages[i] = m
}
@@ -169,20 +188,24 @@ func (mb *FileMailbox) GetMessages() ([]Message, error) {
}
// GetMessage decodes a single message by Id and returns a Message object
func (mb *FileMailbox) GetMessage(id string) (Message, error) {
func (mb *FileMailbox) GetMessage(id string) (datastore.Message, error) {
if !mb.indexLoaded {
if err := mb.readIndex(); err != nil {
return nil, err
}
}
if id == "latest" && len(mb.messages) != 0 {
return mb.messages[len(mb.messages)-1], nil
}
for _, m := range mb.messages {
if m.Fid == id {
return m, nil
}
}
return nil, ErrNotExist
return nil, datastore.ErrNotExist
}
// Purge deletes all messages in this mailbox
@@ -196,8 +219,8 @@ func (mb *FileMailbox) readIndex() error {
// Clear message slice, open index
mb.messages = mb.messages[:0]
// Lock for reading
indexLock.RLock()
defer indexLock.RUnlock()
indexMx.RLock()
defer indexMx.RUnlock()
// Check if index exists
if _, err := os.Stat(mb.indexPath); err != nil {
// Does not exist, but that's not an error in our world
@@ -234,22 +257,11 @@ func (mb *FileMailbox) readIndex() error {
return nil
}
// createDir checks for the presence of the path for this mailbox, creates it if needed
func (mb *FileMailbox) createDir() error {
if _, err := os.Stat(mb.path); err != nil {
if err := os.MkdirAll(mb.path, 0770); err != nil {
log.Errorf("Failed to create directory %v, %v", mb.path, err)
return err
}
}
return nil
}
// writeIndex overwrites the index on disk with the current mailbox data
func (mb *FileMailbox) writeIndex() error {
// Lock for writing
indexLock.Lock()
defer indexLock.Unlock()
indexMx.Lock()
defer indexMx.Unlock()
if len(mb.messages) > 0 {
// Ensure mailbox directory exists
if err := mb.createDir(); err != nil {
@@ -260,269 +272,85 @@ func (mb *FileMailbox) writeIndex() error {
if err != nil {
return err
}
defer func() {
if err := file.Close(); err != nil {
log.Errorf("Failed to close %q: %v", mb.indexPath, err)
}
}()
writer := bufio.NewWriter(file)
// Write each message and then flush
enc := gob.NewEncoder(writer)
for _, m := range mb.messages {
err = enc.Encode(m)
if err != nil {
_ = file.Close()
return err
}
}
if err := writer.Flush(); err != nil {
_ = file.Close()
return err
}
if err := file.Close(); err != nil {
log.Errorf("Failed to close %q: %v", mb.indexPath, err)
return err
}
} else {
// No messages, delete index+maildir
log.Tracef("Removing mailbox %v", mb.path)
return os.RemoveAll(mb.path)
return mb.removeDir()
}
return nil
}
// FileMessage implements Message and contains a little bit of data about a
// particular email message, and methods to retrieve the rest of it from disk.
type FileMessage struct {
mailbox *FileMailbox
// Stored in GOB
Fid string
Fdate time.Time
Ffrom string
Fsubject string
Fsize int64
// These are for creating new messages only
writable bool
writerFile *os.File
writer *bufio.Writer
}
// NewMessage creates a new FileMessage object and sets the Date and Id fields.
// It will also delete messages over messageCap if configured.
func (mb *FileMailbox) NewMessage() (Message, error) {
// Load index
if !mb.indexLoaded {
if err := mb.readIndex(); err != nil {
return nil, err
}
}
// Delete old messages over messageCap
if mb.store.messageCap > 0 {
for len(mb.messages) >= mb.store.messageCap {
log.Infof("Mailbox %q over configured message cap", mb.name)
if err := mb.messages[0].Delete(); err != nil {
log.Errorf("Error deleting message: %s", err)
}
}
}
date := time.Now()
id := generateID(date)
return &FileMessage{mailbox: mb, Fid: id, Fdate: date, writable: true}, nil
}
// ID gets the ID of the Message
func (m *FileMessage) ID() string {
return m.Fid
}
// Date returns the date/time this Message was received by Inbucket
func (m *FileMessage) Date() time.Time {
return m.Fdate
}
// From returns the value of the Message From header
func (m *FileMessage) From() string {
return m.Ffrom
}
// Subject returns the value of the Message Subject header
func (m *FileMessage) Subject() string {
return m.Fsubject
}
// String returns a string in the form: "Subject()" from From()
func (m *FileMessage) String() string {
return fmt.Sprintf("\"%v\" from %v", m.Fsubject, m.Ffrom)
}
// Size returns the size of the Message on disk in bytes
func (m *FileMessage) Size() int64 {
return m.Fsize
}
func (m *FileMessage) rawPath() string {
return filepath.Join(m.mailbox.path, m.Fid+".raw")
}
// ReadHeader opens the .raw portion of a Message and returns a standard Go mail.Message object
func (m *FileMessage) ReadHeader() (msg *mail.Message, err error) {
file, err := os.Open(m.rawPath())
if err != nil {
return nil, err
}
defer func() {
if err := file.Close(); err != nil {
log.Errorf("Failed to close %q: %v", m.rawPath(), err)
}
}()
reader := bufio.NewReader(file)
return mail.ReadMessage(reader)
}
// ReadBody opens the .raw portion of a Message and returns a MIMEBody object
func (m *FileMessage) ReadBody() (body *enmime.MIMEBody, err error) {
file, err := os.Open(m.rawPath())
if err != nil {
return nil, err
}
defer func() {
if err := file.Close(); err != nil {
log.Errorf("Failed to close %q: %v", m.rawPath(), err)
}
}()
reader := bufio.NewReader(file)
msg, err := mail.ReadMessage(reader)
if err != nil {
return nil, err
}
mime, err := enmime.ParseMIMEBody(msg)
if err != nil {
return nil, err
}
return mime, nil
}
// RawReader opens the .raw portion of a Message as an io.ReadCloser
func (m *FileMessage) RawReader() (reader io.ReadCloser, err error) {
file, err := os.Open(m.rawPath())
if err != nil {
return nil, err
}
return file, nil
}
// ReadRaw opens the .raw portion of a Message and returns it as a string
func (m *FileMessage) ReadRaw() (raw *string, err error) {
reader, err := m.RawReader()
if err != nil {
return nil, err
}
defer func() {
if err := reader.Close(); err != nil {
log.Errorf("Failed to close %q: %v", m.rawPath(), err)
}
}()
bodyBytes, err := ioutil.ReadAll(bufio.NewReader(reader))
if err != nil {
return nil, err
}
bodyString := string(bodyBytes)
return &bodyString, nil
}
// Append data to a newly opened Message, this will fail on a pre-existing Message and
// after Close() is called.
func (m *FileMessage) Append(data []byte) error {
// Prevent Appending to a pre-existing Message
if !m.writable {
return ErrNotWritable
}
// Open file for writing if we haven't yet
if m.writer == nil {
// Ensure mailbox directory exists
if err := m.mailbox.createDir(); err != nil {
return err
}
file, err := os.Create(m.rawPath())
if err != nil {
// Set writable false just in case something calls me a million times
m.writable = false
return err
}
m.writerFile = file
m.writer = bufio.NewWriter(file)
}
_, err := m.writer.Write(data)
m.Fsize += int64(len(data))
return err
}
// Close this Message for writing - no more data may be Appended. Close() will also
// trigger the creation of the .gob file.
func (m *FileMessage) Close() error {
// nil out the writer fields so they can't be used
writer := m.writer
writerFile := m.writerFile
m.writer = nil
m.writerFile = nil
if writer != nil {
if err := writer.Flush(); err != nil {
return err
}
}
if writerFile != nil {
if err := writerFile.Close(); err != nil {
// createDir checks for the presence of the path for this mailbox, creates it if needed
func (mb *FileMailbox) createDir() error {
dirMx.Lock()
defer dirMx.Unlock()
if _, err := os.Stat(mb.path); err != nil {
if err := os.MkdirAll(mb.path, 0770); err != nil {
log.Errorf("Failed to create directory %v, %v", mb.path, err)
return err
}
}
return nil
}
// Fetch headers
body, err := m.ReadBody()
if err != nil {
// removeDir removes the mailbox, plus empty higher level directories
func (mb *FileMailbox) removeDir() error {
dirMx.Lock()
defer dirMx.Unlock()
// remove mailbox dir, including index file
if err := os.RemoveAll(mb.path); err != nil {
return err
}
// Only public fields are stored in gob
m.Ffrom = body.GetHeader("From")
m.Fsubject = body.GetHeader("Subject")
// Refresh the index before adding our message
err = m.mailbox.readIndex()
if err != nil {
return err
// remove parents if empty
dir := filepath.Dir(mb.path)
if removeDirIfEmpty(dir) {
removeDirIfEmpty(filepath.Dir(dir))
}
// Made it this far without errors, add it to the index
m.mailbox.messages = append(m.mailbox.messages, m)
return m.mailbox.writeIndex()
return nil
}
// Delete this Message from disk by removing it from the index and deleting the
// raw files.
func (m *FileMessage) Delete() error {
messages := m.mailbox.messages
for i, mm := range messages {
if m == mm {
// Slice around message we are deleting
m.mailbox.messages = append(messages[:i], messages[i+1:]...)
break
}
// removeDirIfEmpty will remove the specified directory if it contains no files or directories.
// Caller should hold dirMx. Returns true if dir was removed.
func removeDirIfEmpty(path string) (removed bool) {
f, err := os.Open(path)
if err != nil {
return false
}
if err := m.mailbox.writeIndex(); err != nil {
return err
files, err := f.Readdirnames(0)
_ = f.Close()
if err != nil {
return false
}
if len(m.mailbox.messages) == 0 {
// This was the last message, thus writeIndex() has removed the entire
// directory; we don't need to delete the raw file.
return nil
if len(files) > 0 {
// Dir not empty
return false
}
// There are still messages in the index
log.Tracef("Deleting %v", m.rawPath())
return os.Remove(m.rawPath())
log.Tracef("Removing dir %v", path)
err = os.Remove(path)
if err != nil {
log.Errorf("Failed to remove %q: %v", path, err)
return false
}
return true
}
// generatePrefix converts a Time object into the ISO style format we use

View File

@@ -1,4 +1,4 @@
package smtpd
package filestore
import (
"bytes"
@@ -458,6 +458,55 @@ func TestFSNoMessageCap(t *testing.T) {
}
}
// Test Get the latest message
func TestGetLatestMessage(t *testing.T) {
ds, logbuf := setupDataStore(config.DataStoreConfig{})
defer teardownDataStore(ds)
// james hashes to 474ba67bdb289c6263b36dfd8a7bed6c85b04943
mbName := "james"
// Test empty mailbox
mb, err := ds.MailboxFor(mbName)
assert.Nil(t, err)
msg, err := mb.GetMessage("latest")
assert.Nil(t, msg)
assert.Error(t, err)
// Deliver test message
deliverMessage(ds, mbName, "test", time.Now())
// Deliver test message 2
id2, _ := deliverMessage(ds, mbName, "test 2", time.Now())
// Test get the latest message
mb, err = ds.MailboxFor(mbName)
assert.Nil(t, err)
msg, err = mb.GetMessage("latest")
assert.Nil(t, err)
assert.True(t, msg.ID() == id2, "Expected %q to be equal to %q", msg.ID(), id2)
// Deliver test message 3
id3, _ := deliverMessage(ds, mbName, "test 3", time.Now())
mb, err = ds.MailboxFor(mbName)
assert.Nil(t, err)
msg, err = mb.GetMessage("latest")
assert.Nil(t, err)
assert.True(t, msg.ID() == id3, "Expected %q to be equal to %q", msg.ID(), id3)
// Test wrong id
_, err = mb.GetMessage("wrongid")
assert.Error(t, err)
if t.Failed() {
// Wait for handler to finish logging
time.Sleep(2 * time.Second)
// Dump buffered log data if there was a failure
_, _ = io.Copy(os.Stderr, logbuf)
}
}
// setupDataStore creates a new FileDataStore in a temporary directory
func setupDataStore(cfg config.DataStoreConfig) (*FileDataStore, *bytes.Buffer) {
path, err := ioutil.TempDir("", "inbucket")

View File

@@ -6,14 +6,18 @@ import (
"github.com/gorilla/mux"
"github.com/gorilla/sessions"
"github.com/jhillyerd/inbucket/smtpd"
"github.com/jhillyerd/inbucket/config"
"github.com/jhillyerd/inbucket/datastore"
"github.com/jhillyerd/inbucket/msghub"
)
// Context is passed into every request handler function
type Context struct {
Vars map[string]string
Session *sessions.Session
DataStore smtpd.DataStore
DataStore datastore.DataStore
MsgHub *msghub.Hub
WebConfig config.WebConfig
IsJSON bool
}
@@ -56,6 +60,8 @@ func NewContext(req *http.Request) (*Context, error) {
Vars: vars,
Session: sess,
DataStore: DataStore,
MsgHub: msgHub,
WebConfig: webConfig,
IsJSON: headerMatch(req, "Accept", "application/json"),
}
return ctx, err

View File

@@ -2,18 +2,20 @@
package httpd
import (
"context"
"expvar"
"fmt"
"net"
"net/http"
"time"
"github.com/goods/httpbuf"
"github.com/gorilla/mux"
"github.com/gorilla/securecookie"
"github.com/gorilla/sessions"
"github.com/jhillyerd/inbucket/config"
"github.com/jhillyerd/inbucket/datastore"
"github.com/jhillyerd/inbucket/log"
"github.com/jhillyerd/inbucket/smtpd"
"github.com/jhillyerd/inbucket/msghub"
)
// Handler is a function type that handles an HTTP request in Inbucket
@@ -21,7 +23,10 @@ type Handler func(http.ResponseWriter, *http.Request, *Context) error
var (
// DataStore is where all the mailboxes and messages live
DataStore smtpd.DataStore
DataStore datastore.DataStore
// msgHub holds a reference to the message pub/sub system
msgHub *msghub.Hub
// Router is shared between httpd, webui and rest packages. It sends
// incoming requests to the correct handler function
@@ -32,15 +37,29 @@ var (
listener net.Listener
sessionStore sessions.Store
globalShutdown chan bool
// ExpWebSocketConnectsCurrent tracks the number of open WebSockets
ExpWebSocketConnectsCurrent = new(expvar.Int)
)
func init() {
m := expvar.NewMap("http")
m.Set("WebSocketConnectsCurrent", ExpWebSocketConnectsCurrent)
}
// Initialize sets up things for unit tests or the Start() method
func Initialize(cfg config.WebConfig, ds smtpd.DataStore, shutdownChan chan bool) {
func Initialize(
cfg config.WebConfig,
shutdownChan chan bool,
ds datastore.DataStore,
mh *msghub.Hub) {
webConfig = cfg
globalShutdown = shutdownChan
// NewContext() will use this DataStore for the web handlers
DataStore = ds
msgHub = mh
// Content Paths
log.Infof("HTTP templates mapped to %q", cfg.TemplateDir)
@@ -60,7 +79,7 @@ func Initialize(cfg config.WebConfig, ds smtpd.DataStore, shutdownChan chan bool
}
// Start begins listening for HTTP requests
func Start() {
func Start(ctx context.Context) {
addr := fmt.Sprintf("%v:%v", webConfig.IP4address, webConfig.IP4port)
server = &http.Server{
Addr: addr,
@@ -80,11 +99,11 @@ func Start() {
}
// Listener go routine
go serve()
go serve(ctx)
// Wait for shutdown
select {
case _ = <-globalShutdown:
case _ = <-ctx.Done():
log.Tracef("HTTP server shutting down on request")
}
@@ -95,12 +114,12 @@ func Start() {
}
// serve begins serving HTTP requests
func serve() {
func serve(ctx context.Context) {
// server.Serve blocks until we close the listener
err := server.Serve(listener)
select {
case _ = <-globalShutdown:
case _ = <-ctx.Done():
// Nop
default:
log.Errorf("HTTP server failed: %v", err)
@@ -121,26 +140,13 @@ func (h Handler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
defer ctx.Close()
// Run the handler, grab the error, and report it
buf := new(httpbuf.Buffer)
log.Tracef("HTTP[%v] %v %v %q", req.RemoteAddr, req.Proto, req.Method, req.RequestURI)
err = h(buf, req, ctx)
err = h(w, req, ctx)
if err != nil {
log.Errorf("HTTP error handling %q: %v", req.RequestURI, err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Save the session
if err = ctx.Session.Save(req, buf); err != nil {
log.Errorf("HTTP failed to save session: %v", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Apply the buffered response to the writer
if _, err = buf.Apply(w); err != nil {
log.Errorf("HTTP failed to write response: %v", err)
}
}
func emergencyShutdown() {

View File

@@ -2,17 +2,21 @@
package main
import (
"context"
"expvar"
"flag"
"fmt"
"os"
"os/signal"
"runtime"
"syscall"
"time"
"github.com/jhillyerd/inbucket/config"
"github.com/jhillyerd/inbucket/filestore"
"github.com/jhillyerd/inbucket/httpd"
"github.com/jhillyerd/inbucket/log"
"github.com/jhillyerd/inbucket/msghub"
"github.com/jhillyerd/inbucket/pop3d"
"github.com/jhillyerd/inbucket/rest"
"github.com/jhillyerd/inbucket/smtpd"
@@ -20,20 +24,17 @@ import (
)
var (
// VERSION contains the build version number, populated during linking by goxc
VERSION = "1.1.0"
// version contains the build version number, populated during linking
version = "undefined"
// BUILDDATE contains the build date, populated during linking by goxc
BUILDDATE = "undefined"
// date contains the build date, populated during linking
date = "undefined"
// Command line flags
help = flag.Bool("help", false, "Displays this help")
pidfile = flag.String("pidfile", "none", "Write our PID into the specified file")
logfile = flag.String("logfile", "stderr", "Write out log into the specified file")
// startTime is used to calculate uptime of Inbucket
startTime = time.Now()
// shutdownChan - close it to tell Inbucket to shut down cleanly
shutdownChan = make(chan bool)
@@ -42,9 +43,27 @@ var (
pop3Server *pop3d.Server
)
func init() {
flag.Usage = func() {
fmt.Fprintln(os.Stderr, "Usage of inbucket [options] <conf file>:")
flag.PrintDefaults()
}
// Server uptime for status page
startTime := time.Now()
expvar.Publish("uptime", expvar.Func(func() interface{} {
return time.Since(startTime) / time.Second
}))
// Goroutine count for status page
expvar.Publish("goroutines", expvar.Func(func() interface{} {
return runtime.NumGoroutine()
}))
}
func main() {
config.Version = VERSION
config.BuildDate = BUILDDATE
config.Version = version
config.BuildDate = date
flag.Parse()
if *help {
@@ -52,6 +71,9 @@ func main() {
return
}
// Root context
rootCtx, rootCancel := context.WithCancel(context.Background())
// Load & Parse config
if flag.NArg() != 1 {
flag.Usage()
@@ -64,12 +86,11 @@ func main() {
}
// Setup signal handler
sigChan := make(chan os.Signal)
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGHUP, syscall.SIGTERM, syscall.SIGINT)
// Initialize logging
level, _ := config.Config.String("logging", "level")
log.SetLogLevel(level)
log.SetLogLevel(config.GetLogLevel())
if err := log.Initialize(*logfile); err != nil {
fmt.Fprintf(os.Stderr, "%v", err)
os.Exit(1)
@@ -91,23 +112,25 @@ func main() {
}
}
// Create message hub
msgHub := msghub.New(rootCtx, config.GetWebConfig().MonitorHistory)
// Grab our datastore
ds := smtpd.DefaultFileDataStore()
ds := filestore.DefaultFileDataStore()
// Start HTTP server
httpd.Initialize(config.GetWebConfig(), ds, shutdownChan)
httpd.Initialize(config.GetWebConfig(), shutdownChan, ds, msgHub)
webui.SetupRoutes(httpd.Router)
rest.SetupRoutes(httpd.Router)
go httpd.Start()
go httpd.Start(rootCtx)
// Start POP3 server
// TODO pass datastore
pop3Server = pop3d.New(shutdownChan)
go pop3Server.Start()
pop3Server = pop3d.New(config.GetPOP3Config(), shutdownChan, ds)
go pop3Server.Start(rootCtx)
// Startup SMTP server
smtpServer = smtpd.NewServer(config.GetSMTPConfig(), ds, shutdownChan)
go smtpServer.Start()
smtpServer = smtpd.NewServer(config.GetSMTPConfig(), shutdownChan, ds, msgHub)
go smtpServer.Start(rootCtx)
// Loop forever waiting for signals or shutdown channel
signalLoop:
@@ -127,7 +150,8 @@ signalLoop:
log.Infof("Received SIGTERM, shutting down")
close(shutdownChan)
}
case _ = <-shutdownChan:
case <-shutdownChan:
rootCancel()
break signalLoop
}
}
@@ -157,17 +181,3 @@ func timedExit() {
removePIDFile()
os.Exit(0)
}
func init() {
flag.Usage = func() {
fmt.Fprintln(os.Stderr, "Usage of inbucket [options] <conf file>:")
flag.PrintDefaults()
}
expvar.Publish("uptime", expvar.Func(uptime))
}
// uptime() is published as an expvar
func uptime() interface{} {
return time.Since(startTime) / time.Second
}

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

110
msghub/hub.go Normal file
View File

@@ -0,0 +1,110 @@
package msghub
import (
"container/ring"
"context"
"time"
)
// Length of msghub operation queue
const opChanLen = 100
// Message contains the basic header data for a message
type Message struct {
Mailbox string
ID string
From string
To []string
Subject string
Date time.Time
Size int64
}
// Listener receives the contents of the history buffer, followed by new messages
type Listener interface {
Receive(msg Message) error
}
// Hub relays messages on to its listeners
type Hub struct {
// history buffer, points next Message to write. Proceeding non-nil entry is oldest Message
history *ring.Ring
listeners map[Listener]struct{} // listeners interested in new messages
opChan chan func(h *Hub) // operations queued for this actor
}
// New constructs a new Hub which will cache historyLen messages in memory for playback to future
// listeners. A goroutine is created to handle incoming messages; it will run until the provided
// context is canceled.
func New(ctx context.Context, historyLen int) *Hub {
h := &Hub{
history: ring.New(historyLen),
listeners: make(map[Listener]struct{}),
opChan: make(chan func(h *Hub), opChanLen),
}
go func() {
for {
select {
case <-ctx.Done():
// Shutdown
close(h.opChan)
return
case op := <-h.opChan:
op(h)
}
}
}()
return h
}
// Dispatch queues a message for broadcast by the hub. The message will be placed into the
// history buffer and then relayed to all registered listeners.
func (hub *Hub) Dispatch(msg Message) {
hub.opChan <- func(h *Hub) {
if h.history != nil {
// Add to history buffer
h.history.Value = msg
h.history = h.history.Next()
// Deliver message to all listeners, removing listeners if they return an error
for l := range h.listeners {
if err := l.Receive(msg); err != nil {
delete(h.listeners, l)
}
}
}
}
}
// AddListener registers a listener to receive broadcasted messages.
func (hub *Hub) AddListener(l Listener) {
hub.opChan <- func(h *Hub) {
// Playback log
h.history.Do(func(v interface{}) {
if v != nil {
l.Receive(v.(Message))
}
})
// Add to listeners
h.listeners[l] = struct{}{}
}
}
// RemoveListener deletes a listener registration, it will cease to receive messages.
func (hub *Hub) RemoveListener(l Listener) {
hub.opChan <- func(h *Hub) {
delete(h.listeners, l)
}
}
// Sync blocks until the msghub has processed its queue up to this point, useful
// for unit tests.
func (hub *Hub) Sync() {
done := make(chan struct{})
hub.opChan <- func(h *Hub) {
close(done)
}
<-done
}

255
msghub/hub_test.go Normal file
View File

@@ -0,0 +1,255 @@
package msghub
import (
"context"
"fmt"
"testing"
"time"
)
// testListener implements the Listener interface, mock for unit tests
type testListener struct {
messages []*Message // received messages
wantMessages int // how many messages this listener wants to receive
errorAfter int // when != 0, messages until Receive() begins returning error
done chan struct{} // closed once we have received wantMessages
overflow chan struct{} // closed if we receive wantMessages+1
}
func newTestListener(want int) *testListener {
l := &testListener{
messages: make([]*Message, 0, want*2),
wantMessages: want,
done: make(chan struct{}),
overflow: make(chan struct{}),
}
if want == 0 {
close(l.done)
}
return l
}
// Receive a Message, store it in the messages slice, close applicable channels, and return an error
// if instructed
func (l *testListener) Receive(msg Message) error {
l.messages = append(l.messages, &msg)
if len(l.messages) == l.wantMessages {
close(l.done)
}
if len(l.messages) == l.wantMessages+1 {
close(l.overflow)
}
if l.errorAfter > 0 && len(l.messages) > l.errorAfter {
return fmt.Errorf("Too many messages")
}
return nil
}
// String formats the got vs wanted message counts
func (l *testListener) String() string {
return fmt.Sprintf("got %v messages, wanted %v", len(l.messages), l.wantMessages)
}
func TestHubNew(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
hub := New(ctx, 5)
if hub == nil {
t.Fatal("New() == nil, expected a new Hub")
}
}
func TestHubZeroLen(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
hub := New(ctx, 0)
m := Message{}
for i := 0; i < 100; i++ {
hub.Dispatch(m)
}
// Just making sure Hub doesn't panic
}
func TestHubZeroListeners(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
hub := New(ctx, 5)
m := Message{}
for i := 0; i < 100; i++ {
hub.Dispatch(m)
}
// Just making sure Hub doesn't panic
}
func TestHubOneListener(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
hub := New(ctx, 5)
m := Message{}
l := newTestListener(1)
hub.AddListener(l)
hub.Dispatch(m)
// Wait for messages
select {
case <-l.done:
case <-time.After(time.Second):
t.Error("Timeout:", l)
}
}
func TestHubRemoveListener(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
hub := New(ctx, 5)
m := Message{}
l := newTestListener(1)
hub.AddListener(l)
hub.Dispatch(m)
hub.RemoveListener(l)
hub.Dispatch(m)
hub.Sync()
// Wait for messages
select {
case <-l.overflow:
t.Error(l)
case <-time.After(50 * time.Millisecond):
// Expected result, no overflow
}
}
func TestHubRemoveListenerOnError(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
hub := New(ctx, 5)
m := Message{}
// error after 1 means listener should receive 2 messages before being removed
l := newTestListener(2)
l.errorAfter = 1
hub.AddListener(l)
hub.Dispatch(m)
hub.Dispatch(m)
hub.Dispatch(m)
hub.Dispatch(m)
hub.Sync()
// Wait for messages
select {
case <-l.overflow:
t.Error(l)
case <-time.After(50 * time.Millisecond):
// Expected result, no overflow
}
}
func TestHubHistoryReplay(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
hub := New(ctx, 100)
l1 := newTestListener(3)
hub.AddListener(l1)
// Broadcast 3 messages with no listeners
msgs := make([]Message, 3)
for i := 0; i < len(msgs); i++ {
msgs[i] = Message{
Subject: fmt.Sprintf("subj %v", i),
}
hub.Dispatch(msgs[i])
}
// Wait for messages (live)
select {
case <-l1.done:
case <-time.After(time.Second):
t.Fatal("Timeout:", l1)
}
// Add a new listener
l2 := newTestListener(3)
hub.AddListener(l2)
// Wait for messages (history)
select {
case <-l2.done:
case <-time.After(time.Second):
t.Fatal("Timeout:", l2)
}
for i := 0; i < len(msgs); i++ {
got := l2.messages[i].Subject
want := msgs[i].Subject
if got != want {
t.Errorf("msg[%v].Subject == %q, want %q", i, got, want)
}
}
}
func TestHubHistoryReplayWrap(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
hub := New(ctx, 5)
l1 := newTestListener(20)
hub.AddListener(l1)
// Broadcast more messages than the hub can hold
msgs := make([]Message, 20)
for i := 0; i < len(msgs); i++ {
msgs[i] = Message{
Subject: fmt.Sprintf("subj %v", i),
}
hub.Dispatch(msgs[i])
}
// Wait for messages (live)
select {
case <-l1.done:
case <-time.After(time.Second):
t.Fatal("Timeout:", l1)
}
// Add a new listener
l2 := newTestListener(5)
hub.AddListener(l2)
// Wait for messages (history)
select {
case <-l2.done:
case <-time.After(time.Second):
t.Fatal("Timeout:", l2)
}
for i := 0; i < 5; i++ {
got := l2.messages[i].Subject
want := msgs[i+15].Subject
if got != want {
t.Errorf("msg[%v].Subject == %q, want %q", i, got, want)
}
}
}
func TestHubContextCancel(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
hub := New(ctx, 5)
m := Message{}
l := newTestListener(1)
hub.AddListener(l)
hub.Dispatch(m)
hub.Sync()
cancel()
// Wait for messages
select {
case <-l.overflow:
t.Error(l)
case <-time.After(50 * time.Millisecond):
// Expected result, no overflow
}
}

View File

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

View File

@@ -1,54 +1,45 @@
package pop3d
import (
"context"
"fmt"
"net"
"sync"
"time"
"github.com/jhillyerd/inbucket/config"
"github.com/jhillyerd/inbucket/datastore"
"github.com/jhillyerd/inbucket/log"
"github.com/jhillyerd/inbucket/smtpd"
)
// Server defines an instance of our POP3 server
type Server struct {
host string
domain string
maxIdleSeconds int
dataStore smtpd.DataStore
dataStore datastore.DataStore
listener net.Listener
globalShutdown chan bool
localShutdown chan bool
waitgroup *sync.WaitGroup
}
// New creates a new Server struct
func New(shutdownChan chan bool) *Server {
// Get a new instance of the the FileDataStore - the locking and counting
// mechanisms are both global variables in the smtpd package. If that
// changes in the future, this should be modified to use the same DataStore
// instance.
ds := smtpd.DefaultFileDataStore()
cfg := config.GetPOP3Config()
func New(cfg config.POP3Config, shutdownChan chan bool, ds datastore.DataStore) *Server {
return &Server{
host: fmt.Sprintf("%v:%v", cfg.IP4address, cfg.IP4port),
domain: cfg.Domain,
dataStore: ds,
maxIdleSeconds: cfg.MaxIdleSeconds,
globalShutdown: shutdownChan,
localShutdown: make(chan bool),
waitgroup: new(sync.WaitGroup),
}
}
// Start the server and listen for connections
func (s *Server) Start() {
cfg := config.GetPOP3Config()
addr, err := net.ResolveTCPAddr("tcp4", fmt.Sprintf("%v:%v",
cfg.IP4address, cfg.IP4port))
func (s *Server) Start(ctx context.Context) {
addr, err := net.ResolveTCPAddr("tcp4", s.host)
if err != nil {
log.Errorf("POP3 Failed to build tcp4 address: %v", err)
// serve() never called, so we do local shutdown here
close(s.localShutdown)
s.emergencyShutdown()
return
}
@@ -57,18 +48,16 @@ func (s *Server) Start() {
s.listener, err = net.ListenTCP("tcp4", addr)
if err != nil {
log.Errorf("POP3 failed to start tcp4 listener: %v", err)
// serve() never called, so we do local shutdown here
close(s.localShutdown)
s.emergencyShutdown()
return
}
// Listener go routine
go s.serve()
go s.serve(ctx)
// Wait for shutdown
select {
case _ = <-s.globalShutdown:
case _ = <-ctx.Done():
}
log.Tracef("POP3 shutdown requested, connections will be drained")
@@ -79,7 +68,7 @@ func (s *Server) Start() {
}
// serve is the listen/accept loop
func (s *Server) serve() {
func (s *Server) serve(ctx context.Context) {
// Handle incoming connections
var tempDelay time.Duration
for sid := 1; ; sid++ {
@@ -100,11 +89,11 @@ func (s *Server) serve() {
} else {
// Permanent error
select {
case _ = <-s.globalShutdown:
close(s.localShutdown)
case <-ctx.Done():
// POP3 is shutting down
return
default:
close(s.localShutdown)
// Something went wrong
s.emergencyShutdown()
return
}
@@ -128,10 +117,6 @@ func (s *Server) emergencyShutdown() {
// Drain causes the caller to block until all active POP3 sessions have finished
func (s *Server) Drain() {
// Wait for listener to exit
select {
case _ = <-s.localShutdown:
}
// Wait for sessions to close
s.waitgroup.Wait()
log.Tracef("POP3 connections have drained")

View File

@@ -4,46 +4,23 @@ import (
"fmt"
"io"
"net/http"
"net/mail"
"time"
"crypto/md5"
"encoding/hex"
"io/ioutil"
"strconv"
"github.com/jhillyerd/inbucket/datastore"
"github.com/jhillyerd/inbucket/httpd"
"github.com/jhillyerd/inbucket/log"
"github.com/jhillyerd/inbucket/smtpd"
"github.com/jhillyerd/inbucket/rest/model"
"github.com/jhillyerd/inbucket/stringutil"
)
// JSONMessageHeaderV1 contains the basic header data for a message
type JSONMessageHeaderV1 struct {
Mailbox string `json:"mailbox"`
ID string `json:"id"`
From string `json:"from"`
Subject string `json:"subject"`
Date time.Time `json:"date"`
Size int64 `json:"size"`
}
// JSONMessageV1 contains the same data as the header plus a JSONMessageBody
type JSONMessageV1 struct {
Mailbox string `json:"mailbox"`
ID string `json:"id"`
From string `json:"from"`
Subject string `json:"subject"`
Date time.Time `json:"date"`
Size int64 `json:"size"`
Body *JSONMessageBodyV1 `json:"body"`
Header mail.Header `json:"header"`
}
// JSONMessageBodyV1 contains the Text and HTML versions of the message body
type JSONMessageBodyV1 struct {
Text string `json:"text"`
HTML string `json:"html"`
}
// MailboxListV1 renders a list of messages in a mailbox
func MailboxListV1(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) (err error) {
// Don't have to validate these aren't empty, Gorilla returns 404
name, err := smtpd.ParseMailboxName(ctx.Vars["name"])
name, err := stringutil.ParseMailboxName(ctx.Vars["name"])
if err != nil {
return err
}
@@ -59,12 +36,13 @@ func MailboxListV1(w http.ResponseWriter, req *http.Request, ctx *httpd.Context)
}
log.Tracef("Got %v messsages", len(messages))
jmessages := make([]*JSONMessageHeaderV1, len(messages))
jmessages := make([]*model.JSONMessageHeaderV1, len(messages))
for i, msg := range messages {
jmessages[i] = &JSONMessageHeaderV1{
jmessages[i] = &model.JSONMessageHeaderV1{
Mailbox: name,
ID: msg.ID(),
From: msg.From(),
To: msg.To(),
Subject: msg.Subject(),
Date: msg.Date(),
Size: msg.Size(),
@@ -77,7 +55,7 @@ func MailboxListV1(w http.ResponseWriter, req *http.Request, ctx *httpd.Context)
func MailboxShowV1(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) (err error) {
// Don't have to validate these aren't empty, Gorilla returns 404
id := ctx.Vars["id"]
name, err := smtpd.ParseMailboxName(ctx.Vars["name"])
name, err := stringutil.ParseMailboxName(ctx.Vars["name"])
if err != nil {
return err
}
@@ -87,7 +65,7 @@ func MailboxShowV1(w http.ResponseWriter, req *http.Request, ctx *httpd.Context)
return fmt.Errorf("Failed to get mailbox for %q: %v", name, err)
}
msg, err := mb.GetMessage(id)
if err == smtpd.ErrNotExist {
if err == datastore.ErrNotExist {
http.NotFound(w, req)
return nil
}
@@ -104,26 +82,42 @@ func MailboxShowV1(w http.ResponseWriter, req *http.Request, ctx *httpd.Context)
return fmt.Errorf("ReadBody(%q) failed: %v", id, err)
}
attachments := make([]*model.JSONMessageAttachmentV1, len(mime.Attachments))
for i, att := range mime.Attachments {
var content []byte
content, err = ioutil.ReadAll(att)
var checksum = md5.Sum(content)
attachments[i] = &model.JSONMessageAttachmentV1{
ContentType: att.ContentType,
FileName: att.FileName,
DownloadLink: "http://" + req.Host + "/mailbox/dattach/" + name + "/" + id + "/" + strconv.Itoa(i) + "/" + att.FileName,
ViewLink: "http://" + req.Host + "/mailbox/vattach/" + name + "/" + id + "/" + strconv.Itoa(i) + "/" + att.FileName,
MD5: hex.EncodeToString(checksum[:]),
}
}
return httpd.RenderJSON(w,
&JSONMessageV1{
&model.JSONMessageV1{
Mailbox: name,
ID: msg.ID(),
From: msg.From(),
To: msg.To(),
Subject: msg.Subject(),
Date: msg.Date(),
Size: msg.Size(),
Header: header.Header,
Body: &JSONMessageBodyV1{
Body: &model.JSONMessageBodyV1{
Text: mime.Text,
HTML: mime.HTML,
},
Attachments: attachments,
})
}
// MailboxPurgeV1 deletes all messages from a mailbox
func MailboxPurgeV1(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) (err error) {
// Don't have to validate these aren't empty, Gorilla returns 404
name, err := smtpd.ParseMailboxName(ctx.Vars["name"])
name, err := stringutil.ParseMailboxName(ctx.Vars["name"])
if err != nil {
return err
}
@@ -146,7 +140,7 @@ func MailboxPurgeV1(w http.ResponseWriter, req *http.Request, ctx *httpd.Context
func MailboxSourceV1(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) (err error) {
// Don't have to validate these aren't empty, Gorilla returns 404
id := ctx.Vars["id"]
name, err := smtpd.ParseMailboxName(ctx.Vars["name"])
name, err := stringutil.ParseMailboxName(ctx.Vars["name"])
if err != nil {
return err
}
@@ -156,7 +150,7 @@ func MailboxSourceV1(w http.ResponseWriter, req *http.Request, ctx *httpd.Contex
return fmt.Errorf("Failed to get mailbox for %q: %v", name, err)
}
message, err := mb.GetMessage(id)
if err == smtpd.ErrNotExist {
if err == datastore.ErrNotExist {
http.NotFound(w, req)
return nil
}
@@ -176,11 +170,11 @@ func MailboxSourceV1(w http.ResponseWriter, req *http.Request, ctx *httpd.Contex
return nil
}
// MailboxDeleteV1 removes a particular message from a mailbox. Renders JSON or plain/text OK
// MailboxDeleteV1 removes a particular message from a mailbox
func MailboxDeleteV1(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) (err error) {
// Don't have to validate these aren't empty, Gorilla returns 404
id := ctx.Vars["id"]
name, err := smtpd.ParseMailboxName(ctx.Vars["name"])
name, err := stringutil.ParseMailboxName(ctx.Vars["name"])
if err != nil {
return err
}
@@ -190,7 +184,7 @@ func MailboxDeleteV1(w http.ResponseWriter, req *http.Request, ctx *httpd.Contex
return fmt.Errorf("Failed to get mailbox for %q: %v", name, err)
}
message, err := mb.GetMessage(id)
if err == smtpd.ErrNotExist {
if err == datastore.ErrNotExist {
http.NotFound(w, req)
return nil
}

View File

@@ -9,7 +9,7 @@ import (
"testing"
"time"
"github.com/jhillyerd/inbucket/smtpd"
"github.com/jhillyerd/inbucket/datastore"
)
const (
@@ -19,6 +19,7 @@ const (
mailboxKey = "mailbox"
idKey = "id"
fromKey = "from"
toKey = "to"
subjectKey = "subject"
dateKey = "date"
sizeKey = "size"
@@ -30,7 +31,7 @@ const (
func TestRestMailboxList(t *testing.T) {
// Setup
ds := &MockDataStore{}
ds := &datastore.MockDataStore{}
logbuf := setupWebServer(ds)
// Test invalid mailbox name
@@ -44,9 +45,9 @@ func TestRestMailboxList(t *testing.T) {
}
// Test empty mailbox
emptybox := &MockMailbox{}
emptybox := &datastore.MockMailbox{}
ds.On("MailboxFor", "empty").Return(emptybox, nil)
emptybox.On("GetMessages").Return([]smtpd.Message{}, nil)
emptybox.On("GetMessages").Return([]datastore.Message{}, nil)
w, err = testRestGet(baseURL + "/mailbox/empty")
expectCode = 200
@@ -58,7 +59,7 @@ func TestRestMailboxList(t *testing.T) {
}
// Test MailboxFor error
ds.On("MailboxFor", "error").Return(&MockMailbox{}, fmt.Errorf("Internal error"))
ds.On("MailboxFor", "error").Return(&datastore.MockMailbox{}, fmt.Errorf("Internal error"))
w, err = testRestGet(baseURL + "/mailbox/error")
expectCode = 500
if err != nil {
@@ -76,9 +77,9 @@ func TestRestMailboxList(t *testing.T) {
}
// Test MailboxFor error
error2box := &MockMailbox{}
error2box := &datastore.MockMailbox{}
ds.On("MailboxFor", "error2").Return(error2box, nil)
error2box.On("GetMessages").Return([]smtpd.Message{}, fmt.Errorf("Internal error 2"))
error2box.On("GetMessages").Return([]datastore.Message{}, fmt.Errorf("Internal error 2"))
w, err = testRestGet(baseURL + "/mailbox/error2")
expectCode = 500
@@ -94,6 +95,7 @@ func TestRestMailboxList(t *testing.T) {
Mailbox: "good",
ID: "0001",
From: "from1",
To: []string{"to1"},
Subject: "subject 1",
Date: time.Date(2012, 2, 1, 10, 11, 12, 253, time.FixedZone("PST", -800)),
}
@@ -101,14 +103,15 @@ func TestRestMailboxList(t *testing.T) {
Mailbox: "good",
ID: "0002",
From: "from2",
To: []string{"to1"},
Subject: "subject 2",
Date: time.Date(2012, 7, 1, 10, 11, 12, 253, time.FixedZone("PDT", -700)),
}
goodbox := &MockMailbox{}
goodbox := &datastore.MockMailbox{}
ds.On("MailboxFor", "good").Return(goodbox, nil)
msg1 := data1.MockMessage()
msg2 := data2.MockMessage()
goodbox.On("GetMessages").Return([]smtpd.Message{msg1, msg2}, nil)
goodbox.On("GetMessages").Return([]datastore.Message{msg1, msg2}, nil)
// Check return code
w, err = testRestGet(baseURL + "/mailbox/good")
@@ -152,7 +155,7 @@ func TestRestMailboxList(t *testing.T) {
func TestRestMessage(t *testing.T) {
// Setup
ds := &MockDataStore{}
ds := &datastore.MockDataStore{}
logbuf := setupWebServer(ds)
// Test invalid mailbox name
@@ -166,9 +169,9 @@ func TestRestMessage(t *testing.T) {
}
// Test requesting a message that does not exist
emptybox := &MockMailbox{}
emptybox := &datastore.MockMailbox{}
ds.On("MailboxFor", "empty").Return(emptybox, nil)
emptybox.On("GetMessage", "0001").Return(&MockMessage{}, smtpd.ErrNotExist)
emptybox.On("GetMessage", "0001").Return(&datastore.MockMessage{}, datastore.ErrNotExist)
w, err = testRestGet(baseURL + "/mailbox/empty/0001")
expectCode = 404
@@ -180,7 +183,7 @@ func TestRestMessage(t *testing.T) {
}
// Test MailboxFor error
ds.On("MailboxFor", "error").Return(&MockMailbox{}, fmt.Errorf("Internal error"))
ds.On("MailboxFor", "error").Return(&datastore.MockMailbox{}, fmt.Errorf("Internal error"))
w, err = testRestGet(baseURL + "/mailbox/error/0001")
expectCode = 500
if err != nil {
@@ -198,9 +201,9 @@ func TestRestMessage(t *testing.T) {
}
// Test GetMessage error
error2box := &MockMailbox{}
error2box := &datastore.MockMailbox{}
ds.On("MailboxFor", "error2").Return(error2box, nil)
error2box.On("GetMessage", "0001").Return(&MockMessage{}, fmt.Errorf("Internal error 2"))
error2box.On("GetMessage", "0001").Return(&datastore.MockMessage{}, fmt.Errorf("Internal error 2"))
w, err = testRestGet(baseURL + "/mailbox/error2/0001")
expectCode = 500
@@ -225,7 +228,7 @@ func TestRestMessage(t *testing.T) {
Text: "This is some text",
HTML: "This is some HTML",
}
goodbox := &MockMailbox{}
goodbox := &datastore.MockMailbox{}
ds.On("MailboxFor", "good").Return(goodbox, nil)
msg1 := data1.MockMessage()
goodbox.On("GetMessage", "0001").Return(msg1, nil)

144
rest/client/apiv1_client.go Normal file
View File

@@ -0,0 +1,144 @@
// Package client provides a basic REST client for Inbucket
package client
import (
"bytes"
"fmt"
"net/http"
"net/url"
"time"
"github.com/jhillyerd/inbucket/rest/model"
)
// Client accesses the Inbucket REST API v1
type Client struct {
restClient
}
// New creates a new v1 REST API client given the base URL of an Inbucket server, ex:
// "http://localhost:9000"
func New(baseURL string) (*Client, error) {
parsedURL, err := url.Parse(baseURL)
if err != nil {
return nil, err
}
c := &Client{
restClient{
client: &http.Client{
Timeout: 30 * time.Second,
},
baseURL: parsedURL,
},
}
return c, nil
}
// ListMailbox returns a list of messages for the requested mailbox
func (c *Client) ListMailbox(name string) (headers []*MessageHeader, err error) {
uri := "/api/v1/mailbox/" + url.QueryEscape(name)
err = c.doJSON("GET", uri, &headers)
if err != nil {
return nil, err
}
for _, h := range headers {
h.client = c
}
return
}
// GetMessage returns the message details given a mailbox name and message ID.
func (c *Client) GetMessage(name, id string) (message *Message, err error) {
uri := "/api/v1/mailbox/" + url.QueryEscape(name) + "/" + id
err = c.doJSON("GET", uri, &message)
if err != nil {
return nil, err
}
message.client = c
return
}
// GetMessageSource returns the message source given a mailbox name and message ID.
func (c *Client) GetMessageSource(name, id string) (*bytes.Buffer, error) {
uri := "/api/v1/mailbox/" + url.QueryEscape(name) + "/" + id + "/source"
resp, err := c.do("GET", uri)
if err != nil {
return nil, err
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode != http.StatusOK {
return nil,
fmt.Errorf("Unexpected HTTP response status %v: %s", resp.StatusCode, resp.Status)
}
buf := new(bytes.Buffer)
_, err = buf.ReadFrom(resp.Body)
return buf, err
}
// DeleteMessage deletes a single message given the mailbox name and message ID.
func (c *Client) DeleteMessage(name, id string) error {
uri := "/api/v1/mailbox/" + url.QueryEscape(name) + "/" + id
resp, err := c.do("DELETE", uri)
if err != nil {
return err
}
_ = resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("Unexpected HTTP response status %v: %s", resp.StatusCode, resp.Status)
}
return nil
}
// PurgeMailbox deletes all messages in the given mailbox
func (c *Client) PurgeMailbox(name string) error {
uri := "/api/v1/mailbox/" + url.QueryEscape(name)
resp, err := c.do("DELETE", uri)
if err != nil {
return err
}
_ = resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("Unexpected HTTP response status %v: %s", resp.StatusCode, resp.Status)
}
return nil
}
// MessageHeader represents an Inbucket message sans content
type MessageHeader struct {
*model.JSONMessageHeaderV1
client *Client
}
// GetMessage returns this message with content
func (h *MessageHeader) GetMessage() (message *Message, err error) {
return h.client.GetMessage(h.Mailbox, h.ID)
}
// GetSource returns the source for this message
func (h *MessageHeader) GetSource() (*bytes.Buffer, error) {
return h.client.GetMessageSource(h.Mailbox, h.ID)
}
// Delete deletes this message from the mailbox
func (h *MessageHeader) Delete() error {
return h.client.DeleteMessage(h.Mailbox, h.ID)
}
// Message represents an Inbucket message including content
type Message struct {
*model.JSONMessageV1
client *Client
}
// GetSource returns the source for this message
func (m *Message) GetSource() (*bytes.Buffer, error) {
return m.client.GetMessageSource(m.Mailbox, m.ID)
}
// Delete deletes this message from the mailbox
func (m *Message) Delete() error {
return m.client.DeleteMessage(m.Mailbox, m.ID)
}

View File

@@ -0,0 +1,323 @@
package client
import "testing"
func TestClientV1ListMailbox(t *testing.T) {
var want, got string
c, err := New(baseURLStr)
if err != nil {
t.Fatal(err)
}
mth := &mockHTTPClient{}
c.client = mth
// Method under test
_, _ = c.ListMailbox("testbox")
want = "GET"
got = mth.req.Method
if got != want {
t.Errorf("req.Method == %q, want %q", got, want)
}
want = baseURLStr + "/api/v1/mailbox/testbox"
got = mth.req.URL.String()
if got != want {
t.Errorf("req.URL == %q, want %q", got, want)
}
}
func TestClientV1GetMessage(t *testing.T) {
var want, got string
c, err := New(baseURLStr)
if err != nil {
t.Fatal(err)
}
mth := &mockHTTPClient{}
c.client = mth
// Method under test
_, _ = c.GetMessage("testbox", "20170107T224128-0000")
want = "GET"
got = mth.req.Method
if got != want {
t.Errorf("req.Method == %q, want %q", got, want)
}
want = baseURLStr + "/api/v1/mailbox/testbox/20170107T224128-0000"
got = mth.req.URL.String()
if got != want {
t.Errorf("req.URL == %q, want %q", got, want)
}
}
func TestClientV1GetMessageSource(t *testing.T) {
var want, got string
c, err := New(baseURLStr)
if err != nil {
t.Fatal(err)
}
mth := &mockHTTPClient{
body: "message source",
}
c.client = mth
// Method under test
source, err := c.GetMessageSource("testbox", "20170107T224128-0000")
if err != nil {
t.Fatal(err)
}
want = "GET"
got = mth.req.Method
if got != want {
t.Errorf("req.Method == %q, want %q", got, want)
}
want = baseURLStr + "/api/v1/mailbox/testbox/20170107T224128-0000/source"
got = mth.req.URL.String()
if got != want {
t.Errorf("req.URL == %q, want %q", got, want)
}
want = "message source"
got = source.String()
if got != want {
t.Errorf("Source == %q, want: %q", got, want)
}
}
func TestClientV1DeleteMessage(t *testing.T) {
var want, got string
c, err := New(baseURLStr)
if err != nil {
t.Fatal(err)
}
mth := &mockHTTPClient{}
c.client = mth
// Method under test
err = c.DeleteMessage("testbox", "20170107T224128-0000")
if err != nil {
t.Fatal(err)
}
want = "DELETE"
got = mth.req.Method
if got != want {
t.Errorf("req.Method == %q, want %q", got, want)
}
want = baseURLStr + "/api/v1/mailbox/testbox/20170107T224128-0000"
got = mth.req.URL.String()
if got != want {
t.Errorf("req.URL == %q, want %q", got, want)
}
}
func TestClientV1PurgeMailbox(t *testing.T) {
var want, got string
c, err := New(baseURLStr)
if err != nil {
t.Fatal(err)
}
mth := &mockHTTPClient{}
c.client = mth
// Method under test
err = c.PurgeMailbox("testbox")
if err != nil {
t.Fatal(err)
}
want = "DELETE"
got = mth.req.Method
if got != want {
t.Errorf("req.Method == %q, want %q", got, want)
}
want = baseURLStr + "/api/v1/mailbox/testbox"
got = mth.req.URL.String()
if got != want {
t.Errorf("req.URL == %q, want %q", got, want)
}
}
func TestClientV1MessageHeader(t *testing.T) {
var want, got string
response := `[
{
"mailbox":"mailbox1",
"id":"id1",
"from":"from1",
"subject":"subject1",
"date":"2017-01-01T00:00:00.000-07:00",
"size":100
}
]`
c, err := New(baseURLStr)
if err != nil {
t.Fatal(err)
}
mth := &mockHTTPClient{body: response}
c.client = mth
// Method under test
headers, err := c.ListMailbox("testbox")
if err != nil {
t.Fatal(err)
}
want = "GET"
got = mth.req.Method
if got != want {
t.Errorf("req.Method == %q, want %q", got, want)
}
want = baseURLStr + "/api/v1/mailbox/testbox"
got = mth.req.URL.String()
if got != want {
t.Errorf("req.URL == %q, want %q", got, want)
}
if len(headers) != 1 {
t.Fatalf("len(headers) == %v, want 1", len(headers))
}
header := headers[0]
want = "mailbox1"
got = header.Mailbox
if got != want {
t.Errorf("Mailbox == %q, want %q", got, want)
}
want = "id1"
got = header.ID
if got != want {
t.Errorf("ID == %q, want %q", got, want)
}
want = "from1"
got = header.From
if got != want {
t.Errorf("From == %q, want %q", got, want)
}
want = "subject1"
got = header.Subject
if got != want {
t.Errorf("Subject == %q, want %q", got, want)
}
// Test MessageHeader.Delete()
mth.body = ""
err = header.Delete()
if err != nil {
t.Fatal(err)
}
want = "DELETE"
got = mth.req.Method
if got != want {
t.Errorf("req.Method == %q, want %q", got, want)
}
want = baseURLStr + "/api/v1/mailbox/mailbox1/id1"
got = mth.req.URL.String()
if got != want {
t.Errorf("req.URL == %q, want %q", got, want)
}
// Test MessageHeader.GetSource()
mth.body = "source1"
_, err = header.GetSource()
if err != nil {
t.Fatal(err)
}
want = "GET"
got = mth.req.Method
if got != want {
t.Errorf("req.Method == %q, want %q", got, want)
}
want = baseURLStr + "/api/v1/mailbox/mailbox1/id1/source"
got = mth.req.URL.String()
if got != want {
t.Errorf("req.URL == %q, want %q", got, want)
}
// Test MessageHeader.GetMessage()
mth.body = `{
"mailbox":"mailbox1",
"id":"id1",
"from":"from1",
"subject":"subject1",
"date":"2017-01-01T00:00:00.000-07:00",
"size":100
}`
message, err := header.GetMessage()
if err != nil {
t.Fatal(err)
}
if message == nil {
t.Fatalf("message was nil, wanted a value")
}
want = "GET"
got = mth.req.Method
if got != want {
t.Errorf("req.Method == %q, want %q", got, want)
}
want = baseURLStr + "/api/v1/mailbox/mailbox1/id1"
got = mth.req.URL.String()
if got != want {
t.Errorf("req.URL == %q, want %q", got, want)
}
// Test Message.Delete()
mth.body = ""
err = message.Delete()
if err != nil {
t.Fatal(err)
}
want = "DELETE"
got = mth.req.Method
if got != want {
t.Errorf("req.Method == %q, want %q", got, want)
}
want = baseURLStr + "/api/v1/mailbox/mailbox1/id1"
got = mth.req.URL.String()
if got != want {
t.Errorf("req.URL == %q, want %q", got, want)
}
// Test MessageHeader.GetSource()
mth.body = "source1"
_, err = message.GetSource()
if err != nil {
t.Fatal(err)
}
want = "GET"
got = mth.req.Method
if got != want {
t.Errorf("req.Method == %q, want %q", got, want)
}
want = baseURLStr + "/api/v1/mailbox/mailbox1/id1/source"
got = mth.req.URL.String()
if got != want {
t.Errorf("req.URL == %q, want %q", got, want)
}
}

59
rest/client/rest.go Normal file
View File

@@ -0,0 +1,59 @@
package client
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
)
// httpClient allows http.Client to be mocked for tests
type httpClient interface {
Do(*http.Request) (*http.Response, error)
}
// Generic REST restClient
type restClient struct {
client httpClient
baseURL *url.URL
}
// do performs an HTTP request with this client and returns the response
func (c *restClient) do(method, uri string) (*http.Response, error) {
rel, err := url.Parse(uri)
if err != nil {
return nil, err
}
url := c.baseURL.ResolveReference(rel)
// Build the request
req, err := http.NewRequest(method, url.String(), nil)
if err != nil {
return nil, err
}
// Send the request
return c.client.Do(req)
}
// doGet performs an HTTP request with this client and marshalls the JSON response into v
func (c *restClient) doJSON(method string, uri string, v interface{}) error {
resp, err := c.do(method, uri)
if err != nil {
return err
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode == http.StatusOK {
if v == nil {
return nil
}
// Decode response body
return json.NewDecoder(resp.Body).Decode(v)
}
return fmt.Errorf("Unexpected HTTP response status %v: %s", resp.StatusCode, resp.Status)
}

125
rest/client/rest_test.go Normal file
View File

@@ -0,0 +1,125 @@
package client
import (
"bytes"
"io/ioutil"
"net/http"
"net/url"
"testing"
)
const baseURLStr = "http://test.local:8080"
var baseURL *url.URL
func init() {
var err error
baseURL, err = url.Parse(baseURLStr)
if err != nil {
panic(err)
}
}
type mockHTTPClient struct {
req *http.Request
statusCode int
body string
}
func (m *mockHTTPClient) Do(req *http.Request) (resp *http.Response, err error) {
m.req = req
if m.statusCode == 0 {
m.statusCode = 200
}
resp = &http.Response{
StatusCode: m.statusCode,
Body: ioutil.NopCloser(bytes.NewBufferString(m.body)),
}
return
}
func TestDo(t *testing.T) {
var want, got string
mth := &mockHTTPClient{}
c := &restClient{mth, baseURL}
_, err := c.do("POST", "/dopost")
if err != nil {
t.Fatal(err)
}
want = "POST"
got = mth.req.Method
if got != want {
t.Errorf("req.Method == %q, want %q", got, want)
}
want = baseURLStr + "/dopost"
got = mth.req.URL.String()
if got != want {
t.Errorf("req.URL == %q, want %q", got, want)
}
}
func TestDoJSON(t *testing.T) {
var want, got string
mth := &mockHTTPClient{
body: `{"foo": "bar"}`,
}
c := &restClient{mth, baseURL}
var v map[string]interface{}
err := c.doJSON("GET", "/doget", &v)
if err != nil {
t.Fatal(err)
}
want = "GET"
got = mth.req.Method
if got != want {
t.Errorf("req.Method == %q, want %q", got, want)
}
want = baseURLStr + "/doget"
got = mth.req.URL.String()
if got != want {
t.Errorf("req.URL == %q, want %q", got, want)
}
want = "bar"
if val, ok := v["foo"]; ok {
got = val.(string)
if got != want {
t.Errorf("map[foo] == %q, want: %q", got, want)
}
} else {
t.Errorf("Map did not contain key foo, want: %q", want)
}
}
func TestDoJSONNilV(t *testing.T) {
var want, got string
mth := &mockHTTPClient{}
c := &restClient{mth, baseURL}
err := c.doJSON("GET", "/doget", nil)
if err != nil {
t.Fatal(err)
}
want = "GET"
got = mth.req.Method
if got != want {
t.Errorf("req.Method == %q, want %q", got, want)
}
want = baseURLStr + "/doget"
got = mth.req.URL.String()
if got != want {
t.Errorf("req.URL == %q, want %q", got, want)
}
}

45
rest/model/apiv1_model.go Normal file
View File

@@ -0,0 +1,45 @@
package model
import (
"net/mail"
"time"
)
// JSONMessageHeaderV1 contains the basic header data for a message
type JSONMessageHeaderV1 struct {
Mailbox string `json:"mailbox"`
ID string `json:"id"`
From string `json:"from"`
To []string `json:"to"`
Subject string `json:"subject"`
Date time.Time `json:"date"`
Size int64 `json:"size"`
}
// JSONMessageV1 contains the same data as the header plus a JSONMessageBody
type JSONMessageV1 struct {
Mailbox string `json:"mailbox"`
ID string `json:"id"`
From string `json:"from"`
To []string `json:"to"`
Subject string `json:"subject"`
Date time.Time `json:"date"`
Size int64 `json:"size"`
Body *JSONMessageBodyV1 `json:"body"`
Header mail.Header `json:"header"`
Attachments []*JSONMessageAttachmentV1 `json:"attachments"`
}
type JSONMessageAttachmentV1 struct {
FileName string `json:"filename"`
ContentType string `json:"content-type"`
DownloadLink string `json:"download-link"`
ViewLink string `json:"view-link"`
MD5 string `json:"md5"`
}
// JSONMessageBodyV1 contains the Text and HTML versions of the message body
type JSONMessageBodyV1 struct {
Text string `json:"text"`
HTML string `json:"html"`
}

View File

@@ -6,9 +6,18 @@ import "github.com/jhillyerd/inbucket/httpd"
// SetupRoutes populates the routes for the REST interface
func SetupRoutes(r *mux.Router) {
// API v1
r.Path("/api/v1/mailbox/{name}").Handler(httpd.Handler(MailboxListV1)).Name("MailboxListV1").Methods("GET")
r.Path("/api/v1/mailbox/{name}").Handler(httpd.Handler(MailboxPurgeV1)).Name("MailboxPurgeV1").Methods("DELETE")
r.Path("/api/v1/mailbox/{name}/{id}").Handler(httpd.Handler(MailboxShowV1)).Name("MailboxShowV1").Methods("GET")
r.Path("/api/v1/mailbox/{name}/{id}").Handler(httpd.Handler(MailboxDeleteV1)).Name("MailboxDeleteV1").Methods("DELETE")
r.Path("/api/v1/mailbox/{name}/{id}/source").Handler(httpd.Handler(MailboxSourceV1)).Name("MailboxSourceV1").Methods("GET")
r.Path("/api/v1/mailbox/{name}").Handler(
httpd.Handler(MailboxListV1)).Name("MailboxListV1").Methods("GET")
r.Path("/api/v1/mailbox/{name}").Handler(
httpd.Handler(MailboxPurgeV1)).Name("MailboxPurgeV1").Methods("DELETE")
r.Path("/api/v1/mailbox/{name}/{id}").Handler(
httpd.Handler(MailboxShowV1)).Name("MailboxShowV1").Methods("GET")
r.Path("/api/v1/mailbox/{name}/{id}").Handler(
httpd.Handler(MailboxDeleteV1)).Name("MailboxDeleteV1").Methods("DELETE")
r.Path("/api/v1/mailbox/{name}/{id}/source").Handler(
httpd.Handler(MailboxSourceV1)).Name("MailboxSourceV1").Methods("GET")
r.Path("/api/v1/monitor/messages").Handler(
httpd.Handler(MonitorAllMessagesV1)).Name("MonitorAllMessagesV1").Methods("GET")
r.Path("/api/v1/monitor/messages/{name}").Handler(
httpd.Handler(MonitorMailboxMessagesV1)).Name("MonitorMailboxMessagesV1").Methods("GET")
}

195
rest/socketv1_controller.go Normal file
View File

@@ -0,0 +1,195 @@
package rest
import (
"net/http"
"time"
"github.com/gorilla/websocket"
"github.com/jhillyerd/inbucket/httpd"
"github.com/jhillyerd/inbucket/log"
"github.com/jhillyerd/inbucket/msghub"
"github.com/jhillyerd/inbucket/rest/model"
"github.com/jhillyerd/inbucket/stringutil"
)
const (
// Time allowed to write a message to the peer.
writeWait = 10 * time.Second
// Send pings to peer with this period. Must be less than pongWait.
pingPeriod = (pongWait * 9) / 10
// Time allowed to read the next pong message from the peer.
pongWait = 60 * time.Second
// Maximum message size allowed from peer.
maxMessageSize = 512
)
// options for gorilla connection upgrader
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
}
// msgListener handles messages from the msghub
type msgListener struct {
hub *msghub.Hub // Global message hub
c chan msghub.Message // Queue of messages from Receive()
mailbox string // Name of mailbox to monitor, "" == all mailboxes
}
// newMsgListener creates a listener and registers it. Optional mailbox parameter will restrict
// messages sent to WebSocket to that mailbox only.
func newMsgListener(hub *msghub.Hub, mailbox string) *msgListener {
ml := &msgListener{
hub: hub,
c: make(chan msghub.Message, 100),
mailbox: mailbox,
}
hub.AddListener(ml)
return ml
}
// Receive handles an incoming message
func (ml *msgListener) Receive(msg msghub.Message) error {
if ml.mailbox != "" && ml.mailbox != msg.Mailbox {
// Did not match mailbox name
return nil
}
ml.c <- msg
return nil
}
// WSReader makes sure the websocket client is still connected, discards any messages from client
func (ml *msgListener) WSReader(conn *websocket.Conn) {
defer ml.Close()
conn.SetReadLimit(maxMessageSize)
conn.SetReadDeadline(time.Now().Add(pongWait))
conn.SetPongHandler(func(string) error {
log.Tracef("HTTP[%v] Got WebSocket pong", conn.RemoteAddr())
conn.SetReadDeadline(time.Now().Add(pongWait))
return nil
})
for {
if _, _, err := conn.ReadMessage(); err != nil {
if websocket.IsUnexpectedCloseError(
err,
websocket.CloseNormalClosure,
websocket.CloseGoingAway,
websocket.CloseNoStatusReceived,
) {
// Unexpected close code
log.Warnf("HTTP[%v] WebSocket error: %v", conn.RemoteAddr(), err)
} else {
log.Tracef("HTTP[%v] Closing WebSocket", conn.RemoteAddr())
}
break
}
}
}
// WSWriter makes sure the websocket client is still connected
func (ml *msgListener) WSWriter(conn *websocket.Conn) {
ticker := time.NewTicker(pingPeriod)
defer func() {
ticker.Stop()
ml.Close()
}()
// Handle messages from hub until msgListener is closed
for {
select {
case msg, ok := <-ml.c:
conn.SetWriteDeadline(time.Now().Add(writeWait))
if !ok {
// msgListener closed, exit
conn.WriteMessage(websocket.CloseMessage, []byte{})
return
}
header := &model.JSONMessageHeaderV1{
Mailbox: msg.Mailbox,
ID: msg.ID,
From: msg.From,
To: msg.To,
Subject: msg.Subject,
Date: msg.Date,
Size: msg.Size,
}
if conn.WriteJSON(header) != nil {
// Write failed
return
}
case <-ticker.C:
// Send ping
conn.SetWriteDeadline(time.Now().Add(writeWait))
if conn.WriteMessage(websocket.PingMessage, []byte{}) != nil {
// Write error
return
}
log.Tracef("HTTP[%v] Sent WebSocket ping", conn.RemoteAddr())
}
}
}
// Close removes the listener registration
func (ml *msgListener) Close() {
select {
case <-ml.c:
// Already closed
default:
ml.hub.RemoveListener(ml)
close(ml.c)
}
}
func MonitorAllMessagesV1(
w http.ResponseWriter, req *http.Request, ctx *httpd.Context) (err error) {
// Upgrade to Websocket
conn, err := upgrader.Upgrade(w, req, nil)
if err != nil {
return err
}
httpd.ExpWebSocketConnectsCurrent.Add(1)
defer func() {
_ = conn.Close()
httpd.ExpWebSocketConnectsCurrent.Add(-1)
}()
log.Tracef("HTTP[%v] Upgraded to websocket", req.RemoteAddr)
// Create, register listener; then interact with conn
ml := newMsgListener(ctx.MsgHub, "")
go ml.WSWriter(conn)
ml.WSReader(conn)
return nil
}
func MonitorMailboxMessagesV1(
w http.ResponseWriter, req *http.Request, ctx *httpd.Context) (err error) {
name, err := stringutil.ParseMailboxName(ctx.Vars["name"])
if err != nil {
return err
}
// Upgrade to Websocket
conn, err := upgrader.Upgrade(w, req, nil)
if err != nil {
return err
}
httpd.ExpWebSocketConnectsCurrent.Add(1)
defer func() {
_ = conn.Close()
httpd.ExpWebSocketConnectsCurrent.Add(-1)
}()
log.Tracef("HTTP[%v] Upgraded to websocket", req.RemoteAddr)
// Create, register listener; then interact with conn
ml := newMsgListener(ctx.MsgHub, name)
go ml.WSWriter(conn)
ml.WSReader(conn)
return nil
}

View File

@@ -1,126 +0,0 @@
package rest
import (
"io"
"net/mail"
"time"
"github.com/jhillyerd/go.enmime"
"github.com/jhillyerd/inbucket/smtpd"
"github.com/stretchr/testify/mock"
)
// Mock DataStore object
type MockDataStore struct {
mock.Mock
}
func (m *MockDataStore) MailboxFor(name string) (smtpd.Mailbox, error) {
args := m.Called(name)
return args.Get(0).(smtpd.Mailbox), args.Error(1)
}
func (m *MockDataStore) AllMailboxes() ([]smtpd.Mailbox, error) {
args := m.Called()
return args.Get(0).([]smtpd.Mailbox), args.Error(1)
}
// Mock Mailbox object
type MockMailbox struct {
mock.Mock
}
func (m *MockMailbox) GetMessages() ([]smtpd.Message, error) {
args := m.Called()
return args.Get(0).([]smtpd.Message), args.Error(1)
}
func (m *MockMailbox) GetMessage(id string) (smtpd.Message, error) {
args := m.Called(id)
return args.Get(0).(smtpd.Message), args.Error(1)
}
func (m *MockMailbox) Purge() error {
args := m.Called()
return args.Error(0)
}
func (m *MockMailbox) NewMessage() (smtpd.Message, error) {
args := m.Called()
return args.Get(0).(smtpd.Message), args.Error(1)
}
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) 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.MIMEBody, err error) {
args := m.Called()
return args.Get(0).(*enmime.MIMEBody), 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

@@ -9,24 +9,27 @@ import (
"net/mail"
"time"
"github.com/jhillyerd/go.enmime"
"github.com/jhillyerd/enmime"
"github.com/jhillyerd/inbucket/config"
"github.com/jhillyerd/inbucket/datastore"
"github.com/jhillyerd/inbucket/httpd"
"github.com/jhillyerd/inbucket/smtpd"
"github.com/jhillyerd/inbucket/msghub"
)
type InputMessageData struct {
Mailbox, ID, From, Subject string
To []string
Date time.Time
Size int
Header mail.Header
HTML, Text string
}
func (d *InputMessageData) MockMessage() *MockMessage {
msg := &MockMessage{}
func (d *InputMessageData) MockMessage() *datastore.MockMessage {
msg := &datastore.MockMessage{}
msg.On("ID").Return(d.ID)
msg.On("From").Return(d.From)
msg.On("To").Return(d.To)
msg.On("Subject").Return(d.Subject)
msg.On("Date").Return(d.Date)
msg.On("Size").Return(d.Size)
@@ -34,7 +37,7 @@ func (d *InputMessageData) MockMessage() *MockMessage {
Header: d.Header,
}
msg.On("ReadHeader").Return(gomsg, nil)
body := &enmime.MIMEBody{
body := &enmime.Envelope{
Text: d.Text,
HTML: d.HTML,
}
@@ -79,6 +82,11 @@ func (d *InputMessageData) CompareToJSONHeaderMap(json interface{}) (errors []st
if msg, ok := isJSONStringEqual(fromKey, d.From, m[fromKey]); !ok {
errors = append(errors, msg)
}
for i, inputTo := range d.To {
if msg, ok := isJSONStringEqual(toKey, inputTo, m[toKey].([]interface{})[i]); !ok {
errors = append(errors, msg)
}
}
if msg, ok := isJSONStringEqual(subjectKey, d.Subject, m[subjectKey]); !ok {
errors = append(errors, msg)
}
@@ -180,7 +188,7 @@ func testRestGet(url string) (*httptest.ResponseRecorder, error) {
return w, nil
}
func setupWebServer(ds smtpd.DataStore) *bytes.Buffer {
func setupWebServer(ds datastore.DataStore) *bytes.Buffer {
// Capture log output
buf := new(bytes.Buffer)
log.SetOutput(buf)
@@ -188,11 +196,11 @@ func setupWebServer(ds smtpd.DataStore) *bytes.Buffer {
// Have to reset default mux to prevent duplicate routes
http.DefaultServeMux = http.NewServeMux()
cfg := config.WebConfig{
TemplateDir: "../themes/integral/templates",
PublicDir: "../themes/integral/public",
TemplateDir: "../themes/bootstrap/templates",
PublicDir: "../themes/bootstrap/public",
}
shutdownChan := make(chan bool)
httpd.Initialize(cfg, ds, shutdownChan)
httpd.Initialize(cfg, shutdownChan, ds, &msghub.Hub{})
SetupRoutes(httpd.Router)
return 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,7 +12,10 @@ import (
"strings"
"time"
"github.com/jhillyerd/inbucket/datastore"
"github.com/jhillyerd/inbucket/log"
"github.com/jhillyerd/inbucket/msghub"
"github.com/jhillyerd/inbucket/stringutil"
)
// State tracks the current mode of our SMTP state machine
@@ -67,6 +70,12 @@ var commands = map[string]bool{
"TURN": true,
}
// recipientDetails for message delivery
type recipientDetails struct {
address, localPart, domainPart string
mailbox datastore.Mailbox
}
// Session holds the state of an SMTP session
type Session struct {
server *Server
@@ -258,7 +267,7 @@ func (ss *Session) readyHandler(cmd string, arg string) {
return
}
from := m[1]
if _, _, err := ParseEmailAddress(from); err != nil {
if _, _, err := stringutil.ParseEmailAddress(from); err != nil {
ss.send("501 Bad sender address syntax")
ss.logWarn("Bad address as MAIL arg: %q, %s", from, err)
return
@@ -307,7 +316,7 @@ func (ss *Session) mailHandler(cmd string, arg string) {
}
// This trim is probably too forgiving
recip := strings.Trim(arg[3:], "<> ")
if _, _, err := ParseEmailAddress(recip); err != nil {
if _, _, err := stringutil.ParseEmailAddress(recip); err != nil {
ss.send("501 Bad recipient address syntax")
ss.logWarn("Bad address as RCPT arg: %q, %s", recip, err)
return
@@ -341,17 +350,13 @@ func (ss *Session) mailHandler(cmd string, arg string) {
// DATA
func (ss *Session) dataHandler() {
// Timestamp for Received header
stamp := time.Now().Format(timeStampFormat)
recipients := make([]recipientDetails, 0, ss.recipients.Len())
// Get a Mailbox and a new Message for each recipient
mailboxes := make([]Mailbox, ss.recipients.Len())
messages := make([]Message, ss.recipients.Len())
msgSize := 0
if ss.server.storeMessages {
i := 0
for e := ss.recipients.Front(); e != nil; e = e.Next() {
recip := e.Value.(string)
local, domain, err := ParseEmailAddress(recip)
local, domain, err := stringutil.ParseEmailAddress(recip)
if err != nil {
ss.logError("Failed to parse address for %q", recip)
ss.send(fmt.Sprintf("451 Failed to open mailbox for %v", recip))
@@ -367,35 +372,19 @@ func (ss *Session) dataHandler() {
ss.reset()
return
}
mailboxes[i] = mb
if messages[i], err = mb.NewMessage(); err != nil {
ss.logError("Failed to create message for %q: %s", local, err)
ss.send(fmt.Sprintf("451 Failed to create message for %v", local))
ss.reset()
return
}
// Generate Received header
recd := fmt.Sprintf("Received: from %s ([%s]) by %s\r\n for <%s>; %s\r\n",
ss.remoteDomain, ss.remoteHost, ss.server.domain, recip, stamp)
if err := messages[i].Append([]byte(recd)); err != nil {
ss.logError("Failed to write received header for %q: %s", local, err)
ss.send(fmt.Sprintf("451 Failed to create message for %v", local))
ss.reset()
return
}
recipients = append(recipients, recipientDetails{recip, local, domain, mb})
} else {
log.Tracef("Not storing message for %q", recip)
}
i++
}
}
ss.send("354 Start mail input; end with <CRLF>.<CRLF>")
var buf bytes.Buffer
var lineBuf bytes.Buffer
msgBuf := make([][]byte, 0, 1024)
for {
buf.Reset()
err := ss.readByteLine(&buf)
lineBuf.Reset()
err := ss.readByteLine(&lineBuf)
if err != nil {
if netErr, ok := err.(net.Error); ok {
if netErr.Timeout() {
@@ -406,20 +395,32 @@ func (ss *Session) dataHandler() {
ss.enterState(QUIT)
return
}
line := buf.Bytes()
if string(line) == ".\r\n" {
line := lineBuf.Bytes()
// ss.logTrace("DATA: %q", line)
if string(line) == ".\r\n" || string(line) == ".\n" {
// Mail data complete
if ss.server.storeMessages {
for _, m := range messages {
if m != nil {
if err := m.Close(); err != nil {
// This logic should be updated to report failures
// writing the initial message file to the client
// after we implement a single-store system (issue
// #23)
ss.logError("Error: %v while writing message", err)
}
// Create a message for each valid recipient
for _, r := range recipients {
// TODO temporary hack to fix #77 until datastore revamp
mu, err := ss.server.dataStore.LockFor(r.localPart)
if err != nil {
ss.logError("Failed to get lock for %q: %s", r.localPart, err)
// Delivery failure
ss.send(fmt.Sprintf("451 Failed to store message for %v", r.localPart))
ss.reset()
return
}
mu.Lock()
ok := ss.deliverMessage(r, msgBuf)
mu.Unlock()
if ok {
expReceivedTotal.Add(1)
} else {
// Delivery failure
ss.send(fmt.Sprintf("451 Failed to store message for %v", r.localPart))
ss.reset()
return
}
}
} else {
@@ -434,6 +435,8 @@ func (ss *Session) dataHandler() {
if len(line) > 0 && line[0] == '.' {
line = line[1:]
}
// Second append copies line/lineBuf so we can reuse it
msgBuf = append(msgBuf, append([]byte{}, line...))
msgSize += len(line)
if msgSize > ss.server.maxMessageBytes {
// Max message size exceeded
@@ -443,21 +446,52 @@ func (ss *Session) dataHandler() {
// Should really cleanup the crap on filesystem (after issue #23)
return
}
// Append to message objects
if ss.server.storeMessages {
for i, m := range messages {
if m != nil {
if err := m.Append(line); err != nil {
ss.logError("Failed to append to mailbox %v: %v", mailboxes[i], err)
ss.send("554 Something went wrong")
ss.reset()
// Should really cleanup the crap on filesystem (after issue #23)
return
}
}
}
} // end for
}
// deliverMessage creates and populates a new Message for the specified recipient
func (ss *Session) deliverMessage(r recipientDetails, msgBuf [][]byte) (ok bool) {
msg, err := r.mailbox.NewMessage()
if err != nil {
ss.logError("Failed to create message for %q: %s", r.localPart, err)
return false
}
// Generate Received header
stamp := time.Now().Format(timeStampFormat)
recd := fmt.Sprintf("Received: from %s ([%s]) by %s\r\n for <%s>; %s\r\n",
ss.remoteDomain, ss.remoteHost, ss.server.domain, r.address, stamp)
if err := msg.Append([]byte(recd)); err != nil {
ss.logError("Failed to write received header for %q: %s", r.localPart, err)
return false
}
// Append lines from msgBuf
for _, line := range msgBuf {
if err := msg.Append(line); err != nil {
ss.logError("Failed to append to mailbox %v: %v", r.mailbox, err)
// Should really cleanup the crap on filesystem
return false
}
}
if err := msg.Close(); err != nil {
ss.logError("Error while closing message for %v: %v", r.mailbox, err)
return false
}
// Broadcast message information
broadcast := msghub.Message{
Mailbox: r.mailbox.Name(),
ID: msg.ID(),
From: msg.From(),
To: msg.To(),
Subject: msg.Subject(),
Date: msg.Date(),
Size: msg.Size(),
}
ss.server.msgHub.Dispatch(broadcast)
return true
}
func (ss *Session) enterState(state State) {
@@ -490,33 +524,16 @@ func (ss *Session) send(msg string) {
// readByteLine reads a line of input into the provided buffer. Does
// not reset the Buffer - please do so prior to calling.
func (ss *Session) readByteLine(buf *bytes.Buffer) error {
func (ss *Session) readByteLine(buf io.Writer) error {
if err := ss.conn.SetReadDeadline(ss.nextDeadline()); err != nil {
return err
}
for {
line, err := ss.reader.ReadBytes('\r')
if err != nil {
return err
}
if _, err = buf.Write(line); err != nil {
return err
}
// Read the next byte looking for '\n'
c, err := ss.reader.ReadByte()
if err != nil {
return err
}
if err = buf.WriteByte(c); err != nil {
return err
}
if c == '\n' {
// We've reached the end of the line, return
return nil
}
// Else, keep looking
line, err := ss.reader.ReadBytes('\n')
if err != nil {
return err
}
// Should be unreachable
_, err = buf.Write(line)
return err
}
// Reads a line of input
@@ -565,7 +582,7 @@ func (ss *Session) parseCmd(line string) (cmd string, arg string, ok bool) {
// The leading space is mandatory.
func (ss *Session) parseArgs(arg string) (args map[string]string, ok bool) {
args = make(map[string]string)
re := regexp.MustCompile(" (\\w+)=(\\w+)")
re := regexp.MustCompile(` (\w+)=(\w+)`)
pm := re.FindAllStringSubmatch(arg, -1)
if pm == nil {
ss.logWarn("Failed to parse arg string: %q")

View File

@@ -2,16 +2,20 @@ package smtpd
import (
"bytes"
"context"
"fmt"
"io"
"github.com/jhillyerd/inbucket/config"
"log"
"net"
"net/textproto"
"os"
"testing"
"time"
"github.com/jhillyerd/inbucket/config"
"github.com/jhillyerd/inbucket/datastore"
"github.com/jhillyerd/inbucket/msghub"
)
type scriptStep struct {
@@ -22,17 +26,13 @@ type scriptStep struct {
// Test commands in GREET state
func TestGreetState(t *testing.T) {
// Setup mock objects
mds := &MockDataStore{}
mb1 := &MockMailbox{}
mds.On("MailboxFor").Return(mb1, nil)
mds := &datastore.MockDataStore{}
server, logbuf := setupSMTPServer(mds)
defer teardownSMTPServer(server)
var script []scriptStep
server, logbuf, teardown := setupSMTPServer(mds)
defer teardown()
// Test out some mangled HELOs
script = []scriptStep{
script := []scriptStep{
{"HELO", 501},
{"EHLO", 501},
{"HELLO", 500},
@@ -83,17 +83,13 @@ func TestGreetState(t *testing.T) {
// Test commands in READY state
func TestReadyState(t *testing.T) {
// Setup mock objects
mds := &MockDataStore{}
mb1 := &MockMailbox{}
mds.On("MailboxFor").Return(mb1, nil)
mds := &datastore.MockDataStore{}
server, logbuf := setupSMTPServer(mds)
defer teardownSMTPServer(server)
var script []scriptStep
server, logbuf, teardown := setupSMTPServer(mds)
defer teardown()
// Test out some mangled READY commands
script = []scriptStep{
script := []scriptStep{
{"HELO localhost", 250},
{"FOOB", 500},
{"HELO", 503},
@@ -148,20 +144,25 @@ func TestReadyState(t *testing.T) {
// Test commands in MAIL state
func TestMailState(t *testing.T) {
// Setup mock objects
mds := &MockDataStore{}
mb1 := &MockMailbox{}
msg1 := &MockMessage{}
mds.On("MailboxFor").Return(mb1, nil)
mds := &datastore.MockDataStore{}
mb1 := &datastore.MockMailbox{}
msg1 := &datastore.MockMessage{}
mds.On("MailboxFor", "u1").Return(mb1, nil)
mb1.On("NewMessage").Return(msg1, nil)
mb1.On("Name").Return("u1")
msg1.On("ID").Return("")
msg1.On("From").Return("")
msg1.On("To").Return(make([]string, 0))
msg1.On("Date").Return(time.Time{})
msg1.On("Subject").Return("")
msg1.On("Size").Return(0)
msg1.On("Close").Return(nil)
server, logbuf := setupSMTPServer(mds)
defer teardownSMTPServer(server)
var script []scriptStep
server, logbuf, teardown := setupSMTPServer(mds)
defer teardown()
// Test out some mangled READY commands
script = []scriptStep{
script := []scriptStep{
{"HELO localhost", 250},
{"MAIL FROM:<john@gmail.com>", 250},
{"FOOB", 500},
@@ -258,15 +259,22 @@ func TestMailState(t *testing.T) {
// Test commands in DATA state
func TestDataState(t *testing.T) {
// Setup mock objects
mds := &MockDataStore{}
mb1 := &MockMailbox{}
msg1 := &MockMessage{}
mds.On("MailboxFor").Return(mb1, nil)
mds := &datastore.MockDataStore{}
mb1 := &datastore.MockMailbox{}
msg1 := &datastore.MockMessage{}
mds.On("MailboxFor", "u1").Return(mb1, nil)
mb1.On("NewMessage").Return(msg1, nil)
mb1.On("Name").Return("u1")
msg1.On("ID").Return("")
msg1.On("From").Return("")
msg1.On("To").Return(make([]string, 0))
msg1.On("Date").Return(time.Time{})
msg1.On("Subject").Return("")
msg1.On("Size").Return(0)
msg1.On("Close").Return(nil)
server, logbuf := setupSMTPServer(mds)
defer teardownSMTPServer(server)
server, logbuf, teardown := setupSMTPServer(mds)
defer teardown()
var script []scriptStep
pipe := setupSMTPSession(server)
@@ -359,7 +367,7 @@ func (m *mockConn) SetDeadline(t time.Time) error { return nil }
func (m *mockConn) SetReadDeadline(t time.Time) error { return nil }
func (m *mockConn) SetWriteDeadline(t time.Time) error { return nil }
func setupSMTPServer(ds DataStore) (*Server, *bytes.Buffer) {
func setupSMTPServer(ds datastore.DataStore) (s *Server, buf *bytes.Buffer, teardown func()) {
// Test Server Config
cfg := config.SMTPConfig{
IP4address: net.IPv4(127, 0, 0, 1),
@@ -373,12 +381,18 @@ func setupSMTPServer(ds DataStore) (*Server, *bytes.Buffer) {
}
// Capture log output
buf := new(bytes.Buffer)
buf = new(bytes.Buffer)
log.SetOutput(buf)
// Create a server, don't start it
shutdownChan := make(chan bool)
return NewServer(cfg, ds, shutdownChan), buf
ctx, cancel := context.WithCancel(context.Background())
teardown = func() {
close(shutdownChan)
cancel()
}
s = NewServer(cfg, shutdownChan, ds, msghub.New(ctx, 100))
return s, buf, teardown
}
var sessionNum int
@@ -393,7 +407,3 @@ func setupSMTPSession(server *Server) net.Conn {
return clientConn
}
func teardownSMTPServer(server *Server) {
//log.SetOutput(os.Stderr)
}

View File

@@ -2,6 +2,7 @@ package smtpd
import (
"container/list"
"context"
"expvar"
"fmt"
"net"
@@ -10,28 +11,51 @@ import (
"time"
"github.com/jhillyerd/inbucket/config"
"github.com/jhillyerd/inbucket/datastore"
"github.com/jhillyerd/inbucket/log"
"github.com/jhillyerd/inbucket/msghub"
)
func init() {
m := expvar.NewMap("smtp")
m.Set("ConnectsTotal", expConnectsTotal)
m.Set("ConnectsHist", expConnectsHist)
m.Set("ConnectsCurrent", expConnectsCurrent)
m.Set("ReceivedTotal", expReceivedTotal)
m.Set("ReceivedHist", expReceivedHist)
m.Set("ErrorsTotal", expErrorsTotal)
m.Set("ErrorsHist", expErrorsHist)
m.Set("WarnsTotal", expWarnsTotal)
m.Set("WarnsHist", expWarnsHist)
log.AddTickerFunc(func() {
expReceivedHist.Set(log.PushMetric(deliveredHist, expReceivedTotal))
expConnectsHist.Set(log.PushMetric(connectsHist, expConnectsTotal))
expErrorsHist.Set(log.PushMetric(errorsHist, expErrorsTotal))
expWarnsHist.Set(log.PushMetric(warnsHist, expWarnsTotal))
})
}
// Server holds the configuration and state of our SMTP server
type Server struct {
// Configuration
host string
domain string
domainNoStore string
maxRecips int
maxIdleSeconds int
maxMessageBytes int
dataStore DataStore
storeMessages bool
listener net.Listener
// globalShutdown is the signal Inbucket needs to shut down
globalShutdown chan bool
// Dependencies
dataStore datastore.DataStore // Mailbox/message store
globalShutdown chan bool // Shuts down Inbucket
msgHub *msghub.Hub // Pub/sub for message info
retentionScanner *datastore.RetentionScanner // Deletes expired messages
// localShutdown indicates this component has completed shutting down
localShutdown chan bool
// waitgroup tracks individual sessions
waitgroup *sync.WaitGroup
// State
listener net.Listener // Incoming network connections
waitgroup *sync.WaitGroup // Waitgroup tracks individual sessions
}
var (
@@ -56,30 +80,32 @@ var (
)
// NewServer creates a new Server instance with the specificed config
func NewServer(cfg config.SMTPConfig, ds DataStore, globalShutdown chan bool) *Server {
func NewServer(
cfg config.SMTPConfig,
globalShutdown chan bool,
ds datastore.DataStore,
msgHub *msghub.Hub) *Server {
return &Server{
dataStore: ds,
domain: cfg.Domain,
maxRecips: cfg.MaxRecipients,
maxIdleSeconds: cfg.MaxIdleSeconds,
maxMessageBytes: cfg.MaxMessageBytes,
storeMessages: cfg.StoreMessages,
domainNoStore: strings.ToLower(cfg.DomainNoStore),
waitgroup: new(sync.WaitGroup),
globalShutdown: globalShutdown,
localShutdown: make(chan bool),
host: fmt.Sprintf("%v:%v", cfg.IP4address, cfg.IP4port),
domain: cfg.Domain,
domainNoStore: strings.ToLower(cfg.DomainNoStore),
maxRecips: cfg.MaxRecipients,
maxIdleSeconds: cfg.MaxIdleSeconds,
maxMessageBytes: cfg.MaxMessageBytes,
storeMessages: cfg.StoreMessages,
globalShutdown: globalShutdown,
dataStore: ds,
msgHub: msgHub,
retentionScanner: datastore.NewRetentionScanner(ds, globalShutdown),
waitgroup: new(sync.WaitGroup),
}
}
// Start the listener and handle incoming connections
func (s *Server) Start() {
cfg := config.GetSMTPConfig()
addr, err := net.ResolveTCPAddr("tcp4", fmt.Sprintf("%v:%v",
cfg.IP4address, cfg.IP4port))
func (s *Server) Start(ctx context.Context) {
addr, err := net.ResolveTCPAddr("tcp4", s.host)
if err != nil {
log.Errorf("Failed to build tcp4 address: %v", err)
// serve() never called, so we do local shutdown here
close(s.localShutdown)
s.emergencyShutdown()
return
}
@@ -88,8 +114,6 @@ func (s *Server) Start() {
s.listener, err = net.ListenTCP("tcp4", addr)
if err != nil {
log.Errorf("SMTP failed to start tcp4 listener: %v", err)
// serve() never called, so we do local shutdown here
close(s.localShutdown)
s.emergencyShutdown()
return
}
@@ -101,16 +125,14 @@ func (s *Server) Start() {
}
// Start retention scanner
StartRetentionScanner(s.dataStore, s.globalShutdown)
s.retentionScanner.Start()
// Listener go routine
go s.serve()
go s.serve(ctx)
// Wait for shutdown
select {
case _ = <-s.globalShutdown:
log.Tracef("SMTP shutdown requested, connections will be drained")
}
<-ctx.Done()
log.Tracef("SMTP shutdown requested, connections will be drained")
// Closing the listener will cause the serve() go routine to exit
if err := s.listener.Close(); err != nil {
@@ -119,7 +141,7 @@ func (s *Server) Start() {
}
// serve is the listen/accept loop
func (s *Server) serve() {
func (s *Server) serve(ctx context.Context) {
// Handle incoming connections
var tempDelay time.Duration
for sessionID := 1; ; sessionID++ {
@@ -141,11 +163,11 @@ func (s *Server) serve() {
} else {
// Permanent error
select {
case _ = <-s.globalShutdown:
close(s.localShutdown)
case <-ctx.Done():
// SMTP is shutting down
return
default:
close(s.localShutdown)
// Something went wrong
s.emergencyShutdown()
return
}
@@ -162,7 +184,7 @@ func (s *Server) serve() {
func (s *Server) emergencyShutdown() {
// Shutdown Inbucket
select {
case _ = <-s.globalShutdown:
case <-s.globalShutdown:
default:
close(s.globalShutdown)
}
@@ -170,53 +192,8 @@ func (s *Server) emergencyShutdown() {
// Drain causes the caller to block until all active SMTP sessions have finished
func (s *Server) Drain() {
// Wait for listener to exit
select {
case _ = <-s.localShutdown:
}
// Wait for sessions to close
s.waitgroup.Wait()
log.Tracef("SMTP connections have drained")
RetentionJoin()
}
// 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)
s.retentionScanner.Join()
}

View File

@@ -1,8 +1,7 @@
package smtpd
package stringutil
import (
"bytes"
"container/list"
"crypto/sha1"
"fmt"
"io"
@@ -42,7 +41,7 @@ func ParseMailboxName(localPart string) (result string, err error) {
return result, nil
}
// HashMailboxName accepts a mailbox name and hashes it. Inbucket uses this as
// HashMailboxName accepts a mailbox name and hashes it. filestore uses this as
// the directory to house the mailbox
func HashMailboxName(mailbox string) string {
h := sha1.New()
@@ -53,18 +52,6 @@ func HashMailboxName(mailbox string) string {
return fmt.Sprintf("%x", h.Sum(nil))
}
// JoinStringList joins a List containing strings by commas
func JoinStringList(listOfStrings *list.List) string {
if listOfStrings.Len() == 0 {
return ""
}
s := make([]string, 0, listOfStrings.Len())
for e := listOfStrings.Front(); e != nil; e = e.Next() {
s = append(s, e.Value.(string))
}
return strings.Join(s, ",")
}
// ValidateDomainPart returns true if the domain part complies to RFC3696, RFC1035
func ValidateDomainPart(domain string) bool {
if len(domain) == 0 {
@@ -143,15 +130,24 @@ LOOP:
switch {
case ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z'):
// Letters are OK
_ = buf.WriteByte(c)
err = buf.WriteByte(c)
if err != nil {
return
}
inCharQuote = false
case '0' <= c && c <= '9':
// Numbers are OK
_ = buf.WriteByte(c)
err = buf.WriteByte(c)
if err != nil {
return
}
inCharQuote = false
case bytes.IndexByte([]byte("!#$%&'*+-/=?^_`{|}~"), c) >= 0:
// These specials can be used unquoted
_ = buf.WriteByte(c)
err = buf.WriteByte(c)
if err != nil {
return
}
inCharQuote = false
case c == '.':
// A single period is OK
@@ -159,13 +155,19 @@ LOOP:
// Sequence of periods is not permitted
return "", "", fmt.Errorf("Sequence of periods is not permitted")
}
_ = buf.WriteByte(c)
err = buf.WriteByte(c)
if err != nil {
return
}
inCharQuote = false
case c == '\\':
inCharQuote = true
case c == '"':
if inCharQuote {
_ = buf.WriteByte(c)
err = buf.WriteByte(c)
if err != nil {
return
}
inCharQuote = false
} else if inStringQuote {
inStringQuote = false
@@ -178,12 +180,15 @@ LOOP:
}
case c == '@':
if inCharQuote || inStringQuote {
_ = buf.WriteByte(c)
err = buf.WriteByte(c)
if err != nil {
return
}
inCharQuote = false
} else {
// End of local-part
if i > 63 {
return "", "", fmt.Errorf("Local part must not exceed 64 characters")
if i > 128 {
return "", "", fmt.Errorf("Local part must not exceed 128 characters")
}
if prev == '.' {
return "", "", fmt.Errorf("Local part cannot end with a period")
@@ -195,7 +200,10 @@ LOOP:
return "", "", fmt.Errorf("Characters outside of US-ASCII range not permitted")
default:
if inCharQuote || inStringQuote {
_ = buf.WriteByte(c)
err = buf.WriteByte(c)
if err != nil {
return
}
inCharQuote = false
} else {
return "", "", fmt.Errorf("Character %q must be quoted", c)

View File

@@ -1,4 +1,4 @@
package smtpd
package stringutil
import (
"strings"
@@ -99,7 +99,8 @@ func TestValidateLocal(t *testing.T) {
}{
{"", false, "Empty local is not valid"},
{"a", true, "Single letter should be fine"},
{strings.Repeat("a", 65), false, "Only valid up to 64 characters"},
{strings.Repeat("a", 128), true, "Valid up to 128 characters"},
{strings.Repeat("a", 129), false, "Only valid up to 128 characters"},
{"FirstLast", true, "Mixed case permitted"},
{"user123", true, "Numbers permitted"},
{"a!#$%&'*+-/=?^_`{|}~", true, "Any of !#$%&'*+-/=?^_`{|}~ are permitted"},

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%
To: %TO_ADDRESS%
From: %FROM_ADDRESS%
Subject: tutsplus responsive
Subject: tutsplus responsive external CSS
MIME-Version: 1.0
Content-Type: text/html; charset="UTF-8"

View File

@@ -5,4 +5,6 @@ Subject: Swaks HTML
MIME-Version: 1.0
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

@@ -1,8 +1,9 @@
#!/bin/sh
#!/bin/bash
# run-tests.sh
# description: Generate test emails for Inbucket
set -eo pipefail
[ $TRACE ] && set -x
# We need to be in swaks-tests directory
cmdpath="$(dirname "$0")"
@@ -19,6 +20,7 @@ case "$1" in
;;
*)
to="$1"
shift
;;
esac
@@ -28,6 +30,10 @@ export SWAKS_OPT_to="$to@inbucket.local"
# Basic test
swaks $* --h-Subject: "Swaks Plain Text" --body text.txt
# Multi-recipient test
swaks $* --to="$to@inbucket.local,alternate@inbucket.local" --h-Subject: "Swaks Multi-Recipient" \
--body text.txt
# HTML test
swaks $* --h-Subject: "Swaks HTML" --data mime-html.raw
@@ -35,7 +41,8 @@ swaks $* --h-Subject: "Swaks HTML" --data mime-html.raw
swaks $* --h-Subject: "Swaks Top Level HTML" --data nonmime-html.raw
# Attachment test
swaks $* --h-Subject: "Swaks Attachment" --attach-type image/png --attach favicon.png --body text.txt
swaks $* --h-Subject: "Swaks Attachment" --attach-type image/png --attach favicon.png \
--body text.txt
# Encoded subject line test
swaks $* --data utf8-subject.raw
@@ -46,5 +53,6 @@ swaks $* --data gmail.raw
# Outlook test
swaks $* --data outlook.raw
# Nonemime responsive HTML test
# Non-mime responsive HTML test
swaks $* --data nonmime-html-responsive.raw
swaks $* --data nonmime-html-inlined.raw

View File

@@ -29,16 +29,16 @@
"test-infra"
],
"dependencies": {
"jquery": "1.9.1 - 2"
"jquery": "1.9.1 - 3"
},
"version": "3.3.6",
"_release": "3.3.6",
"version": "3.3.7",
"_release": "3.3.7",
"_resolution": {
"type": "version",
"tag": "v3.3.6",
"commit": "81df608a40bf0629a1dc08e584849bb1e43e0b7a"
"tag": "v3.3.7",
"commit": "0b9c4a4007c44201dce9a6cc1a38407005c26c86"
},
"_source": "git://github.com/twbs/bootstrap.git",
"_source": "https://github.com/twbs/bootstrap.git",
"_target": "3.3",
"_originalSource": "bootstrap"
}

View File

@@ -0,0 +1,6 @@
source 'https://rubygems.org'
group :development, :test do
gem 'jekyll', '~> 3.1.2'
gem 'jekyll-sitemap', '~> 0.11.0'
end

View File

@@ -0,0 +1,43 @@
GEM
remote: https://rubygems.org/
specs:
addressable (2.4.0)
colorator (0.1)
ffi (1.9.14-x64-mingw32)
jekyll (3.1.6)
colorator (~> 0.1)
jekyll-sass-converter (~> 1.0)
jekyll-watch (~> 1.1)
kramdown (~> 1.3)
liquid (~> 3.0)
mercenary (~> 0.3.3)
rouge (~> 1.7)
safe_yaml (~> 1.0)
jekyll-sass-converter (1.4.0)
sass (~> 3.4)
jekyll-sitemap (0.11.0)
addressable (~> 2.4.0)
jekyll-watch (1.4.0)
listen (~> 3.0, < 3.1)
kramdown (1.11.1)
liquid (3.0.6)
listen (3.0.8)
rb-fsevent (~> 0.9, >= 0.9.4)
rb-inotify (~> 0.9, >= 0.9.7)
mercenary (0.3.6)
rb-fsevent (0.9.7)
rb-inotify (0.9.7)
ffi (>= 0.5.0)
rouge (1.11.1)
safe_yaml (1.0.4)
sass (3.4.22)
PLATFORMS
x64-mingw32
DEPENDENCIES
jekyll (~> 3.1.2)
jekyll-sitemap (~> 0.11.0)
BUNDLED WITH
1.12.5

View File

@@ -1,7 +1,7 @@
/*!
* Bootstrap's Gruntfile
* http://getbootstrap.com
* Copyright 2013-2015 Twitter, Inc.
* Copyright 2013-2016 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
*/
@@ -17,7 +17,6 @@ module.exports = function (grunt) {
var fs = require('fs');
var path = require('path');
var npmShrinkwrap = require('npm-shrinkwrap');
var generateGlyphiconsData = require('./grunt/bs-glyphicons-data-generator.js');
var BsLessdocParser = require('./grunt/bs-lessdoc-parser.js');
var getLessVarsData = function () {
@@ -130,7 +129,7 @@ module.exports = function (grunt) {
warnings: false
},
mangle: true,
preserveComments: 'some'
preserveComments: /^!|@preserve|@license|@cc_on/i
},
core: {
src: '<%= concat.bootstrap.dest %>',
@@ -232,6 +231,7 @@ module.exports = function (grunt) {
compatibility: 'ie8',
keepSpecialComments: '*',
sourceMap: true,
sourceMapInlineSources: true,
advanced: false
},
minifyCore: {
@@ -277,7 +277,7 @@ module.exports = function (grunt) {
copy: {
fonts: {
expand: true,
src: 'fonts/*',
src: 'fonts/**',
dest: 'dist/'
},
docs: {
@@ -301,7 +301,9 @@ module.exports = function (grunt) {
jekyll: {
options: {
config: '_config.yml'
bundleExec: true,
config: '_config.yml',
incremental: false
},
docs: {},
github: {
@@ -314,12 +316,27 @@ module.exports = function (grunt) {
htmlmin: {
dist: {
options: {
collapseBooleanAttributes: true,
collapseWhitespace: true,
conservativeCollapse: true,
minifyCSS: true,
decodeEntities: false,
minifyCSS: {
compatibility: 'ie8',
keepSpecialComments: 0
},
minifyJS: true,
minifyURLs: false,
processConditionalComments: true,
removeAttributeQuotes: true,
removeComments: true
removeComments: true,
removeOptionalAttributes: true,
removeOptionalTags: true,
removeRedundantAttributes: true,
removeScriptTypeAttributes: true,
removeStyleLinkTypeAttributes: true,
removeTagWhitespace: false,
sortAttributes: true,
sortClassName: true
},
expand: true,
cwd: '_gh_pages',
@@ -331,17 +348,17 @@ module.exports = function (grunt) {
}
},
jade: {
pug: {
options: {
pretty: true,
data: getLessVarsData
},
customizerVars: {
src: 'docs/_jade/customizer-variables.jade',
src: 'docs/_pug/customizer-variables.pug',
dest: 'docs/_includes/customizer-variables.html'
},
customizerNav: {
src: 'docs/_jade/customizer-nav.jade',
src: 'docs/_pug/customizer-nav.pug',
dest: 'docs/_includes/nav/customize.html'
}
},
@@ -350,7 +367,7 @@ module.exports = function (grunt) {
options: {
ignore: [
'Attribute "autocomplete" not allowed on element "button" at this point.',
'Attribute "autocomplete" is only allowed when the input type is "color", "date", "datetime", "datetime-local", "email", "month", "number", "password", "range", "search", "tel", "text", "time", "url", or "week".',
'Attribute "autocomplete" is only allowed when the input type is "color", "date", "datetime", "datetime-local", "email", "hidden", "month", "number", "password", "range", "search", "tel", "text", "time", "url", or "week".',
'Element "img" is missing required attribute "src".'
]
},
@@ -372,25 +389,6 @@ module.exports = function (grunt) {
}
},
sed: {
versionNumber: {
pattern: (function () {
var old = grunt.option('oldver');
return old ? RegExp.quote(old) : old;
})(),
replacement: grunt.option('newver'),
exclude: [
'dist/fonts',
'docs/assets',
'fonts',
'js/tests/vendor',
'node_modules',
'test-infra'
],
recursive: true
}
},
'saucelabs-qunit': {
all: {
options: {
@@ -485,16 +483,11 @@ module.exports = function (grunt) {
// Default task.
grunt.registerTask('default', ['clean:dist', 'copy:fonts', 'test']);
// Version numbering task.
// grunt change-version-number --oldver=A.B.C --newver=X.Y.Z
// This can be overzealous, so its changes should always be manually reviewed!
grunt.registerTask('change-version-number', 'sed');
grunt.registerTask('build-glyphicons-data', function () { generateGlyphiconsData.call(this, grunt); });
// task for building customizer
grunt.registerTask('build-customizer', ['build-customizer-html', 'build-raw-files']);
grunt.registerTask('build-customizer-html', 'jade');
grunt.registerTask('build-customizer-html', 'pug');
grunt.registerTask('build-raw-files', 'Add scripts/less files to customizer.', function () {
var banner = grunt.template.process('<%= banner %>');
generateRawFiles(grunt, banner);
@@ -512,22 +505,7 @@ module.exports = function (grunt) {
grunt.registerTask('docs-js', ['uglify:docsJs', 'uglify:customize']);
grunt.registerTask('lint-docs-js', ['jshint:assets', 'jscs:assets']);
grunt.registerTask('docs', ['docs-css', 'lint-docs-css', 'docs-js', 'lint-docs-js', 'clean:docs', 'copy:docs', 'build-glyphicons-data', 'build-customizer']);
grunt.registerTask('docs-github', ['jekyll:github', 'htmlmin']);
grunt.registerTask('prep-release', ['dist', 'docs', 'jekyll:github', 'htmlmin', 'compress']);
// Task for updating the cached npm packages used by the Travis build (which are controlled by test-infra/npm-shrinkwrap.json).
// This task should be run and the updated file should be committed whenever Bootstrap's dependencies change.
grunt.registerTask('update-shrinkwrap', ['exec:npmUpdate', '_update-shrinkwrap']);
grunt.registerTask('_update-shrinkwrap', function () {
var done = this.async();
npmShrinkwrap({ dev: true, dirname: __dirname }, function (err) {
if (err) {
grunt.fail.warn(err);
}
var dest = 'test-infra/npm-shrinkwrap.json';
fs.renameSync('npm-shrinkwrap.json', dest);
grunt.log.writeln('File ' + dest.cyan + ' updated.');
done();
});
});
grunt.registerTask('prep-release', ['dist', 'docs', 'docs-github', 'compress']);
};

View File

@@ -0,0 +1,22 @@
Before opening an issue:
- [Search for duplicate or closed issues](https://github.com/twbs/bootstrap/issues?utf8=%E2%9C%93&q=is%3Aissue)
- [Validate](http://validator.w3.org/nu/) and [lint](https://github.com/twbs/bootlint#in-the-browser) any HTML to avoid common problems
- Prepare a [reduced test case](https://css-tricks.com/reduced-test-cases/) for any bugs
- Read the [contributing guidelines](https://github.com/twbs/bootstrap/blob/master/CONTRIBUTING.md)
When asking general "how to" questions:
- Please do not open an issue here
- Instead, ask for help on [StackOverflow, IRC, or Slack](https://github.com/twbs/bootstrap/blob/master/README.md#community)
When reporting a bug, include:
- Operating system and version (Windows, Mac OS X, Android, iOS, Win10 Mobile)
- Browser and version (Chrome, Firefox, Safari, IE, MS Edge, Opera 15+, Android Browser)
- Reduced test cases and potential fixes using [JS Bin](https://jsbin.com)
When suggesting a feature, include:
- As much detail as possible for what we should add and why it's important to Bootstrap
- Relevant links to prior art, screenshots, or live demos whenever possible

View File

@@ -1,6 +1,6 @@
The MIT License (MIT)
Copyright (c) 2011-2015 Twitter, Inc
Copyright (c) 2011-2016 Twitter, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

@@ -29,10 +29,10 @@ To get started, check out <http://getbootstrap.com>!
Several quick start options are available:
* [Download the latest release](https://github.com/twbs/bootstrap/archive/v3.3.6.zip).
* [Download the latest release](https://github.com/twbs/bootstrap/archive/v3.3.7.zip).
* Clone the repo: `git clone https://github.com/twbs/bootstrap.git`.
* Install with [Bower](http://bower.io): `bower install bootstrap`.
* Install with [npm](https://www.npmjs.com): `npm install bootstrap`.
* Install with [npm](https://www.npmjs.com): `npm install bootstrap@3`.
* Install with [Meteor](https://www.meteor.com): `meteor add twbs:bootstrap`.
* Install with [Composer](https://getcomposer.org): `composer require twbs/bootstrap`.
@@ -71,6 +71,8 @@ We provide compiled CSS and JS (`bootstrap.*`), as well as compiled and minified
Have a bug or a feature request? Please first read the [issue guidelines](https://github.com/twbs/bootstrap/blob/master/CONTRIBUTING.md#using-the-issue-tracker) and search for existing and closed issues. If your problem or idea is not addressed yet, [please open a new issue](https://github.com/twbs/bootstrap/issues/new).
Note that **feature requests must target [Bootstrap v4](https://github.com/twbs/bootstrap/tree/v4-dev),** because Bootstrap v3 is now in maintenance mode and is closed off to new features. This is so that we can focus our efforts on Bootstrap v4.
## Documentation
@@ -78,10 +80,9 @@ Bootstrap's documentation, included in this repo in the root directory, is built
### Running documentation locally
1. If necessary, [install Jekyll](http://jekyllrb.com/docs/installation) (requires v3.0.x).
1. If necessary, [install Jekyll](http://jekyllrb.com/docs/installation) and other Ruby dependencies with `bundle install`.
**Note for Windows users:** Read [this unofficial guide](http://jekyll-windows.juthilo.com/) to get Jekyll up and running without problems.
2. Install the Ruby-based syntax highlighter, [Rouge](https://github.com/jneen/rouge), with `gem install rouge`.
3. From the root `/bootstrap` directory, run `jekyll serve` in the command line.
2. From the root `/bootstrap` directory, run `bundle exec jekyll serve` in the command line.
4. Open `http://localhost:9001` in your browser, and voilà.
Learn more about using Jekyll by reading its [documentation](http://jekyllrb.com/docs/home/).
@@ -99,6 +100,8 @@ Please read through our [contributing guidelines](https://github.com/twbs/bootst
Moreover, if your pull request contains JavaScript patches or features, you must include [relevant unit tests](https://github.com/twbs/bootstrap/tree/master/js/tests). All HTML and CSS should conform to the [Code Guide](https://github.com/mdo/code-guide), maintained by [Mark Otto](https://github.com/mdo).
**Bootstrap v3 is now closed off to new features.** It has gone into maintenance mode so that we can focus our efforts on [Bootstrap v4](https://github.com/twbs/bootstrap/tree/v4-dev), the future of the framework. Pull requests which add new features (rather than fix bugs) should target [Bootstrap v4 (the `v4-dev` git branch)](https://github.com/twbs/bootstrap/tree/v4-dev) instead.
Editor preferences are available in the [editor config](https://github.com/twbs/bootstrap/blob/master/.editorconfig) for easy use in common text editors. Read more and download plugins at <http://editorconfig.org>.
@@ -136,4 +139,4 @@ See [the Releases section of our GitHub project](https://github.com/twbs/bootstr
## Copyright and license
Code and documentation copyright 2011-2015 Twitter, Inc. Code released under [the MIT license](https://github.com/twbs/bootstrap/blob/master/LICENSE). Docs released under [Creative Commons](https://github.com/twbs/bootstrap/blob/master/docs/LICENSE).
Code and documentation copyright 2011-2016 Twitter, Inc. Code released under [the MIT license](https://github.com/twbs/bootstrap/blob/master/LICENSE). Docs released under [Creative Commons](https://github.com/twbs/bootstrap/blob/master/docs/LICENSE).

View File

@@ -29,6 +29,6 @@
"test-infra"
],
"dependencies": {
"jquery": "1.9.1 - 2"
"jquery": "1.9.1 - 3"
}
}

View File

@@ -1,6 +1,6 @@
/*!
* Bootstrap v3.3.6 (http://getbootstrap.com)
* Copyright 2011-2015 Twitter, Inc.
* Bootstrap v3.3.7 (http://getbootstrap.com)
* Copyright 2011-2016 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
*/
.btn-default,

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,6 @@
/*!
* Bootstrap v3.3.6 (http://getbootstrap.com)
* Copyright 2011-2015 Twitter, Inc.
* Bootstrap v3.3.7 (http://getbootstrap.com)
* Copyright 2011-2016 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
*/
/*! normalize.css v3.0.3 | MIT License | github.com/necolas/normalize.css */
@@ -1106,7 +1106,6 @@ a:focus {
text-decoration: underline;
}
a:focus {
outline: thin dotted;
outline: 5px auto -webkit-focus-ring-color;
outline-offset: -2px;
}
@@ -2537,7 +2536,6 @@ select[size] {
input[type="file"]:focus,
input[type="radio"]:focus,
input[type="checkbox"]:focus {
outline: thin dotted;
outline: 5px auto -webkit-focus-ring-color;
outline-offset: -2px;
}
@@ -3029,7 +3027,6 @@ select[multiple].input-lg {
.btn.focus,
.btn:active.focus,
.btn.active.focus {
outline: thin dotted;
outline: 5px auto -webkit-focus-ring-color;
outline-offset: -2px;
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,6 @@
/*!
* Bootstrap v3.3.6 (http://getbootstrap.com)
* Copyright 2011-2015 Twitter, Inc.
* Bootstrap v3.3.7 (http://getbootstrap.com)
* Copyright 2011-2016 Twitter, Inc.
* Licensed under the MIT license
*/
@@ -11,16 +11,16 @@ if (typeof jQuery === 'undefined') {
+function ($) {
'use strict';
var version = $.fn.jquery.split(' ')[0].split('.')
if ((version[0] < 2 && version[1] < 9) || (version[0] == 1 && version[1] == 9 && version[2] < 1) || (version[0] > 2)) {
throw new Error('Bootstrap\'s JavaScript requires jQuery version 1.9.1 or higher, but lower than version 3')
if ((version[0] < 2 && version[1] < 9) || (version[0] == 1 && version[1] == 9 && version[2] < 1) || (version[0] > 3)) {
throw new Error('Bootstrap\'s JavaScript requires jQuery version 1.9.1 or higher, but lower than version 4')
}
}(jQuery);
/* ========================================================================
* Bootstrap: transition.js v3.3.6
* Bootstrap: transition.js v3.3.7
* http://getbootstrap.com/javascript/#transitions
* ========================================================================
* Copyright 2011-2015 Twitter, Inc.
* Copyright 2011-2016 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
* ======================================================================== */
@@ -77,10 +77,10 @@ if (typeof jQuery === 'undefined') {
}(jQuery);
/* ========================================================================
* Bootstrap: alert.js v3.3.6
* Bootstrap: alert.js v3.3.7
* http://getbootstrap.com/javascript/#alerts
* ========================================================================
* Copyright 2011-2015 Twitter, Inc.
* Copyright 2011-2016 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
* ======================================================================== */
@@ -96,7 +96,7 @@ if (typeof jQuery === 'undefined') {
$(el).on('click', dismiss, this.close)
}
Alert.VERSION = '3.3.6'
Alert.VERSION = '3.3.7'
Alert.TRANSITION_DURATION = 150
@@ -109,7 +109,7 @@ if (typeof jQuery === 'undefined') {
selector = selector && selector.replace(/.*(?=#[^\s]*$)/, '') // strip for ie7
}
var $parent = $(selector)
var $parent = $(selector === '#' ? [] : selector)
if (e) e.preventDefault()
@@ -172,10 +172,10 @@ if (typeof jQuery === 'undefined') {
}(jQuery);
/* ========================================================================
* Bootstrap: button.js v3.3.6
* Bootstrap: button.js v3.3.7
* http://getbootstrap.com/javascript/#buttons
* ========================================================================
* Copyright 2011-2015 Twitter, Inc.
* Copyright 2011-2016 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
* ======================================================================== */
@@ -192,7 +192,7 @@ if (typeof jQuery === 'undefined') {
this.isLoading = false
}
Button.VERSION = '3.3.6'
Button.VERSION = '3.3.7'
Button.DEFAULTS = {
loadingText: 'loading...'
@@ -214,10 +214,10 @@ if (typeof jQuery === 'undefined') {
if (state == 'loadingText') {
this.isLoading = true
$el.addClass(d).attr(d, d)
$el.addClass(d).attr(d, d).prop(d, true)
} else if (this.isLoading) {
this.isLoading = false
$el.removeClass(d).removeAttr(d)
$el.removeClass(d).removeAttr(d).prop(d, false)
}
}, this), 0)
}
@@ -281,10 +281,15 @@ if (typeof jQuery === 'undefined') {
$(document)
.on('click.bs.button.data-api', '[data-toggle^="button"]', function (e) {
var $btn = $(e.target)
if (!$btn.hasClass('btn')) $btn = $btn.closest('.btn')
var $btn = $(e.target).closest('.btn')
Plugin.call($btn, 'toggle')
if (!($(e.target).is('input[type="radio"]') || $(e.target).is('input[type="checkbox"]'))) e.preventDefault()
if (!($(e.target).is('input[type="radio"], input[type="checkbox"]'))) {
// Prevent double click on radios, and the double selections (so cancellation) on checkboxes
e.preventDefault()
// The target component still receive the focus
if ($btn.is('input,button')) $btn.trigger('focus')
else $btn.find('input:visible,button:visible').first().trigger('focus')
}
})
.on('focus.bs.button.data-api blur.bs.button.data-api', '[data-toggle^="button"]', function (e) {
$(e.target).closest('.btn').toggleClass('focus', /^focus(in)?$/.test(e.type))
@@ -293,10 +298,10 @@ if (typeof jQuery === 'undefined') {
}(jQuery);
/* ========================================================================
* Bootstrap: carousel.js v3.3.6
* Bootstrap: carousel.js v3.3.7
* http://getbootstrap.com/javascript/#carousel
* ========================================================================
* Copyright 2011-2015 Twitter, Inc.
* Copyright 2011-2016 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
* ======================================================================== */
@@ -324,7 +329,7 @@ if (typeof jQuery === 'undefined') {
.on('mouseleave.bs.carousel', $.proxy(this.cycle, this))
}
Carousel.VERSION = '3.3.6'
Carousel.VERSION = '3.3.7'
Carousel.TRANSITION_DURATION = 600
@@ -531,13 +536,14 @@ if (typeof jQuery === 'undefined') {
}(jQuery);
/* ========================================================================
* Bootstrap: collapse.js v3.3.6
* Bootstrap: collapse.js v3.3.7
* http://getbootstrap.com/javascript/#collapse
* ========================================================================
* Copyright 2011-2015 Twitter, Inc.
* Copyright 2011-2016 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
* ======================================================================== */
/* jshint latedef: false */
+function ($) {
'use strict';
@@ -561,7 +567,7 @@ if (typeof jQuery === 'undefined') {
if (this.options.toggle) this.toggle()
}
Collapse.VERSION = '3.3.6'
Collapse.VERSION = '3.3.7'
Collapse.TRANSITION_DURATION = 350
@@ -743,10 +749,10 @@ if (typeof jQuery === 'undefined') {
}(jQuery);
/* ========================================================================
* Bootstrap: dropdown.js v3.3.6
* Bootstrap: dropdown.js v3.3.7
* http://getbootstrap.com/javascript/#dropdowns
* ========================================================================
* Copyright 2011-2015 Twitter, Inc.
* Copyright 2011-2016 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
* ======================================================================== */
@@ -763,7 +769,7 @@ if (typeof jQuery === 'undefined') {
$(element).on('click.bs.dropdown', this.toggle)
}
Dropdown.VERSION = '3.3.6'
Dropdown.VERSION = '3.3.7'
function getParent($this) {
var selector = $this.attr('data-target')
@@ -909,10 +915,10 @@ if (typeof jQuery === 'undefined') {
}(jQuery);
/* ========================================================================
* Bootstrap: modal.js v3.3.6
* Bootstrap: modal.js v3.3.7
* http://getbootstrap.com/javascript/#modals
* ========================================================================
* Copyright 2011-2015 Twitter, Inc.
* Copyright 2011-2016 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
* ======================================================================== */
@@ -943,7 +949,7 @@ if (typeof jQuery === 'undefined') {
}
}
Modal.VERSION = '3.3.6'
Modal.VERSION = '3.3.7'
Modal.TRANSITION_DURATION = 300
Modal.BACKDROP_TRANSITION_DURATION = 150
@@ -1050,7 +1056,9 @@ if (typeof jQuery === 'undefined') {
$(document)
.off('focusin.bs.modal') // guard against infinite focus loop
.on('focusin.bs.modal', $.proxy(function (e) {
if (this.$element[0] !== e.target && !this.$element.has(e.target).length) {
if (document !== e.target &&
this.$element[0] !== e.target &&
!this.$element.has(e.target).length) {
this.$element.trigger('focus')
}
}, this))
@@ -1247,11 +1255,11 @@ if (typeof jQuery === 'undefined') {
}(jQuery);
/* ========================================================================
* Bootstrap: tooltip.js v3.3.6
* Bootstrap: tooltip.js v3.3.7
* http://getbootstrap.com/javascript/#tooltip
* Inspired by the original jQuery.tipsy by Jason Frame
* ========================================================================
* Copyright 2011-2015 Twitter, Inc.
* Copyright 2011-2016 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
* ======================================================================== */
@@ -1274,7 +1282,7 @@ if (typeof jQuery === 'undefined') {
this.init('tooltip', element, options)
}
Tooltip.VERSION = '3.3.6'
Tooltip.VERSION = '3.3.7'
Tooltip.TRANSITION_DURATION = 150
@@ -1565,9 +1573,11 @@ if (typeof jQuery === 'undefined') {
function complete() {
if (that.hoverState != 'in') $tip.detach()
that.$element
.removeAttr('aria-describedby')
.trigger('hidden.bs.' + that.type)
if (that.$element) { // TODO: Check whether guarding this code with this `if` is really necessary.
that.$element
.removeAttr('aria-describedby')
.trigger('hidden.bs.' + that.type)
}
callback && callback()
}
@@ -1610,7 +1620,10 @@ if (typeof jQuery === 'undefined') {
// width and height are missing in IE8, so compute them manually; see https://github.com/twbs/bootstrap/issues/14093
elRect = $.extend({}, elRect, { width: elRect.right - elRect.left, height: elRect.bottom - elRect.top })
}
var elOffset = isBody ? { top: 0, left: 0 } : $element.offset()
var isSvg = window.SVGElement && el instanceof window.SVGElement
// Avoid using $.offset() on SVGs since it gives incorrect results in jQuery 3.
// See https://github.com/twbs/bootstrap/issues/20280
var elOffset = isBody ? { top: 0, left: 0 } : (isSvg ? null : $element.offset())
var scroll = { scroll: isBody ? document.documentElement.scrollTop || document.body.scrollTop : $element.scrollTop() }
var outerDims = isBody ? { width: $(window).width(), height: $(window).height() } : null
@@ -1726,6 +1739,7 @@ if (typeof jQuery === 'undefined') {
that.$tip = null
that.$arrow = null
that.$viewport = null
that.$element = null
})
}
@@ -1762,10 +1776,10 @@ if (typeof jQuery === 'undefined') {
}(jQuery);
/* ========================================================================
* Bootstrap: popover.js v3.3.6
* Bootstrap: popover.js v3.3.7
* http://getbootstrap.com/javascript/#popovers
* ========================================================================
* Copyright 2011-2015 Twitter, Inc.
* Copyright 2011-2016 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
* ======================================================================== */
@@ -1782,7 +1796,7 @@ if (typeof jQuery === 'undefined') {
if (!$.fn.tooltip) throw new Error('Popover requires tooltip.js')
Popover.VERSION = '3.3.6'
Popover.VERSION = '3.3.7'
Popover.DEFAULTS = $.extend({}, $.fn.tooltip.Constructor.DEFAULTS, {
placement: 'right',
@@ -1871,10 +1885,10 @@ if (typeof jQuery === 'undefined') {
}(jQuery);
/* ========================================================================
* Bootstrap: scrollspy.js v3.3.6
* Bootstrap: scrollspy.js v3.3.7
* http://getbootstrap.com/javascript/#scrollspy
* ========================================================================
* Copyright 2011-2015 Twitter, Inc.
* Copyright 2011-2016 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
* ======================================================================== */
@@ -1900,7 +1914,7 @@ if (typeof jQuery === 'undefined') {
this.process()
}
ScrollSpy.VERSION = '3.3.6'
ScrollSpy.VERSION = '3.3.7'
ScrollSpy.DEFAULTS = {
offset: 10
@@ -2044,10 +2058,10 @@ if (typeof jQuery === 'undefined') {
}(jQuery);
/* ========================================================================
* Bootstrap: tab.js v3.3.6
* Bootstrap: tab.js v3.3.7
* http://getbootstrap.com/javascript/#tabs
* ========================================================================
* Copyright 2011-2015 Twitter, Inc.
* Copyright 2011-2016 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
* ======================================================================== */
@@ -2064,7 +2078,7 @@ if (typeof jQuery === 'undefined') {
// jscs:enable requireDollarBeforejQueryAssignment
}
Tab.VERSION = '3.3.6'
Tab.VERSION = '3.3.7'
Tab.TRANSITION_DURATION = 150
@@ -2200,10 +2214,10 @@ if (typeof jQuery === 'undefined') {
}(jQuery);
/* ========================================================================
* Bootstrap: affix.js v3.3.6
* Bootstrap: affix.js v3.3.7
* http://getbootstrap.com/javascript/#affix
* ========================================================================
* Copyright 2011-2015 Twitter, Inc.
* Copyright 2011-2016 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
* ======================================================================== */
@@ -2229,7 +2243,7 @@ if (typeof jQuery === 'undefined') {
this.checkPosition()
}
Affix.VERSION = '3.3.6'
Affix.VERSION = '3.3.7'
Affix.RESET = 'affix affix-top affix-bottom'

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,109 @@
#!/usr/bin/env node
'use strict';
/* globals Set */
/*!
* Script to update version number references in the project.
* Copyright 2015 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
*/
var fs = require('fs');
var path = require('path');
var sh = require('shelljs');
sh.config.fatal = true;
var sed = sh.sed;
// Blame TC39... https://github.com/benjamingr/RegExp.escape/issues/37
RegExp.quote = function (string) {
return string.replace(/[-\\^$*+?.()|[\]{}]/g, '\\$&');
};
RegExp.quoteReplacement = function (string) {
return string.replace(/[$]/g, '$$');
};
var DRY_RUN = false;
function walkAsync(directory, excludedDirectories, fileCallback, errback) {
if (excludedDirectories.has(path.parse(directory).base)) {
return;
}
fs.readdir(directory, function (err, names) {
if (err) {
errback(err);
return;
}
names.forEach(function (name) {
var filepath = path.join(directory, name);
fs.lstat(filepath, function (err, stats) {
if (err) {
process.nextTick(errback, err);
return;
}
if (stats.isSymbolicLink()) {
return;
}
else if (stats.isDirectory()) {
process.nextTick(walkAsync, filepath, excludedDirectories, fileCallback, errback);
}
else if (stats.isFile()) {
process.nextTick(fileCallback, filepath);
}
});
});
});
}
function replaceRecursively(directory, excludedDirectories, allowedExtensions, original, replacement) {
original = new RegExp(RegExp.quote(original), 'g');
replacement = RegExp.quoteReplacement(replacement);
var updateFile = !DRY_RUN ? function (filepath) {
if (allowedExtensions.has(path.parse(filepath).ext)) {
sed('-i', original, replacement, filepath);
}
} : function (filepath) {
if (allowedExtensions.has(path.parse(filepath).ext)) {
console.log('FILE: ' + filepath);
}
else {
console.log('EXCLUDED:' + filepath);
}
};
walkAsync(directory, excludedDirectories, updateFile, function (err) {
console.error('ERROR while traversing directory!:');
console.error(err);
process.exit(1);
});
}
function main(args) {
if (args.length !== 2) {
console.error('USAGE: change-version old_version new_version');
console.error('Got arguments:', args);
process.exit(1);
}
var oldVersion = args[0];
var newVersion = args[1];
var EXCLUDED_DIRS = new Set([
'.git',
'node_modules',
'vendor'
]);
var INCLUDED_EXTENSIONS = new Set([
// This extension whitelist is how we avoid modifying binary files
'',
'.css',
'.html',
'.js',
'.json',
'.less',
'.md',
'.nuspec',
'.ps1',
'.scss',
'.txt',
'.yml'
]);
replaceRecursively('.', EXCLUDED_DIRS, INCLUDED_EXTENSIONS, oldVersion, newVersion);
}
main(process.argv.slice(2));

View File

@@ -13,7 +13,7 @@
"docsJs": [
"../assets/js/vendor/holder.min.js",
"../assets/js/vendor/ZeroClipboard.min.js",
"../assets/js/vendor/anchor.js",
"../assets/js/vendor/anchor.min.js",
"../assets/js/src/application.js"
]
},
@@ -37,8 +37,8 @@
"+function ($) {",
" 'use strict';",
" var version = $.fn.jquery.split(' ')[0].split('.')",
" if ((version[0] < 2 && version[1] < 9) || (version[0] == 1 && version[1] == 9 && version[2] < 1) || (version[0] > 2)) {",
" throw new Error('Bootstrap\\'s JavaScript requires jQuery version 1.9.1 or higher, but lower than version 3')",
" if ((version[0] < 2 && version[1] < 9) || (version[0] == 1 && version[1] == 9 && version[2] < 1) || (version[0] > 3)) {",
" throw new Error('Bootstrap\\'s JavaScript requires jQuery version 1.9.1 or higher, but lower than version 4')",
" }",
"}(jQuery);\n\n"
]

File diff suppressed because it is too large Load Diff

View File

@@ -57,7 +57,7 @@
{
browserName: "iphone",
platform: "OS X 10.10",
version: "8.2"
version: "9.2"
},
# iOS Chrome not currently supported by Sauce Labs

View File

@@ -1,8 +1,8 @@
/* ========================================================================
* Bootstrap: affix.js v3.3.6
* Bootstrap: affix.js v3.3.7
* http://getbootstrap.com/javascript/#affix
* ========================================================================
* Copyright 2011-2015 Twitter, Inc.
* Copyright 2011-2016 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
* ======================================================================== */
@@ -28,7 +28,7 @@
this.checkPosition()
}
Affix.VERSION = '3.3.6'
Affix.VERSION = '3.3.7'
Affix.RESET = 'affix affix-top affix-bottom'

View File

@@ -1,8 +1,8 @@
/* ========================================================================
* Bootstrap: alert.js v3.3.6
* Bootstrap: alert.js v3.3.7
* http://getbootstrap.com/javascript/#alerts
* ========================================================================
* Copyright 2011-2015 Twitter, Inc.
* Copyright 2011-2016 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
* ======================================================================== */
@@ -18,7 +18,7 @@
$(el).on('click', dismiss, this.close)
}
Alert.VERSION = '3.3.6'
Alert.VERSION = '3.3.7'
Alert.TRANSITION_DURATION = 150
@@ -31,7 +31,7 @@
selector = selector && selector.replace(/.*(?=#[^\s]*$)/, '') // strip for ie7
}
var $parent = $(selector)
var $parent = $(selector === '#' ? [] : selector)
if (e) e.preventDefault()

View File

@@ -1,8 +1,8 @@
/* ========================================================================
* Bootstrap: button.js v3.3.6
* Bootstrap: button.js v3.3.7
* http://getbootstrap.com/javascript/#buttons
* ========================================================================
* Copyright 2011-2015 Twitter, Inc.
* Copyright 2011-2016 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
* ======================================================================== */
@@ -19,7 +19,7 @@
this.isLoading = false
}
Button.VERSION = '3.3.6'
Button.VERSION = '3.3.7'
Button.DEFAULTS = {
loadingText: 'loading...'
@@ -41,10 +41,10 @@
if (state == 'loadingText') {
this.isLoading = true
$el.addClass(d).attr(d, d)
$el.addClass(d).attr(d, d).prop(d, true)
} else if (this.isLoading) {
this.isLoading = false
$el.removeClass(d).removeAttr(d)
$el.removeClass(d).removeAttr(d).prop(d, false)
}
}, this), 0)
}
@@ -108,10 +108,15 @@
$(document)
.on('click.bs.button.data-api', '[data-toggle^="button"]', function (e) {
var $btn = $(e.target)
if (!$btn.hasClass('btn')) $btn = $btn.closest('.btn')
var $btn = $(e.target).closest('.btn')
Plugin.call($btn, 'toggle')
if (!($(e.target).is('input[type="radio"]') || $(e.target).is('input[type="checkbox"]'))) e.preventDefault()
if (!($(e.target).is('input[type="radio"], input[type="checkbox"]'))) {
// Prevent double click on radios, and the double selections (so cancellation) on checkboxes
e.preventDefault()
// The target component still receive the focus
if ($btn.is('input,button')) $btn.trigger('focus')
else $btn.find('input:visible,button:visible').first().trigger('focus')
}
})
.on('focus.bs.button.data-api blur.bs.button.data-api', '[data-toggle^="button"]', function (e) {
$(e.target).closest('.btn').toggleClass('focus', /^focus(in)?$/.test(e.type))

View File

@@ -1,8 +1,8 @@
/* ========================================================================
* Bootstrap: carousel.js v3.3.6
* Bootstrap: carousel.js v3.3.7
* http://getbootstrap.com/javascript/#carousel
* ========================================================================
* Copyright 2011-2015 Twitter, Inc.
* Copyright 2011-2016 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
* ======================================================================== */
@@ -30,7 +30,7 @@
.on('mouseleave.bs.carousel', $.proxy(this.cycle, this))
}
Carousel.VERSION = '3.3.6'
Carousel.VERSION = '3.3.7'
Carousel.TRANSITION_DURATION = 600

View File

@@ -1,11 +1,12 @@
/* ========================================================================
* Bootstrap: collapse.js v3.3.6
* Bootstrap: collapse.js v3.3.7
* http://getbootstrap.com/javascript/#collapse
* ========================================================================
* Copyright 2011-2015 Twitter, Inc.
* Copyright 2011-2016 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
* ======================================================================== */
/* jshint latedef: false */
+function ($) {
'use strict';
@@ -29,7 +30,7 @@
if (this.options.toggle) this.toggle()
}
Collapse.VERSION = '3.3.6'
Collapse.VERSION = '3.3.7'
Collapse.TRANSITION_DURATION = 350

Some files were not shown because too many files have changed in this diff Show More