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

Compare commits

...

153 Commits

Author SHA1 Message Date
James Hillyerd
30d8d6c64f Merge branch 'release/2.0.0' 2018-05-05 10:17:19 -07:00
James Hillyerd
37361e08e8 Change log release prep 2018-05-05 10:13:20 -07:00
James Hillyerd
2ceb510f70 Update change log 2018-05-05 10:08:40 -07:00
James Hillyerd
62fa52f42c log: disable color output on windows, closes #93 2018-05-05 09:57:01 -07:00
James Hillyerd
568474da32 travis: back to 1.10.x 2018-05-05 09:36:46 -07:00
evilmrburns
562332258d Fix INBUCKET_STORAGE_PARAMS example (#95) 2018-04-21 10:02:45 -07:00
evilmrburns
941b682197 Fix INBUCKET_STORAGE_PARAMS example (#95) 2018-04-21 10:01:10 -07:00
James Hillyerd
7fc5e06517 travis: install rpm 2018-04-07 20:35:37 -07:00
James Hillyerd
704ba04c51 travis: specify go 1.10.1 2018-04-07 20:35:19 -07:00
James Hillyerd
a291944a7d travis: install rpm 2018-04-07 20:22:38 -07:00
James Hillyerd
7afc49d88f travis: specify go 1.10.1 2018-04-07 20:07:49 -07:00
James Hillyerd
8a30b9717e Merge tag 'v2.0.0-rc1' into develop
v2.0.0-rc1 see CHANGELOG.md
2018-04-07 19:36:34 -07:00
James Hillyerd
61ac42379e Merge branch 'release/2.0.0-rc1' 2018-04-07 19:36:13 -07:00
James Hillyerd
1ed8723bd7 2.0.0-rc1 changelog update 2018-04-07 19:33:43 -07:00
James Hillyerd
bcede38453 webui: Friendly URL support for #73 2018-04-07 18:17:39 -07:00
James Hillyerd
7e71b4a42c Move swaks-tests into etc for #79 2018-04-07 17:22:44 -07:00
James Hillyerd
e8f57fb4ed Update changelog for mailbox naming 2018-04-07 17:17:37 -07:00
James Hillyerd
d846f04186 docker: Switch to multistage build, closes #66 2018-04-07 17:00:37 -07:00
James Hillyerd
7a783efd5d config: Document MailboxNaming for #33 2018-04-07 16:20:00 -07:00
James Hillyerd
a40c92d221 Merge branch 'feature/fullemail-33' for #33 2018-04-07 14:49:33 -07:00
James Hillyerd
c6bb7d1d4d Fix some null pointers during message retrieval 2018-04-07 14:40:54 -07:00
James Hillyerd
12f98868ba smtp/policy: Store messages with full naming for #33
- Added some tests to make sure stores do not enforce their own naming.
- Improve recipient structured logging.
2018-04-07 13:06:56 -07:00
James Hillyerd
bf152adbef Use enum for MailboxNaming for #33 2018-04-04 21:55:52 -07:00
James Hillyerd
ff2121fbb9 policy: Add support for MailboxNaming to ExtractMailbox for #33 2018-04-04 20:22:40 -07:00
James Hillyerd
939ff19991 policy: Tie mailbox extraction to Addressing for #33 2018-04-02 20:15:50 -07:00
James Hillyerd
c2e1d58b90 policy: Accept Root config instead of SMTP for #33 2018-04-02 19:43:05 -07:00
James Hillyerd
8c66a24513 config: lowercase log levels for #90 2018-04-01 20:50:18 -07:00
James Hillyerd
d1dbcf6e63 Merge branch 'feature/whitelist-51', closes #51 2018-04-01 20:02:56 -07:00
James Hillyerd
064549f576 policy: Implement store domain policy for #51
- Update docs, change log, status.html
2018-04-01 19:59:04 -07:00
James Hillyerd
a7d2b00a9c policy: Implement recipient domain policy for #51
- INBUCKET_SMTP_DEFAULTACCEPT
- INBUCKET_SMTP_ACCEPTDOMAINS
- INBUCKET_SMTP_REJECTDOMAINS
2018-04-01 18:05:21 -07:00
James Hillyerd
0b3c18eba9 Merge branch 'feature/read-58' for #58 2018-04-01 15:32:36 -07:00
James Hillyerd
c695a2690d ui: Mark messages as seen after 1.5s for #58
Embolden subject font for unseen messages.
2018-04-01 15:16:48 -07:00
James Hillyerd
dc02092cf6 rest: Implement MarkSeen for #58
- message: Add MarkSeen to Manager, StoreManager.
- rest: Add PATCH for /mailbox/name/id.
- rest: Add MailboxMarkSeenV1 handler.
- rest: Add Seen to model.
- rest: Update handlers to set Seen.
- rest: Add doJSONBody func.
2018-04-01 13:30:43 -07:00
James Hillyerd
cc5cd7f9c3 storage: Add Seen flag, tests for #58 2018-04-01 13:30:43 -07:00
James Hillyerd
e3be5362dc dev-start.sh: update TRACE to DEBUG 2018-04-01 13:30:01 -07:00
James Hillyerd
3fe4140733 pop3, smtp: embed Server struct into Session for #91 2018-03-31 17:09:30 -07:00
James Hillyerd
7b073562eb pop3: Use config.POP3 directly in server #91 2018-03-31 17:01:02 -07:00
James Hillyerd
2c813081eb smtp: Use config.SMTP directly in Server #91 2018-03-31 16:49:52 -07:00
James Hillyerd
acd48773da Merge branch 'feature/zerolog' into develop, closes #90 2018-03-31 16:17:04 -07:00
James Hillyerd
87bab63aa2 docker: Default to JSON log output for #90 2018-03-31 16:12:05 -07:00
James Hillyerd
47b526824b travis: Move to Go 1.10.x 2018-03-31 15:36:39 -07:00
James Hillyerd
5a28e9f9e7 config: Use log level name DEBUG instead of TRACE
Add log level parsing into openLog() for #90
2018-03-31 15:30:36 -07:00
James Hillyerd
deceb29377 inbucket: respect -logfile flag again for #90
Removed log file rotation, too racy, not needed in the world of docker
and systemd.
2018-03-31 15:16:22 -07:00
James Hillyerd
e076f80416 smtp: Use zerolog hooks for warns/errors expvars #90 2018-03-31 14:06:58 -07:00
James Hillyerd
92f2da5025 server: -netdebug flag now controls tracing for #90
Network trace is sent to stdout, no longer part of normal debug
logging.
2018-03-31 13:37:42 -07:00
James Hillyerd
cbdb96a421 log: package deleted for #90 2018-03-31 12:25:54 -07:00
James Hillyerd
6601d156be metric: new pkg refactored from log for #90 2018-03-31 12:16:54 -07:00
James Hillyerd
779b1e63af smtp, pop3: Use zerolog for session logging #90 2018-03-27 21:52:28 -07:00
James Hillyerd
6f25a1320e pop3, smtp: rename Session method receivers to s 2018-03-27 20:51:13 -07:00
James Hillyerd
e2ba10c8ca Replace pkg/log with zerolog for normal logging #90 2018-03-27 20:26:06 -07:00
James Hillyerd
64ecd810b4 Merge branch 'feature/distro-pkgs' into develop, closes #89 2018-03-25 20:19:59 -07:00
James Hillyerd
393a5b8d4e redhat: Use goreleaser to generate .rpm package for #89 2018-03-25 20:13:19 -07:00
James Hillyerd
0055b84916 debian: Use goreleaser to generate .deb package for #89 2018-03-25 19:34:51 -07:00
James Hillyerd
7ab9ea92ad Merge branch 'feature/envconfig' into develop 2018-03-25 17:05:52 -07:00
James Hillyerd
06989c8218 Update goreleaser config for #86
- Remove inbucket.bat, with new env defaults Windows does not need a
  script to launch.
2018-03-25 16:52:29 -07:00
James Hillyerd
23dc357202 etc: Add dev-start.sh script for #86 2018-03-25 16:16:06 -07:00
James Hillyerd
2d09e94f87 log: Fix another deadlock. 2018-03-25 16:08:34 -07:00
James Hillyerd
86c8ccf9ea docker: Update for environment config for #86
- Change to default ports (less surprising)
- Drop `/con/` volume naming, never caught on
2018-03-25 15:39:04 -07:00
James Hillyerd
ce2339ee9c conf: Delete obsolete config files for #86 2018-03-25 14:30:34 -07:00
James Hillyerd
69a0d355f9 doc: Add doc/config.md to document config for #86
- Increase default max message size to 10MB.
2018-03-25 14:12:18 -07:00
James Hillyerd
04bb842549 config: Combine TemplateDir and PublicDir into UIDir
- Define static names for `templates` and `static`
2018-03-25 11:55:23 -07:00
James Hillyerd
b50c926745 webui: Renamed themes dir to ui
- Eliminated intermediate bootstrap dir
2018-03-25 11:32:59 -07:00
James Hillyerd
0d6936d1b3 Merge branch 'feature/memstore' into develop 2018-03-24 21:07:25 -07:00
James Hillyerd
412b62d6fa storage/mem: implement size enforcer for #88 2018-03-24 20:27:05 -07:00
James Hillyerd
b42ea130ea storage/mem: implement message cap for #88
- Move message cap tests into storage test suite.
- Update change log.
2018-03-24 14:36:23 -07:00
James Hillyerd
281cc21412 storage: Make type/params configurable for #88 2018-03-24 13:18:51 -07:00
James Hillyerd
bb0fb410c1 mem: Initial in-memory store implementation for #88
- Reduce default retention sleep, change description.
2018-03-22 22:29:25 -07:00
James Hillyerd
3c7c24b698 storage: Calculate size of store for status page 2018-03-22 20:30:23 -07:00
James Hillyerd
f0a94f4848 More config cleanups for #86 2018-03-22 20:03:05 -07:00
James Hillyerd
845cbedc0d config: Replace robfig with envconfig for #86
- Initial envconfig system is working, not bulletproof.
- Added sane defaults for required parameters.
2018-03-21 22:50:21 -07:00
James Hillyerd
be940dd2bc rest: fix timezone in controller tests 2018-03-20 19:18:07 -07:00
James Hillyerd
e7a86bd8f8 Hide envelope, use Part.Content for #85 2018-03-20 17:55:43 -07:00
James Hillyerd
6d250a47b4 Merge giant storage/service layer refactor #69 #81 2018-03-18 15:24:21 -07:00
James Hillyerd
e5785e81aa Update CHANGELOG for refactor 2018-03-18 15:14:48 -07:00
James Hillyerd
30f5c163e4 log: Add locking to prevent race 2018-03-18 14:30:56 -07:00
James Hillyerd
0d0e07da70 file: Remove index and dir mutexes
HashLock makes these redundant.  #77
2018-03-18 13:58:47 -07:00
James Hillyerd
5cb07d5780 rest: Refactor JSON result value testing 2018-03-18 12:08:40 -07:00
James Hillyerd
30a329c0d3 Renames, closes #69
- storage: rename StoreMessage to Message
- storage: rename Message.RawReader() to Source()
2018-03-17 17:56:06 -07:00
James Hillyerd
f953bcf4bb smtp: Move delivery into message.Manager for #69 2018-03-17 17:04:12 -07:00
James Hillyerd
a22412f65e manager: Add MailboxForAddress(), calls policy pkg #84 2018-03-17 15:17:44 -07:00
James Hillyerd
dc4db59211 smtp: Don't require MIME headers for metadata
This was a regression, will again fall back to MAIL FROM/RCPT TO data.
2018-03-17 14:41:03 -07:00
James Hillyerd
e84b1f8952 storage: Make locking an implementation detail for #69
- file: Store handles its own locking #77
- file: Move mbox into its own file
- file & test: remove LockFor()
2018-03-17 14:02:50 -07:00
James Hillyerd
b9003a9328 smtp: Wire in policy.Recipient for #84 2018-03-17 12:39:09 -07:00
James Hillyerd
469a778d81 policy: Impl Addressing{} and Recipient{} for #84 2018-03-17 11:16:20 -07:00
James Hillyerd
d132efd6fa policy: Create new policy package for #84 2018-03-17 09:48:53 -07:00
James Hillyerd
9b3d3c2ea8 test: Finish initial storage test suite, closes #82 2018-03-16 22:43:32 -07:00
James Hillyerd
5e13e50763 test: Start work on test suite for #82
- smtp: Tidy up []byte/buffer/string use in delivery #69
2018-03-14 22:51:40 -07:00
James Hillyerd
519779b7ba storage: eliminate mocks, closes #80 2018-03-14 21:05:59 -07:00
James Hillyerd
2cc0da3093 storage: More refactoring for #69
- impl Store.AddMessage
- file: Use AddMessage() in tests
- smtp: Switch to AddMessage
- storage: Remove NewMessage, Append, Close methods
2018-03-14 20:37:20 -07:00
James Hillyerd
9be4eec31c storage: eliminate ReadBody, ReadRaw for #69 2018-03-12 21:23:13 -07:00
James Hillyerd
219862797e web: remove DataStore from Context and controllers for #81 2018-03-12 20:49:06 -07:00
James Hillyerd
10bc07a18e message: Implement service layer, stubs for #81
I've made some effort to wire the manager into the controllers, but
tests are currently failing.
2018-03-12 20:21:59 -07:00
James Hillyerd
3bc66d2788 storage: Store addresses as mail.Address for #69 2018-03-11 16:57:53 -07:00
James Hillyerd
487e491d6f storage: Message refactoring for #69
- Message interface renamed to StoreMessage
- Message.Delete becomes Store.RemoveMessage
- Added deleted message tracking to Store stub for #80
2018-03-11 15:01:40 -07:00
James Hillyerd
12ad0cb3f0 storage: Eliminate storage.Mailbox interface for #69
storage/file Mailbox has been renamed mbox, and is now just an
implementation detail.
2018-03-11 11:54:35 -07:00
James Hillyerd
137466f89b storage: Move NewMessage() into Store interface for #69 2018-03-11 10:48:50 -07:00
James Hillyerd
d9b5e40c87 storage: More refactoring for #69
- retention: Start from pkg main instead of server/smtp
- file: Remove DefaultStore() constructor
- storage: AllMailboxes replaced with VisitMailboxes for #69
- test: Stub VisitMailboxes for #80
2018-03-10 22:05:10 -08:00
James Hillyerd
9c18f1fb30 Large refactor for #69
- makefile: Don't refresh deps automatically, causes double build
- storage: Move GetMessage, GetMessages (Mailbox), PurgeMessages to the
  Store API for #69
- storage: Remove Mailbox.Name method for #69
- test: Create new test package for #79
- test: Implement StoreStub, migrate some tests off MockDataStore for
  task #80
- rest & webui: update controllers to use new Store methods
2018-03-10 19:34:51 -08:00
James Hillyerd
a58dfc5e4f storage: finish renaming storage packages for #79 #69
- storage: rename DataStore to Store
- file: rename types to appease linter
2018-03-10 14:00:21 -08:00
James Hillyerd
98d8288244 Merge branch 'feature/pkg' into develop, closes #79 2018-03-10 13:02:44 -08:00
James Hillyerd
1f56e06fb9 docker: fix build for #79
- Build with Go 1.10
- install.sh: git fetch fails with ssh remotes, removed
2018-03-10 12:45:56 -08:00
James Hillyerd
0016c6d5df readme: updated for reorg, #79
- noted Homebrew is broken
2018-03-10 12:15:05 -08:00
James Hillyerd
94167fa313 Resolve linter errors exposed by fixed Makefile
- TravisCI didn't like "POSIX" ::= syntax
2018-03-10 12:04:13 -08:00
James Hillyerd
f8c30a678a Reorganize packages, closes #79
- All packages go into either cmd or pkg directories
- Most packages renamed
- Server packages moved into pkg/server
- sanitize moved into webui, as that's the only place it's used
- filestore moved into pkg/storage/file
- Makefile updated, and PKG variable use fixed
2018-03-10 12:04:13 -08:00
James Hillyerd
68cfd33fbe Merge tag 'v1.3.1' into develop
See CHANGELOG.md
2018-03-10 10:14:08 -08:00
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
782 changed files with 7748 additions and 6472 deletions

9
.gitignore vendored
View File

@@ -26,11 +26,12 @@ _testmain.go
*.swo
# our binaries
/client
/client.exe
/inbucket
/inbucket.exe
/target/**
/dist/**
/cmd/client/client
/cmd/client/client.exe
# local goxc config
.goxc.local.json
/cmd/inbucket/inbucket
/cmd/inbucket/inbucket.exe

80
.goreleaser.yml Normal file
View File

@@ -0,0 +1,80 @@
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: ./cmd/inbucket
ldflags: -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}
- binary: inbucket-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*
- etc/**/*
- ui/**/*
nfpm:
vendor: inbucket.org
homepage: https://www.inbucket.org/
maintainer: github@hillyerd.com
description: All-in-one disposable webmail service.
license: MIT
formats:
- deb
- rpm
files:
"ui/**/*": "/usr/local/share/inbucket/ui"
config_files:
"etc/linux/inbucket.service": "/lib/systemd/system/inbucket.service"
"ui/greeting.html": "/etc/inbucket/greeting.html"
snapshot:
name_template: SNAPSHOT-{{ .Commit }}
checksum:
name_template: '{{ .ProjectName }}_{{ .Version }}_checksums.txt'
dist: dist
sign:
artifacts: none

View File

@@ -1,18 +0,0 @@
{
"ArtifactsDest": "target",
"TasksExclude": [
"pkg-build"
],
"Arch": "amd64",
"Os": "darwin freebsd linux windows",
"ResourcesInclude": "README*,LICENSE*,CHANGELOG*,inbucket.bat,etc,themes",
"PackageVersion": "1.2.0",
"PrereleaseInfo": "rc2",
"ConfigVersion": "0.9",
"BuildSettings": {
"LdFlagsXVars": {
"TimeNow": "main.BUILDDATE",
"Version": "main.VERSION"
}
}
}

View File

@@ -1,11 +1,24 @@
language: go
sudo: false
addons:
apt:
packages:
- rpm
env:
- DEPLOY_WITH_MAJOR="1.10"
before_script:
- go vet ./...
- go get github.com/golang/lint/golint
- make deps
go:
- 1.8.5
- 1.9.2
- "1.10.1"
script: go test -race -v ./...
deploy:
provider: script
script: etc/travis-deploy.sh
on:
tags: true
branch: master

View File

@@ -4,8 +4,76 @@ Change Log
All notable changes to this project will be documented in this file.
This project adheres to [Semantic Versioning](http://semver.org/).
[1.2.0-rc2] - 2017-12-15
------------------------
## [v2.0.0] - 2018-05-05
### Changed
- Corrected docs for INBUCKET_STORAGE_PARAMS (thanks evilmrburns.)
- Disabled color log output on Windows, doesn't work there.
## [v2.0.0-rc1] - 2018-04-07
### Added
- Inbucket is now configured using environment variables instead of a config
file.
- In-memory storage option, best for small installations and desktops. Will be
used by default.
- Storage type is now displayed on Status page.
- Store size is now calculated during retention scan and displayed on the Status
page.
- Debian `.deb` package generation to release process.
- RedHat `.rpm` package generation to release process.
- Message seen flag in REST and Web UI so you can see which messages have
already been read.
- Recipient domain accept policy; Inbucket can now reject mail to specific
domains.
- Configurable support for identifying a mailbox by full email address instead
of just the local part (username).
- Friendly URL support: `<inbucket-url>/<mailbox>` will redirect your browser to
that mailbox.
### Changed
- Massive refactor of back-end code. Inbucket should now be both easier and
more enjoyable to work on.
- Changes to file storage format, will require pre-2.0 mail store directories to
be deleted.
- Renamed `themes` directory to `ui` and eliminated the intermediate `bootstrap`
directory.
- Docker build:
- Uses the same default ports as other builds; smtp:2500 http:9000 pop3:1100
- Uses volume `/config` for `greeting.html`
- Uses volume `/storage` for mail storage
- Log output is now structured, and will be output as JSON with the `-logjson`
flag; which is enabled by default for the Docker container.
- SMTP and POP3 network tracing is no longer logged regardless of level, but can
be sent to stdout via `-netdebug` flag.
- Replaced store/nostore config variables with a storage policy that mirrors the
domain accept policy.
### Removed
- No longer support SIGHUP or log file rotation.
## [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;
@@ -20,8 +88,8 @@ This project adheres to [Semantic Versioning](http://semver.org/).
types
- Fixed panic when `monitor.history` set to 0
[1.2.0-rc1] - 2017-01-29
------------------------
## [v1.2.0-rc1] - 2017-01-29
### Added
- Storage of `To:` header in messages (likely breaks existing datastores)
@@ -47,8 +115,8 @@ This project adheres to [Semantic Versioning](http://semver.org/).
- Allow increased local-part length of 128 chars for Mailgun
- RedHat and Ubuntu now use systemd instead of legacy init systems
[1.1.0] - 2016-09-03
--------------------
## [v1.1.0] - 2016-09-03
### Added
- Homebrew inbucket.conf and formula (see README)
@@ -56,8 +124,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
@@ -67,8 +135,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
@@ -82,8 +150,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
@@ -91,29 +159,32 @@ This project adheres to [Semantic Versioning](http://semver.org/).
- Add Link button to messages, allows for directing another person to a
specific message.
[Unreleased]: https://github.com/jhillyerd/inbucket/compare/master...develop
[1.2.0-rc2]: https://github.com/jhillyerd/inbucket/compare/1.2.0-rc1...1.2.0-rc2
[1.2.0-rc1]: https://github.com/jhillyerd/inbucket/compare/1.1.0...1.2.0-rc1
[1.1.0]: https://github.com/jhillyerd/inbucket/compare/1.1.0-rc2...1.1.0
[1.1.0-rc2]: https://github.com/jhillyerd/inbucket/compare/1.1.0-rc1...1.1.0-rc2
[1.1.0-rc1]: https://github.com/jhillyerd/inbucket/compare/1.0...1.1.0-rc1
[1.0]: https://github.com/jhillyerd/inbucket/compare/1.0-rc1...1.0
[Unreleased]: https://github.com/jhillyerd/inbucket/compare/master...develop
[v2.0.0]: https://github.com/jhillyerd/inbucket/compare/v2.0.0-rc1...v2.0.0
[v2.0.0-rc1]: https://github.com/jhillyerd/inbucket/compare/v1.3.1...v2.0.0-rc1
[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=rc1`
4. Run: `goxc interpolate-source` to update VERSION var
5. Run tests
6. Test cross-compile: `goxc`
7. Commit changes and merge release: `git flow release finish 1.x.0`
8. Upload to bintray: `goxc bintray`
9. Update `binary_versions` option in `inbucket-site/_config.yml`
- Ensure *Unreleased* section is up to date
- Rename *Unreleased* section to release name and date.
- Add new GitHub `/compare` link
3. Run tests
4. Test cross-compile: `goreleaser --snapshot`
5. Commit changes and merge release: `git flow release finish`
6. Push tags and wait for https://travis-ci.org/jhillyerd/inbucket build to
complete
7. Update `binary_versions` option in `inbucket-site/_config.yml`
See http://keepachangelog.com/ for additional instructions on how to update this file.

View File

@@ -1,25 +1,43 @@
# Docker build file for Inbucket, see https://www.docker.io/
# Inbucket website: http://www.inbucket.org/
# Docker build file for Inbucket: https://www.inbucket.org/
FROM golang:1.9-alpine
MAINTAINER James Hillyerd, @jameshillyerd
# Build
FROM golang:1.10-alpine as builder
RUN apk add --no-cache --virtual .build-deps git make
WORKDIR /go/src/github.com/jhillyerd/inbucket
COPY . .
ENV CGO_ENABLED 0
RUN make clean deps
RUN go build -o inbucket \
-ldflags "-X 'main.version=$(git describe --tags --always)' -X 'main.date=$(date -Iseconds)'" \
-v ./cmd/inbucket
# Configuration (WORKDIR doesn't support env vars)
ENV INBUCKET_SRC $GOPATH/src/github.com/jhillyerd/inbucket
ENV INBUCKET_HOME /opt/inbucket
WORKDIR $INBUCKET_HOME
ENTRYPOINT ["/con/context/start-inbucket.sh"]
CMD ["/con/configuration/inbucket.conf"]
# Run in minimal image
FROM alpine:3.7
ENV SRC /go/src/github.com/jhillyerd/inbucket
WORKDIR /opt/inbucket
RUN mkdir bin defaults ui
COPY --from=builder $SRC/inbucket bin
COPY etc/docker/defaults/greeting.html defaults
COPY ui ui
COPY etc/docker/defaults/start-inbucket.sh /
# Configuration
ENV INBUCKET_SMTP_DISCARDDOMAINS bitbucket.local
ENV INBUCKET_SMTP_TIMEOUT 30s
ENV INBUCKET_POP3_TIMEOUT 30s
ENV INBUCKET_WEB_GREETINGFILE /config/greeting.html
ENV INBUCKET_WEB_COOKIEAUTHKEY secret-inbucket-session-cookie-key
ENV INBUCKET_STORAGE_TYPE file
ENV INBUCKET_STORAGE_PARAMS path:/storage
ENV INBUCKET_STORAGE_RETENTIONPERIOD 72h
ENV INBUCKET_STORAGE_MAILBOXMSGCAP 300
# Ports: SMTP, HTTP, POP3
EXPOSE 10025 10080 10110
EXPOSE 2500 9000 1100
# Persistent Volumes, following convention at:
# https://github.com/docker/docker/issues/9277
# NOTE /con/context is also used, not exposed by default
VOLUME /con/configuration
VOLUME /con/data
# Persistent Volumes
VOLUME /config
VOLUME /storage
# Build Inbucket
COPY . $INBUCKET_SRC/
RUN "$INBUCKET_SRC/etc/docker/install.sh"
ENTRYPOINT ["/start-inbucket.sh"]
CMD ["-logjson"]

40
Makefile Normal file
View File

@@ -0,0 +1,40 @@
SHELL = /bin/sh
SRC := $(shell find . -type f -name '*.go' -not -path "./vendor/*")
PKGS := $(shell go list ./... | grep -v /vendor/)
.PHONY: all build clean fmt lint reflex simplify test
commands = client inbucket
all: clean test lint build
$(commands): %: cmd/%
go build ./$<
clean:
go clean $(PKGS)
rm -f $(commands)
rm -rf dist
deps:
go get -t ./...
build: $(commands)
test:
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)
reflex:
reflex -r '\.go$$' -- sh -c 'echo; date; echo; go test ./... && echo ALL PASS'

View File

@@ -21,6 +21,9 @@ to contribute code to the project check out [CONTRIBUTING.md].
## Homebrew Tap
(currently broken, being tracked in [issue
#68](https://github.com/jhillyerd/inbucket/issues/68))
Inbucket has an OS X [Homebrew] tap available as [jhillyerd/inbucket][Homebrew Tap],
see the `README.md` there for installation instructions.
@@ -31,7 +34,7 @@ You will need a functioning [Go installation][Google Go] for this to work.
Grab the Inbucket source code and compile the daemon:
go get -v github.com/jhillyerd/inbucket
go get -v github.com/jhillyerd/inbucket/cmd/inbucket
Edit etc/inbucket.conf and tailor to your environment. It should work on most
Unix and OS X machines as is. Launch the daemon:

View File

@@ -6,7 +6,7 @@ import (
"fmt"
"github.com/google/subcommands"
"github.com/jhillyerd/inbucket/rest/client"
"github.com/jhillyerd/inbucket/pkg/rest/client"
)
type listCmd struct {

View File

@@ -10,7 +10,7 @@ import (
"time"
"github.com/google/subcommands"
"github.com/jhillyerd/inbucket/rest/client"
"github.com/jhillyerd/inbucket/pkg/rest/client"
)
type matchCmd struct {

View File

@@ -7,7 +7,7 @@ import (
"os"
"github.com/google/subcommands"
"github.com/jhillyerd/inbucket/rest/client"
"github.com/jhillyerd/inbucket/pkg/rest/client"
)
type mboxCmd struct {

231
cmd/inbucket/main.go Normal file
View File

@@ -0,0 +1,231 @@
// main is the inbucket daemon launcher
package main
import (
"bufio"
"context"
"expvar"
"flag"
"fmt"
"io"
"os"
"os/signal"
"runtime"
"syscall"
"time"
"github.com/jhillyerd/inbucket/pkg/config"
"github.com/jhillyerd/inbucket/pkg/message"
"github.com/jhillyerd/inbucket/pkg/msghub"
"github.com/jhillyerd/inbucket/pkg/policy"
"github.com/jhillyerd/inbucket/pkg/rest"
"github.com/jhillyerd/inbucket/pkg/server/pop3"
"github.com/jhillyerd/inbucket/pkg/server/smtp"
"github.com/jhillyerd/inbucket/pkg/server/web"
"github.com/jhillyerd/inbucket/pkg/storage"
"github.com/jhillyerd/inbucket/pkg/storage/file"
"github.com/jhillyerd/inbucket/pkg/storage/mem"
"github.com/jhillyerd/inbucket/pkg/webui"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
var (
// version contains the build version number, populated during linking.
version = "undefined"
// date contains the build date, populated during linking.
date = "undefined"
)
func init() {
// 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()
}))
// Register storage implementations.
storage.Constructors["file"] = file.New
storage.Constructors["memory"] = mem.New
}
func main() {
// Command line flags.
help := flag.Bool("help", false, "Displays help on flags and env variables.")
pidfile := flag.String("pidfile", "", "Write our PID into the specified file.")
logfile := flag.String("logfile", "stderr", "Write out log into the specified file.")
logjson := flag.Bool("logjson", false, "Logs are written in JSON format.")
netdebug := flag.Bool("netdebug", false, "Dump SMTP & POP3 network traffic to stdout.")
flag.Usage = func() {
fmt.Fprintln(os.Stderr, "Usage: inbucket [options]")
flag.PrintDefaults()
}
flag.Parse()
if *help {
flag.Usage()
fmt.Fprintln(os.Stderr, "")
config.Usage()
return
}
// Process configuration.
config.Version = version
config.BuildDate = date
conf, err := config.Process()
if err != nil {
fmt.Fprintf(os.Stderr, "Configuration error: %v\n", err)
os.Exit(1)
}
if *netdebug {
conf.POP3.Debug = true
conf.SMTP.Debug = true
}
// Logger setup.
closeLog, err := openLog(conf.LogLevel, *logfile, *logjson)
if err != nil {
fmt.Fprintf(os.Stderr, "Log error: %v\n", err)
os.Exit(1)
}
startupLog := log.With().Str("phase", "startup").Logger()
// Setup signal handler.
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGTERM, syscall.SIGINT)
// Initialize logging.
startupLog.Info().Str("version", config.Version).Str("buildDate", config.BuildDate).
Msg("Inbucket starting")
// Write pidfile if requested.
if *pidfile != "" {
pidf, err := os.Create(*pidfile)
if err != nil {
startupLog.Fatal().Err(err).Str("path", *pidfile).Msg("Failed to create pidfile")
}
fmt.Fprintf(pidf, "%v\n", os.Getpid())
if err := pidf.Close(); err != nil {
startupLog.Fatal().Err(err).Str("path", *pidfile).Msg("Failed to close pidfile")
}
}
// Configure internal services.
rootCtx, rootCancel := context.WithCancel(context.Background())
shutdownChan := make(chan bool)
store, err := storage.FromConfig(conf.Storage)
if err != nil {
removePIDFile(*pidfile)
startupLog.Fatal().Err(err).Str("module", "storage").Msg("Fatal storage error")
}
msgHub := msghub.New(rootCtx, conf.Web.MonitorHistory)
addrPolicy := &policy.Addressing{Config: conf}
mmanager := &message.StoreManager{AddrPolicy: addrPolicy, Store: store, Hub: msgHub}
// Start Retention scanner.
retentionScanner := storage.NewRetentionScanner(conf.Storage, store, shutdownChan)
retentionScanner.Start()
// Start HTTP server.
web.Initialize(conf, shutdownChan, mmanager, msgHub)
rest.SetupRoutes(web.Router)
webui.SetupRoutes(web.Router)
go web.Start(rootCtx)
// Start POP3 server.
pop3Server := pop3.New(conf.POP3, shutdownChan, store)
go pop3Server.Start(rootCtx)
// Start SMTP server.
smtpServer := smtp.NewServer(conf.SMTP, shutdownChan, mmanager, addrPolicy)
go smtpServer.Start(rootCtx)
// Loop forever waiting for signals or shutdown channel.
signalLoop:
for {
select {
case sig := <-sigChan:
switch sig {
case syscall.SIGINT:
// Shutdown requested
log.Info().Str("phase", "shutdown").Str("signal", "SIGINT").
Msg("Received SIGINT, shutting down")
close(shutdownChan)
case syscall.SIGTERM:
// Shutdown requested
log.Info().Str("phase", "shutdown").Str("signal", "SIGTERM").
Msg("Received SIGTERM, shutting down")
close(shutdownChan)
}
case <-shutdownChan:
rootCancel()
break signalLoop
}
}
// Wait for active connections to finish.
go timedExit(*pidfile)
smtpServer.Drain()
pop3Server.Drain()
retentionScanner.Join()
removePIDFile(*pidfile)
closeLog()
}
// openLog configures zerolog output, returns func to close logfile.
func openLog(level string, logfile string, json bool) (close func(), err error) {
switch level {
case "debug":
zerolog.SetGlobalLevel(zerolog.DebugLevel)
case "info":
zerolog.SetGlobalLevel(zerolog.InfoLevel)
case "warn":
zerolog.SetGlobalLevel(zerolog.WarnLevel)
case "error":
zerolog.SetGlobalLevel(zerolog.ErrorLevel)
default:
return nil, fmt.Errorf("Log level %q not one of: debug, info, warn, error", level)
}
close = func() {}
var w io.Writer
color := runtime.GOOS != "windows"
switch logfile {
case "stderr":
w = os.Stderr
case "stdout":
w = os.Stdout
default:
logf, err := os.OpenFile(logfile, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0666)
if err != nil {
return nil, err
}
bw := bufio.NewWriter(logf)
w = bw
color = false
close = func() {
_ = bw.Flush()
_ = logf.Close()
}
}
w = zerolog.SyncWriter(w)
if json {
log.Logger = log.Output(w)
return close, nil
}
log.Logger = log.Output(zerolog.ConsoleWriter{
Out: w,
NoColor: !color,
})
return close, nil
}
// removePIDFile removes the PID file if created.
func removePIDFile(pidfile string) {
if pidfile != "" {
if err := os.Remove(pidfile); err != nil {
log.Error().Str("phase", "shutdown").Err(err).Str("path", pidfile).
Msg("Failed to remove pidfile")
}
}
}
// timedExit is called as a goroutine during shutdown, it will force an exit after 15 seconds.
func timedExit(pidfile string) {
time.Sleep(15 * time.Second)
removePIDFile(pidfile)
log.Error().Str("phase", "shutdown").Msg("Clean shutdown took too long, forcing exit")
os.Exit(0)
}

View File

@@ -1,270 +0,0 @@
package config
import (
"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.
type SMTPConfig struct {
IP4address net.IP
IP4port int
Domain string
DomainNoStore string
MaxRecipients int
MaxIdleSeconds int
MaxMessageBytes int
StoreMessages bool
}
// POP3Config contains the POP3 server configuration
type POP3Config struct {
IP4address net.IP
IP4port int
Domain string
MaxIdleSeconds int
}
// WebConfig contains the HTTP server configuration
type WebConfig struct {
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
type DataStoreConfig struct {
Path string
RetentionMinutes int
RetentionSleep int
MailboxMsgCap int
}
const (
missingErrorFmt = "[%v] missing required option %q"
parseErrorFmt = "[%v] option %q error: %v"
)
var (
// Version of this build, set by main
Version = ""
// BuildDate for this build, set by main
BuildDate = ""
// Config is our global robfig/config object
Config *config.Config
logLevel string
// Parsed specific configs
smtpConfig = &SMTPConfig{}
pop3Config = &POP3Config{}
webConfig = &WebConfig{}
dataStoreConfig = &DataStoreConfig{}
)
// GetSMTPConfig returns a copy of the SmtpConfig object
func GetSMTPConfig() SMTPConfig {
return *smtpConfig
}
// GetPOP3Config returns a copy of the Pop3Config object
func GetPOP3Config() POP3Config {
return *pop3Config
}
// GetWebConfig returns a copy of the WebConfig object
func GetWebConfig() WebConfig {
return *webConfig
}
// GetDataStoreConfig returns a copy of the DataStoreConfig object
func GetDataStoreConfig() DataStoreConfig {
return *dataStoreConfig
}
// 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
}
// Validation error messages
messages := make([]string, 0)
// Validate sections
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 _, m := range messages {
fmt.Fprintln(os.Stderr, " -", m)
}
return fmt.Errorf("Failed to validate configuration")
}
// 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
}
if str == "" && opt.required {
messages = append(messages, fmt.Sprintf(missingErrorFmt, opt.section, opt.name))
}
*opt.target = str
}
// 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},
}
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))
}
}
}
// 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},
}
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))
}
}
}
// 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},
}
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:
messages = append(messages,
fmt.Sprintf("Invalid value provided for [logging]level: %q", logLevel))
}
// 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)
}
return fmt.Errorf("Failed to validate configuration")
}
return nil
}

424
doc/config.md Normal file
View File

@@ -0,0 +1,424 @@
# Inbucket Configuration
Inbucket is configured via environment variables. Most options have a
reasonable default, but it is likely you will need to change some to suite your
desired use cases.
Running `inbucket -help` will yield a condensed summary of the environment
variables it supports:
KEY DEFAULT DESCRIPTION
INBUCKET_LOGLEVEL info debug, info, warn, or error
INBUCKET_MAILBOXNAMING local Use local or full addressing
INBUCKET_SMTP_ADDR 0.0.0.0:2500 SMTP server IP4 host:port
INBUCKET_SMTP_DOMAIN inbucket HELO domain
INBUCKET_SMTP_MAXRECIPIENTS 200 Maximum RCPT TO per message
INBUCKET_SMTP_MAXMESSAGEBYTES 10240000 Maximum message size
INBUCKET_SMTP_DEFAULTACCEPT true Accept all mail by default?
INBUCKET_SMTP_ACCEPTDOMAINS Domains to accept mail for
INBUCKET_SMTP_REJECTDOMAINS Domains to reject mail for
INBUCKET_SMTP_DEFAULTSTORE true Store all mail by default?
INBUCKET_SMTP_STOREDOMAINS Domains to store mail for
INBUCKET_SMTP_DISCARDDOMAINS Domains to discard mail for
INBUCKET_SMTP_TIMEOUT 300s Idle network timeout
INBUCKET_POP3_ADDR 0.0.0.0:1100 POP3 server IP4 host:port
INBUCKET_POP3_DOMAIN inbucket HELLO domain
INBUCKET_POP3_TIMEOUT 600s Idle network timeout
INBUCKET_WEB_ADDR 0.0.0.0:9000 Web server IP4 host:port
INBUCKET_WEB_UIDIR ui User interface dir
INBUCKET_WEB_GREETINGFILE ui/greeting.html Home page greeting HTML
INBUCKET_WEB_TEMPLATECACHE true Cache templates after first use?
INBUCKET_WEB_MAILBOXPROMPT @inbucket Prompt next to mailbox input
INBUCKET_WEB_COOKIEAUTHKEY Session cipher key (text)
INBUCKET_WEB_MONITORVISIBLE true Show monitor tab in UI?
INBUCKET_WEB_MONITORHISTORY 30 Monitor remembered messages
INBUCKET_STORAGE_TYPE memory Storage impl: file or memory
INBUCKET_STORAGE_PARAMS Storage impl parameters, see docs.
INBUCKET_STORAGE_RETENTIONPERIOD 24h Duration to retain messages
INBUCKET_STORAGE_RETENTIONSLEEP 50ms Duration to sleep between mailboxes
INBUCKET_STORAGE_MAILBOXMSGCAP 500 Maximum messages per mailbox
The following documentation will describe each of these in more detail.
## Global
### Log Level
`INBUCKET_LOGLEVEL`
This setting controls the verbosity of log output. A small desktop installation
should probably select `info`, but a busy shared installation would be better
off with `warn` or `error`.
- Default: `info`
- Values: one of `debug`, `info`, `warn`, or `error`
### Mailbox Naming
`INBUCKET_MAILBOXNAMING`
The mailbox naming setting determines the name of a mailbox for an incoming
message, and thus where it must be retrieved from later.
#### `local` ensures the domain is removed, such that:
- `james@inbucket.org` is stored in `james`
- `james+spam@inbucket.org` is stored in `james`
#### `full` retains the domain as part of the name, such that:
- `james@inbucket.org` is stored in `james@inbucket.org`
- `james+spam@inbucket.org` is stored in `james@inbucket.org`
Prior to the addition of the mailbox naming setting, Inbucket always operated in
local mode. Regardless of this setting, the `+` wildcard/extension is not
incorporated into the mailbox name.
- Default: `local`
- Values: one of `local` or `full`
## SMTP
### Address and Port
`INBUCKET_SMTP_ADDR`
The IPv4 address and TCP port number the SMTP server should listen on, separated
by a colon. Some operating systems may prevent Inbucket from listening on port
25 without escalated privileges. Using an IP address of 0.0.0.0 will cause
Inbucket to listen on all available network interfaces.
- Default: `0.0.0.0:2500`
### Greeting Domain
`INBUCKET_SMTP_DOMAIN`
The domain used in the SMTP greeting:
220 domain Inbucket SMTP ready
Most SMTP clients appear to ignore this value.
- Default: `inbucket`
### Maximum Recipients
`INBUCKET_SMTP_MAXRECIPIENTS`
Maximum number of recipients allowed (SMTP `RCPT TO` phase). If you are testing
a mailing list server, you may need to increase this value. For comparison, the
Postfix SMTP server uses a default of 1000, it would be unwise to exceed this.
- Default: `200`
### Maximum Message Size
`INBUCKET_SMTP_MAXMESSAGEBYTES`
Maximum allowable size of a message (including headers) in bytes. Messages
exceeding this size will be rejected during the SMTP `DATA` phase.
- Default: `10240000` (10MB)
### Default Recipient Accept Policy
`INBUCKET_SMTP_DEFAULTACCEPT`
If true, Inbucket will accept mail to any domain unless present in the reject
domains list. If false, recipients will be rejected unless their domain is
present in the accept domains list.
- Default: `true`
- Values: `true` or `false`
### Accepted Recipient Domain List
`INBUCKET_SMTP_ACCEPTDOMAINS`
List of domains to accept mail for when `INBUCKET_SMTP_DEFAULTACCEPT` is false;
has no effect when true.
- Default: None
- Values: Comma separated list of domains
- Example: `localhost,mysite.org`
### Rejected Recipient Domain List
`INBUCKET_SMTP_REJECTDOMAINS`
List of domains to reject mail for when `INBUCKET_SMTP_DEFAULTACCEPT` is true;
has no effect when false.
- Default: None
- Values: Comma separated list of domains
- Example: `reject.com,gmail.com`
### Default Recipient Store Policy
`INBUCKET_SMTP_DEFAULTSTORE`
If true, Inbucket will store mail sent to any domain unless present in the
discard domains list. If false, messages will be discarded unless their domain
is present in the store domains list.
- Default: `true`
- Values: `true` or `false`
### Stored Recipient Domain List
`INBUCKET_SMTP_STOREDOMAINS`
List of domains to store mail for when `INBUCKET_SMTP_DEFAULTSTORE` is false;
has no effect when true.
- Default: None
- Values: Comma separated list of domains
- Example: `localhost,mysite.org`
### Discarded Recipient Domain List
`INBUCKET_SMTP_DISCARDDOMAINS`
Mail sent to these domains will not be stored by Inbucket. This is helpful if
you are load or soak testing a service, and do not plan to inspect the resulting
emails. Messages sent to a domain other than this will be stored normally.
Only has an effect when `INBUCKET_SMTP_DEFAULTSTORE` is true.
- Default: None
- Values: Comma separated list of domains
- Example: `recycle.com,loadtest.org`
### Network Idle Timeout
`INBUCKET_SMTP_TIMEOUT`
Delay before closing an idle SMTP connection. The SMTP RFC recommends 300
seconds. Consider reducing this *significantly* if you plan to expose Inbucket
to the public internet.
- Default: `300s`
- Values: Duration ending in `s` for seconds, `m` for minutes
## POP3
### Address and Port
`INBUCKET_POP3_ADDR`
The IPv4 address and TCP port number the POP3 server should listen on, separated
by a colon. Some operating systems may prevent Inbucket from listening on port
110 without escalated privileges. Using an IP address of 0.0.0.0 will cause
Inbucket to listen on all available network interfaces.
- Default: `0.0.0.0:1100`
### Greeting Domain
`INBUCKET_POP3_DOMAIN`
The domain used in the POP3 greeting:
+OK Inbucket POP3 server ready <26641.1522000423@domain>
Most POP3 clients appear to ignore this value.
- Default: `inbucket`
### Network Idle Timeout
`INBUCKET_POP3_TIMEOUT`
Delay before closing an idle POP3 connection. The POP3 RFC recommends 600
seconds. Consider reducing this *significantly* if you plan to expose Inbucket
to the public internet.
- Default: `600s`
- Values: Duration ending in `s` for seconds, `m` for minutes
## Web
### Address and Port
`INBUCKET_WEB_ADDR`
The IPv4 address and TCP port number the HTTP server should listen on, separated
by a colon. Some operating systems may prevent Inbucket from listening on port
80 without escalated privileges. Using an IP address of 0.0.0.0 will cause
Inbucket to listen on all available network interfaces.
- Default: `0.0.0.0:9000`
### UI Directory
`INBUCKET_WEB_UIDIR`
This directory contains the templates and static assets for the web user
interface. You will need to change this if the current working directory
doesn't contain the `ui` directory at startup.
Inbucket will load templates from the `templates` sub-directory, and serve
static assets from the `static` sub-directory.
- Default: `ui`
- Values: Operating system specific path syntax
### Greeting HTML File
`INBUCKET_WEB_GREETINGFILE`
The content of the greeting file will be injected into the front page of
Inbucket. It can be used to instruct users on how to send mail into your
Inbucket installation, as well as link to REST documentation, etc.
- Default: `ui/greeting.html`
### Template Caching
`INBUCKET_WEB_TEMPLATECACHE`
Tells Inbucket to cache parsed template files. This should be left as default
unless you are a developer working on the Inbucket web interface.
- Default: `true`
- Values: `true` or `false`
### Mailbox Prompt
`INBUCKET_WEB_MAILBOXPROMPT`
Text prompt displayed to the right of the mailbox name input field in the web
interface. Can be used to nudge your users into typing just the mailbox name
instead of an entire email address.
Set to an empty string to hide the prompt.
- Default: `@inbucket`
### Cookie Authentication Key
`INBUCKET_WEB_COOKIEAUTHKEY`
Inbucket stores session information in an encrypted browser cookie. Unless
specified, Inbucket generates a random key at startup. The only notable data
stored in a user session is the list of recently accessed mailboxes.
- Default: None
- Value: Text string, no particular format required
### Monitor Visible
`INBUCKET_WEB_MONITORVISIBLE`
If true, the Monitor tab will be available, allowing users to observe all
messages received by Inbucket as they arrive. Disabling the monitor facilitates
security through obscurity.
This setting has no impact on the availability of the underlying WebSocket,
which may be used by other parts of the Inbucket interface or continuous
integration tests.
- Default: `true`
- Values: `true` or `false`
### Monitor History
`INBUCKET_WEB_MONITORHISTORY`
The number of messages to remember on the *server* for new Monitor clients.
Does not impact the amount of *new* messages displayed by the Monitor.
Increasing this has no appreciable impact on memory use, but may slow down the
Monitor user interface.
This setting has the same effect on the amount of messages available via
WebSocket.
Setting to 0 will disable the monitor, but will probably break new mail
notifications in the web interface when I finally get around to implementing
them.
- Default: `30`
- Values: Integer greater than or equal to 0
## Storage
### Type
`INBUCKET_STORAGE_TYPE`
Selects the storage implementation to use. Currently Inbucket supports two:
- `file`: stores messages as individual files in a nested directory structure
based on the hash of the mailbox name. Each mailbox also includes an index
file to speed up enumeration of the mailbox contents.
- `memory`: stores messages in RAM, they will be lost if Inbucket is restarted,
or crashes, etc.
File storage is recommended for larger/shared installations. Memory is better
suited to desktop or continuous integration test use cases.
- Default: `memory`
- Values: `file` or `memory`
### Parameters
`INBUCKET_STORAGE_PARAMS`
Parameters specific to the storage type selected. Formatted as a comma
separated list of key:value pairs.
- Default: None
- Examples: `maxkb:10240` or `path:/tmp/inbucket`
#### `file` type parameters
- `path`: Operating system specific path to the directory where mail should be
stored.
#### `memory` type parameters
- `maxkb`: Maximum size of the mail store in kilobytes. The oldest messages in
the store will be deleted to enforce the limit. In-memory storage has some
overhead, for now it is recommended to set this to half the total amount of
memory you are willing to allocate to Inbucket.
### Retention Period
`INBUCKET_STORAGE_RETENTIONPERIOD`
If set, Inbucket will scan the contents of its mail store once per minute,
removing messages older than this. This will be enforced regardless of the type
of storage configured.
- Default: `24h`
- Values: Duration ending in `m` for minutes, `h` for hours. Should be
significantly longer than one minute, or `0` to disable.
### Retention Sleep
`INBUCKET_STORAGE_RETENTIONSLEEP`
Duration to sleep between scanning each mailbox for expired messages.
Increasing this number will reduce disk thrashing, but extend the length of time
required to complete a scan of the entire mail store.
This delay is still enforced for memory stores, but could be reduced from the
default. Setting to `0` may degrade performance of HTTP/SMTP/POP3 services.
- Default: `50ms`
- Values: Duration ending in `ms` for milliseconds, `s` for seconds
### Per Mailbox Message Cap
`INBUCKET_STORAGE_MAILBOXMSGCAP`
Maximum messages allowed in a single mailbox, exceeding this will cause older
messages to be deleted from the mailbox.
- Default: `500`
- Values: Positive integer, or `0` to disable

19
etc/dev-start.sh Executable file
View File

@@ -0,0 +1,19 @@
#!/bin/sh
# dev-start.sh
# description: Developer friendly Inbucket configuration
export INBUCKET_LOGLEVEL="debug"
export INBUCKET_SMTP_DISCARDDOMAINS="bitbucket.local"
export INBUCKET_WEB_TEMPLATECACHE="false"
export INBUCKET_WEB_COOKIEAUTHKEY="not-secret"
export INBUCKET_STORAGE_TYPE="file"
export INBUCKET_STORAGE_PARAMS="path:/tmp/inbucket"
export INBUCKET_STORAGE_RETENTIONPERIOD="15m"
if ! test -x ./inbucket; then
echo "$PWD/inbucket not found/executable!" >&2
echo "Run this script from the inbucket root directory after running make" >&2
exit 1
fi
exec ./inbucket $*

View File

@@ -1,129 +0,0 @@
# devel.conf
# Sample development configuration
#############################################################################
[DEFAULT]
# Not used directly, but is typically referenced below in %()s format.
install.dir=.
default.domain=inbucket.local
#############################################################################
[logging]
# Options from least to most verbose: ERROR, WARN, INFO, TRACE
level=TRACE
#############################################################################
[smtp]
# IPv4 address to listen for SMTP connections on.
ip4.address=0.0.0.0
# IPv4 port to listen for SMTP connections on.
ip4.port=2500
# used in SMTP greeting
domain=%(default.domain)s
# optional: mail sent to accounts at this domain will not be stored,
# for mixed use (content and load testing)
domain.nostore=bitbucket.local
# Maximum number of RCPT TO: addresses we allow from clients, the SMTP
# RFC recommends this be at least 100.
max.recipients=100
# How long we allow a network connection to be idle before hanging up on the
# client, SMTP RFC recommends at least 5 minutes (300 seconds).
max.idle.seconds=30
# Maximum allowable size of message body in bytes (including attachments)
max.message.bytes=20480000
# Should we place messages into the datastore, or just throw them away
# (for load testing): true or false
store.messages=true
#############################################################################
[pop3]
# IPv4 address to listen for POP3 connections on.
ip4.address=0.0.0.0
# IPv4 port to listen for POP3 connections on.
ip4.port=1100
# used in POP3 greeting
domain=%(default.domain)s
# How long we allow a network connection to be idle before hanging up on the
# client, POP3 RFC requires at least 10 minutes (600 seconds).
max.idle.seconds=600
#############################################################################
[web]
# IPv4 address to serve HTTP web interface on
ip4.address=0.0.0.0
# IPv4 port to serve HTTP web interface on
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
# Should we cache parsed templates (set to false during theme dev)
template.cache=false
# Path to the selected themes public (static) files
public.dir=%(install.dir)s/themes/%(theme)s/public
# Path to the greeting HTML displayed on front page, can be moved out of
# installation dir for customization
greeting.file=%(install.dir)s/themes/greeting.html
# Key used to sign session cookie data so that it cannot be tampered with.
# If this is left unset, Inbucket will generate a random key at startup
# 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]
# Path to the datastore, mail will be written into subdirectories
path=/tmp/inbucket
# How many minutes after receipt should a message be stored until it's
# automatically purged. To retain messages until manually deleted, set this
# to 0
retention.minutes=0
# How many milliseconds to sleep after purging messages from a mailbox.
# This should help reduce disk I/O when there are a large number of messages
# to purge.
retention.sleep.millis=100
# Maximum number of messages we will store in a single mailbox. If this
# number is exceeded, the oldest message in the box will be deleted each
# time a new message is received for it.
mailbox.message.cap=100

View File

@@ -1,131 +0,0 @@
# inbucket.conf
# Configuration for Inbucket inside of Docker
#
# These should be reasonable defaults for a production install of Inbucket
#############################################################################
[DEFAULT]
# Not used directly, but is typically referenced below in %()s format.
install.dir=/opt/inbucket
default.domain=inbucket.local
#############################################################################
[logging]
# Options from least to most verbose: ERROR, WARN, INFO, TRACE
level=INFO
#############################################################################
[smtp]
# IPv4 address to listen for SMTP connections on.
ip4.address=0.0.0.0
# IPv4 port to listen for SMTP connections on.
ip4.port=10025
# used in SMTP greeting
domain=%(default.domain)s
# optional: mail sent to accounts at this domain will not be stored,
# for mixed use (content and load testing)
domain.nostore=bitbucket.local
# Maximum number of RCPT TO: addresses we allow from clients, the SMTP
# RFC recommends this be at least 100.
max.recipients=100
# How long we allow a network connection to be idle before hanging up on the
# client, SMTP RFC recommends at least 5 minutes (300 seconds).
max.idle.seconds=300
# Maximum allowable size of message body in bytes (including attachments)
max.message.bytes=2048000
# Should we place messages into the datastore, or just throw them away
# (for load testing): true or false
store.messages=true
#############################################################################
[pop3]
# IPv4 address to listen for POP3 connections on.
ip4.address=0.0.0.0
# IPv4 port to listen for POP3 connections on.
ip4.port=10110
# used in POP3 greeting
domain=%(default.domain)s
# How long we allow a network connection to be idle before hanging up on the
# client, POP3 RFC requires at least 10 minutes (600 seconds).
max.idle.seconds=600
#############################################################################
[web]
# IPv4 address to serve HTTP web interface on
ip4.address=0.0.0.0
# IPv4 port to serve HTTP web interface on
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
# Should we cache parsed templates (set to false during theme dev)
template.cache=true
# Path to the selected themes public (static) files
public.dir=%(install.dir)s/themes/%(theme)s/public
# Path to the greeting HTML displayed on front page, can be moved out of
# installation dir for customization
greeting.file=/con/configuration/greeting.html
# Key used to sign session cookie data so that it cannot be tampered with.
# If this is left unset, Inbucket will generate a random key at startup
# 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]
# Path to the datastore, mail will be written into subdirectories
path=/con/data
# How many minutes after receipt should a message be stored until it's
# automatically purged. To retain messages until manually deleted, set this
# to 0
retention.minutes=4320
# How many milliseconds to sleep after purging messages from a mailbox.
# This should help reduce disk I/O when there are a large number of messages
# to purge.
retention.sleep.millis=100
# Maximum number of messages we will store in a single mailbox. If this
# number is exceeded, the oldest message in the box will be deleted each
# time a new message is received for it.
mailbox.message.cap=300

View File

@@ -2,8 +2,9 @@
# start-inbucket.sh
# description: start inbucket (runs within a docker container)
INBUCKET_HOME="/opt/inbucket"
CONF_SOURCE="$INBUCKET_HOME/defaults"
CONF_TARGET="/con/configuration"
CONF_TARGET="/config"
set -eo pipefail
@@ -18,7 +19,6 @@ install_default_config() {
fi
}
install_default_config "inbucket.conf"
install_default_config "greeting.html"
exec "$INBUCKET_HOME/bin/inbucket" $*

View File

@@ -1,48 +0,0 @@
#!/bin/sh
# install.sh
# description: Build, test, and install Inbucket. Should be executed inside a Docker container.
set -eo pipefail
installdir="$INBUCKET_HOME"
srcdir="$INBUCKET_SRC"
bindir="$installdir/bin"
defaultsdir="$installdir/defaults"
contextdir="/con/context"
echo "### Installing OS Build Dependencies"
apk add --no-cache --virtual .build-deps git
# Setup
export GOBIN="$bindir"
builddate="$(date -Iseconds)"
cd "$srcdir"
go clean
# Build
echo "### Fetching Dependencies"
go get -t -v ./...
echo "### Testing Inbucket"
go test ./...
echo "### Building Inbucket"
go build -o inbucket -ldflags "-X 'main.BUILDDATE=$builddate'" -v .
echo "### Installing Inbucket"
set -x
mkdir -p "$bindir"
install inbucket "$bindir"
mkdir -p "$contextdir"
install etc/docker/defaults/start-inbucket.sh "$contextdir"
cp -r themes "$installdir/"
mkdir -p "$defaultsdir"
cp etc/docker/defaults/inbucket.conf "$defaultsdir"
cp etc/docker/defaults/greeting.html "$defaultsdir"
set +x
echo "### Removing OS Build Dependencies"
apk del .build-deps
echo "### Removing $GOPATH"
rm -rf "$GOPATH"

View File

@@ -1,131 +0,0 @@
# inbucket.conf
# homebrew inbucket configuration
# {{}} values will be replaced during installation
#############################################################################
[DEFAULT]
# Not used directly, but is typically referenced below in %()s format.
default.domain=inbucket.local
themes.dir={{HOMEBREW_PREFIX}}/share/inbucket/themes
datastore.dir={{HOMEBREW_PREFIX}}/var/inbucket/datastore
#############################################################################
[logging]
# Options from least to most verbose: ERROR, WARN, INFO, TRACE
level=INFO
#############################################################################
[smtp]
# IPv4 address to listen for SMTP connections on.
ip4.address=0.0.0.0
# IPv4 port to listen for SMTP connections on.
ip4.port=2500
# used in SMTP greeting
domain=%(default.domain)s
# optional: mail sent to accounts at this domain will not be stored,
# for mixed use (content and load testing)
domain.nostore=bitbucket.local
# Maximum number of RCPT TO: addresses we allow from clients, the SMTP
# RFC recommends this be at least 100.
max.recipients=100
# How long we allow a network connection to be idle before hanging up on the
# client, SMTP RFC recommends at least 5 minutes (300 seconds).
max.idle.seconds=300
# Maximum allowable size of message body in bytes (including attachments)
max.message.bytes=2048000
# Should we place messages into the datastore, or just throw them away
# (for load testing): true or false
store.messages=true
#############################################################################
[pop3]
# IPv4 address to listen for POP3 connections on.
ip4.address=0.0.0.0
# IPv4 port to listen for POP3 connections on.
ip4.port=1100
# used in POP3 greeting
domain=%(default.domain)s
# How long we allow a network connection to be idle before hanging up on the
# client, POP3 RFC requires at least 10 minutes (600 seconds).
max.idle.seconds=600
#############################################################################
[web]
# IPv4 address to serve HTTP web interface on
ip4.address=0.0.0.0
# IPv4 port to serve HTTP web interface on
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
# Should we cache parsed templates (set to false during theme dev)
template.cache=true
# Path to the selected themes public (static) files
public.dir=%(themes.dir)s/%(theme)s/public
# Path to the greeting HTML displayed on front page, can be moved out of
# installation dir for customization
greeting.file=%(themes.dir)s/greeting.html
# Key used to sign session cookie data so that it cannot be tampered with.
# If this is left unset, Inbucket will generate a random key at startup
# 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]
# Path to the datastore, mail will be written into subdirectories
path=%(datastore.dir)s
# How many minutes after receipt should a message be stored until it's
# automatically purged. To retain messages until manually deleted, set this
# to 0
retention.minutes=10080
# How many milliseconds to sleep after purging messages from a mailbox.
# This should help reduce disk I/O when there are a large number of messages
# to purge.
retention.sleep.millis=100
# Maximum number of messages we will store in a single mailbox. If this
# number is exceeded, the oldest message in the box will be deleted each
# time a new message is received for it.
mailbox.message.cap=100

View File

@@ -1,129 +0,0 @@
# inbucket.conf
# Sample inbucket configuration
#############################################################################
[DEFAULT]
# Not used directly, but is typically referenced below in %()s format.
install.dir=.
default.domain=inbucket.local
#############################################################################
[logging]
# Options from least to most verbose: ERROR, WARN, INFO, TRACE
level=INFO
#############################################################################
[smtp]
# IPv4 address to listen for SMTP connections on.
ip4.address=0.0.0.0
# IPv4 port to listen for SMTP connections on.
ip4.port=2500
# used in SMTP greeting
domain=%(default.domain)s
# optional: mail sent to accounts at this domain will not be stored,
# for mixed use (content and load testing)
#domain.nostore=bitbucket.local
# Maximum number of RCPT TO: addresses we allow from clients, the SMTP
# RFC recommends this be at least 100.
max.recipients=100
# How long we allow a network connection to be idle before hanging up on the
# client, SMTP RFC recommends at least 5 minutes (300 seconds).
max.idle.seconds=300
# Maximum allowable size of message body in bytes (including attachments)
max.message.bytes=2048000
# Should we place messages into the datastore, or just throw them away
# (for load testing): true or false
store.messages=true
#############################################################################
[pop3]
# IPv4 address to listen for POP3 connections on.
ip4.address=0.0.0.0
# IPv4 port to listen for POP3 connections on.
ip4.port=1100
# used in POP3 greeting
domain=%(default.domain)s
# How long we allow a network connection to be idle before hanging up on the
# client, POP3 RFC requires at least 10 minutes (600 seconds).
max.idle.seconds=600
#############################################################################
[web]
# IPv4 address to serve HTTP web interface on
ip4.address=0.0.0.0
# IPv4 port to serve HTTP web interface on
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
# Should we cache parsed templates (set to false during theme dev)
template.cache=true
# Path to the selected themes public (static) files
public.dir=%(install.dir)s/themes/%(theme)s/public
# Path to the greeting HTML displayed on front page, can be moved out of
# installation dir for customization
greeting.file=%(install.dir)s/themes/greeting.html
# Key used to sign session cookie data so that it cannot be tampered with.
# If this is left unset, Inbucket will generate a random key at startup
# 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]
# Path to the datastore, mail will be written into subdirectories
path=/tmp/inbucket
# How many minutes after receipt should a message be stored until it's
# automatically purged. To retain messages until manually deleted, set this
# to 0
retention.minutes=240
# How many milliseconds to sleep after purging messages from a mailbox.
# This should help reduce disk I/O when there are a large number of messages
# to purge.
retention.sleep.millis=100
# Maximum number of messages we will store in a single mailbox. If this
# number is exceeded, the oldest message in the box will be deleted each
# time a new message is received for it.
mailbox.message.cap=500

View File

@@ -0,0 +1,33 @@
[Unit]
Description=Inbucket Disposable Email Service
After=network.target
[Service]
Type=simple
User=daemon
Group=daemon
PermissionsStartOnly=true
Environment=INBUCKET_LOGLEVEL=warn
Environment=INBUCKET_SMTP_ADDR=0.0.0.0:2500
Environment=INBUCKET_POP3_ADDR=0.0.0.0:1100
Environment=INBUCKET_WEB_ADDR=0.0.0.0:9000
Environment=INBUCKET_WEB_UIDIR=/usr/local/share/inbucket/ui
Environment=INBUCKET_WEB_GREETINGFILE=/etc/inbucket/greeting.html
Environment=INBUCKET_STORAGE_TYPE=file
Environment=INBUCKET_STORAGE_PARAMS=path:/var/local/inbucket
# Uncomment line below to use low numbered ports
#ExecStartPre=/sbin/setcap 'cap_net_bind_service=+ep' /usr/local/bin/inbucket
ExecStartPre=/bin/mkdir -p /var/local/inbucket
ExecStartPre=/bin/chown daemon:daemon /var/local/inbucket
ExecStart=/usr/local/bin/inbucket
# Give SMTP connections time to drain
TimeoutStopSec=20
KillMode=mixed
[Install]
WantedBy=multi-user.target

View File

@@ -1,3 +0,0 @@
Please see the RedHat installation guide on our website:
http://www.inbucket.org/installation/redhat.html

View File

@@ -1,17 +0,0 @@
# Inbucket reverse proxy, Apache will forward requests from port 80
# to Inbucket's built in web server on port 9000
#
# Replace SERVERFQDN with your servers fully qualified domain name
<VirtualHost *:80>
ServerName SERVERFQDN
ProxyRequests off
<Proxy *>
Order allow,deny
Allow from all
</Proxy>
RewriteRule ^/$ http://SERVERFQDN:9000
ProxyPass / http://SERVERFQDN:9000/
ProxyPassReverse / http://SERVERFQDN:9000/
</VirtualHost>

View File

@@ -1,117 +0,0 @@
#!/bin/sh
#
# inbucket Inbucket email testing service
#
# chkconfig: 2345 80 30
# description: Inbucket is a disposable email service for testing email
# functionality of other applications.
# processname: inbucket
# pidfile: /var/run/inbucket/inbucket.pid
### BEGIN INIT INFO
# Provides: Inbucket service
# Required-Start: $local_fs $network $remote_fs
# Required-Stop: $local_fs $network $remote_fs
# Default-Start: 2 3 4 5
# Default-Stop: 0 1 6
# Short-Description: start and stop inbucket
# Description: Inbucket is a disposable email service for testing email
# functionality of other applications.
# moves mail from one machine to another.
### END INIT INFO
# Source function library.
. /etc/rc.d/init.d/functions
# Source networking configuration.
. /etc/sysconfig/network
RETVAL=0
program=/opt/inbucket/inbucket
prog=${program##*/}
config=/etc/opt/inbucket.conf
runas=inbucket
lockfile=/var/lock/subsys/$prog
pidfile=/var/run/$prog/$prog.pid
logfile=/var/log/$prog.log
conf_check() {
[ -x $program ] || exit 5
[ -f $config ] || exit 6
}
perms_check() {
mkdir -p /var/run/$prog
chown $runas: /var/run/$prog
touch $logfile
chown $runas: $logfile
# Allow bind to ports under 1024
setcap 'cap_net_bind_service=+ep' $program
}
start() {
[ "$EUID" != "0" ] && exit 4
# Check that networking is up.
[ ${NETWORKING} = "no" ] && exit 1
# Check config sanity
conf_check
perms_check
# Start daemon
echo -n $"Starting $prog: "
daemon --user $runas --pidfile $pidfile $program \
-pidfile $pidfile -logfile $logfile $config \&
RETVAL=$?
[ $RETVAL -eq 0 ] && touch $lockfile
echo
return $RETVAL
}
stop() {
[ "$EUID" != "0" ] && exit 4
conf_check
# Stop daemon
echo -n $"Shutting down $prog: "
killproc -p "$pidfile" -d 15 "$program"
RETVAL=$?
[ $RETVAL -eq 0 ] && rm -f $lockfile $pidfile
echo
return $RETVAL
}
reload() {
[ "$EUID" != "0" ] && exit 4
echo -n $"Reloading $prog: "
killproc -p "$pidfile" "$program" -HUP
RETVAL=$?
echo
return $RETVAL
}
# See how we were called.
case "$1" in
start)
[ -e $lockfile ] && exit 0
start
;;
stop)
[ -e $lockfile ] || exit 0
stop
;;
reload)
[ -e $lockfile ] || exit 0
reload
;;
restart|force-reload)
stop
start
;;
status)
status -p $pidfile -l $(basename $lockfile) $prog
;;
*)
echo $"Usage: $0 {start|stop|restart|status}"
exit 2
esac
exit $?

View File

@@ -1,8 +0,0 @@
/var/log/inbucket.log {
missingok
notifempty
create 0644 inbucket inbucket
postrotate
[ -x /bin/systemctl ] && /bin/systemctl reload inbucket >/dev/null 2>&1 || true
endscript
}

View File

@@ -1,20 +0,0 @@
[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

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

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

@@ -0,0 +1,10 @@
Date: %DATE%
To: %TO_ADDRESS%
From: %FROM_ADDRESS%
Subject: Swaks HTML
MIME-Version: 1.0
Content-Type: text/html; charset="UTF-8"
<p style="font-family: 'Courier New', Courier, monospace;">
This is a test of <b>HTML</b> at the <i>top</i> level.
</p>

View File

@@ -53,5 +53,6 @@ swaks $* --data gmail.raw
# Outlook test
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

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

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

View File

@@ -1,3 +0,0 @@
Please see the Ubuntu installation guide on our website:
http://www.inbucket.org/installation/ubuntu.html

View File

@@ -1,29 +0,0 @@
# inbucket - disposable email service
#
# Inbucket is an SMTP server with a web interface for testing application
# functionality
description "inbucket - disposable email service"
author "http://jhillyerd.github.com/inbucket"
start on (local-filesystems and net-device-up IFACE!=lo)
stop on runlevel [!2345]
env program=/opt/inbucket/inbucket
env config=/etc/opt/inbucket.conf
env logfile=/var/log/inbucket.log
env runas=inbucket
# Give SMTP connections time to drain
kill timeout 20
pre-start script
[ -x $program ]
[ -r $config ]
touch $logfile
chown $runas: $logfile
# Allow bind to ports under 1024
setcap 'cap_net_bind_service=+ep' $program
end script
exec start-stop-daemon --start --chuid $runas --exec $program -- -logfile $logfile $config

View File

@@ -1,8 +0,0 @@
/var/log/inbucket.log {
missingok
notifempty
create 0644 inbucket inbucket
postrotate
[ -x /bin/systemctl ] && /bin/systemctl reload inbucket >/dev/null 2>&1 || true
endscript
}

View File

@@ -1,20 +0,0 @@
[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

@@ -1,129 +0,0 @@
# inbucket.conf
# Sample inbucket configuration
#############################################################################
[DEFAULT]
# Not used directly, but is typically referenced below in %()s format.
install.dir=/opt/inbucket
default.domain=inbucket.local
#############################################################################
[logging]
# Options from least to most verbose: ERROR, WARN, INFO, TRACE
level=INFO
#############################################################################
[smtp]
# IPv4 address to listen for SMTP connections on.
ip4.address=0.0.0.0
# IPv4 port to listen for SMTP connections on.
ip4.port=25
# used in SMTP greeting
domain=%(default.domain)s
# optional: mail sent to accounts at this domain will not be stored,
# for mixed use (content and load testing)
#domain.nostore=bitbucket.local
# Maximum number of RCPT TO: addresses we allow from clients, the SMTP
# RFC recommends this be at least 100.
max.recipients=100
# How long we allow a network connection to be idle before hanging up on the
# client, SMTP RFC recommends at least 5 minutes (300 seconds).
max.idle.seconds=300
# Maximum allowable size of message body in bytes (including attachments)
max.message.bytes=2048000
# Should we place messages into the datastore, or just throw them away
# (for load testing): true or false
store.messages=true
#############################################################################
[pop3]
# IPv4 address to listen for POP3 connections on.
ip4.address=0.0.0.0
# IPv4 port to listen for POP3 connections on.
ip4.port=110
# used in POP3 greeting
domain=%(default.domain)s
# How long we allow a network connection to be idle before hanging up on the
# client, POP3 RFC requires at least 10 minutes (600 seconds).
max.idle.seconds=600
#############################################################################
[web]
# IPv4 address to serve HTTP web interface on
ip4.address=0.0.0.0
# IPv4 port to serve HTTP web interface on
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
# Should we cache parsed templates (set to false during theme dev)
template.cache=true
# Path to the selected themes public (static) files
public.dir=%(install.dir)s/themes/%(theme)s/public
# Path to the greeting HTML displayed on front page, can be moved out of
# installation dir for customization
greeting.file=%(install.dir)s/themes/greeting.html
# Key used to sign session cookie data so that it cannot be tampered with.
# If this is left unset, Inbucket will generate a random key at startup
# 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]
# Path to the datastore, mail will be written into subdirectories
path=/var/opt/inbucket
# How many minutes after receipt should a message be stored until it's
# automatically purged. To retain messages until manually deleted, set this
# to 0
retention.minutes=240
# How many milliseconds to sleep after purging messages from a mailbox.
# This should help reduce disk I/O when there are a large number of messages
# to purge.
retention.sleep.millis=100
# Maximum number of messages we will store in a single mailbox. If this
# number is exceeded, the oldest message in the box will be deleted each
# time a new message is received for it.
mailbox.message.cap=500

View File

@@ -1,129 +0,0 @@
# win-sample.conf
# Sample inbucket configuration for Windows
#############################################################################
[DEFAULT]
# Not used directly, but is typically referenced below in %()s format.
install.dir=.
default.domain=inbucket.local
#############################################################################
[logging]
# Options from least to most verbose: ERROR, WARN, INFO, TRACE
level=INFO
#############################################################################
[smtp]
# IPv4 address to listen for SMTP connections on.
ip4.address=0.0.0.0
# IPv4 port to listen for SMTP connections on.
ip4.port=2500
# used in SMTP greeting
domain=%(default.domain)s
# optional: mail sent to accounts at this domain will not be stored,
# for mixed use (content and load testing)
#domain.nostore=bitbucket.local
# Maximum number of RCPT TO: addresses we allow from clients, the SMTP
# RFC recommends this be at least 100.
max.recipients=100
# How long we allow a network connection to be idle before hanging up on the
# client, SMTP RFC recommends at least 5 minutes (300 seconds).
max.idle.seconds=300
# Maximum allowable size of message body in bytes (including attachments)
max.message.bytes=2048000
# Should we place messages into the datastore, or just throw them away
# (for load testing): true or false
store.messages=true
#############################################################################
[pop3]
# IPv4 address to listen for POP3 connections on.
ip4.address=0.0.0.0
# IPv4 port to listen for POP3 connections on.
ip4.port=1100
# used in POP3 greeting
domain=%(default.domain)s
# How long we allow a network connection to be idle before hanging up on the
# client, POP3 RFC requires at least 10 minutes (600 seconds).
max.idle.seconds=600
#############################################################################
[web]
# IPv4 address to serve HTTP web interface on
ip4.address=0.0.0.0
# IPv4 port to serve HTTP web interface on
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
# Should we cache parsed templates (set to false during theme dev)
template.cache=true
# Path to the selected themes public (static) files
public.dir=%(install.dir)s\themes\%(theme)s\public
# Path to the greeting HTML displayed on front page, can be moved out of
# installation dir for customization
greeting.file=%(install.dir)s\themes\greeting.html
# Key used to sign session cookie data so that it cannot be tampered with.
# If this is left unset, Inbucket will generate a random key at startup
# 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]
# Path to the datastore, mail will be written into subdirectories
path=.\inbucket-data
# How many minutes after receipt should a message be stored until it's
# automatically purged. To retain messages until manually deleted, set this
# to 0
retention.minutes=240
# How many milliseconds to sleep after purging messages from a mailbox.
# This should help reduce disk I/O when there are a large number of messages
# to purge.
retention.sleep.millis=100
# Maximum number of messages we will store in a single mailbox. If this
# number is exceeded, the oldest message in the box will be deleted each
# time a new message is received for it.
mailbox.message.cap=500

View File

@@ -1 +0,0 @@
inbucket.exe etc\win-sample.conf

View File

@@ -1,183 +0,0 @@
// main is the inbucket daemon launcher
package main
import (
"context"
"expvar"
"flag"
"fmt"
"os"
"os/signal"
"runtime"
"syscall"
"time"
"github.com/jhillyerd/inbucket/config"
"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"
"github.com/jhillyerd/inbucket/webui"
)
var (
// VERSION contains the build version number, populated during linking by goxc
VERSION = "1.2.0-rc2"
// BUILDDATE contains the build date, populated during linking by goxc
BUILDDATE = "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")
// shutdownChan - close it to tell Inbucket to shut down cleanly
shutdownChan = make(chan bool)
// Server instances
smtpServer *smtpd.Server
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
flag.Parse()
if *help {
flag.Usage()
return
}
// Root context
rootCtx, rootCancel := context.WithCancel(context.Background())
// Load & Parse config
if flag.NArg() != 1 {
flag.Usage()
os.Exit(1)
}
err := config.LoadConfig(flag.Arg(0))
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to parse config: %v\n", err)
os.Exit(1)
}
// Setup signal handler
sigChan := make(chan os.Signal)
signal.Notify(sigChan, syscall.SIGHUP, syscall.SIGTERM, syscall.SIGINT)
// Initialize logging
log.SetLogLevel(config.GetLogLevel())
if err := log.Initialize(*logfile); err != nil {
fmt.Fprintf(os.Stderr, "%v", err)
os.Exit(1)
}
defer log.Close()
log.Infof("Inbucket %v (%v) starting...", config.Version, config.BuildDate)
// Write pidfile if requested
if *pidfile != "none" {
pidf, err := os.Create(*pidfile)
if err != nil {
log.Errorf("Failed to create %q: %v", *pidfile, err)
os.Exit(1)
}
fmt.Fprintf(pidf, "%v\n", os.Getpid())
if err := pidf.Close(); err != nil {
log.Errorf("Failed to close PID file %q: %v", *pidfile, err)
}
}
// Create message hub
msgHub := msghub.New(rootCtx, config.GetWebConfig().MonitorHistory)
// Grab our datastore
ds := smtpd.DefaultFileDataStore()
// Start HTTP server
httpd.Initialize(config.GetWebConfig(), shutdownChan, ds, msgHub)
webui.SetupRoutes(httpd.Router)
rest.SetupRoutes(httpd.Router)
go httpd.Start(rootCtx)
// Start POP3 server
// TODO pass datastore
pop3Server = pop3d.New(shutdownChan)
go pop3Server.Start(rootCtx)
// Startup SMTP server
smtpServer = smtpd.NewServer(config.GetSMTPConfig(), shutdownChan, ds, msgHub)
go smtpServer.Start(rootCtx)
// Loop forever waiting for signals or shutdown channel
signalLoop:
for {
select {
case sig := <-sigChan:
switch sig {
case syscall.SIGHUP:
log.Infof("Recieved SIGHUP, cycling logfile")
log.Rotate()
case syscall.SIGINT:
// Shutdown requested
log.Infof("Received SIGINT, shutting down")
close(shutdownChan)
case syscall.SIGTERM:
// Shutdown requested
log.Infof("Received SIGTERM, shutting down")
close(shutdownChan)
}
case _ = <-shutdownChan:
rootCancel()
break signalLoop
}
}
// Wait for active connections to finish
go timedExit()
smtpServer.Drain()
pop3Server.Drain()
removePIDFile()
}
// removePIDFile removes the PID file if created
func removePIDFile() {
if *pidfile != "none" {
if err := os.Remove(*pidfile); err != nil {
log.Errorf("Failed to remove %q: %v", *pidfile, err)
}
}
}
// timedExit is called as a goroutine during shutdown, it will force an exit
// after 15 seconds
func timedExit() {
time.Sleep(15 * time.Second)
log.Errorf("Clean shutdown took too long, forcing exit")
removePIDFile()
os.Exit(0)
}

View File

@@ -1,145 +0,0 @@
package log
import (
"fmt"
golog "log"
"os"
"strings"
)
// Level is used to indicate the severity of a log entry
type Level int
const (
// ERROR indicates a significant problem was encountered
ERROR Level = iota
// WARN indicates something that may be a problem
WARN
// INFO indicates a purely informational log entry
INFO
// TRACE entries are meant for development purposes only
TRACE
)
var (
// MaxLevel is the highest Level we will log (max TRACE, min ERROR)
MaxLevel = TRACE
// logfname is the name of the logfile
logfname string
// logf is the file we send log output to, will be nil for stderr or stdout
logf *os.File
)
// Initialize logging. If logfile is equal to "stderr" or "stdout", then
// we will log to that output stream. Otherwise the specificed file will
// opened for writing, and all log data will be placed in it.
func Initialize(logfile string) error {
if logfile != "stderr" {
// stderr is the go logging default
if logfile == "stdout" {
// set to stdout
golog.SetOutput(os.Stdout)
} else {
logfname = logfile
if err := openLogFile(); err != nil {
return err
}
// Platform specific
closeStdin()
}
}
return nil
}
// SetLogLevel sets MaxLevel based on the provided string
func SetLogLevel(level string) (ok bool) {
switch strings.ToUpper(level) {
case "ERROR":
MaxLevel = ERROR
case "WARN":
MaxLevel = WARN
case "INFO":
MaxLevel = INFO
case "TRACE":
MaxLevel = TRACE
default:
Errorf("Unknown log level requested: " + level)
return false
}
return true
}
// Errorf logs a message to the 'standard' Logger (always), accepts format strings
func Errorf(msg string, args ...interface{}) {
msg = "[ERROR] " + msg
golog.Printf(msg, args...)
}
// Warnf logs a message to the 'standard' Logger if MaxLevel is >= WARN, accepts format strings
func Warnf(msg string, args ...interface{}) {
if MaxLevel >= WARN {
msg = "[WARN ] " + msg
golog.Printf(msg, args...)
}
}
// Infof logs a message to the 'standard' Logger if MaxLevel is >= INFO, accepts format strings
func Infof(msg string, args ...interface{}) {
if MaxLevel >= INFO {
msg = "[INFO ] " + msg
golog.Printf(msg, args...)
}
}
// Tracef logs a message to the 'standard' Logger if MaxLevel is >= TRACE, accepts format strings
func Tracef(msg string, args ...interface{}) {
if MaxLevel >= TRACE {
msg = "[TRACE] " + msg
golog.Printf(msg, args...)
}
}
// Rotate closes the current log file, then reopens it. This gives an external
// log rotation system the opportunity to move the existing log file out of the
// way and have Inbucket create a new one.
func Rotate() {
// Rotate logs if configured
if logf != nil {
closeLogFile()
// There is nothing we can do if the log open fails
_ = openLogFile()
} else {
Infof("Ignoring SIGHUP, logfile not configured")
}
}
// Close the log file if we have one open
func Close() {
if logf != nil {
closeLogFile()
}
}
// openLogFile creates or appends to the logfile passed on commandline
func openLogFile() error {
// use specified log file
var err error
logf, err = os.OpenFile(logfname, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0666)
if err != nil {
return fmt.Errorf("Failed to create %v: %v\n", logfname, err)
}
golog.SetOutput(logf)
Tracef("Opened new logfile")
// Platform specific
reassignStdout()
return nil
}
// closeLogFile closes the current logfile
func closeLogFile() {
Tracef("Closing logfile")
// We are never in a situation where we can do anything about failing to close
_ = logf.Close()
}

View File

@@ -1,31 +0,0 @@
// +build !windows
package log
import (
"golang.org/x/sys/unix"
"os"
)
// closeStdin will close stdin on Unix platforms - this is standard practice
// for daemons
func closeStdin() {
if err := os.Stdin.Close(); err != nil {
// Not a fatal error
Errorf("Failed to close os.Stdin during log setup")
}
}
// reassignStdout points stdout/stderr to our logfile on systems that support
// the Dup2 syscall per https://github.com/golang/go/issues/325
func reassignStdout() {
Tracef("Unix reassignStdout()")
if err := unix.Dup2(int(logf.Fd()), 1); err != nil {
// Not considered fatal
Errorf("Failed to re-assign stdout to logfile: %v", err)
}
if err := unix.Dup2(int(logf.Fd()), 2); err != nil {
// Not considered fatal
Errorf("Failed to re-assign stderr to logfile: %v", err)
}
}

View File

@@ -1,37 +0,0 @@
// +build windows
package log
import (
"os"
)
var stdOutsClosed = false
// closeStdin does nothing on Windows, it would always fail
func closeStdin() {
// Nop
}
// reassignStdout points stdout/stderr to our logfile on systems that do not
// support the Dup2 syscall
func reassignStdout() {
Tracef("Windows reassignStdout()")
if !stdOutsClosed {
// Close std* streams to prevent accidental output, they will be redirected to
// our logfile below
// Warning: this will hide panic() output, sorry Windows users
if err := os.Stderr.Close(); err != nil {
// Not considered fatal
Errorf("Failed to close os.Stderr during log setup")
}
if err := os.Stdin.Close(); err != nil {
// Not considered fatal
Errorf("Failed to close os.Stdin during log setup")
}
os.Stdout = logf
os.Stderr = logf
stdOutsClosed = true
}
}

130
pkg/config/config.go Normal file
View File

@@ -0,0 +1,130 @@
package config
import (
"fmt"
"log"
"os"
"strings"
"text/tabwriter"
"time"
"github.com/jhillyerd/inbucket/pkg/stringutil"
"github.com/kelseyhightower/envconfig"
)
const (
prefix = "inbucket"
tableFormat = `Inbucket is configured via the environment. The following environment variables
can be used:
KEY DEFAULT DESCRIPTION
{{range .}}{{usage_key .}} {{usage_default .}} {{usage_description .}}
{{end}}`
)
var (
// Version of this build, set by main
Version = ""
// BuildDate for this build, set by main
BuildDate = ""
)
// mbNaming represents a mailbox naming strategy.
type mbNaming int
// Mailbox naming strategies.
const (
UnknownNaming mbNaming = iota
LocalNaming
FullNaming
)
// Decode a naming strategy from string.
func (n *mbNaming) Decode(v string) error {
switch strings.ToLower(v) {
case "local":
*n = LocalNaming
case "full":
*n = FullNaming
default:
return fmt.Errorf("Unknown MailboxNaming strategy: %q", v)
}
return nil
}
// Root contains global configuration, and structs with for specific sub-systems.
type Root struct {
LogLevel string `required:"true" default:"info" desc:"debug, info, warn, or error"`
MailboxNaming mbNaming `required:"true" default:"local" desc:"Use local or full addressing"`
SMTP SMTP
POP3 POP3
Web Web
Storage Storage
}
// SMTP contains the SMTP server configuration.
type SMTP struct {
Addr string `required:"true" default:"0.0.0.0:2500" desc:"SMTP server IP4 host:port"`
Domain string `required:"true" default:"inbucket" desc:"HELO domain"`
MaxRecipients int `required:"true" default:"200" desc:"Maximum RCPT TO per message"`
MaxMessageBytes int `required:"true" default:"10240000" desc:"Maximum message size"`
DefaultAccept bool `required:"true" default:"true" desc:"Accept all mail by default?"`
AcceptDomains []string `desc:"Domains to accept mail for"`
RejectDomains []string `desc:"Domains to reject mail for"`
DefaultStore bool `required:"true" default:"true" desc:"Store all mail by default?"`
StoreDomains []string `desc:"Domains to store mail for"`
DiscardDomains []string `desc:"Domains to discard mail for"`
Timeout time.Duration `required:"true" default:"300s" desc:"Idle network timeout"`
Debug bool `ignored:"true"`
}
// POP3 contains the POP3 server configuration.
type POP3 struct {
Addr string `required:"true" default:"0.0.0.0:1100" desc:"POP3 server IP4 host:port"`
Domain string `required:"true" default:"inbucket" desc:"HELLO domain"`
Timeout time.Duration `required:"true" default:"600s" desc:"Idle network timeout"`
Debug bool `ignored:"true"`
}
// Web contains the HTTP server configuration.
type Web struct {
Addr string `required:"true" default:"0.0.0.0:9000" desc:"Web server IP4 host:port"`
UIDir string `required:"true" default:"ui" desc:"User interface dir"`
GreetingFile string `required:"true" default:"ui/greeting.html" desc:"Home page greeting HTML"`
TemplateCache bool `required:"true" default:"true" desc:"Cache templates after first use?"`
MailboxPrompt string `required:"true" default:"@inbucket" desc:"Prompt next to mailbox input"`
CookieAuthKey string `desc:"Session cipher key (text)"`
MonitorVisible bool `required:"true" default:"true" desc:"Show monitor tab in UI?"`
MonitorHistory int `required:"true" default:"30" desc:"Monitor remembered messages"`
}
// Storage contains the mail store configuration.
type Storage struct {
Type string `required:"true" default:"memory" desc:"Storage impl: file or memory"`
Params map[string]string `desc:"Storage impl parameters, see docs."`
RetentionPeriod time.Duration `required:"true" default:"24h" desc:"Duration to retain messages"`
RetentionSleep time.Duration `required:"true" default:"50ms" desc:"Duration to sleep between mailboxes"`
MailboxMsgCap int `required:"true" default:"500" desc:"Maximum messages per mailbox"`
}
// Process loads and parses configuration from the environment.
func Process() (*Root, error) {
c := &Root{}
err := envconfig.Process(prefix, c)
c.LogLevel = strings.ToLower(c.LogLevel)
stringutil.SliceToLower(c.SMTP.AcceptDomains)
stringutil.SliceToLower(c.SMTP.RejectDomains)
stringutil.SliceToLower(c.SMTP.StoreDomains)
stringutil.SliceToLower(c.SMTP.DiscardDomains)
return c, err
}
// Usage prints out the envconfig usage to Stderr.
func Usage() {
tabs := tabwriter.NewWriter(os.Stderr, 1, 0, 4, ' ', 0)
if err := envconfig.Usagef(prefix, &Root{}, tabs, tableFormat); err != nil {
log.Fatalf("Unable to parse env config: %v", err)
}
tabs.Flush()
}

174
pkg/message/manager.go Normal file
View File

@@ -0,0 +1,174 @@
package message
import (
"bytes"
"io"
"net/mail"
"strings"
"time"
"github.com/jhillyerd/enmime"
"github.com/jhillyerd/inbucket/pkg/msghub"
"github.com/jhillyerd/inbucket/pkg/policy"
"github.com/jhillyerd/inbucket/pkg/storage"
"github.com/jhillyerd/inbucket/pkg/stringutil"
"github.com/rs/zerolog/log"
)
// Manager is the interface controllers use to interact with messages.
type Manager interface {
Deliver(
to *policy.Recipient,
from string,
recipients []*policy.Recipient,
prefix string,
content []byte,
) (id string, err error)
GetMetadata(mailbox string) ([]*Metadata, error)
GetMessage(mailbox, id string) (*Message, error)
MarkSeen(mailbox, id string) error
PurgeMessages(mailbox string) error
RemoveMessage(mailbox, id string) error
SourceReader(mailbox, id string) (io.ReadCloser, error)
MailboxForAddress(address string) (string, error)
}
// StoreManager is a message Manager backed by the storage.Store.
type StoreManager struct {
AddrPolicy *policy.Addressing
Store storage.Store
Hub *msghub.Hub
}
// Deliver submits a new message to the store.
func (s *StoreManager) Deliver(
to *policy.Recipient,
from string,
recipients []*policy.Recipient,
prefix string,
source []byte,
) (string, error) {
// TODO enmime is too heavy for this step, only need header.
// Go's header parsing isn't good enough, so this is blocked on enmime issue #64.
env, err := enmime.ReadEnvelope(bytes.NewReader(source))
if err != nil {
return "", err
}
fromaddr, err := env.AddressList("From")
if err != nil || len(fromaddr) == 0 {
fromaddr = []*mail.Address{{Address: from}}
}
toaddr, err := env.AddressList("To")
if err != nil {
toaddr = make([]*mail.Address, len(recipients))
for i, torecip := range recipients {
toaddr[i] = &torecip.Address
}
}
log.Debug().Str("module", "message").Str("mailbox", to.Mailbox).Msg("Delivering message")
delivery := &Delivery{
Meta: Metadata{
Mailbox: to.Mailbox,
From: fromaddr[0],
To: toaddr,
Date: time.Now(),
Subject: env.GetHeader("Subject"),
},
Reader: io.MultiReader(strings.NewReader(prefix), bytes.NewReader(source)),
}
id, err := s.Store.AddMessage(delivery)
if err != nil {
return "", err
}
if s.Hub != nil {
// Broadcast message information.
broadcast := msghub.Message{
Mailbox: to.Mailbox,
ID: id,
From: delivery.From().String(),
To: stringutil.StringAddressList(delivery.To()),
Subject: delivery.Subject(),
Date: delivery.Date(),
Size: delivery.Size(),
}
s.Hub.Dispatch(broadcast)
}
return id, nil
}
// GetMetadata returns a slice of metadata for the specified mailbox.
func (s *StoreManager) GetMetadata(mailbox string) ([]*Metadata, error) {
messages, err := s.Store.GetMessages(mailbox)
if err != nil {
return nil, err
}
metas := make([]*Metadata, len(messages))
for i, sm := range messages {
metas[i] = makeMetadata(sm)
}
return metas, nil
}
// GetMessage returns the specified message.
func (s *StoreManager) GetMessage(mailbox, id string) (*Message, error) {
sm, err := s.Store.GetMessage(mailbox, id)
if err != nil || sm == nil {
return nil, err
}
r, err := sm.Source()
if err != nil {
return nil, err
}
env, err := enmime.ReadEnvelope(r)
if err != nil {
return nil, err
}
_ = r.Close()
header := makeMetadata(sm)
return &Message{Metadata: *header, env: env}, nil
}
// MarkSeen marks the message as having been read.
func (s *StoreManager) MarkSeen(mailbox, id string) error {
log.Debug().Str("module", "manager").Str("mailbox", mailbox).Str("id", id).
Msg("Marking as seen")
return s.Store.MarkSeen(mailbox, id)
}
// PurgeMessages removes all messages from the specified mailbox.
func (s *StoreManager) PurgeMessages(mailbox string) error {
return s.Store.PurgeMessages(mailbox)
}
// RemoveMessage deletes the specified message.
func (s *StoreManager) RemoveMessage(mailbox, id string) error {
return s.Store.RemoveMessage(mailbox, id)
}
// SourceReader allows the stored message source to be read.
func (s *StoreManager) SourceReader(mailbox, id string) (io.ReadCloser, error) {
sm, err := s.Store.GetMessage(mailbox, id)
if err != nil || sm == nil {
return nil, err
}
return sm.Source()
}
// MailboxForAddress parses an email address to return the canonical mailbox name.
func (s *StoreManager) MailboxForAddress(mailbox string) (string, error) {
return s.AddrPolicy.ExtractMailbox(mailbox)
}
// makeMetadata populates Metadata from a storage.Message.
func makeMetadata(m storage.Message) *Metadata {
return &Metadata{
Mailbox: m.Mailbox(),
ID: m.ID(),
From: m.From(),
To: m.To(),
Date: m.Date(),
Subject: m.Subject(),
Size: m.Size(),
Seen: m.Seen(),
}
}

117
pkg/message/message.go Normal file
View File

@@ -0,0 +1,117 @@
// Package message contains message handling logic.
package message
import (
"io"
"io/ioutil"
"net/mail"
"net/textproto"
"time"
"github.com/jhillyerd/enmime"
"github.com/jhillyerd/inbucket/pkg/storage"
)
// Metadata holds information about a message, but not the content.
type Metadata struct {
Mailbox string
ID string
From *mail.Address
To []*mail.Address
Date time.Time
Subject string
Size int64
Seen bool
}
// Message holds both the metadata and content of a message.
type Message struct {
Metadata
env *enmime.Envelope
}
// New constructs a new Message
func New(m Metadata, e *enmime.Envelope) *Message {
return &Message{
Metadata: m,
env: e,
}
}
// Attachments returns the MIME attachments for the message.
func (m *Message) Attachments() []*enmime.Part {
return m.env.Attachments
}
// Header returns the header map for this message.
func (m *Message) Header() textproto.MIMEHeader {
return m.env.Root.Header
}
// HTML returns the HTML body of the message.
func (m *Message) HTML() string {
return m.env.HTML
}
// MIMEErrors returns MIME parsing errors and warnings.
func (m *Message) MIMEErrors() []*enmime.Error {
return m.env.Errors
}
// Text returns the plain text body of the message.
func (m *Message) Text() string {
return m.env.Text
}
// Delivery is used to add a message to storage.
type Delivery struct {
Meta Metadata
Reader io.Reader
}
var _ storage.Message = &Delivery{}
// Mailbox getter.
func (d *Delivery) Mailbox() string {
return d.Meta.Mailbox
}
// ID getter.
func (d *Delivery) ID() string {
return d.Meta.ID
}
// From getter.
func (d *Delivery) From() *mail.Address {
return d.Meta.From
}
// To getter.
func (d *Delivery) To() []*mail.Address {
return d.Meta.To
}
// Date getter.
func (d *Delivery) Date() time.Time {
return d.Meta.Date
}
// Subject getter.
func (d *Delivery) Subject() string {
return d.Meta.Subject
}
// Size getter.
func (d *Delivery) Size() int64 {
return d.Meta.Size
}
// Source contains the raw content of the message.
func (d *Delivery) Source() (io.ReadCloser, error) {
return ioutil.NopCloser(d.Reader), nil
}
// Seen getter.
func (d *Delivery) Seen() bool {
return d.Meta.Seen
}

63
pkg/metric/metric.go Normal file
View File

@@ -0,0 +1,63 @@
package metric
import (
"container/list"
"expvar"
"strings"
"time"
)
// TickerFunc is the function signature accepted by AddTickerFunc, will be called once per minute.
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
}
// Push 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 Push(history *list.List, ev expvar.Var) string {
history.PushBack(ev.String())
if history.Len() > 61 {
history.Remove(history.Front())
}
return joinStringList(history)
}
// metricsTicker calls the current list of TickerFuncs once per minute.
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)
}
}
}
// 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, ",")
}

View File

@@ -1,71 +1,112 @@
package smtpd
package policy
import (
"bytes"
"container/list"
"crypto/sha1"
"fmt"
"io"
"net/mail"
"strings"
"github.com/jhillyerd/inbucket/pkg/config"
"github.com/jhillyerd/inbucket/pkg/stringutil"
)
// ParseMailboxName takes a localPart string (ex: "user+ext" without "@domain")
// and returns just the mailbox name (ex: "user"). Returns an error if
// localPart contains invalid characters; it won't accept any that must be
// quoted according to RFC3696.
func ParseMailboxName(localPart string) (result string, err error) {
if localPart == "" {
return "", fmt.Errorf("Mailbox name cannot be empty")
}
result = strings.ToLower(localPart)
invalid := make([]byte, 0, 10)
for i := 0; i < len(result); i++ {
c := result[i]
switch {
case 'a' <= c && c <= 'z':
case '0' <= c && c <= '9':
case bytes.IndexByte([]byte("!#$%&'*+-=/?^_`.{|}~"), c) >= 0:
default:
invalid = append(invalid, c)
}
}
if len(invalid) > 0 {
return "", fmt.Errorf("Mailbox name contained invalid character(s): %q", invalid)
}
if idx := strings.Index(result, "+"); idx > -1 {
result = result[0:idx]
}
return result, nil
// Addressing handles email address policy.
type Addressing struct {
Config *config.Root
}
// HashMailboxName accepts a mailbox name and hashes it. Inbucket uses this as
// the directory to house the mailbox
func HashMailboxName(mailbox string) string {
h := sha1.New()
if _, err := io.WriteString(h, mailbox); err != nil {
// This shouldn't ever happen
return ""
// ExtractMailbox extracts the mailbox name from a partial email address.
func (a *Addressing) ExtractMailbox(address string) (string, error) {
local, domain, err := parseEmailAddress(address)
if err != nil {
return "", err
}
return fmt.Sprintf("%x", h.Sum(nil))
local, err = parseMailboxName(local)
if err != nil {
return "", err
}
if a.Config.MailboxNaming == config.LocalNaming {
return local, nil
}
if a.Config.MailboxNaming != config.FullNaming {
return "", fmt.Errorf("Unknown MailboxNaming value: %v", a.Config.MailboxNaming)
}
if domain == "" {
return local, nil
}
if !ValidateDomainPart(domain) {
return "", fmt.Errorf("Domain part %q in %q failed validation", domain, address)
}
return local + "@" + domain, nil
}
// JoinStringList joins a List containing strings by commas
func JoinStringList(listOfStrings *list.List) string {
if listOfStrings.Len() == 0 {
return ""
// NewRecipient parses an address into a Recipient.
func (a *Addressing) NewRecipient(address string) (*Recipient, error) {
local, domain, err := ParseEmailAddress(address)
if err != nil {
return nil, err
}
s := make([]string, 0, listOfStrings.Len())
for e := listOfStrings.Front(); e != nil; e = e.Next() {
s = append(s, e.Value.(string))
mailbox, err := a.ExtractMailbox(address)
if err != nil {
return nil, err
}
return strings.Join(s, ",")
ar, err := mail.ParseAddress(address)
if err != nil {
return nil, err
}
return &Recipient{
Address: *ar,
addrPolicy: a,
LocalPart: local,
Domain: domain,
Mailbox: mailbox,
}, nil
}
// ValidateDomainPart returns true if the domain part complies to RFC3696, RFC1035
// ShouldAcceptDomain indicates if Inbucket accepts mail destined for the specified domain.
func (a *Addressing) ShouldAcceptDomain(domain string) bool {
domain = strings.ToLower(domain)
if a.Config.SMTP.DefaultAccept &&
!stringutil.SliceContains(a.Config.SMTP.RejectDomains, domain) {
return true
}
if !a.Config.SMTP.DefaultAccept &&
stringutil.SliceContains(a.Config.SMTP.AcceptDomains, domain) {
return true
}
return false
}
// ShouldStoreDomain indicates if Inbucket stores mail destined for the specified domain.
func (a *Addressing) ShouldStoreDomain(domain string) bool {
domain = strings.ToLower(domain)
if a.Config.SMTP.DefaultStore &&
!stringutil.SliceContains(a.Config.SMTP.DiscardDomains, domain) {
return true
}
if !a.Config.SMTP.DefaultStore &&
stringutil.SliceContains(a.Config.SMTP.StoreDomains, domain) {
return true
}
return false
}
// ParseEmailAddress unescapes an email address, and splits the local part from the domain part.
// An error is returned if the local or domain parts fail validation following the guidelines
// in RFC3696.
func ParseEmailAddress(address string) (local string, domain string, err error) {
local, domain, err = parseEmailAddress(address)
if err != nil {
return "", "", err
}
if !ValidateDomainPart(domain) {
return "", "", fmt.Errorf("Domain part validation failed")
}
return local, domain, nil
}
// ValidateDomainPart returns true if the domain part complies to RFC3696, RFC1035. Used by
// ParseEmailAddress().
func ValidateDomainPart(domain string) bool {
if len(domain) == 0 {
return false
@@ -79,22 +120,21 @@ func ValidateDomainPart(domain string) bool {
prev := '.'
labelLen := 0
hasAlphaNum := false
for _, c := range domain {
switch {
case ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z') ||
('0' <= c && c <= '9') || c == '_':
// Must contain some of these to be a valid label
// Must contain some of these to be a valid label.
hasAlphaNum = true
labelLen++
case c == '-':
if prev == '.' {
// Cannot lead with hyphen
// Cannot lead with hyphen.
return false
}
case c == '.':
if prev == '.' || prev == '-' {
// Cannot end with hyphen or double-dot
// Cannot end with hyphen or double-dot.
return false
}
if labelLen > 63 {
@@ -106,19 +146,18 @@ func ValidateDomainPart(domain string) bool {
labelLen = 0
hasAlphaNum = false
default:
// Unknown character
// Unknown character.
return false
}
prev = c
}
return true
}
// ParseEmailAddress unescapes an email address, and splits the local part from the domain part.
// An error is returned if the local or domain parts fail validation following the guidelines
// in RFC3696.
func ParseEmailAddress(address string) (local string, domain string, err error) {
// parseEmailAddress unescapes an email address, and splits the local part from the domain part. An
// error is returned if the local part fails validation following the guidelines in RFC3696. The
// domain part is optional and not validated.
func parseEmailAddress(address string) (local string, domain string, err error) {
if address == "" {
return "", "", fmt.Errorf("Empty address")
}
@@ -131,8 +170,7 @@ func ParseEmailAddress(address string) (local string, domain string, err error)
if address[0] == '.' {
return "", "", fmt.Errorf("Address cannot start with a period")
}
// Loop over address parsing out local part
// Loop over address parsing out local part.
buf := new(bytes.Buffer)
prev := byte('.')
inCharQuote := false
@@ -142,30 +180,45 @@ LOOP:
c := address[i]
switch {
case ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z'):
// Letters are OK
_ = buf.WriteByte(c)
// Letters are OK.
err = buf.WriteByte(c)
if err != nil {
return
}
inCharQuote = false
case '0' <= c && c <= '9':
// Numbers are OK
_ = buf.WriteByte(c)
// Numbers are OK.
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)
// These specials can be used unquoted.
err = buf.WriteByte(c)
if err != nil {
return
}
inCharQuote = false
case c == '.':
// A single period is OK
// A single period is OK.
if prev == '.' {
// Sequence of periods is not permitted
// 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,10 +231,13 @@ LOOP:
}
case c == '@':
if inCharQuote || inStringQuote {
_ = buf.WriteByte(c)
err = buf.WriteByte(c)
if err != nil {
return
}
inCharQuote = false
} else {
// End of local-part
// End of local-part.
if i > 128 {
return "", "", fmt.Errorf("Local part must not exceed 128 characters")
}
@@ -195,7 +251,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)
@@ -209,10 +268,34 @@ LOOP:
if inStringQuote {
return "", "", fmt.Errorf("Cannot end address with unterminated string quote")
}
if !ValidateDomainPart(domain) {
return "", "", fmt.Errorf("Domain part validation failed")
}
return buf.String(), domain, nil
}
// ParseMailboxName takes a localPart string (ex: "user+ext" without "@domain")
// and returns just the mailbox name (ex: "user"). Returns an error if
// localPart contains invalid characters; it won't accept any that must be
// quoted according to RFC3696.
func parseMailboxName(localPart string) (result string, err error) {
if localPart == "" {
return "", fmt.Errorf("Mailbox name cannot be empty")
}
result = strings.ToLower(localPart)
invalid := make([]byte, 0, 10)
for i := 0; i < len(result); i++ {
c := result[i]
switch {
case 'a' <= c && c <= 'z':
case '0' <= c && c <= '9':
case bytes.IndexByte([]byte("!#$%&'*+-=/?^_`.{|}~"), c) >= 0:
default:
invalid = append(invalid, c)
}
}
if len(invalid) > 0 {
return "", fmt.Errorf("Mailbox name contained invalid character(s): %q", invalid)
}
if idx := strings.Index(result, "+"); idx > -1 {
result = result[0:idx]
}
return result, nil
}

374
pkg/policy/address_test.go Normal file
View File

@@ -0,0 +1,374 @@
package policy_test
import (
"strings"
"testing"
"github.com/jhillyerd/inbucket/pkg/config"
"github.com/jhillyerd/inbucket/pkg/policy"
)
func TestShouldAcceptDomain(t *testing.T) {
// Test with default accept.
ap := &policy.Addressing{
Config: &config.Root{
SMTP: config.SMTP{
DefaultAccept: true,
RejectDomains: []string{"a.deny.com", "deny.com"},
},
},
}
testCases := []struct {
domain string
want bool
}{
{domain: "bar.com", want: true},
{domain: "DENY.com", want: false},
{domain: "a.deny.com", want: false},
{domain: "b.deny.com", want: true},
}
for _, tc := range testCases {
t.Run(tc.domain, func(t *testing.T) {
got := ap.ShouldAcceptDomain(tc.domain)
if got != tc.want {
t.Errorf("Got %v for %q, want: %v", got, tc.domain, tc.want)
}
})
}
// Test with default reject.
ap = &policy.Addressing{
Config: &config.Root{
SMTP: config.SMTP{
DefaultAccept: false,
AcceptDomains: []string{"a.allow.com", "allow.com"},
},
},
}
testCases = []struct {
domain string
want bool
}{
{domain: "bar.com", want: false},
{domain: "ALLOW.com", want: true},
{domain: "a.allow.com", want: true},
{domain: "b.allow.com", want: false},
}
for _, tc := range testCases {
t.Run(tc.domain, func(t *testing.T) {
got := ap.ShouldAcceptDomain(tc.domain)
if got != tc.want {
t.Errorf("Got %v for %q, want: %v", got, tc.domain, tc.want)
}
})
}
}
func TestShouldStoreDomain(t *testing.T) {
// Test with storage enabled.
ap := &policy.Addressing{
Config: &config.Root{
SMTP: config.SMTP{
DefaultStore: false,
StoreDomains: []string{"store.com", "a.store.com"},
},
},
}
testCases := []struct {
domain string
want bool
}{
{domain: "foo.com", want: false},
{domain: "STORE.com", want: true},
{domain: "a.store.com", want: true},
{domain: "b.store.com", want: false},
}
for _, tc := range testCases {
t.Run(tc.domain, func(t *testing.T) {
got := ap.ShouldStoreDomain(tc.domain)
if got != tc.want {
t.Errorf("Got store %v for %q, want: %v", got, tc.domain, tc.want)
}
})
}
// Test with storage disabled.
ap = &policy.Addressing{
Config: &config.Root{
SMTP: config.SMTP{
DefaultStore: true,
DiscardDomains: []string{"discard.com", "a.discard.com"},
},
},
}
testCases = []struct {
domain string
want bool
}{
{domain: "foo.com", want: true},
{domain: "DISCARD.com", want: false},
{domain: "a.discard.com", want: false},
{domain: "b.discard.com", want: true},
}
for _, tc := range testCases {
t.Run(tc.domain, func(t *testing.T) {
got := ap.ShouldStoreDomain(tc.domain)
if got != tc.want {
t.Errorf("Got store %v for %q, want: %v", got, tc.domain, tc.want)
}
})
}
}
func TestExtractMailboxValid(t *testing.T) {
localPolicy := policy.Addressing{Config: &config.Root{MailboxNaming: config.LocalNaming}}
fullPolicy := policy.Addressing{Config: &config.Root{MailboxNaming: config.FullNaming}}
testTable := []struct {
input string // Input to test
local string // Expected output when mailbox naming = local
full string // Expected output when mailbox naming = full
}{
{
input: "mailbox",
local: "mailbox",
full: "mailbox",
},
{
input: "user123",
local: "user123",
full: "user123",
},
{
input: "MailBOX",
local: "mailbox",
full: "mailbox",
},
{
input: "First.Last",
local: "first.last",
full: "first.last",
},
{
input: "user+label",
local: "user",
full: "user",
},
{
input: "chars!#$%",
local: "chars!#$%",
full: "chars!#$%",
},
{
input: "chars&'*-",
local: "chars&'*-",
full: "chars&'*-",
},
{
input: "chars=/?^",
local: "chars=/?^",
full: "chars=/?^",
},
{
input: "chars_`.{",
local: "chars_`.{",
full: "chars_`.{",
},
{
input: "chars|}~",
local: "chars|}~",
full: "chars|}~",
},
{
input: "mailbox@domain.com",
local: "mailbox",
full: "mailbox@domain.com",
},
{
input: "user123@domain.com",
local: "user123",
full: "user123@domain.com",
},
{
input: "MailBOX@domain.com",
local: "mailbox",
full: "mailbox@domain.com",
},
{
input: "First.Last@domain.com",
local: "first.last",
full: "first.last@domain.com",
},
{
input: "user+label@domain.com",
local: "user",
full: "user@domain.com",
},
{
input: "chars!#$%@domain.com",
local: "chars!#$%",
full: "chars!#$%@domain.com",
},
{
input: "chars&'*-@domain.com",
local: "chars&'*-",
full: "chars&'*-@domain.com",
},
{
input: "chars=/?^@domain.com",
local: "chars=/?^",
full: "chars=/?^@domain.com",
},
{
input: "chars_`.{@domain.com",
local: "chars_`.{",
full: "chars_`.{@domain.com",
},
{
input: "chars|}~@domain.com",
local: "chars|}~",
full: "chars|}~@domain.com",
},
}
for _, tc := range testTable {
if result, err := localPolicy.ExtractMailbox(tc.input); err != nil {
t.Errorf("Error while parsing with local naming %q: %v", tc.input, err)
} else {
if result != tc.local {
t.Errorf("Parsing %q, expected %q, got %q", tc.input, tc.local, result)
}
}
if result, err := fullPolicy.ExtractMailbox(tc.input); err != nil {
t.Errorf("Error while parsing with full naming %q: %v", tc.input, err)
} else {
if result != tc.full {
t.Errorf("Parsing %q, expected %q, got %q", tc.input, tc.full, result)
}
}
}
}
func TestExtractMailboxInvalid(t *testing.T) {
localPolicy := policy.Addressing{Config: &config.Root{MailboxNaming: config.LocalNaming}}
fullPolicy := policy.Addressing{Config: &config.Root{MailboxNaming: config.FullNaming}}
// Test local mailbox naming policy.
localInvalidTable := []struct {
input, msg string
}{
{"", "Empty mailbox name is not permitted"},
{"first last", "Space not permitted"},
{"first\"last", "Double quote not permitted"},
{"first\nlast", "Control chars not permitted"},
}
for _, tt := range localInvalidTable {
if _, err := localPolicy.ExtractMailbox(tt.input); err == nil {
t.Errorf("Didn't get an error while parsing in local mode %q: %v", tt.input, tt.msg)
}
}
// Test full mailbox naming policy.
fullInvalidTable := []struct {
input, msg string
}{
{"", "Empty mailbox name is not permitted"},
{"user@host@domain.com", "@ symbol not permitted"},
{"first last@domain.com", "Space not permitted"},
{"first\"last@domain.com", "Double quote not permitted"},
{"first\nlast@domain.com", "Control chars not permitted"},
}
for _, tt := range fullInvalidTable {
if _, err := fullPolicy.ExtractMailbox(tt.input); err == nil {
t.Errorf("Didn't get an error while parsing in full mode %q: %v", tt.input, tt.msg)
}
}
}
func TestValidateDomain(t *testing.T) {
testTable := []struct {
input string
expect bool
msg string
}{
{"", false, "Empty domain is not valid"},
{"hostname", true, "Just a hostname is valid"},
{"github.com", true, "Two labels should be just fine"},
{"my-domain.com", true, "Hyphen is allowed mid-label"},
{"_domainkey.foo.com", true, "Underscores are allowed"},
{"bar.com.", true, "Must be able to end with a dot"},
{"ABC.6DBS.com", true, "Mixed case is OK"},
{"mail.123.com", true, "Number only label valid"},
{"123.com", true, "Number only label valid"},
{"google..com", false, "Double dot not valid"},
{".foo.com", false, "Cannot start with a dot"},
{"google\r.com", false, "Special chars not allowed"},
{"foo.-bar.com", false, "Label cannot start with hyphen"},
{"foo-.bar.com", false, "Label cannot end with hyphen"},
{strings.Repeat("a", 256), false, "Max domain length is 255"},
{strings.Repeat("a", 63) + ".com", true, "Should allow 63 char domain label"},
{strings.Repeat("a", 64) + ".com", false, "Max domain label length is 63"},
}
for _, tt := range testTable {
if policy.ValidateDomainPart(tt.input) != tt.expect {
t.Errorf("Expected %v for %q: %s", tt.expect, tt.input, tt.msg)
}
}
}
func TestValidateLocal(t *testing.T) {
testTable := []struct {
input string
expect bool
msg string
}{
{"", false, "Empty local is not valid"},
{"a", true, "Single letter should be fine"},
{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"},
{"first.last", true, "Embedded period is permitted"},
{"first..last", false, "Sequence of periods is not allowed"},
{".user", false, "Cannot lead with a period"},
{"user.", false, "Cannot end with a period"},
// {"james@mail", false, "Unquoted @ not permitted"},
{"first last", false, "Unquoted space not permitted"},
{"tricky\\. ", false, "Unquoted space not permitted"},
{"no,commas", false, "Unquoted comma not allowed"},
{"t[es]t", false, "Unquoted square brackets not allowed"},
// {"james\\", false, "Cannot end with backslash quote"},
{"james\\@mail", true, "Quoted @ permitted"},
{"quoted\\ space", true, "Quoted space permitted"},
{"no\\,commas", true, "Quoted comma is OK"},
{"t\\[es\\]t", true, "Quoted brackets are OK"},
{"user\\name", true, "Should be able to quote a-z"},
{"USER\\NAME", true, "Should be able to quote A-Z"},
{"user\\1", true, "Should be able to quote a digit"},
{"one\\$\\|", true, "Should be able to quote plain specials"},
{"return\\\r", true, "Should be able to quote ASCII control chars"},
{"high\\\x80", false, "Should not accept > 7-bit quoted chars"},
{"quote\\\"", true, "Quoted double quote is permitted"},
{"\"james\"", true, "Quoted a-z is permitted"},
{"\"first last\"", true, "Quoted space is permitted"},
{"\"quoted@sign\"", true, "Quoted @ is allowed"},
{"\"qp\\\"quote\"", true, "Quoted quote within quoted string is OK"},
{"\"unterminated", false, "Quoted string must be terminated"},
{"\"unterminated\\\"", false, "Quoted string must be terminated"},
{"embed\"quote\"string", false, "Embedded quoted string is illegal"},
{"user+mailbox", true, "RFC3696 test case should be valid"},
{"customer/department=shipping", true, "RFC3696 test case should be valid"},
{"$A12345", true, "RFC3696 test case should be valid"},
{"!def!xyz%abc", true, "RFC3696 test case should be valid"},
{"_somename", true, "RFC3696 test case should be valid"},
}
for _, tt := range testTable {
_, _, err := policy.ParseEmailAddress(tt.input + "@domain.com")
if (err != nil) == tt.expect {
if err != nil {
t.Logf("Got error: %s", err)
}
t.Errorf("Expected %v for %q: %s", tt.expect, tt.input, tt.msg)
}
}
}

25
pkg/policy/recipient.go Normal file
View File

@@ -0,0 +1,25 @@
package policy
import "net/mail"
// Recipient represents a potential email recipient, allows policies for it to be queried.
type Recipient struct {
mail.Address
addrPolicy *Addressing
// LocalPart is the part of the address before @, including +extension.
LocalPart string
// Domain is the part of the address after @.
Domain string
// Mailbox is the canonical mailbox name for this recipient.
Mailbox string
}
// ShouldAccept returns true if Inbucket should accept mail for this recipient.
func (r *Recipient) ShouldAccept() bool {
return r.addrPolicy.ShouldAcceptDomain(r.Domain)
}
// ShouldStore returns true if Inbucket should store mail for this recipient.
func (r *Recipient) ShouldStore() bool {
return r.addrPolicy.ShouldStoreDomain(r.Domain)
}

View File

@@ -0,0 +1,179 @@
package rest
import (
"fmt"
"io"
"net/http"
"crypto/md5"
"encoding/hex"
"encoding/json"
"strconv"
"github.com/jhillyerd/inbucket/pkg/rest/model"
"github.com/jhillyerd/inbucket/pkg/server/web"
"github.com/jhillyerd/inbucket/pkg/storage"
"github.com/jhillyerd/inbucket/pkg/stringutil"
)
// MailboxListV1 renders a list of messages in a mailbox
func MailboxListV1(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
// Don't have to validate these aren't empty, Gorilla returns 404
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
if err != nil {
return err
}
messages, err := ctx.Manager.GetMetadata(name)
if err != nil {
// This doesn't indicate empty, likely an IO error
return fmt.Errorf("Failed to get messages for %v: %v", name, err)
}
jmessages := make([]*model.JSONMessageHeaderV1, len(messages))
for i, msg := range messages {
jmessages[i] = &model.JSONMessageHeaderV1{
Mailbox: name,
ID: msg.ID,
From: msg.From.String(),
To: stringutil.StringAddressList(msg.To),
Subject: msg.Subject,
Date: msg.Date,
Size: msg.Size,
Seen: msg.Seen,
}
}
return web.RenderJSON(w, jmessages)
}
// MailboxShowV1 renders a particular message from a mailbox
func MailboxShowV1(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
// Don't have to validate these aren't empty, Gorilla returns 404
id := ctx.Vars["id"]
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
if err != nil {
return err
}
msg, err := ctx.Manager.GetMessage(name, id)
if err != nil && err != storage.ErrNotExist {
return fmt.Errorf("GetMessage(%q) failed: %v", id, err)
}
if msg == nil {
http.NotFound(w, req)
return nil
}
attachParts := msg.Attachments()
attachments := make([]*model.JSONMessageAttachmentV1, len(attachParts))
for i, part := range attachParts {
content := part.Content
var checksum = md5.Sum(content)
attachments[i] = &model.JSONMessageAttachmentV1{
ContentType: part.ContentType,
FileName: part.FileName,
DownloadLink: "http://" + req.Host + "/mailbox/dattach/" + name + "/" + id + "/" +
strconv.Itoa(i) + "/" + part.FileName,
ViewLink: "http://" + req.Host + "/mailbox/vattach/" + name + "/" + id + "/" +
strconv.Itoa(i) + "/" + part.FileName,
MD5: hex.EncodeToString(checksum[:]),
}
}
return web.RenderJSON(w,
&model.JSONMessageV1{
Mailbox: name,
ID: msg.ID,
From: msg.From.String(),
To: stringutil.StringAddressList(msg.To),
Subject: msg.Subject,
Date: msg.Date,
Size: msg.Size,
Seen: msg.Seen,
Header: msg.Header(),
Body: &model.JSONMessageBodyV1{
Text: msg.Text(),
HTML: msg.HTML(),
},
Attachments: attachments,
})
}
// MailboxMarkSeenV1 marks a message as read.
func MailboxMarkSeenV1(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
// Don't have to validate these aren't empty, Gorilla returns 404
id := ctx.Vars["id"]
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
if err != nil {
return err
}
dec := json.NewDecoder(req.Body)
dm := model.JSONMessageHeaderV1{}
if err := dec.Decode(&dm); err != nil {
return fmt.Errorf("Failed to decode JSON: %v", err)
}
if dm.Seen {
err = ctx.Manager.MarkSeen(name, id)
if err == storage.ErrNotExist {
http.NotFound(w, req)
return nil
}
if err != nil {
// This doesn't indicate empty, likely an IO error
return fmt.Errorf("MarkSeen(%q) failed: %v", id, err)
}
}
return web.RenderJSON(w, "OK")
}
// MailboxPurgeV1 deletes all messages from a mailbox
func MailboxPurgeV1(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
// Don't have to validate these aren't empty, Gorilla returns 404
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
if err != nil {
return err
}
// Delete all messages
err = ctx.Manager.PurgeMessages(name)
if err != nil {
return fmt.Errorf("Mailbox(%q) purge failed: %v", name, err)
}
return web.RenderJSON(w, "OK")
}
// MailboxSourceV1 displays the raw source of a message, including headers. Renders text/plain
func MailboxSourceV1(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
// Don't have to validate these aren't empty, Gorilla returns 404
id := ctx.Vars["id"]
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
if err != nil {
return err
}
r, err := ctx.Manager.SourceReader(name, id)
if err != nil && err != storage.ErrNotExist {
return fmt.Errorf("SourceReader(%q) failed: %v", id, err)
}
if r == nil {
http.NotFound(w, req)
return nil
}
// Output message source
w.Header().Set("Content-Type", "text/plain")
_, err = io.Copy(w, r)
return err
}
// MailboxDeleteV1 removes a particular message from a mailbox
func MailboxDeleteV1(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
// Don't have to validate these aren't empty, Gorilla returns 404
id := ctx.Vars["id"]
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
if err != nil {
return err
}
err = ctx.Manager.RemoveMessage(name, id)
if err == storage.ErrNotExist {
http.NotFound(w, req)
return nil
}
if err != nil {
// This doesn't indicate missing, likely an IO error
return fmt.Errorf("RemoveMessage(%q) failed: %v", id, err)
}
return web.RenderJSON(w, "OK")
}

View File

@@ -0,0 +1,304 @@
package rest
import (
"encoding/json"
"io"
"net/mail"
"net/textproto"
"os"
"testing"
"time"
"github.com/jhillyerd/enmime"
"github.com/jhillyerd/inbucket/pkg/message"
"github.com/jhillyerd/inbucket/pkg/test"
)
const (
baseURL = "http://localhost/api/v1"
// JSON map keys
mailboxKey = "mailbox"
idKey = "id"
fromKey = "from"
toKey = "to"
subjectKey = "subject"
dateKey = "date"
sizeKey = "size"
headerKey = "header"
bodyKey = "body"
textKey = "text"
htmlKey = "html"
)
func TestRestMailboxList(t *testing.T) {
// Setup
mm := test.NewManager()
logbuf := setupWebServer(mm)
// Test invalid mailbox name
w, err := testRestGet(baseURL + "/mailbox/foo%20bar")
expectCode := 500
if err != nil {
t.Fatal(err)
}
if w.Code != expectCode {
t.Errorf("Expected code %v, got %v", expectCode, w.Code)
}
// Test empty mailbox
w, err = testRestGet(baseURL + "/mailbox/empty")
expectCode = 200
if err != nil {
t.Fatal(err)
}
if w.Code != expectCode {
t.Errorf("Expected code %v, got %v", expectCode, w.Code)
}
// Test Mailbox error
w, err = testRestGet(baseURL + "/mailbox/messageserr")
expectCode = 500
if err != nil {
t.Fatal(err)
}
if w.Code != expectCode {
t.Errorf("Expected code %v, got %v", expectCode, w.Code)
}
// Test JSON message headers
tzPDT := time.FixedZone("PDT", -7*3600)
tzPST := time.FixedZone("PST", -8*3600)
meta1 := message.Metadata{
Mailbox: "good",
ID: "0001",
From: &mail.Address{Name: "", Address: "from1@host"},
To: []*mail.Address{{Name: "", Address: "to1@host"}},
Subject: "subject 1",
Date: time.Date(2012, 2, 1, 10, 11, 12, 253, tzPST),
}
meta2 := message.Metadata{
Mailbox: "good",
ID: "0002",
From: &mail.Address{Name: "", Address: "from2@host"},
To: []*mail.Address{{Name: "", Address: "to1@host"}},
Subject: "subject 2",
Date: time.Date(2012, 7, 1, 10, 11, 12, 253, tzPDT),
}
mm.AddMessage("good", &message.Message{Metadata: meta1})
mm.AddMessage("good", &message.Message{Metadata: meta2})
// Check return code
w, err = testRestGet(baseURL + "/mailbox/good")
expectCode = 200
if err != nil {
t.Fatal(err)
}
if w.Code != expectCode {
t.Fatalf("Expected code %v, got %v", expectCode, w.Code)
}
// Check JSON
dec := json.NewDecoder(w.Body)
var result []interface{}
if err := dec.Decode(&result); err != nil {
t.Errorf("Failed to decode JSON: %v", err)
}
if len(result) != 2 {
t.Fatalf("Expected 2 results, got %v", len(result))
}
decodedStringEquals(t, result, "[0]/mailbox", "good")
decodedStringEquals(t, result, "[0]/id", "0001")
decodedStringEquals(t, result, "[0]/from", "<from1@host>")
decodedStringEquals(t, result, "[0]/to/[0]", "<to1@host>")
decodedStringEquals(t, result, "[0]/subject", "subject 1")
decodedStringEquals(t, result, "[0]/date", "2012-02-01T10:11:12.000000253-08:00")
decodedNumberEquals(t, result, "[0]/size", 0)
decodedBoolEquals(t, result, "[0]/seen", false)
decodedStringEquals(t, result, "[1]/mailbox", "good")
decodedStringEquals(t, result, "[1]/id", "0002")
decodedStringEquals(t, result, "[1]/from", "<from2@host>")
decodedStringEquals(t, result, "[1]/to/[0]", "<to1@host>")
decodedStringEquals(t, result, "[1]/subject", "subject 2")
decodedStringEquals(t, result, "[1]/date", "2012-07-01T10:11:12.000000253-07:00")
decodedNumberEquals(t, result, "[1]/size", 0)
decodedBoolEquals(t, result, "[1]/seen", false)
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)
}
}
func TestRestMessage(t *testing.T) {
// Setup
mm := test.NewManager()
logbuf := setupWebServer(mm)
// Test invalid mailbox name
w, err := testRestGet(baseURL + "/mailbox/foo%20bar/0001")
expectCode := 500
if err != nil {
t.Fatal(err)
}
if w.Code != expectCode {
t.Errorf("Expected code %v, got %v", expectCode, w.Code)
}
// Test requesting a message that does not exist
w, err = testRestGet(baseURL + "/mailbox/empty/0001")
expectCode = 404
if err != nil {
t.Fatal(err)
}
if w.Code != expectCode {
t.Errorf("Expected code %v, got %v", expectCode, w.Code)
}
// Test GetMessage error
w, err = testRestGet(baseURL + "/mailbox/messageerr/0001")
expectCode = 500
if err != nil {
t.Fatal(err)
}
if w.Code != expectCode {
t.Errorf("Expected code %v, got %v", expectCode, w.Code)
}
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)
}
// Test JSON message headers
tzPST := time.FixedZone("PST", -8*3600)
msg1 := message.New(
message.Metadata{
Mailbox: "good",
ID: "0001",
From: &mail.Address{Name: "", Address: "from1@host"},
To: []*mail.Address{{Name: "", Address: "to1@host"}},
Subject: "subject 1",
Date: time.Date(2012, 2, 1, 10, 11, 12, 253, tzPST),
Seen: true,
},
&enmime.Envelope{
Text: "This is some text",
HTML: "This is some HTML",
Root: &enmime.Part{
Header: textproto.MIMEHeader{
"To": []string{"fred@fish.com", "keyword@nsa.gov"},
"From": []string{"noreply@inbucket.org"},
},
},
},
)
mm.AddMessage("good", msg1)
// Check return code
w, err = testRestGet(baseURL + "/mailbox/good/0001")
expectCode = 200
if err != nil {
t.Fatal(err)
}
if w.Code != expectCode {
t.Fatalf("Expected code %v, got %v", expectCode, w.Code)
}
// Check JSON
dec := json.NewDecoder(w.Body)
var result map[string]interface{}
if err := dec.Decode(&result); err != nil {
t.Errorf("Failed to decode JSON: %v", err)
}
decodedStringEquals(t, result, "mailbox", "good")
decodedStringEquals(t, result, "id", "0001")
decodedStringEquals(t, result, "from", "<from1@host>")
decodedStringEquals(t, result, "to/[0]", "<to1@host>")
decodedStringEquals(t, result, "subject", "subject 1")
decodedStringEquals(t, result, "date", "2012-02-01T10:11:12.000000253-08:00")
decodedNumberEquals(t, result, "size", 0)
decodedBoolEquals(t, result, "seen", true)
decodedStringEquals(t, result, "body/text", "This is some text")
decodedStringEquals(t, result, "body/html", "This is some HTML")
decodedStringEquals(t, result, "header/To/[0]", "fred@fish.com")
decodedStringEquals(t, result, "header/To/[1]", "keyword@nsa.gov")
decodedStringEquals(t, result, "header/From/[0]", "noreply@inbucket.org")
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)
}
}
func TestRestMarkSeen(t *testing.T) {
mm := test.NewManager()
logbuf := setupWebServer(mm)
// Create some messages.
tzPDT := time.FixedZone("PDT", -7*3600)
tzPST := time.FixedZone("PST", -8*3600)
meta1 := message.Metadata{
Mailbox: "good",
ID: "0001",
From: &mail.Address{Name: "", Address: "from1@host"},
To: []*mail.Address{{Name: "", Address: "to1@host"}},
Subject: "subject 1",
Date: time.Date(2012, 2, 1, 10, 11, 12, 253, tzPST),
}
meta2 := message.Metadata{
Mailbox: "good",
ID: "0002",
From: &mail.Address{Name: "", Address: "from2@host"},
To: []*mail.Address{{Name: "", Address: "to1@host"}},
Subject: "subject 2",
Date: time.Date(2012, 7, 1, 10, 11, 12, 253, tzPDT),
}
mm.AddMessage("good", &message.Message{Metadata: meta1})
mm.AddMessage("good", &message.Message{Metadata: meta2})
// Mark one read.
w, err := testRestPatch(baseURL+"/mailbox/good/0002", `{"seen":true}`)
expectCode := 200
if err != nil {
t.Fatal(err)
}
if w.Code != expectCode {
t.Fatalf("Expected code %v, got %v", expectCode, w.Code)
}
// Get mailbox.
w, err = testRestGet(baseURL + "/mailbox/good")
expectCode = 200
if err != nil {
t.Fatal(err)
}
if w.Code != expectCode {
t.Fatalf("Expected code %v, got %v", expectCode, w.Code)
}
// Check JSON.
dec := json.NewDecoder(w.Body)
var result []interface{}
if err := dec.Decode(&result); err != nil {
t.Errorf("Failed to decode JSON: %v", err)
}
if len(result) != 2 {
t.Fatalf("Expected 2 results, got %v", len(result))
}
decodedStringEquals(t, result, "[0]/id", "0001")
decodedBoolEquals(t, result, "[0]/seen", false)
decodedStringEquals(t, result, "[1]/id", "0002")
decodedBoolEquals(t, result, "[1]/seen", true)
if t.Failed() {
// Wait for handler to finish logging
time.Sleep(2 * time.Second)
// Dump buffered log data if there was a failure
_, _ = io.Copy(os.Stderr, logbuf)
}
}

View File

@@ -8,7 +8,7 @@ import (
"net/url"
"time"
"github.com/jhillyerd/inbucket/rest/model"
"github.com/jhillyerd/inbucket/pkg/rest/model"
)
// Client accesses the Inbucket REST API v1
@@ -58,10 +58,20 @@ func (c *Client) GetMessage(name, id string) (message *Message, err error) {
return
}
// MarkSeen marks the specified message as having been read.
func (c *Client) MarkSeen(name, id string) error {
uri := "/api/v1/mailbox/" + url.QueryEscape(name) + "/" + id
err := c.doJSON("PATCH", uri, nil)
if err != nil {
return err
}
return nil
}
// GetMessageSource returns the message source given a mailbox name and message ID.
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)
resp, err := c.do("GET", uri, nil)
if err != nil {
return nil, err
}
@@ -81,7 +91,7 @@ func (c *Client) GetMessageSource(name, id string) (*bytes.Buffer, error) {
// DeleteMessage deletes a single message given the mailbox name and message ID.
func (c *Client) DeleteMessage(name, id string) error {
uri := "/api/v1/mailbox/" + url.QueryEscape(name) + "/" + id
resp, err := c.do("DELETE", uri)
resp, err := c.do("DELETE", uri, nil)
if err != nil {
return err
}
@@ -95,7 +105,7 @@ func (c *Client) DeleteMessage(name, id string) error {
// 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)
resp, err := c.do("DELETE", uri, nil)
if err != nil {
return err
}

View File

@@ -54,6 +54,32 @@ func TestClientV1GetMessage(t *testing.T) {
}
}
func TestClientV1MarkSeen(t *testing.T) {
var want, got string
c, err := New(baseURLStr)
if err != nil {
t.Fatal(err)
}
mth := &mockHTTPClient{}
c.client = mth
// Method under test
_ = c.MarkSeen("testbox", "20170107T224128-0000")
want = "PATCH"
got = mth.req.Method
if got != want {
t.Errorf("req.Method == %q, want %q", got, want)
}
want = baseURLStr + "/api/v1/mailbox/testbox/20170107T224128-0000"
got = mth.req.URL.String()
if got != want {
t.Errorf("req.URL == %q, want %q", got, want)
}
}
func TestClientV1GetMessageSource(t *testing.T) {
var want, got string
@@ -158,7 +184,8 @@ func TestClientV1MessageHeader(t *testing.T) {
"from":"from1",
"subject":"subject1",
"date":"2017-01-01T00:00:00.000-07:00",
"size":100
"size":100,
"seen":true
}
]`
@@ -216,6 +243,12 @@ func TestClientV1MessageHeader(t *testing.T) {
t.Errorf("Subject == %q, want %q", got, want)
}
wantb := true
gotb := header.Seen
if gotb != wantb {
t.Errorf("Seen == %v, want %v", gotb, wantb)
}
// Test MessageHeader.Delete()
mth.body = ""
err = header.Delete()

View File

@@ -1,8 +1,10 @@
package client
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
)
@@ -18,28 +20,48 @@ type restClient struct {
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) {
// do performs an HTTP request with this client and returns the response.
func (c *restClient) do(method, uri string, body []byte) (*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)
var r io.Reader
if body != nil {
r = bytes.NewReader(body)
}
req, err := http.NewRequest(method, url.String(), r)
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
// doJSON performs an HTTP request with this client and marshalls the JSON response into v.
func (c *restClient) doJSON(method string, uri string, v interface{}) error {
resp, err := c.do(method, uri)
resp, err := c.do(method, uri, nil)
if err != nil {
return err
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode == http.StatusOK {
if v == nil {
return nil
}
// Decode response body
return json.NewDecoder(resp.Body).Decode(v)
}
return fmt.Errorf("Unexpected HTTP response status %v: %s", resp.StatusCode, resp.Status)
}
// doJSONBody performs an HTTP request with this client and marshalls the JSON response into v.
func (c *restClient) doJSONBody(method string, uri string, body []byte, v interface{}) error {
resp, err := c.do(method, uri, body)
if err != nil {
return err
}

View File

@@ -35,17 +35,29 @@ func (m *mockHTTPClient) Do(req *http.Request) (resp *http.Response, err error)
StatusCode: m.statusCode,
Body: ioutil.NopCloser(bytes.NewBufferString(m.body)),
}
return
}
func (m *mockHTTPClient) ReqBody() []byte {
r, err := m.req.GetBody()
if err != nil {
return nil
}
body, err := ioutil.ReadAll(r)
if err != nil {
return nil
}
_ = r.Close()
return body
}
func TestDo(t *testing.T) {
var want, got string
mth := &mockHTTPClient{}
c := &restClient{mth, baseURL}
body := []byte("Test body")
_, err := c.do("POST", "/dopost")
_, err := c.do("POST", "/dopost", body)
if err != nil {
t.Fatal(err)
}
@@ -61,6 +73,11 @@ func TestDo(t *testing.T) {
if got != want {
t.Errorf("req.URL == %q, want %q", got, want)
}
b := mth.ReqBody()
if !bytes.Equal(b, body) {
t.Errorf("req.Body == %q, want %q", b, body)
}
}
func TestDoJSON(t *testing.T) {

View File

@@ -1,7 +1,6 @@
package model
import (
"net/mail"
"time"
)
@@ -14,6 +13,7 @@ type JSONMessageHeaderV1 struct {
Subject string `json:"subject"`
Date time.Time `json:"date"`
Size int64 `json:"size"`
Seen bool `json:"seen"`
}
// JSONMessageV1 contains the same data as the header plus a JSONMessageBody
@@ -25,11 +25,13 @@ type JSONMessageV1 struct {
Subject string `json:"subject"`
Date time.Time `json:"date"`
Size int64 `json:"size"`
Seen bool `json:"seen"`
Body *JSONMessageBodyV1 `json:"body"`
Header mail.Header `json:"header"`
Header map[string][]string `json:"header"`
Attachments []*JSONMessageAttachmentV1 `json:"attachments"`
}
// JSONMessageAttachmentV1 contains information about a MIME attachment
type JSONMessageAttachmentV1 struct {
FileName string `json:"filename"`
ContentType string `json:"content-type"`

25
pkg/rest/routes.go Normal file
View File

@@ -0,0 +1,25 @@
package rest
import "github.com/gorilla/mux"
import "github.com/jhillyerd/inbucket/pkg/server/web"
// SetupRoutes populates the routes for the REST interface
func SetupRoutes(r *mux.Router) {
// API v1
r.Path("/api/v1/mailbox/{name}").Handler(
web.Handler(MailboxListV1)).Name("MailboxListV1").Methods("GET")
r.Path("/api/v1/mailbox/{name}").Handler(
web.Handler(MailboxPurgeV1)).Name("MailboxPurgeV1").Methods("DELETE")
r.Path("/api/v1/mailbox/{name}/{id}").Handler(
web.Handler(MailboxShowV1)).Name("MailboxShowV1").Methods("GET")
r.Path("/api/v1/mailbox/{name}/{id}").Handler(
web.Handler(MailboxMarkSeenV1)).Name("MailboxMarkSeenV1").Methods("PATCH")
r.Path("/api/v1/mailbox/{name}/{id}").Handler(
web.Handler(MailboxDeleteV1)).Name("MailboxDeleteV1").Methods("DELETE")
r.Path("/api/v1/mailbox/{name}/{id}/source").Handler(
web.Handler(MailboxSourceV1)).Name("MailboxSourceV1").Methods("GET")
r.Path("/api/v1/monitor/messages").Handler(
web.Handler(MonitorAllMessagesV1)).Name("MonitorAllMessagesV1").Methods("GET")
r.Path("/api/v1/monitor/messages/{name}").Handler(
web.Handler(MonitorMailboxMessagesV1)).Name("MonitorMailboxMessagesV1").Methods("GET")
}

View File

@@ -5,11 +5,10 @@ import (
"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/smtpd"
"github.com/jhillyerd/inbucket/pkg/msghub"
"github.com/jhillyerd/inbucket/pkg/rest/model"
"github.com/jhillyerd/inbucket/pkg/server/web"
"github.com/rs/zerolog/log"
)
const (
@@ -63,11 +62,13 @@ func (ml *msgListener) Receive(msg msghub.Message) error {
// WSReader makes sure the websocket client is still connected, discards any messages from client
func (ml *msgListener) WSReader(conn *websocket.Conn) {
slog := log.With().Str("module", "rest").Str("proto", "WebSocket").
Str("remote", conn.RemoteAddr().String()).Logger()
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())
slog.Debug().Msg("Got pong")
conn.SetReadDeadline(time.Now().Add(pongWait))
return nil
})
@@ -81,9 +82,9 @@ func (ml *msgListener) WSReader(conn *websocket.Conn) {
websocket.CloseNoStatusReceived,
) {
// Unexpected close code
log.Warnf("HTTP[%v] WebSocket error: %v", conn.RemoteAddr(), err)
slog.Warn().Err(err).Msg("Socket error")
} else {
log.Tracef("HTTP[%v] Closing WebSocket", conn.RemoteAddr())
slog.Debug().Msg("Closing socket")
}
break
}
@@ -128,7 +129,8 @@ func (ml *msgListener) WSWriter(conn *websocket.Conn) {
// Write error
return
}
log.Tracef("HTTP[%v] Sent WebSocket ping", conn.RemoteAddr())
log.Debug().Str("module", "rest").Str("proto", "WebSocket").
Str("remote", conn.RemoteAddr().String()).Msg("Sent ping")
}
}
}
@@ -144,52 +146,52 @@ func (ml *msgListener) Close() {
}
}
// MonitorAllMessagesV1 is a web handler which upgrades the connection to a websocket and notifies
// the client of all messages received.
func MonitorAllMessagesV1(
w http.ResponseWriter, req *http.Request, ctx *httpd.Context) (err error) {
// Upgrade to Websocket
w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
// Upgrade to Websocket.
conn, err := upgrader.Upgrade(w, req, nil)
if err != nil {
return err
}
httpd.ExpWebSocketConnectsCurrent.Add(1)
web.ExpWebSocketConnectsCurrent.Add(1)
defer func() {
_ = conn.Close()
httpd.ExpWebSocketConnectsCurrent.Add(-1)
web.ExpWebSocketConnectsCurrent.Add(-1)
}()
log.Tracef("HTTP[%v] Upgraded to websocket", req.RemoteAddr)
// Create, register listener; then interact with conn
log.Debug().Str("module", "rest").Str("proto", "WebSocket").
Str("remote", conn.RemoteAddr().String()).Msg("Upgraded to WebSocket")
// Create, register listener; then interact with conn.
ml := newMsgListener(ctx.MsgHub, "")
go ml.WSWriter(conn)
ml.WSReader(conn)
return nil
}
// MonitorMailboxMessagesV1 is a web handler which upgrades the connection to a websocket and
// notifies the client of messages received by a particular mailbox.
func MonitorMailboxMessagesV1(
w http.ResponseWriter, req *http.Request, ctx *httpd.Context) (err error) {
name, err := smtpd.ParseMailboxName(ctx.Vars["name"])
w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
if err != nil {
return err
}
// Upgrade to Websocket
// Upgrade to Websocket.
conn, err := upgrader.Upgrade(w, req, nil)
if err != nil {
return err
}
httpd.ExpWebSocketConnectsCurrent.Add(1)
web.ExpWebSocketConnectsCurrent.Add(1)
defer func() {
_ = conn.Close()
httpd.ExpWebSocketConnectsCurrent.Add(-1)
web.ExpWebSocketConnectsCurrent.Add(-1)
}()
log.Tracef("HTTP[%v] Upgraded to websocket", req.RemoteAddr)
// Create, register listener; then interact with conn
log.Debug().Str("module", "rest").Str("proto", "WebSocket").
Str("remote", conn.RemoteAddr().String()).Msg("Upgraded to WebSocket")
// Create, register listener; then interact with conn.
ml := newMsgListener(ctx.MsgHub, name)
go ml.WSWriter(conn)
ml.WSReader(conn)
return nil
}

159
pkg/rest/testutils_test.go Normal file
View File

@@ -0,0 +1,159 @@
package rest
import (
"bytes"
"log"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"github.com/jhillyerd/inbucket/pkg/config"
"github.com/jhillyerd/inbucket/pkg/message"
"github.com/jhillyerd/inbucket/pkg/msghub"
"github.com/jhillyerd/inbucket/pkg/server/web"
)
func testRestGet(url string) (*httptest.ResponseRecorder, error) {
req, err := http.NewRequest("GET", url, nil)
req.Header.Add("Accept", "application/json")
if err != nil {
return nil, err
}
w := httptest.NewRecorder()
web.Router.ServeHTTP(w, req)
return w, nil
}
func testRestPatch(url string, body string) (*httptest.ResponseRecorder, error) {
req, err := http.NewRequest("PATCH", url, strings.NewReader(body))
req.Header.Add("Accept", "application/json")
if err != nil {
return nil, err
}
w := httptest.NewRecorder()
web.Router.ServeHTTP(w, req)
return w, nil
}
func setupWebServer(mm message.Manager) *bytes.Buffer {
// Capture log output
buf := new(bytes.Buffer)
log.SetOutput(buf)
// Have to reset default mux to prevent duplicate routes
http.DefaultServeMux = http.NewServeMux()
cfg := &config.Root{
Web: config.Web{
UIDir: "../ui",
},
}
shutdownChan := make(chan bool)
web.Initialize(cfg, shutdownChan, mm, &msghub.Hub{})
SetupRoutes(web.Router)
return buf
}
func decodedBoolEquals(t *testing.T, json interface{}, path string, want bool) {
t.Helper()
els := strings.Split(path, "/")
val, msg := getDecodedPath(json, els...)
if msg != "" {
t.Errorf("JSON result%s", msg)
return
}
if got, ok := val.(bool); ok {
if got == want {
return
}
}
t.Errorf("JSON result/%s == %v (%T), want: %v", path, val, val, want)
}
func decodedNumberEquals(t *testing.T, json interface{}, path string, want float64) {
t.Helper()
els := strings.Split(path, "/")
val, msg := getDecodedPath(json, els...)
if msg != "" {
t.Errorf("JSON result%s", msg)
return
}
if got, ok := val.(float64); ok {
if got == want {
return
}
}
t.Errorf("JSON result/%s == %v (%T), want: %v", path, val, val, want)
}
func decodedStringEquals(t *testing.T, json interface{}, path string, want string) {
t.Helper()
els := strings.Split(path, "/")
val, msg := getDecodedPath(json, els...)
if msg != "" {
t.Errorf("JSON result%s", msg)
return
}
if got, ok := val.(string); ok {
if got == want {
return
}
}
t.Errorf("JSON result/%s == %v (%T), want: %v", path, val, val, want)
}
// getDecodedPath recursively navigates the specified path, returing the requested element. If
// something goes wrong, the returned string will contain an explanation.
//
// Named path elements require the parent element to be a map[string]interface{}, numbers in square
// brackets require the parent element to be a []interface{}.
//
// getDecodedPath(o, "users", "[1]", "name")
//
// is equivalent to the JavaScript:
//
// o.users[1].name
//
func getDecodedPath(o interface{}, path ...string) (interface{}, string) {
if len(path) == 0 {
return o, ""
}
if o == nil {
return nil, " is nil"
}
key := path[0]
present := false
var val interface{}
if key[0] == '[' {
// Expecting slice.
index, err := strconv.Atoi(strings.Trim(key, "[]"))
if err != nil {
return nil, "/" + key + " is not a slice index"
}
oslice, ok := o.([]interface{})
if !ok {
return nil, " is not a slice"
}
if index >= len(oslice) {
return nil, "/" + key + " is out of bounds"
}
val, present = oslice[index], true
} else {
// Expecting map.
omap, ok := o.(map[string]interface{})
if !ok {
return nil, " is not a map"
}
val, present = omap[key]
}
if !present {
return nil, "/" + key + " is missing"
}
result, msg := getDecodedPath(val, path[1:]...)
if msg != "" {
return nil, "/" + key + msg
}
return result, ""
}

601
pkg/server/pop3/handler.go Normal file
View File

@@ -0,0 +1,601 @@
package pop3
import (
"bufio"
"fmt"
"io"
"net"
"os"
"strconv"
"strings"
"time"
"github.com/jhillyerd/inbucket/pkg/storage"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
// State tracks the current mode of our POP3 state machine
type State int
const (
// AUTHORIZATION state: the client must now identify and authenticate
AUTHORIZATION State = iota
// TRANSACTION state: mailbox open, client may now issue commands
TRANSACTION
// QUIT state: client requests us to end session
QUIT
)
func (s State) String() string {
switch s {
case AUTHORIZATION:
return "AUTHORIZATION"
case TRANSACTION:
return "TRANSACTION"
case QUIT:
return "QUIT"
}
return "Unknown"
}
var commands = map[string]bool{
"QUIT": true,
"STAT": true,
"LIST": true,
"RETR": true,
"DELE": true,
"NOOP": true,
"RSET": true,
"TOP": true,
"UIDL": true,
"USER": true,
"PASS": true,
"APOP": true,
"CAPA": true,
}
// Session defines an active POP3 session
type Session struct {
*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.
messages []storage.Message // Slice of messages in mailbox.
retain []bool // Messages to retain upon UPDATE (true=retain).
msgCount int // Number of undeleted messages.
logger zerolog.Logger // Session specific logger.
debug bool // Print network traffic to stdout.
}
// NewSession creates a new POP3 session
func NewSession(server *Server, id int, conn net.Conn, logger zerolog.Logger) *Session {
reader := bufio.NewReader(conn)
host, _, _ := net.SplitHostPort(conn.RemoteAddr().String())
return &Session{
Server: server,
id: id,
conn: conn,
state: AUTHORIZATION,
reader: reader,
remoteHost: host,
logger: logger,
debug: server.config.Debug,
}
}
func (s *Session) String() string {
return fmt.Sprintf("Session{id: %v, state: %v}", s.id, s.state)
}
/* Session flow:
* 1. Send initial greeting
* 2. Receive cmd
* 3. If good cmd, respond, optionally change state
* 4. If bad cmd, respond error
* 5. Goto 2
*/
func (s *Server) startSession(id int, conn net.Conn) {
logger := log.With().Str("module", "pop3").Str("remote", conn.RemoteAddr().String()).
Int("session", id).Logger()
logger.Info().Msg("Starting POP3 session")
defer func() {
if err := conn.Close(); err != nil {
logger.Warn().Err(err).Msg("Closing connection")
}
s.wg.Done()
}()
ssn := NewSession(s, id, conn, logger)
ssn.send(fmt.Sprintf("+OK Inbucket POP3 server ready <%v.%v@%v>", os.Getpid(),
time.Now().Unix(), s.config.Domain))
// This is our command reading loop
for ssn.state != QUIT && ssn.sendError == nil {
line, err := ssn.readLine()
if err == nil {
if cmd, arg, ok := ssn.parseCmd(line); ok {
// Check against valid SMTP commands
if cmd == "" {
ssn.send("-ERR Speak up")
continue
}
if !commands[cmd] {
ssn.send(fmt.Sprintf("-ERR Syntax error, %v command unrecognized", cmd))
ssn.logger.Warn().Msgf("Unrecognized command: %v", cmd)
continue
}
// Commands we handle in any state
switch cmd {
case "CAPA":
// List our capabilities per RFC2449
ssn.send("+OK Capability list follows")
ssn.send("TOP")
ssn.send("USER")
ssn.send("UIDL")
ssn.send("IMPLEMENTATION Inbucket")
ssn.send(".")
continue
}
// Send command to handler for current state
switch ssn.state {
case AUTHORIZATION:
ssn.authorizationHandler(cmd, arg)
continue
case TRANSACTION:
ssn.transactionHandler(cmd, arg)
continue
}
ssn.logger.Error().Msgf("Session entered unexpected state %v", ssn.state)
break
} else {
ssn.send("-ERR Syntax error, command garbled")
}
} else {
// readLine() returned an error
if err == io.EOF {
switch ssn.state {
case AUTHORIZATION:
// EOF is common here
ssn.logger.Info().Msgf("Client closed connection (state %v)", ssn.state)
default:
ssn.logger.Warn().Msgf("Got EOF while in state %v", ssn.state)
}
break
}
// not an EOF
ssn.logger.Warn().Msgf("Connection error: %v", err)
if netErr, ok := err.(net.Error); ok {
if netErr.Timeout() {
ssn.send("-ERR Idle timeout, bye bye")
break
}
}
ssn.send("-ERR Connection error, sorry")
break
}
}
if ssn.sendError != nil {
ssn.logger.Warn().Msgf("Network send error: %v", ssn.sendError)
}
ssn.logger.Info().Msgf("Closing connection")
}
// AUTHORIZATION state
func (s *Session) authorizationHandler(cmd string, args []string) {
switch cmd {
case "QUIT":
s.send("+OK Goodnight and good luck")
s.enterState(QUIT)
case "USER":
if len(args) > 0 {
s.user = args[0]
s.send(fmt.Sprintf("+OK Hello %v, welcome to Inbucket", s.user))
} else {
s.send("-ERR Missing username argument")
}
case "PASS":
if s.user == "" {
s.ooSeq(cmd)
} else {
s.loadMailbox()
s.send(fmt.Sprintf("+OK Found %v messages for %v", s.msgCount, s.user))
s.enterState(TRANSACTION)
}
case "APOP":
if len(args) != 2 {
s.logger.Warn().Msgf("Expected two arguments for APOP")
s.send("-ERR APOP requires two arguments")
return
}
s.user = args[0]
s.loadMailbox()
s.send(fmt.Sprintf("+OK Found %v messages for %v", s.msgCount, s.user))
s.enterState(TRANSACTION)
default:
s.ooSeq(cmd)
}
}
// TRANSACTION state
func (s *Session) transactionHandler(cmd string, args []string) {
switch cmd {
case "STAT":
if len(args) != 0 {
s.logger.Warn().Msgf("STAT got an unexpected argument")
s.send("-ERR STAT command must have no arguments")
return
}
var count int
var size int64
for i, msg := range s.messages {
if s.retain[i] {
count++
size += msg.Size()
}
}
s.send(fmt.Sprintf("+OK %v %v", count, size))
case "LIST":
if len(args) > 1 {
s.logger.Warn().Msgf("LIST command had more than 1 argument")
s.send("-ERR LIST command must have zero or one argument")
return
}
if len(args) == 1 {
msgNum, err := strconv.ParseInt(args[0], 10, 32)
if err != nil {
s.logger.Warn().Msgf("LIST command argument was not an integer")
s.send("-ERR LIST command requires an integer argument")
return
}
if msgNum < 1 {
s.logger.Warn().Msgf("LIST command argument was less than 1")
s.send("-ERR LIST argument must be greater than 0")
return
}
if int(msgNum) > len(s.messages) {
s.logger.Warn().Msgf("LIST command argument was greater than number of messages")
s.send("-ERR LIST argument must not exceed the number of messages")
return
}
if !s.retain[msgNum-1] {
s.logger.Warn().Msgf("Client tried to LIST a message it had deleted")
s.send(fmt.Sprintf("-ERR You deleted message %v", msgNum))
return
}
s.send(fmt.Sprintf("+OK %v %v", msgNum, s.messages[msgNum-1].Size()))
} else {
s.send(fmt.Sprintf("+OK Listing %v messages", s.msgCount))
for i, msg := range s.messages {
if s.retain[i] {
s.send(fmt.Sprintf("%v %v", i+1, msg.Size()))
}
}
s.send(".")
}
case "UIDL":
if len(args) > 1 {
s.logger.Warn().Msgf("UIDL command had more than 1 argument")
s.send("-ERR UIDL command must have zero or one argument")
return
}
if len(args) == 1 {
msgNum, err := strconv.ParseInt(args[0], 10, 32)
if err != nil {
s.logger.Warn().Msgf("UIDL command argument was not an integer")
s.send("-ERR UIDL command requires an integer argument")
return
}
if msgNum < 1 {
s.logger.Warn().Msgf("UIDL command argument was less than 1")
s.send("-ERR UIDL argument must be greater than 0")
return
}
if int(msgNum) > len(s.messages) {
s.logger.Warn().Msgf("UIDL command argument was greater than number of messages")
s.send("-ERR UIDL argument must not exceed the number of messages")
return
}
if !s.retain[msgNum-1] {
s.logger.Warn().Msgf("Client tried to UIDL a message it had deleted")
s.send(fmt.Sprintf("-ERR You deleted message %v", msgNum))
return
}
s.send(fmt.Sprintf("+OK %v %v", msgNum, s.messages[msgNum-1].ID()))
} else {
s.send(fmt.Sprintf("+OK Listing %v messages", s.msgCount))
for i, msg := range s.messages {
if s.retain[i] {
s.send(fmt.Sprintf("%v %v", i+1, msg.ID()))
}
}
s.send(".")
}
case "DELE":
if len(args) != 1 {
s.logger.Warn().Msgf("DELE command had invalid number of arguments")
s.send("-ERR DELE command requires a single argument")
return
}
msgNum, err := strconv.ParseInt(args[0], 10, 32)
if err != nil {
s.logger.Warn().Msgf("DELE command argument was not an integer")
s.send("-ERR DELE command requires an integer argument")
return
}
if msgNum < 1 {
s.logger.Warn().Msgf("DELE command argument was less than 1")
s.send("-ERR DELE argument must be greater than 0")
return
}
if int(msgNum) > len(s.messages) {
s.logger.Warn().Msgf("DELE command argument was greater than number of messages")
s.send("-ERR DELE argument must not exceed the number of messages")
return
}
if s.retain[msgNum-1] {
s.retain[msgNum-1] = false
s.msgCount--
s.send(fmt.Sprintf("+OK Deleted message %v", msgNum))
} else {
s.logger.Warn().Msgf("Client tried to DELE an already deleted message")
s.send(fmt.Sprintf("-ERR Message %v has already been deleted", msgNum))
}
case "RETR":
if len(args) != 1 {
s.logger.Warn().Msgf("RETR command had invalid number of arguments")
s.send("-ERR RETR command requires a single argument")
return
}
msgNum, err := strconv.ParseInt(args[0], 10, 32)
if err != nil {
s.logger.Warn().Msgf("RETR command argument was not an integer")
s.send("-ERR RETR command requires an integer argument")
return
}
if msgNum < 1 {
s.logger.Warn().Msgf("RETR command argument was less than 1")
s.send("-ERR RETR argument must be greater than 0")
return
}
if int(msgNum) > len(s.messages) {
s.logger.Warn().Msgf("RETR command argument was greater than number of messages")
s.send("-ERR RETR argument must not exceed the number of messages")
return
}
s.send(fmt.Sprintf("+OK %v bytes follows", s.messages[msgNum-1].Size()))
s.sendMessage(s.messages[msgNum-1])
case "TOP":
if len(args) != 2 {
s.logger.Warn().Msgf("TOP command had invalid number of arguments")
s.send("-ERR TOP command requires two arguments")
return
}
msgNum, err := strconv.ParseInt(args[0], 10, 32)
if err != nil {
s.logger.Warn().Msgf("TOP command first argument was not an integer")
s.send("-ERR TOP command requires an integer argument")
return
}
if msgNum < 1 {
s.logger.Warn().Msgf("TOP command first argument was less than 1")
s.send("-ERR TOP first argument must be greater than 0")
return
}
if int(msgNum) > len(s.messages) {
s.logger.Warn().Msgf("TOP command first argument was greater than number of messages")
s.send("-ERR TOP first argument must not exceed the number of messages")
return
}
var lines int64
lines, err = strconv.ParseInt(args[1], 10, 32)
if err != nil {
s.logger.Warn().Msgf("TOP command second argument was not an integer")
s.send("-ERR TOP command requires an integer argument")
return
}
if lines < 0 {
s.logger.Warn().Msgf("TOP command second argument was negative")
s.send("-ERR TOP second argument must be non-negative")
return
}
s.send("+OK Top of message follows")
s.sendMessageTop(s.messages[msgNum-1], int(lines))
case "QUIT":
s.send("+OK We will process your deletes")
s.processDeletes()
s.enterState(QUIT)
case "NOOP":
s.send("+OK I have sucessfully done nothing")
case "RSET":
// Reset session, don't actually delete anything I told you to
s.logger.Debug().Msgf("Resetting session state on RSET request")
s.reset()
s.send("+OK Session reset")
default:
s.ooSeq(cmd)
}
}
// Send the contents of the message to the client
func (s *Session) sendMessage(msg storage.Message) {
reader, err := msg.Source()
if err != nil {
s.logger.Error().Msgf("Failed to read message for RETR command")
s.send("-ERR Failed to RETR that message, internal error")
return
}
defer func() {
if err := reader.Close(); err != nil {
s.logger.Error().Msgf("Failed to close message: %v", err)
}
}()
scanner := bufio.NewScanner(reader)
for scanner.Scan() {
line := scanner.Text()
// Lines starting with . must be prefixed with another .
if strings.HasPrefix(line, ".") {
line = "." + line
}
s.send(line)
}
if err = scanner.Err(); err != nil {
s.logger.Error().Msgf("Failed to read message for RETR command")
s.send(".")
s.send("-ERR Failed to RETR that message, internal error")
return
}
s.send(".")
}
// Send the headers plus the top N lines to the client
func (s *Session) sendMessageTop(msg storage.Message, lineCount int) {
reader, err := msg.Source()
if err != nil {
s.logger.Error().Msgf("Failed to read message for RETR command")
s.send("-ERR Failed to RETR that message, internal error")
return
}
defer func() {
if err := reader.Close(); err != nil {
s.logger.Error().Msgf("Failed to close message: %v", err)
}
}()
scanner := bufio.NewScanner(reader)
inBody := false
for scanner.Scan() {
line := scanner.Text()
// Lines starting with . must be prefixed with another .
if strings.HasPrefix(line, ".") {
line = "." + line
}
if inBody {
// Check if we need to send anymore lines
if lineCount < 1 {
break
} else {
lineCount--
}
} else {
if line == "" {
// We've hit the end of the header
inBody = true
}
}
s.send(line)
}
if err = scanner.Err(); err != nil {
s.logger.Error().Msgf("Failed to read message for RETR command")
s.send(".")
s.send("-ERR Failed to RETR that message, internal error")
return
}
s.send(".")
}
// Load the users mailbox
func (s *Session) loadMailbox() {
s.logger = s.logger.With().Str("mailbox", s.user).Logger()
m, err := s.store.GetMessages(s.user)
if err != nil {
s.logger.Error().Msgf("Failed to load messages for %v: %v", s.user, err)
}
s.messages = m
s.retainAll()
}
// Reset retain flag to true for all messages
func (s *Session) retainAll() {
s.retain = make([]bool, len(s.messages))
for i := range s.retain {
s.retain[i] = true
}
s.msgCount = len(s.messages)
}
// This would be considered the "UPDATE" state in the RFC, but it does not fit
// with our state-machine design here, since no commands are accepted - it just
// indicates that the session was closed cleanly and that deletes should be
// processed.
func (s *Session) processDeletes() {
s.logger.Info().Msgf("Processing deletes")
for i, msg := range s.messages {
if !s.retain[i] {
s.logger.Debug().Str("id", msg.ID()).Msg("Deleting message")
if err := s.store.RemoveMessage(s.user, msg.ID()); err != nil {
s.logger.Warn().Str("id", msg.ID()).Err(err).Msg("Error deleting message")
}
}
}
}
func (s *Session) enterState(state State) {
s.state = state
s.logger.Debug().Msgf("Entering state %v", state)
}
// nextDeadline calculates the next read or write deadline based on configured timeout.
func (s *Session) nextDeadline() time.Time {
return time.Now().Add(s.config.Timeout)
}
// Send requested message, store errors in Session.sendError
func (s *Session) send(msg string) {
if err := s.conn.SetWriteDeadline(s.nextDeadline()); err != nil {
s.sendError = err
return
}
if _, err := fmt.Fprint(s.conn, msg+"\r\n"); err != nil {
s.sendError = err
s.logger.Warn().Msgf("Failed to send: %q", msg)
return
}
if s.debug {
fmt.Printf("%04d > %v\n", s.id, msg)
}
}
// Reads a line of input
func (s *Session) readLine() (line string, err error) {
if err = s.conn.SetReadDeadline(s.nextDeadline()); err != nil {
return "", err
}
line, err = s.reader.ReadString('\n')
if err != nil {
return "", err
}
if s.debug {
fmt.Printf("%04d %v\n", s.id, strings.TrimRight(line, "\r\n"))
}
return line, nil
}
func (s *Session) parseCmd(line string) (cmd string, args []string, ok bool) {
line = strings.TrimRight(line, "\r\n")
if line == "" {
return "", nil, true
}
words := strings.Split(line, " ")
return strings.ToUpper(words[0]), words[1:], true
}
func (s *Session) reset() {
s.retainAll()
}
func (s *Session) ooSeq(cmd string) {
s.send(fmt.Sprintf("-ERR Command %v is out of sequence", cmd))
s.logger.Warn().Msgf("Wasn't expecting %v here", cmd)
}

117
pkg/server/pop3/listener.go Normal file
View File

@@ -0,0 +1,117 @@
package pop3
import (
"context"
"net"
"sync"
"time"
"github.com/jhillyerd/inbucket/pkg/config"
"github.com/jhillyerd/inbucket/pkg/storage"
"github.com/rs/zerolog/log"
)
// Server defines an instance of the POP3 server.
type Server struct {
config config.POP3 // POP3 configuration.
store storage.Store // Mail store.
listener net.Listener // TCP listener.
globalShutdown chan bool // Inbucket shutdown signal.
wg *sync.WaitGroup // Waitgroup tracking sessions.
}
// New creates a new Server struct.
func New(pop3Config config.POP3, shutdownChan chan bool, store storage.Store) *Server {
return &Server{
config: pop3Config,
store: store,
globalShutdown: shutdownChan,
wg: new(sync.WaitGroup),
}
}
// Start the server and listen for connections
func (s *Server) Start(ctx context.Context) {
slog := log.With().Str("module", "pop3").Str("phase", "startup").Logger()
addr, err := net.ResolveTCPAddr("tcp4", s.config.Addr)
if err != nil {
slog.Error().Err(err).Msg("Failed to build tcp4 address")
s.emergencyShutdown()
return
}
slog.Info().Str("addr", addr.String()).Msg("POP3 listening on tcp4")
s.listener, err = net.ListenTCP("tcp4", addr)
if err != nil {
slog.Error().Err(err).Msg("Failed to start tcp4 listener")
s.emergencyShutdown()
return
}
// Listener go routine.
go s.serve(ctx)
// Wait for shutdown.
select {
case _ = <-ctx.Done():
}
slog = log.With().Str("module", "pop3").Str("phase", "shutdown").Logger()
slog.Debug().Msg("POP3 shutdown requested, connections will be drained")
// Closing the listener will cause the serve() go routine to exit.
if err := s.listener.Close(); err != nil {
slog.Error().Err(err).Msg("Failed to close POP3 listener")
}
}
// serve is the listen/accept loop.
func (s *Server) serve(ctx context.Context) {
// Handle incoming connections.
var tempDelay time.Duration
for sid := 1; ; sid++ {
if conn, err := s.listener.Accept(); err != nil {
if nerr, ok := err.(net.Error); ok && nerr.Temporary() {
// Temporary error, sleep for a bit and try again.
if tempDelay == 0 {
tempDelay = 5 * time.Millisecond
} else {
tempDelay *= 2
}
if max := 1 * time.Second; tempDelay > max {
tempDelay = max
}
log.Error().Str("module", "pop3").Err(err).
Msgf("POP3 accept error; retrying in %v", tempDelay)
time.Sleep(tempDelay)
continue
} else {
// Permanent error.
select {
case <-ctx.Done():
// POP3 is shutting down.
return
default:
// Something went wrong.
s.emergencyShutdown()
return
}
}
} else {
tempDelay = 0
s.wg.Add(1)
go s.startSession(sid, conn)
}
}
}
func (s *Server) emergencyShutdown() {
// Shutdown Inbucket
select {
case _ = <-s.globalShutdown:
default:
close(s.globalShutdown)
}
}
// Drain causes the caller to block until all active POP3 sessions have finished
func (s *Server) Drain() {
// Wait for sessions to close
s.wg.Wait()
log.Debug().Str("module", "pop3").Str("phase", "shutdown").Msg("POP3 connections have drained")
}

535
pkg/server/smtp/handler.go Normal file
View File

@@ -0,0 +1,535 @@
package smtp
import (
"bufio"
"bytes"
"fmt"
"io"
"net"
"regexp"
"strconv"
"strings"
"time"
"github.com/jhillyerd/inbucket/pkg/policy"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
// State tracks the current mode of our SMTP state machine.
type State int
const (
// timeStampFormat to use in Received header.
timeStampFormat = "Mon, 02 Jan 2006 15:04:05 -0700 (MST)"
// GREET State: Waiting for HELO
GREET State = iota
// READY State: Got HELO, waiting for MAIL
READY
// MAIL State: Got MAIL, accepting RCPTs
MAIL
// DATA State: Got DATA, waiting for "."
DATA
// QUIT State: Client requested end of session
QUIT
)
// fromRegex captures the from address and optional BODY=8BITMIME clause. Matches FROM, while
// accepting '>' as quoted pair and in double quoted strings (?i) makes the regex case insensitive,
// (?:) is non-grouping sub-match
var fromRegex = regexp.MustCompile(
"(?i)^FROM:\\s*<((?:\\\\>|[^>])+|\"[^\"]+\"@[^>]+)>( [\\w= ]+)?$")
func (s State) String() string {
switch s {
case GREET:
return "GREET"
case READY:
return "READY"
case MAIL:
return "MAIL"
case DATA:
return "DATA"
case QUIT:
return "QUIT"
}
return "Unknown"
}
var commands = map[string]bool{
"HELO": true,
"EHLO": true,
"MAIL": true,
"RCPT": true,
"DATA": true,
"RSET": true,
"SEND": true,
"SOML": true,
"SAML": true,
"VRFY": true,
"EXPN": true,
"HELP": true,
"NOOP": true,
"QUIT": true,
"TURN": true,
}
// Session holds the state of an SMTP session
type Session struct {
*Server // Server this session belongs to.
id int // Session ID.
conn net.Conn // TCP connection.
remoteDomain string // Remote domain from HELO command.
remoteHost string // Remote host.
sendError error // Last network send error.
state State // Session state machine.
reader *bufio.Reader // Buffered reading for TCP conn.
from string // Sender from MAIL command.
recipients []*policy.Recipient // Recipients from RCPT commands.
logger zerolog.Logger // Session specific logger.
debug bool // Print network traffic to stdout.
}
// NewSession creates a new Session for the given connection
func NewSession(server *Server, id int, conn net.Conn, logger zerolog.Logger) *Session {
reader := bufio.NewReader(conn)
host, _, _ := net.SplitHostPort(conn.RemoteAddr().String())
return &Session{
Server: server,
id: id,
conn: conn,
state: GREET,
reader: reader,
remoteHost: host,
recipients: make([]*policy.Recipient, 0),
logger: logger,
debug: server.config.Debug,
}
}
func (s *Session) String() string {
return fmt.Sprintf("Session{id: %v, state: %v}", s.id, s.state)
}
/* Session flow:
* 1. Send initial greeting
* 2. Receive cmd
* 3. If good cmd, respond, optionally change state
* 4. If bad cmd, respond error
* 5. Goto 2
*/
func (s *Server) startSession(id int, conn net.Conn) {
logger := log.Hook(logHook{}).With().
Str("module", "smtp").
Str("remote", conn.RemoteAddr().String()).
Int("session", id).Logger()
logger.Info().Msg("Starting SMTP session")
expConnectsCurrent.Add(1)
defer func() {
if err := conn.Close(); err != nil {
logger.Warn().Err(err).Msg("Closing connection")
}
s.wg.Done()
expConnectsCurrent.Add(-1)
}()
ssn := NewSession(s, id, conn, logger)
ssn.greet()
// This is our command reading loop
for ssn.state != QUIT && ssn.sendError == nil {
if ssn.state == DATA {
// Special case, does not use SMTP command format
ssn.dataHandler()
continue
}
line, err := ssn.readLine()
if err == nil {
if cmd, arg, ok := ssn.parseCmd(line); ok {
// Check against valid SMTP commands
if cmd == "" {
ssn.send("500 Speak up")
continue
}
if !commands[cmd] {
ssn.send(fmt.Sprintf("500 Syntax error, %v command unrecognized", cmd))
ssn.logger.Warn().Msgf("Unrecognized command: %v", cmd)
continue
}
// Commands we handle in any state
switch cmd {
case "SEND", "SOML", "SAML", "EXPN", "HELP", "TURN":
// These commands are not implemented in any state
ssn.send(fmt.Sprintf("502 %v command not implemented", cmd))
ssn.logger.Warn().Msgf("Command %v not implemented by Inbucket", cmd)
continue
case "VRFY":
ssn.send("252 Cannot VRFY user, but will accept message")
continue
case "NOOP":
ssn.send("250 I have sucessfully done nothing")
continue
case "RSET":
// Reset session
ssn.logger.Debug().Msgf("Resetting session state on RSET request")
ssn.reset()
ssn.send("250 Session reset")
continue
case "QUIT":
ssn.send("221 Goodnight and good luck")
ssn.enterState(QUIT)
continue
}
// Send command to handler for current state
switch ssn.state {
case GREET:
ssn.greetHandler(cmd, arg)
continue
case READY:
ssn.readyHandler(cmd, arg)
continue
case MAIL:
ssn.mailHandler(cmd, arg)
continue
}
ssn.logger.Error().Msgf("Session entered unexpected state %v", ssn.state)
break
} else {
ssn.send("500 Syntax error, command garbled")
}
} else {
// readLine() returned an error
if err == io.EOF {
switch ssn.state {
case GREET, READY:
// EOF is common here
ssn.logger.Info().Msgf("Client closed connection (state %v)", ssn.state)
default:
ssn.logger.Warn().Msgf("Got EOF while in state %v", ssn.state)
}
break
}
// not an EOF
ssn.logger.Warn().Msgf("Connection error: %v", err)
if netErr, ok := err.(net.Error); ok {
if netErr.Timeout() {
ssn.send("221 Idle timeout, bye bye")
break
}
}
ssn.send("221 Connection error, sorry")
break
}
}
if ssn.sendError != nil {
ssn.logger.Warn().Msgf("Network send error: %v", ssn.sendError)
}
ssn.logger.Info().Msgf("Closing connection")
}
// GREET state -> waiting for HELO
func (s *Session) greetHandler(cmd string, arg string) {
switch cmd {
case "HELO":
domain, err := parseHelloArgument(arg)
if err != nil {
s.send("501 Domain/address argument required for HELO")
return
}
s.remoteDomain = domain
s.send("250 Great, let's get this show on the road")
s.enterState(READY)
case "EHLO":
domain, err := parseHelloArgument(arg)
if err != nil {
s.send("501 Domain/address argument required for EHLO")
return
}
s.remoteDomain = domain
s.send("250-Great, let's get this show on the road")
s.send("250-8BITMIME")
s.send(fmt.Sprintf("250 SIZE %v", s.config.MaxMessageBytes))
s.enterState(READY)
default:
s.ooSeq(cmd)
}
}
func parseHelloArgument(arg string) (string, error) {
domain := arg
if idx := strings.IndexRune(arg, ' '); idx >= 0 {
domain = arg[:idx]
}
if domain == "" {
return "", fmt.Errorf("Invalid domain")
}
return domain, nil
}
// READY state -> waiting for MAIL
func (s *Session) readyHandler(cmd string, arg string) {
if cmd == "MAIL" {
// Capture group 1: from address. 2: optional params.
m := fromRegex.FindStringSubmatch(arg)
if m == nil {
s.send("501 Was expecting MAIL arg syntax of FROM:<address>")
s.logger.Warn().Msgf("Bad MAIL argument: %q", arg)
return
}
from := m[1]
if _, _, err := policy.ParseEmailAddress(from); err != nil {
s.send("501 Bad sender address syntax")
s.logger.Warn().Msgf("Bad address as MAIL arg: %q, %s", from, err)
return
}
// This is where the client may put BODY=8BITMIME, but we already
// read the DATA as bytes, so it does not effect our processing.
if m[2] != "" {
args, ok := s.parseArgs(m[2])
if !ok {
s.send("501 Unable to parse MAIL ESMTP parameters")
s.logger.Warn().Msgf("Bad MAIL argument: %q", arg)
return
}
if args["SIZE"] != "" {
size, err := strconv.ParseInt(args["SIZE"], 10, 32)
if err != nil {
s.send("501 Unable to parse SIZE as an integer")
s.logger.Warn().Msgf("Unable to parse SIZE %q as an integer", args["SIZE"])
return
}
if int(size) > s.config.MaxMessageBytes {
s.send("552 Max message size exceeded")
s.logger.Warn().Msgf("Client wanted to send oversized message: %v", args["SIZE"])
return
}
}
}
s.from = from
s.logger.Info().Msgf("Mail from: %v", from)
s.send(fmt.Sprintf("250 Roger, accepting mail from <%v>", from))
s.enterState(MAIL)
} else {
s.ooSeq(cmd)
}
}
// MAIL state -> waiting for RCPTs followed by DATA
func (s *Session) mailHandler(cmd string, arg string) {
switch cmd {
case "RCPT":
if (len(arg) < 4) || (strings.ToUpper(arg[0:3]) != "TO:") {
s.send("501 Was expecting RCPT arg syntax of TO:<address>")
s.logger.Warn().Msgf("Bad RCPT argument: %q", arg)
return
}
addr := strings.Trim(arg[3:], "<> ")
recip, err := s.addrPolicy.NewRecipient(addr)
if err != nil {
s.send("501 Bad recipient address syntax")
s.logger.Warn().Str("to", addr).Err(err).Msg("Bad address as RCPT arg")
return
}
if !recip.ShouldAccept() {
s.logger.Warn().Str("to", addr).Msg("Rejecting recipient domain")
s.send("550 Relay not permitted")
return
}
if len(s.recipients) >= s.config.MaxRecipients {
s.logger.Warn().Msgf("Limit of %v recipients exceeded", s.config.MaxRecipients)
s.send(fmt.Sprintf("552 Limit of %v recipients exceeded", s.config.MaxRecipients))
return
}
s.recipients = append(s.recipients, recip)
s.logger.Debug().Str("to", addr).Msg("Recipient added")
s.send(fmt.Sprintf("250 I'll make sure <%v> gets this", addr))
return
case "DATA":
if arg != "" {
s.send("501 DATA command should not have any arguments")
s.logger.Warn().Msgf("Got unexpected args on DATA: %q", arg)
return
}
if len(s.recipients) == 0 {
// DATA out of sequence
s.ooSeq(cmd)
return
}
s.enterState(DATA)
return
}
s.ooSeq(cmd)
}
// DATA
func (s *Session) dataHandler() {
s.send("354 Start mail input; end with <CRLF>.<CRLF>")
msgBuf := &bytes.Buffer{}
for {
lineBuf, err := s.readByteLine()
if err != nil {
if netErr, ok := err.(net.Error); ok {
if netErr.Timeout() {
s.send("221 Idle timeout, bye bye")
}
}
s.logger.Warn().Msgf("Error: %v while reading", err)
s.enterState(QUIT)
return
}
if bytes.Equal(lineBuf, []byte(".\r\n")) || bytes.Equal(lineBuf, []byte(".\n")) {
// Mail data complete.
tstamp := time.Now().Format(timeStampFormat)
for _, recip := range s.recipients {
if recip.ShouldStore() {
// Generate Received header.
prefix := fmt.Sprintf("Received: from %s ([%s]) by %s\r\n for <%s>; %s\r\n",
s.remoteDomain, s.remoteHost, s.config.Domain, recip.Address.Address,
tstamp)
// Deliver message.
_, err := s.manager.Deliver(
recip, s.from, s.recipients, prefix, msgBuf.Bytes())
if err != nil {
s.logger.Error().Msgf("delivery for %v: %v", recip.LocalPart, err)
s.send(fmt.Sprintf("451 Failed to store message for %v", recip.LocalPart))
s.reset()
return
}
}
expReceivedTotal.Add(1)
}
s.send("250 Mail accepted for delivery")
s.logger.Info().Msgf("Message size %v bytes", msgBuf.Len())
s.reset()
return
}
// RFC: remove leading periods from DATA.
if len(lineBuf) > 0 && lineBuf[0] == '.' {
lineBuf = lineBuf[1:]
}
msgBuf.Write(lineBuf)
if msgBuf.Len() > s.config.MaxMessageBytes {
s.send("552 Maximum message size exceeded")
s.logger.Warn().Msgf("Max message size exceeded while in DATA")
s.reset()
return
}
}
}
func (s *Session) enterState(state State) {
s.state = state
s.logger.Debug().Msgf("Entering state %v", state)
}
func (s *Session) greet() {
s.send(fmt.Sprintf("220 %v Inbucket SMTP ready", s.config.Domain))
}
// nextDeadline calculates the next read or write deadline based on configured timeout.
func (s *Session) nextDeadline() time.Time {
return time.Now().Add(s.config.Timeout)
}
// Send requested message, store errors in Session.sendError
func (s *Session) send(msg string) {
if err := s.conn.SetWriteDeadline(s.nextDeadline()); err != nil {
s.sendError = err
return
}
if _, err := fmt.Fprint(s.conn, msg+"\r\n"); err != nil {
s.sendError = err
s.logger.Warn().Msgf("Failed to send: %q", msg)
return
}
if s.debug {
fmt.Printf("%04d > %v\n", s.id, msg)
}
}
// readByteLine reads a line of input, returns byte slice.
func (s *Session) readByteLine() ([]byte, error) {
if err := s.conn.SetReadDeadline(s.nextDeadline()); err != nil {
return nil, err
}
b, err := s.reader.ReadBytes('\n')
if err == nil && s.debug {
fmt.Printf("%04d %s\n", s.id, bytes.TrimRight(b, "\r\n"))
}
return b, err
}
// Reads a line of input
func (s *Session) readLine() (line string, err error) {
if err = s.conn.SetReadDeadline(s.nextDeadline()); err != nil {
return "", err
}
line, err = s.reader.ReadString('\n')
if err != nil {
return "", err
}
if s.debug {
fmt.Printf("%04d %v\n", s.id, strings.TrimRight(line, "\r\n"))
}
return line, nil
}
func (s *Session) parseCmd(line string) (cmd string, arg string, ok bool) {
line = strings.TrimRight(line, "\r\n")
l := len(line)
switch {
case l == 0:
return "", "", true
case l < 4:
s.logger.Warn().Msgf("Command too short: %q", line)
return "", "", false
case l == 4:
return strings.ToUpper(line), "", true
case l == 5:
// Too long to be only command, too short to have args
s.logger.Warn().Msgf("Mangled command: %q", line)
return "", "", false
}
// If we made it here, command is long enough to have args
if line[4] != ' ' {
// There wasn't a space after the command?
s.logger.Warn().Msgf("Mangled command: %q", line)
return "", "", false
}
// I'm not sure if we should trim the args or not, but we will for now
return strings.ToUpper(line[0:4]), strings.Trim(line[5:], " "), true
}
// parseArgs takes the arguments proceeding a command and files them
// into a map[string]string after uppercasing each key. Sample arg
// string:
// " BODY=8BITMIME SIZE=1024"
// The leading space is mandatory.
func (s *Session) parseArgs(arg string) (args map[string]string, ok bool) {
args = make(map[string]string)
re := regexp.MustCompile(` (\w+)=(\w+)`)
pm := re.FindAllStringSubmatch(arg, -1)
if pm == nil {
s.logger.Warn().Msgf("Failed to parse arg string: %q")
return nil, false
}
for _, m := range pm {
args[strings.ToUpper(m[1])] = m[2]
}
s.logger.Debug().Msgf("ESMTP params: %v", args)
return args, true
}
func (s *Session) reset() {
s.enterState(READY)
s.from = ""
s.recipients = nil
}
func (s *Session) ooSeq(cmd string) {
s.send(fmt.Sprintf("503 Command %v is out of sequence", cmd))
s.logger.Warn().Msgf("Wasn't expecting %v here", cmd)
}

View File

@@ -1,8 +1,7 @@
package smtpd
package smtp
import (
"bytes"
"context"
"fmt"
"io"
@@ -13,8 +12,11 @@ import (
"testing"
"time"
"github.com/jhillyerd/inbucket/config"
"github.com/jhillyerd/inbucket/msghub"
"github.com/jhillyerd/inbucket/pkg/config"
"github.com/jhillyerd/inbucket/pkg/message"
"github.com/jhillyerd/inbucket/pkg/policy"
"github.com/jhillyerd/inbucket/pkg/storage"
"github.com/jhillyerd/inbucket/pkg/test"
)
type scriptStep struct {
@@ -24,18 +26,12 @@ 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)
server, logbuf, teardown := setupSMTPServer(mds)
ds := test.NewStore()
server, logbuf, teardown := setupSMTPServer(ds)
defer teardown()
var script []scriptStep
// Test out some mangled HELOs
script = []scriptStep{
script := []scriptStep{
{"HELO", 501},
{"EHLO", 501},
{"HELLO", 500},
@@ -85,18 +81,12 @@ 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)
server, logbuf, teardown := setupSMTPServer(mds)
ds := test.NewStore()
server, logbuf, teardown := setupSMTPServer(ds)
defer teardown()
var script []scriptStep
// Test out some mangled READY commands
script = []scriptStep{
script := []scriptStep{
{"HELO localhost", 250},
{"FOOB", 500},
{"HELO", 503},
@@ -150,28 +140,12 @@ 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)
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)
mds := test.NewStore()
server, logbuf, teardown := setupSMTPServer(mds)
defer teardown()
var script []scriptStep
// Test out some mangled READY commands
script = []scriptStep{
script := []scriptStep{
{"HELO localhost", 250},
{"MAIL FROM:<john@gmail.com>", 250},
{"FOOB", 500},
@@ -195,13 +169,11 @@ func TestMailState(t *testing.T) {
{"RCPT TO:<u1@gmail.com>", 250},
{"RCPT TO: <u2@gmail.com>", 250},
{"RCPT TO:u3@gmail.com", 250},
{"RCPT TO:u3@deny.com", 550},
{"RCPT TO: u4@gmail.com", 250},
{"RSET", 250},
{"MAIL FROM:<john@gmail.com>", 250},
{"RCPT TO:<user\\@internal@external.com", 250},
{"RCPT TO:<\"first last\"@host.com", 250},
{"RCPT TO:<user\\>name@host.com>", 250},
{"RCPT TO:<\"user>name\"@host.com>", 250},
{`RCPT TO:<"first/last"@host.com`, 250},
}
if err := playSession(t, server, script); err != nil {
t.Error(err)
@@ -267,21 +239,7 @@ 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)
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)
mds := test.NewStore()
server, logbuf, teardown := setupSMTPServer(mds)
defer teardown()
@@ -289,7 +247,6 @@ func TestDataState(t *testing.T) {
pipe := setupSMTPSession(server)
c := textproto.NewConn(pipe)
// Get us into DATA state
if code, _, err := c.ReadCodeLine(220); err != nil {
t.Errorf("Expected a 220 greeting, got %v", code)
}
@@ -316,6 +273,33 @@ Hi!
t.Errorf("Expected a 250 greeting, got %v", code)
}
// Test with no useful headers.
pipe = setupSMTPSession(server)
c = textproto.NewConn(pipe)
if code, _, err := c.ReadCodeLine(220); err != nil {
t.Errorf("Expected a 220 greeting, got %v", code)
}
script = []scriptStep{
{"HELO localhost", 250},
{"MAIL FROM:<john@gmail.com>", 250},
{"RCPT TO:<u1@gmail.com>", 250},
{"DATA", 354},
}
if err := playScriptAgainst(t, c, script); err != nil {
t.Error(err)
}
// Send a message
body = `X-Useless-Header: true
Hi! Can you still deliver this?
`
dw = c.DotWriter()
_, _ = io.WriteString(dw, body)
_ = dw.Close()
if code, _, err := c.ReadCodeLine(250); err != nil {
t.Errorf("Expected a 250 greeting, got %v", code)
}
if t.Failed() {
// Wait for handler to finish logging
time.Sleep(2 * time.Second)
@@ -376,43 +360,41 @@ func (m *mockConn) SetDeadline(t time.Time) error { return nil }
func (m *mockConn) SetReadDeadline(t time.Time) error { return nil }
func (m *mockConn) SetWriteDeadline(t time.Time) error { return nil }
func setupSMTPServer(ds DataStore) (s *Server, buf *bytes.Buffer, teardown func()) {
// Test Server Config
cfg := config.SMTPConfig{
IP4address: net.IPv4(127, 0, 0, 1),
IP4port: 2500,
Domain: "inbucket.local",
DomainNoStore: "bitbucket.local",
MaxRecipients: 5,
MaxIdleSeconds: 5,
MaxMessageBytes: 5000,
StoreMessages: true,
func setupSMTPServer(ds storage.Store) (s *Server, buf *bytes.Buffer, teardown func()) {
cfg := &config.Root{
MailboxNaming: config.FullNaming,
SMTP: config.SMTP{
Addr: "127.0.0.1:2500",
Domain: "inbucket.local",
MaxRecipients: 5,
MaxMessageBytes: 5000,
DefaultAccept: true,
RejectDomains: []string{"deny.com"},
Timeout: 5,
},
}
// Capture log output
// Capture log output.
buf = new(bytes.Buffer)
log.SetOutput(buf)
// Create a server, don't start it
// Create a server, don't start it.
shutdownChan := make(chan bool)
ctx, cancel := context.WithCancel(context.Background())
teardown = func() {
close(shutdownChan)
cancel()
}
s = NewServer(cfg, shutdownChan, ds, msghub.New(ctx, 100))
addrPolicy := &policy.Addressing{Config: cfg}
manager := &message.StoreManager{Store: ds}
s = NewServer(cfg.SMTP, shutdownChan, manager, addrPolicy)
return s, buf, teardown
}
var sessionNum int
func setupSMTPSession(server *Server) net.Conn {
// Pair of pipes to communicate
// Pair of pipes to communicate.
serverConn, clientConn := net.Pipe()
// Start the session
server.waitgroup.Add(1)
// Start the session.
server.wg.Add(1)
sessionNum++
go server.startSession(sessionNum, &mockConn{serverConn})
return clientConn
}

168
pkg/server/smtp/listener.go Normal file
View File

@@ -0,0 +1,168 @@
package smtp
import (
"container/list"
"context"
"expvar"
"net"
"sync"
"time"
"github.com/jhillyerd/inbucket/pkg/config"
"github.com/jhillyerd/inbucket/pkg/message"
"github.com/jhillyerd/inbucket/pkg/metric"
"github.com/jhillyerd/inbucket/pkg/policy"
"github.com/rs/zerolog/log"
)
var (
// Raw stat collectors
expConnectsTotal = new(expvar.Int)
expConnectsCurrent = new(expvar.Int)
expReceivedTotal = new(expvar.Int)
expErrorsTotal = new(expvar.Int)
expWarnsTotal = new(expvar.Int)
// History of certain stats
deliveredHist = list.New()
connectsHist = list.New()
errorsHist = list.New()
warnsHist = list.New()
// History rendered as comma delim string
expReceivedHist = new(expvar.String)
expConnectsHist = new(expvar.String)
expErrorsHist = new(expvar.String)
expWarnsHist = new(expvar.String)
)
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)
metric.AddTickerFunc(func() {
expReceivedHist.Set(metric.Push(deliveredHist, expReceivedTotal))
expConnectsHist.Set(metric.Push(connectsHist, expConnectsTotal))
expErrorsHist.Set(metric.Push(errorsHist, expErrorsTotal))
expWarnsHist.Set(metric.Push(warnsHist, expWarnsTotal))
})
}
// Server holds the configuration and state of our SMTP server.
type Server struct {
config config.SMTP // SMTP configuration.
addrPolicy *policy.Addressing // Address policy.
globalShutdown chan bool // Shuts down Inbucket.
manager message.Manager // Used to deliver messages.
listener net.Listener // Incoming network connections.
wg *sync.WaitGroup // Waitgroup tracks individual sessions.
}
// NewServer creates a new Server instance with the specificed config.
func NewServer(
smtpConfig config.SMTP,
globalShutdown chan bool,
manager message.Manager,
apolicy *policy.Addressing,
) *Server {
return &Server{
config: smtpConfig,
globalShutdown: globalShutdown,
manager: manager,
addrPolicy: apolicy,
wg: new(sync.WaitGroup),
}
}
// Start the listener and handle incoming connections.
func (s *Server) Start(ctx context.Context) {
slog := log.With().Str("module", "smtp").Str("phase", "startup").Logger()
addr, err := net.ResolveTCPAddr("tcp4", s.config.Addr)
if err != nil {
slog.Error().Err(err).Msg("Failed to build tcp4 address")
s.emergencyShutdown()
return
}
slog.Info().Str("addr", addr.String()).Msg("SMTP listening on tcp4")
s.listener, err = net.ListenTCP("tcp4", addr)
if err != nil {
slog.Error().Err(err).Msg("Failed to start tcp4 listener")
s.emergencyShutdown()
return
}
// Listener go routine.
go s.serve(ctx)
// Wait for shutdown.
<-ctx.Done()
slog = log.With().Str("module", "smtp").Str("phase", "shutdown").Logger()
slog.Debug().Msg("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 {
slog.Error().Err(err).Msg("Failed to close SMTP listener")
}
}
// serve is the listen/accept loop.
func (s *Server) serve(ctx context.Context) {
// Handle incoming connections.
var tempDelay time.Duration
for sessionID := 1; ; sessionID++ {
if conn, err := s.listener.Accept(); err != nil {
// There was an error accepting the connection.
if nerr, ok := err.(net.Error); ok && nerr.Temporary() {
// Temporary error, sleep for a bit and try again.
if tempDelay == 0 {
tempDelay = 5 * time.Millisecond
} else {
tempDelay *= 2
}
if max := 1 * time.Second; tempDelay > max {
tempDelay = max
}
log.Error().Str("module", "smtp").Err(err).
Msgf("SMTP accept error; retrying in %v", tempDelay)
time.Sleep(tempDelay)
continue
} else {
// Permanent error.
select {
case <-ctx.Done():
// SMTP is shutting down.
return
default:
// Something went wrong.
s.emergencyShutdown()
return
}
}
} else {
tempDelay = 0
expConnectsTotal.Add(1)
s.wg.Add(1)
go s.startSession(sessionID, conn)
}
}
}
func (s *Server) emergencyShutdown() {
// Shutdown Inbucket.
select {
case <-s.globalShutdown:
default:
close(s.globalShutdown)
}
}
// Drain causes the caller to block until all active SMTP sessions have finished
func (s *Server) Drain() {
// Wait for sessions to close.
s.wg.Wait()
log.Debug().Str("module", "smtp").Str("phase", "shutdown").Msg("SMTP connections have drained")
}

View File

@@ -0,0 +1,15 @@
package smtp
import "github.com/rs/zerolog"
type logHook struct{}
// Run implements a zerolog hook that updates the SMTP warning/error expvars.
func (h logHook) Run(e *zerolog.Event, level zerolog.Level, msg string) {
switch level {
case zerolog.WarnLevel:
expWarnsTotal.Add(1)
case zerolog.ErrorLevel:
expErrorsTotal.Add(1)
}
}

View File

@@ -1,4 +1,4 @@
package httpd
package web
import (
"net/http"
@@ -6,19 +6,21 @@ import (
"github.com/gorilla/mux"
"github.com/gorilla/sessions"
"github.com/jhillyerd/inbucket/config"
"github.com/jhillyerd/inbucket/msghub"
"github.com/jhillyerd/inbucket/smtpd"
"github.com/jhillyerd/inbucket/pkg/config"
"github.com/jhillyerd/inbucket/pkg/message"
"github.com/jhillyerd/inbucket/pkg/msghub"
)
// Context is passed into every request handler function
// TODO remove redundant web config
type Context struct {
Vars map[string]string
Session *sessions.Session
DataStore smtpd.DataStore
MsgHub *msghub.Hub
WebConfig config.WebConfig
IsJSON bool
Vars map[string]string
Session *sessions.Session
MsgHub *msghub.Hub
Manager message.Manager
RootConfig *config.Root
WebConfig config.Web
IsJSON bool
}
// Close the Context (currently does nothing)
@@ -57,12 +59,13 @@ func NewContext(req *http.Request) (*Context, error) {
err = nil
}
ctx := &Context{
Vars: vars,
Session: sess,
DataStore: DataStore,
MsgHub: msgHub,
WebConfig: webConfig,
IsJSON: headerMatch(req, "Accept", "application/json"),
Vars: vars,
Session: sess,
MsgHub: msgHub,
Manager: manager,
RootConfig: rootConfig,
WebConfig: rootConfig.Web,
IsJSON: headerMatch(req, "Accept", "application/json"),
}
return ctx, err
}

View File

@@ -1,4 +1,4 @@
package httpd
package web
import (
"fmt"
@@ -8,13 +8,14 @@ import (
"strings"
"time"
"github.com/jhillyerd/inbucket/log"
"github.com/rs/zerolog/log"
)
// TemplateFuncs declares functions made available to all templates (including partials)
var TemplateFuncs = template.FuncMap{
"friendlyTime": FriendlyTime,
"reverse": Reverse,
"stringsJoin": strings.Join,
"textToHtml": TextToHTML,
}
@@ -42,7 +43,8 @@ func Reverse(name string, things ...interface{}) string {
// Grab the route
u, err := Router.Get(name).URL(strs...)
if err != nil {
log.Errorf("Failed to reverse route: %v", err)
log.Error().Str("module", "web").Str("name", name).Err(err).
Msg("Failed to reverse route")
return "/ROUTE-ERROR"
}
return u.Path

View File

@@ -1,4 +1,4 @@
package httpd
package web
import (
"html/template"

View File

@@ -1,4 +1,4 @@
package httpd
package web
import (
"encoding/json"

View File

@@ -1,38 +1,41 @@
// Package httpd provides the plumbing for Inbucket's web GUI and RESTful API
package httpd
// Package web provides the plumbing for Inbucket's web GUI and RESTful API
package web
import (
"context"
"expvar"
"fmt"
"net"
"net/http"
"path/filepath"
"time"
"github.com/gorilla/mux"
"github.com/gorilla/securecookie"
"github.com/gorilla/sessions"
"github.com/jhillyerd/inbucket/config"
"github.com/jhillyerd/inbucket/log"
"github.com/jhillyerd/inbucket/msghub"
"github.com/jhillyerd/inbucket/smtpd"
"github.com/jhillyerd/inbucket/pkg/config"
"github.com/jhillyerd/inbucket/pkg/message"
"github.com/jhillyerd/inbucket/pkg/msghub"
"github.com/rs/zerolog/log"
)
// Handler is a function type that handles an HTTP request in Inbucket
type Handler func(http.ResponseWriter, *http.Request, *Context) error
var (
// DataStore is where all the mailboxes and messages live
DataStore smtpd.DataStore
const (
staticDir = "static"
templateDir = "templates"
)
var (
// msgHub holds a reference to the message pub/sub system
msgHub *msghub.Hub
msgHub *msghub.Hub
manager message.Manager
// Router is shared between httpd, webui and rest packages. It sends
// incoming requests to the correct handler function
Router = mux.NewRouter()
webConfig config.WebConfig
rootConfig *config.Root
server *http.Server
listener net.Listener
sessionStore sessions.Store
@@ -49,51 +52,55 @@ func init() {
// Initialize sets up things for unit tests or the Start() method
func Initialize(
cfg config.WebConfig,
conf *config.Root,
shutdownChan chan bool,
ds smtpd.DataStore,
mm message.Manager,
mh *msghub.Hub) {
webConfig = cfg
rootConfig = conf
globalShutdown = shutdownChan
// NewContext() will use this DataStore for the web handlers
DataStore = ds
msgHub = mh
manager = mm
// Content Paths
log.Infof("HTTP templates mapped to %q", cfg.TemplateDir)
log.Infof("HTTP static content mapped to %q", cfg.PublicDir)
staticPath := filepath.Join(conf.Web.UIDir, staticDir)
log.Info().Str("module", "web").Str("phase", "startup").Str("path", conf.Web.UIDir).
Msg("Web UI content mapped")
Router.PathPrefix("/public/").Handler(http.StripPrefix("/public/",
http.FileServer(http.Dir(cfg.PublicDir))))
http.FileServer(http.Dir(staticPath))))
http.Handle("/", Router)
// Session cookie setup
if cfg.CookieAuthKey == "" {
log.Infof("HTTP generating random cookie.auth.key")
if conf.Web.CookieAuthKey == "" {
log.Info().Str("module", "web").Str("phase", "startup").
Msg("Generating random cookie.auth.key")
sessionStore = sessions.NewCookieStore(securecookie.GenerateRandomKey(64))
} else {
log.Tracef("HTTP using configured cookie.auth.key")
sessionStore = sessions.NewCookieStore([]byte(cfg.CookieAuthKey))
log.Info().Str("module", "web").Str("phase", "startup").
Msg("Using configured cookie.auth.key")
sessionStore = sessions.NewCookieStore([]byte(conf.Web.CookieAuthKey))
}
}
// Start begins listening for HTTP requests
func Start(ctx context.Context) {
addr := fmt.Sprintf("%v:%v", webConfig.IP4address, webConfig.IP4port)
server = &http.Server{
Addr: addr,
Addr: rootConfig.Web.Addr,
Handler: nil,
ReadTimeout: 60 * time.Second,
WriteTimeout: 60 * time.Second,
}
// We don't use ListenAndServe because it lacks a way to close the listener
log.Infof("HTTP listening on TCP4 %v", addr)
log.Info().Str("module", "web").Str("phase", "startup").Str("addr", server.Addr).
Msg("HTTP listening on tcp4")
var err error
listener, err = net.Listen("tcp", addr)
listener, err = net.Listen("tcp", server.Addr)
if err != nil {
log.Errorf("HTTP failed to start TCP4 listener: %v", err)
log.Error().Str("module", "web").Str("phase", "startup").Err(err).
Msg("HTTP failed to start TCP4 listener")
emergencyShutdown()
return
}
@@ -104,12 +111,14 @@ func Start(ctx context.Context) {
// Wait for shutdown
select {
case _ = <-ctx.Done():
log.Tracef("HTTP server shutting down on request")
log.Debug().Str("module", "web").Str("phase", "shutdown").
Msg("HTTP server shutting down on request")
}
// Closing the listener will cause the serve() go routine to exit
if err := listener.Close(); err != nil {
log.Errorf("Failed to close HTTP listener: %v", err)
log.Debug().Str("module", "web").Str("phase", "shutdown").Err(err).
Msg("Failed to close HTTP listener")
}
}
@@ -122,7 +131,8 @@ func serve(ctx context.Context) {
case _ = <-ctx.Done():
// Nop
default:
log.Errorf("HTTP server failed: %v", err)
log.Error().Str("module", "web").Str("phase", "startup").Err(err).
Msg("HTTP server failed")
emergencyShutdown()
return
}
@@ -133,17 +143,19 @@ func (h Handler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
// Create the context
ctx, err := NewContext(req)
if err != nil {
log.Errorf("HTTP failed to create context: %v", err)
log.Error().Str("module", "web").Err(err).Msg("HTTP failed to create context")
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer ctx.Close()
// Run the handler, grab the error, and report it
log.Tracef("HTTP[%v] %v %v %q", req.RemoteAddr, req.Proto, req.Method, req.RequestURI)
log.Debug().Str("module", "web").Str("remote", req.RemoteAddr).Str("proto", req.Proto).
Str("method", req.Method).Str("path", req.RequestURI).Msg("Request")
err = h(w, req, ctx)
if err != nil {
log.Errorf("HTTP error handling %q: %v", req.RequestURI, err)
log.Error().Str("module", "web").Str("path", req.RequestURI).Err(err).
Msg("Error handling request")
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}

View File

@@ -1,14 +1,13 @@
package httpd
package web
import (
"html/template"
"net/http"
"path"
"path/filepath"
"strings"
"sync"
"github.com/jhillyerd/inbucket/log"
"github.com/rs/zerolog/log"
)
var cachedMutex sync.Mutex
@@ -20,7 +19,8 @@ var cachedPartials = map[string]*template.Template{}
func RenderTemplate(name string, w http.ResponseWriter, data interface{}) error {
t, err := ParseTemplate(name, false)
if err != nil {
log.Errorf("Error in template '%v': %v", name, err)
log.Error().Str("module", "web").Str("path", name).Err(err).
Msg("Error in template")
return err
}
w.Header().Set("Expires", "-1")
@@ -32,7 +32,8 @@ func RenderTemplate(name string, w http.ResponseWriter, data interface{}) error
func RenderPartial(name string, w http.ResponseWriter, data interface{}) error {
t, err := ParseTemplate(name, true)
if err != nil {
log.Errorf("Error in template '%v': %v", name, err)
log.Error().Str("module", "web").Str("path", name).Err(err).
Msg("Error in template")
return err
}
w.Header().Set("Expires", "-1")
@@ -49,9 +50,8 @@ func ParseTemplate(name string, partial bool) (*template.Template, error) {
return t, nil
}
tempPath := strings.Replace(name, "/", string(filepath.Separator), -1)
tempFile := filepath.Join(webConfig.TemplateDir, tempPath)
log.Tracef("Parsing template %v", tempFile)
tempFile := filepath.Join(rootConfig.Web.UIDir, templateDir, filepath.FromSlash(name))
log.Debug().Str("module", "web").Str("path", name).Msg("Parsing template")
var err error
var t *template.Template
@@ -62,19 +62,20 @@ func ParseTemplate(name string, partial bool) (*template.Template, error) {
t, err = t.ParseFiles(tempFile)
} else {
t = template.New("_base.html").Funcs(TemplateFuncs)
t, err = t.ParseFiles(filepath.Join(webConfig.TemplateDir, "_base.html"), tempFile)
t, err = t.ParseFiles(
filepath.Join(rootConfig.Web.UIDir, templateDir, "_base.html"), tempFile)
}
if err != nil {
return nil, err
}
// Allows us to disable caching for theme development
if webConfig.TemplateCache {
if rootConfig.Web.TemplateCache {
if partial {
log.Tracef("Caching partial %v", name)
log.Debug().Str("module", "web").Str("path", name).Msg("Caching partial")
cachedTemplates[name] = t
} else {
log.Tracef("Caching template %v", name)
log.Debug().Str("module", "web").Str("path", name).Msg("Caching template")
cachedTemplates[name] = t
}
}

View File

@@ -0,0 +1,104 @@
package file
import (
"io"
"net/mail"
"os"
"path/filepath"
"time"
"github.com/rs/zerolog/log"
)
// Message 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 Message struct {
mailbox *mbox
// Stored in GOB
Fid string
Fdate time.Time
Ffrom *mail.Address
Fto []*mail.Address
Fsubject string
Fsize int64
Fseen bool
}
// newMessage creates a new FileMessage object and sets the Date and ID fields.
// It will also delete messages over messageCap if configured.
func (mb *mbox) 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.Info().Str("module", "storage").Str("mailbox", mb.name).
Msg("Mailbox over message cap")
id := mb.messages[0].ID()
if err := mb.removeMessage(id); err != nil {
log.Error().Str("module", "storage").Str("mailbox", mb.name).Str("id", id).
Err(err).Msg("Unable to delete message")
}
}
}
date := time.Now()
id := generateID(date)
return &Message{mailbox: mb, Fid: id, Fdate: date}, nil
}
// Mailbox returns the name of the mailbox this message resides in.
func (m *Message) Mailbox() string {
return m.mailbox.name
}
// ID gets the ID of the Message
func (m *Message) ID() string {
return m.Fid
}
// Date returns the date/time this Message was received by Inbucket
func (m *Message) Date() time.Time {
return m.Fdate
}
// From returns the value of the Message From header
func (m *Message) From() *mail.Address {
return m.Ffrom
}
// To returns the value of the Message To header
func (m *Message) To() []*mail.Address {
return m.Fto
}
// Subject returns the value of the Message Subject header
func (m *Message) Subject() string {
return m.Fsubject
}
// Size returns the size of the Message on disk in bytes
func (m *Message) Size() int64 {
return m.Fsize
}
func (m *Message) rawPath() string {
return filepath.Join(m.mailbox.path, m.Fid+".raw")
}
// Source opens the .raw portion of a Message as an io.ReadCloser
func (m *Message) Source() (reader io.ReadCloser, err error) {
file, err := os.Open(m.rawPath())
if err != nil {
return nil, err
}
return file, nil
}
// Seen returns the seen flag value.
func (m *Message) Seen() bool {
return m.Fseen
}

267
pkg/storage/file/fstore.go Normal file
View File

@@ -0,0 +1,267 @@
package file
import (
"bufio"
"fmt"
"io"
"io/ioutil"
"os"
"path/filepath"
"time"
"github.com/jhillyerd/inbucket/pkg/config"
"github.com/jhillyerd/inbucket/pkg/storage"
"github.com/jhillyerd/inbucket/pkg/stringutil"
"github.com/rs/zerolog/log"
)
// Name of index file in each mailbox
const indexFileName = "index.gob"
var (
// countChannel is filled with a sequential numbers (0000..9999), which are
// used by generateID() to generate unique message IDs. It's global
// because we only want one regardless of the number of DataStore objects
countChannel = make(chan int, 10)
)
func init() {
// Start generator
go countGenerator(countChannel)
}
// Populates the channel with numbers
func countGenerator(c chan int) {
for i := 0; true; i = (i + 1) % 10000 {
c <- i
}
}
// Store implements DataStore aand is the root of the mail storage
// hiearchy. It provides access to Mailbox objects
type Store struct {
hashLock storage.HashLock
path string
mailPath string
messageCap int
}
// New creates a new DataStore object using the specified path
func New(cfg config.Storage) (storage.Store, error) {
path := cfg.Params["path"]
if path == "" {
return nil, fmt.Errorf("'path' parameter not specified")
}
mailPath := filepath.Join(path, "mail")
if _, err := os.Stat(mailPath); err != nil {
// Mail datastore does not yet exist
if err = os.MkdirAll(mailPath, 0770); err != nil {
log.Error().Str("module", "storage").Str("path", mailPath).Err(err).
Msg("Error creating dir")
}
}
return &Store{path: path, mailPath: mailPath, messageCap: cfg.MailboxMsgCap}, nil
}
// AddMessage adds a message to the specified mailbox.
func (fs *Store) AddMessage(m storage.Message) (id string, err error) {
mb := fs.mbox(m.Mailbox())
mb.Lock()
defer mb.Unlock()
r, err := m.Source()
if err != nil {
return "", err
}
// Create a new message.
fm, err := mb.newMessage()
if err != nil {
return "", err
}
// Ensure mailbox directory exists.
if err := mb.createDir(); err != nil {
return "", err
}
// Write the message content
file, err := os.Create(fm.rawPath())
if err != nil {
return "", err
}
w := bufio.NewWriter(file)
size, err := io.Copy(w, r)
if err != nil {
// Try to remove the file
_ = file.Close()
_ = os.Remove(fm.rawPath())
return "", err
}
_ = r.Close()
if err := w.Flush(); err != nil {
// Try to remove the file
_ = file.Close()
_ = os.Remove(fm.rawPath())
return "", err
}
if err := file.Close(); err != nil {
// Try to remove the file
_ = os.Remove(fm.rawPath())
return "", err
}
// Update the index.
fm.Fdate = m.Date()
fm.Ffrom = m.From()
fm.Fto = m.To()
fm.Fsize = size
fm.Fsubject = m.Subject()
mb.messages = append(mb.messages, fm)
if err := mb.writeIndex(); err != nil {
// Try to remove the file
_ = os.Remove(fm.rawPath())
return "", err
}
return fm.Fid, nil
}
// GetMessage returns the messages in the named mailbox, or an error.
func (fs *Store) GetMessage(mailbox, id string) (storage.Message, error) {
mb := fs.mbox(mailbox)
mb.RLock()
defer mb.RUnlock()
return mb.getMessage(id)
}
// GetMessages returns the messages in the named mailbox, or an error.
func (fs *Store) GetMessages(mailbox string) ([]storage.Message, error) {
mb := fs.mbox(mailbox)
mb.RLock()
defer mb.RUnlock()
return mb.getMessages()
}
// MarkSeen flags the message as having been read.
func (fs *Store) MarkSeen(mailbox, id string) error {
mb := fs.mbox(mailbox)
mb.Lock()
defer mb.Unlock()
if !mb.indexLoaded {
if err := mb.readIndex(); err != nil {
return err
}
}
for _, m := range mb.messages {
if m.Fid == id {
if m.Fseen {
// Already marked seen.
return nil
}
m.Fseen = true
break
}
}
return mb.writeIndex()
}
// RemoveMessage deletes a message by ID from the specified mailbox.
func (fs *Store) RemoveMessage(mailbox, id string) error {
mb := fs.mbox(mailbox)
mb.Lock()
defer mb.Unlock()
return mb.removeMessage(id)
}
// PurgeMessages deletes all messages in the named mailbox, or returns an error.
func (fs *Store) PurgeMessages(mailbox string) error {
mb := fs.mbox(mailbox)
mb.Lock()
defer mb.Unlock()
return mb.purge()
}
// VisitMailboxes accepts a function that will be called with the messages in each mailbox while it
// continues to return true.
func (fs *Store) VisitMailboxes(f func([]storage.Message) (cont bool)) error {
infos1, err := ioutil.ReadDir(fs.mailPath)
if err != nil {
return err
}
// Loop over level 1 directories
for _, inf1 := range infos1 {
if inf1.IsDir() {
l1 := inf1.Name()
infos2, err := ioutil.ReadDir(filepath.Join(fs.mailPath, l1))
if err != nil {
return err
}
// Loop over level 2 directories
for _, inf2 := range infos2 {
if inf2.IsDir() {
l2 := inf2.Name()
infos3, err := ioutil.ReadDir(filepath.Join(fs.mailPath, l1, l2))
if err != nil {
return err
}
// Loop over mailboxes
for _, inf3 := range infos3 {
if inf3.IsDir() {
mb := fs.mboxFromHash(inf3.Name())
mb.RLock()
msgs, err := mb.getMessages()
mb.RUnlock()
if err != nil {
return err
}
if !f(msgs) {
return nil
}
}
}
}
}
}
}
return nil
}
// mbox returns the named mailbox.
func (fs *Store) mbox(mailbox string) *mbox {
hash := stringutil.HashMailboxName(mailbox)
s1 := hash[0:3]
s2 := hash[0:6]
path := filepath.Join(fs.mailPath, s1, s2, hash)
indexPath := filepath.Join(path, indexFileName)
return &mbox{
RWMutex: fs.hashLock.Get(hash),
store: fs,
name: mailbox,
dirName: hash,
path: path,
indexPath: indexPath,
}
}
// mboxFromPath constructs a mailbox based on name hash.
func (fs *Store) mboxFromHash(hash string) *mbox {
s1 := hash[0:3]
s2 := hash[0:6]
path := filepath.Join(fs.mailPath, s1, s2, hash)
indexPath := filepath.Join(path, indexFileName)
return &mbox{
RWMutex: fs.hashLock.Get(hash),
store: fs,
dirName: hash,
path: path,
indexPath: indexPath,
}
}
// generatePrefix converts a Time object into the ISO style format we use
// as a prefix for message files. Note: It is used directly by unit
// tests.
func generatePrefix(date time.Time) string {
return date.Format("20060102T150405")
}
// generateId adds a 4-digit unique number onto the end of the string
// returned by generatePrefix()
func generateID(date time.Time) string {
return generatePrefix(date) + "-" + fmt.Sprintf("%04d", <-countChannel)
}

View File

@@ -0,0 +1,257 @@
package file
import (
"bytes"
"fmt"
"io"
"io/ioutil"
"log"
"net/mail"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/jhillyerd/inbucket/pkg/config"
"github.com/jhillyerd/inbucket/pkg/message"
"github.com/jhillyerd/inbucket/pkg/storage"
"github.com/jhillyerd/inbucket/pkg/test"
"github.com/stretchr/testify/assert"
)
// TestSuite runs storage package test suite on file store.
func TestSuite(t *testing.T) {
test.StoreSuite(t, func(conf config.Storage) (storage.Store, func(), error) {
ds, _ := setupDataStore(conf)
destroy := func() {
teardownDataStore(ds)
}
return ds, destroy, nil
})
}
// Test directory structure created by filestore
func TestFSDirStructure(t *testing.T) {
ds, logbuf := setupDataStore(config.Storage{})
defer teardownDataStore(ds)
root := ds.path
// james hashes to 474ba67bdb289c6263b36dfd8a7bed6c85b04943
mbName := "james"
// Check filestore root exists
assert.True(t, isDir(root), "Expected %q to be a directory", root)
// Check mail dir exists
expect := filepath.Join(root, "mail")
assert.True(t, isDir(expect), "Expected %q to be a directory", expect)
// Check first hash section does not exist
expect = filepath.Join(root, "mail", "474")
assert.False(t, isDir(expect), "Expected %q to not exist", expect)
// Deliver test message
id1, _ := deliverMessage(ds, mbName, "test", time.Now())
// Check path to message exists
assert.True(t, isDir(expect), "Expected %q to be a directory", expect)
expect = filepath.Join(expect, "474ba6")
assert.True(t, isDir(expect), "Expected %q to be a directory", expect)
expect = filepath.Join(expect, "474ba67bdb289c6263b36dfd8a7bed6c85b04943")
assert.True(t, isDir(expect), "Expected %q to be a directory", expect)
// Check files
mbPath := expect
expect = filepath.Join(mbPath, "index.gob")
assert.True(t, isFile(expect), "Expected %q to be a file", expect)
expect = filepath.Join(mbPath, id1+".raw")
assert.True(t, isFile(expect), "Expected %q to be a file", expect)
// Deliver second test message
id2, _ := deliverMessage(ds, mbName, "test 2", time.Now())
// Check files
expect = filepath.Join(mbPath, "index.gob")
assert.True(t, isFile(expect), "Expected %q to be a file", expect)
expect = filepath.Join(mbPath, id2+".raw")
assert.True(t, isFile(expect), "Expected %q to be a file", expect)
// Delete message
err := ds.RemoveMessage(mbName, id1)
assert.Nil(t, err)
// Message should be removed
expect = filepath.Join(mbPath, id1+".raw")
assert.False(t, isPresent(expect), "Did not expect %q to exist", expect)
expect = filepath.Join(mbPath, "index.gob")
assert.True(t, isFile(expect), "Expected %q to be a file", expect)
// Delete message
err = ds.RemoveMessage(mbName, id2)
assert.Nil(t, err)
// Message should be removed
expect = filepath.Join(mbPath, id2+".raw")
assert.False(t, isPresent(expect), "Did not expect %q to exist", expect)
// No messages, index & maildir should be removed
expect = filepath.Join(mbPath, "index.gob")
assert.False(t, isPresent(expect), "Did not expect %q to exist", expect)
expect = mbPath
assert.False(t, isPresent(expect), "Did not expect %q to exist", expect)
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)
}
}
// Test missing files
func TestFSMissing(t *testing.T) {
ds, logbuf := setupDataStore(config.Storage{})
defer teardownDataStore(ds)
mbName := "fred"
subjects := []string{"a", "b", "c"}
sentIds := make([]string, len(subjects))
for i, subj := range subjects {
// Add a message
id, _ := deliverMessage(ds, mbName, subj, time.Now())
sentIds[i] = id
}
// Delete a message file without removing it from index
msg, err := ds.GetMessage(mbName, sentIds[1])
assert.Nil(t, err)
fmsg := msg.(*Message)
_ = os.Remove(fmsg.rawPath())
msg, err = ds.GetMessage(mbName, sentIds[1])
assert.Nil(t, err)
// Try to read parts of message
_, err = msg.Source()
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)
}
}
// Test Get the latest message
func TestGetLatestMessage(t *testing.T) {
ds, logbuf := setupDataStore(config.Storage{})
defer teardownDataStore(ds)
// james hashes to 474ba67bdb289c6263b36dfd8a7bed6c85b04943
mbName := "james"
// Test empty mailbox
msg, err := ds.GetMessage(mbName, "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
msg, err = ds.GetMessage(mbName, "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())
msg, err = ds.GetMessage(mbName, "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 = ds.GetMessage(mbName, "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.Storage) (*Store, *bytes.Buffer) {
path, err := ioutil.TempDir("", "inbucket")
if err != nil {
panic(err)
}
// Capture log output.
buf := new(bytes.Buffer)
log.SetOutput(buf)
if cfg.Params == nil {
cfg.Params = make(map[string]string)
}
cfg.Params["path"] = path
s, err := New(cfg)
if err != nil {
panic(err)
}
return s.(*Store), buf
}
// deliverMessage creates and delivers a message to the specific mailbox, returning
// the size of the generated message.
func deliverMessage(ds *Store, mbName string, subject string, date time.Time) (string, int64) {
// Build message for delivery
meta := message.Metadata{
Mailbox: mbName,
To: []*mail.Address{{Name: "", Address: "somebody@host"}},
From: &mail.Address{Name: "", Address: "somebodyelse@host"},
Subject: subject,
Date: date,
}
testMsg := fmt.Sprintf("To: %s\r\nFrom: %s\r\nSubject: %s\r\n\r\nTest Body\r\n",
meta.To[0].Address, meta.From.Address, subject)
delivery := &message.Delivery{
Meta: meta,
Reader: ioutil.NopCloser(strings.NewReader(testMsg)),
}
id, err := ds.AddMessage(delivery)
if err != nil {
panic(err)
}
return id, int64(len(testMsg))
}
func teardownDataStore(ds *Store) {
if err := os.RemoveAll(ds.path); err != nil {
panic(err)
}
}
func isPresent(path string) bool {
_, err := os.Lstat(path)
return err == nil
}
func isFile(path string) bool {
if fi, err := os.Lstat(path); err == nil {
return !fi.IsDir()
}
return false
}
func isDir(path string) bool {
if fi, err := os.Lstat(path); err == nil {
return fi.IsDir()
}
return false
}

237
pkg/storage/file/mbox.go Normal file
View File

@@ -0,0 +1,237 @@
package file
import (
"bufio"
"encoding/gob"
"fmt"
"io"
"os"
"path/filepath"
"sync"
"github.com/jhillyerd/inbucket/pkg/storage"
"github.com/rs/zerolog/log"
)
// mbox manages the mail for a specific user and correlates to a particular directory on disk.
// mbox methods are not thread safe, mbox.RWMutex must be held prior to calling.
type mbox struct {
*sync.RWMutex
store *Store
name string
dirName string
path string
indexLoaded bool
indexPath string
messages []*Message
}
// getMessages scans the mailbox directory for .gob files and decodes them into
// a slice of Message objects.
func (mb *mbox) getMessages() ([]storage.Message, error) {
if !mb.indexLoaded {
if err := mb.readIndex(); err != nil {
return nil, err
}
}
messages := make([]storage.Message, len(mb.messages))
for i, m := range mb.messages {
messages[i] = m
}
return messages, nil
}
// getMessage decodes a single message by ID and returns a Message object.
func (mb *mbox) getMessage(id string) (storage.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, storage.ErrNotExist
}
// removeMessage deletes the message off disk and removes it from the index.
func (mb *mbox) removeMessage(id string) error {
if !mb.indexLoaded {
if err := mb.readIndex(); err != nil {
return err
}
}
var msg *Message
for i, m := range mb.messages {
if id == m.ID() {
msg = m
// Slice around message we are deleting
mb.messages = append(mb.messages[:i], mb.messages[i+1:]...)
break
}
}
if msg == nil {
return storage.ErrNotExist
}
if err := mb.writeIndex(); err != nil {
return err
}
if len(mb.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.Debug().Str("module", "storage").Str("path", msg.rawPath()).Msg("Deleting file")
return os.Remove(msg.rawPath())
}
// purge deletes all messages in this mailbox.
func (mb *mbox) purge() error {
mb.messages = mb.messages[:0]
return mb.writeIndex()
}
// readIndex loads the mailbox index data from disk
func (mb *mbox) readIndex() error {
// Clear message slice, open index
mb.messages = mb.messages[:0]
// Check if index exists
if _, err := os.Stat(mb.indexPath); err != nil {
// Does not exist, but that's not an error in our world
log.Debug().Str("module", "storage").Str("path", mb.indexPath).
Msg("Index does not yet exist")
mb.indexLoaded = true
return nil
}
file, err := os.Open(mb.indexPath)
if err != nil {
return err
}
defer func() {
if err := file.Close(); err != nil {
log.Error().Str("module", "storage").Str("path", mb.indexPath).Err(err).
Msg("Failed to close")
}
}()
// Decode gob data
dec := gob.NewDecoder(bufio.NewReader(file))
name := ""
if err = dec.Decode(&name); err != nil {
return fmt.Errorf("Corrupt mailbox %q: %v", mb.indexPath, err)
}
mb.name = name
for {
// Load messages until EOF
msg := &Message{}
if err = dec.Decode(msg); err != nil {
if err == io.EOF {
break
}
return fmt.Errorf("Corrupt mailbox %q: %v", mb.indexPath, err)
}
msg.mailbox = mb
mb.messages = append(mb.messages, msg)
}
mb.indexLoaded = true
return nil
}
// writeIndex overwrites the index on disk with the current mailbox data
func (mb *mbox) writeIndex() error {
// Lock for writing
if len(mb.messages) > 0 {
// Ensure mailbox directory exists
if err := mb.createDir(); err != nil {
return err
}
// Open index for writing
file, err := os.Create(mb.indexPath)
if err != nil {
return err
}
writer := bufio.NewWriter(file)
// Write each message and then flush
enc := gob.NewEncoder(writer)
if err = enc.Encode(mb.name); err != nil {
_ = file.Close()
return err
}
for _, m := range mb.messages {
if err = enc.Encode(m); err != nil {
_ = file.Close()
return err
}
}
if err := writer.Flush(); err != nil {
_ = file.Close()
return err
}
if err := file.Close(); err != nil {
log.Error().Str("module", "storage").Str("path", mb.indexPath).Err(err).
Msg("Failed to close")
return err
}
} else {
// No messages, delete index+maildir
log.Debug().Str("module", "storage").Str("path", mb.path).Msg("Removing mailbox")
return mb.removeDir()
}
return nil
}
// createDir checks for the presence of the path for this mailbox, creates it if needed
func (mb *mbox) createDir() error {
if _, err := os.Stat(mb.path); err != nil {
if err := os.MkdirAll(mb.path, 0770); err != nil {
log.Error().Str("module", "storage").Str("path", mb.path).Err(err).
Msg("Failed to create directory")
return err
}
}
return nil
}
// removeDir removes the mailbox, plus empty higher level directories
func (mb *mbox) removeDir() error {
// remove mailbox dir, including index file
if err := os.RemoveAll(mb.path); err != nil {
return err
}
// remove parents if empty
dir := filepath.Dir(mb.path)
if removeDirIfEmpty(dir) {
removeDirIfEmpty(filepath.Dir(dir))
}
return nil
}
// removeDirIfEmpty will remove the specified directory if it contains no files or directories.
// Returns true if dir was removed.
func removeDirIfEmpty(path string) (removed bool) {
f, err := os.Open(path)
if err != nil {
return false
}
files, err := f.Readdirnames(0)
_ = f.Close()
if err != nil {
return false
}
if len(files) > 0 {
// Dir not empty
return false
}
log.Debug().Str("module", "storage").Str("path", path).Msg("Removing dir")
err = os.Remove(path)
if err != nil {
log.Error().Str("module", "storage").Str("path", path).Err(err).Msg("Failed to remove")
return false
}
return true
}

23
pkg/storage/lock.go Normal file
View File

@@ -0,0 +1,23 @@
package storage
import (
"strconv"
"sync"
)
// HashLock holds a fixed length array of mutexes. This approach allows concurrent mailbox
// access in most cases without requiring an infinite number of mutexes.
type HashLock [4096]sync.RWMutex
// Get returns a RWMutex based on the first 12 bits of the mailbox hash. Hash must be a hexidecimal
// string of three or more characters.
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
pkg/storage/lock_test.go Normal file
View File

@@ -0,0 +1,61 @@
package storage_test
import (
"testing"
"github.com/jhillyerd/inbucket/pkg/storage"
)
func TestHashLock(t *testing.T) {
hl := &storage.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

@@ -0,0 +1,73 @@
package mem
import "container/list"
type msgDone struct {
msg *Message
done chan struct{}
}
// maxSizeEnforcer will delete the oldest message until the entire mail store is equal to or less
// than maxSize bytes.
func (s *Store) maxSizeEnforcer(maxSize int64) {
all := &list.List{}
curSize := int64(0)
for {
select {
case md, ok := <-s.incoming:
if !ok {
return
}
// Add message to all.
m := md.msg
el := all.PushBack(m)
m.el = el
curSize += int64(m.Size())
for curSize > maxSize {
// Remove oldest message.
el := all.Front()
all.Remove(el)
m := el.Value.(*Message)
if s.removeMessage(m.mailbox, m.id) != nil {
curSize -= int64(m.Size())
}
}
close(md.done)
case md, ok := <-s.remove:
if !ok {
return
}
// Remove message from all.
m := md.msg
el := all.Remove(m.el)
if el != nil {
curSize -= int64(m.Size())
}
close(md.done)
}
}
}
// enforcerDeliver sends delivery to enforcer if configured, and waits for completion.
func (s *Store) enforcerDeliver(m *Message) {
if s.incoming != nil {
md := &msgDone{
msg: m,
done: make(chan struct{}),
}
s.incoming <- md
<-md.done
}
}
// enforcerRemove sends removal to enforcer if configured, and waits for completion.
func (s *Store) enforcerRemove(m *Message) {
if s.remove != nil {
md := &msgDone{
msg: m,
done: make(chan struct{}),
}
s.remove <- md
<-md.done
}
}

View File

@@ -0,0 +1,57 @@
package mem
import (
"bytes"
"container/list"
"io"
"io/ioutil"
"net/mail"
"time"
"github.com/jhillyerd/inbucket/pkg/storage"
)
// Message is a memory store message.
type Message struct {
index int
mailbox string
id string
from *mail.Address
to []*mail.Address
date time.Time
subject string
source []byte
seen bool
el *list.Element // This message in Store.messages
}
var _ storage.Message = &Message{}
// Mailbox returns the mailbox name.
func (m *Message) Mailbox() string { return m.mailbox }
// ID the message ID.
func (m *Message) ID() string { return m.id }
// From returns the from address.
func (m *Message) From() *mail.Address { return m.from }
// To returns the to address list.
func (m *Message) To() []*mail.Address { return m.to }
// Date returns the date received.
func (m *Message) Date() time.Time { return m.date }
// Subject returns the subject line.
func (m *Message) Subject() string { return m.subject }
// Source returns a reader for the message source.
func (m *Message) Source() (io.ReadCloser, error) {
return ioutil.NopCloser(bytes.NewReader(m.source)), nil
}
// Size returns the message size in bytes.
func (m *Message) Size() int64 { return int64(len(m.source)) }
// Seen returns the message seen flag.
func (m *Message) Seen() bool { return m.seen }

212
pkg/storage/mem/store.go Normal file
View File

@@ -0,0 +1,212 @@
package mem
import (
"fmt"
"io/ioutil"
"sort"
"strconv"
"sync"
"github.com/jhillyerd/inbucket/pkg/config"
"github.com/jhillyerd/inbucket/pkg/storage"
)
// Store implements an in-memory message store.
type Store struct {
sync.Mutex
boxes map[string]*mbox
cap int // Per-mailbox message cap.
incoming chan *msgDone // New messages for size enforcer.
remove chan *msgDone // Remove deleted messages from size enforcer.
}
type mbox struct {
sync.RWMutex
name string
last int
first int
messages map[string]*Message
}
var _ storage.Store = &Store{}
// New returns an emtpy memory store.
func New(cfg config.Storage) (storage.Store, error) {
s := &Store{
boxes: make(map[string]*mbox),
cap: cfg.MailboxMsgCap,
}
if str, ok := cfg.Params["maxkb"]; ok {
maxKB, err := strconv.ParseInt(str, 10, 64)
if err != nil {
return nil, fmt.Errorf("failed to parse maxkb: %v", err)
}
if maxKB > 0 {
// Setup enforcer.
s.incoming = make(chan *msgDone)
s.remove = make(chan *msgDone)
go s.maxSizeEnforcer(maxKB * 1024)
}
}
return s, nil
}
// AddMessage stores the message, message ID and Size will be ignored.
func (s *Store) AddMessage(message storage.Message) (id string, err error) {
r, ierr := message.Source()
if ierr != nil {
err = ierr
return
}
source, ierr := ioutil.ReadAll(r)
if ierr != nil {
err = ierr
return
}
m := &Message{
mailbox: message.Mailbox(),
from: message.From(),
to: message.To(),
date: message.Date(),
subject: message.Subject(),
}
s.withMailbox(message.Mailbox(), true, func(mb *mbox) {
// Generate message ID.
mb.last++
m.index = mb.last
id = strconv.Itoa(mb.last)
m.id = id
m.source = source
mb.messages[id] = m
if s.cap > 0 {
// Enforce cap.
for len(mb.messages) > s.cap {
delete(mb.messages, strconv.Itoa(mb.first))
mb.first++
}
}
})
s.enforcerDeliver(m)
return id, err
}
// GetMessage gets a mesage.
func (s *Store) GetMessage(mailbox, id string) (m storage.Message, err error) {
s.withMailbox(mailbox, false, func(mb *mbox) {
var ok bool
m, ok = mb.messages[id]
if !ok {
m = nil
}
})
return m, err
}
// GetMessages gets a list of messages.
func (s *Store) GetMessages(mailbox string) (ms []storage.Message, err error) {
s.withMailbox(mailbox, false, func(mb *mbox) {
ms = make([]storage.Message, 0, len(mb.messages))
for _, v := range mb.messages {
ms = append(ms, v)
}
sort.Slice(ms, func(i, j int) bool {
return ms[i].(*Message).index < ms[j].(*Message).index
})
})
return ms, err
}
// MarkSeen marks a message as having been read.
func (s *Store) MarkSeen(mailbox, id string) error {
s.withMailbox(mailbox, true, func(mb *mbox) {
m := mb.messages[id]
if m != nil {
m.seen = true
}
})
return nil
}
// PurgeMessages deletes the contents of a mailbox.
func (s *Store) PurgeMessages(mailbox string) error {
var messages map[string]*Message
s.withMailbox(mailbox, true, func(mb *mbox) {
messages = mb.messages
mb.messages = make(map[string]*Message)
})
if len(messages) > 0 && s.remove != nil {
for _, m := range messages {
s.enforcerRemove(m)
}
}
return nil
}
// removeMessage deletes a single message without notifying the size enforcer. Returns the message
// that was removed.
func (s *Store) removeMessage(mailbox, id string) *Message {
var m *Message
s.withMailbox(mailbox, true, func(mb *mbox) {
m = mb.messages[id]
if m != nil {
delete(mb.messages, id)
}
})
return m
}
// RemoveMessage deletes a single message.
func (s *Store) RemoveMessage(mailbox, id string) error {
m := s.removeMessage(mailbox, id)
if m != nil {
s.enforcerRemove(m)
}
return nil
}
// VisitMailboxes visits each mailbox in the store.
func (s *Store) VisitMailboxes(f func([]storage.Message) (cont bool)) error {
// Lock store, get names of all mailboxes.
s.Lock()
boxNames := make([]string, 0, len(s.boxes))
for k := range s.boxes {
boxNames = append(boxNames, k)
}
s.Unlock()
// Process mailboxes.
for _, mailbox := range boxNames {
ms, _ := s.GetMessages(mailbox)
if !f(ms) {
break
}
}
return nil
}
// withMailbox gets or creates a mailbox, locks it, then calls f.
func (s *Store) withMailbox(mailbox string, writeLock bool, f func(mb *mbox)) {
s.Lock()
mb, ok := s.boxes[mailbox]
if !ok {
// Create mailbox
mb = &mbox{
name: mailbox,
messages: make(map[string]*Message),
}
s.boxes[mailbox] = mb
}
s.Unlock()
if writeLock {
mb.Lock()
} else {
mb.RLock()
}
defer func() {
if writeLock {
mb.Unlock()
} else {
mb.RUnlock()
}
}()
f(mb)
}

View File

@@ -0,0 +1,82 @@
package mem
import (
"sync"
"testing"
"time"
"github.com/jhillyerd/inbucket/pkg/config"
"github.com/jhillyerd/inbucket/pkg/storage"
"github.com/jhillyerd/inbucket/pkg/test"
)
// TestSuite runs storage package test suite on file store.
func TestSuite(t *testing.T) {
test.StoreSuite(t, func(conf config.Storage) (storage.Store, func(), error) {
s, _ := New(conf)
destroy := func() {}
return s, destroy, nil
})
}
// TestMessageList verifies the operation of the global message list: mem.Store.messages.
func TestMaxSize(t *testing.T) {
maxSize := int64(2048)
s, _ := New(config.Storage{Params: map[string]string{"maxkb": "2"}})
boxes := []string{"alpha", "beta", "whiskey", "tango", "foxtrot"}
n := 10
// total := 50
sizeChan := make(chan int64, len(boxes))
// Populate mailboxes concurrently.
for _, mailbox := range boxes {
go func(mailbox string) {
size := int64(0)
for i := 0; i < n; i++ {
_, nbytes := test.DeliverToStore(t, s, mailbox, "subject", time.Now())
size += nbytes
}
sizeChan <- size
}(mailbox)
}
// Wait for sizes.
sentBytesTotal := int64(0)
for range boxes {
sentBytesTotal += <-sizeChan
}
// Calculate actual size.
gotSize := int64(0)
s.VisitMailboxes(func(messages []storage.Message) bool {
for _, m := range messages {
gotSize += m.Size()
}
return true
})
// Verify state. Messages are ~75 bytes each.
if gotSize < 2048-75 {
t.Errorf("Got total size %v, want greater than: %v", gotSize, 2048-75)
}
if gotSize > maxSize {
t.Errorf("Got total size %v, want less than: %v", gotSize, maxSize)
}
// Purge all messages concurrently, testing for deadlocks.
wg := &sync.WaitGroup{}
wg.Add(len(boxes))
for _, mailbox := range boxes {
go func(mailbox string) {
err := s.PurgeMessages(mailbox)
if err != nil {
t.Fatal(err)
}
wg.Done()
}(mailbox)
}
wg.Wait()
count := 0
s.VisitMailboxes(func(messages []storage.Message) bool {
count += len(messages)
return true
})
if count != 0 {
t.Errorf("Got %v total messages, want: %v", count, 0)
}
}

View File

@@ -1,4 +1,4 @@
package smtpd
package storage
import (
"container/list"
@@ -6,8 +6,9 @@ import (
"sync"
"time"
"github.com/jhillyerd/inbucket/config"
"github.com/jhillyerd/inbucket/log"
"github.com/jhillyerd/inbucket/pkg/config"
"github.com/jhillyerd/inbucket/pkg/metric"
"github.com/rs/zerolog/log"
)
var (
@@ -18,14 +19,17 @@ var (
expRetentionDeletesTotal = new(expvar.Int)
expRetentionPeriod = new(expvar.Int)
expRetainedCurrent = new(expvar.Int)
expRetainedSize = new(expvar.Int)
// History of certain stats
retentionDeletesHist = list.New()
retainedHist = list.New()
sizeHist = list.New()
// History rendered as comma delimited string
expRetentionDeletesHist = new(expvar.String)
expRetainedHist = new(expvar.String)
expSizeHist = new(expvar.String)
)
func init() {
@@ -36,46 +40,58 @@ func init() {
rm.Set("Period", expRetentionPeriod)
rm.Set("RetainedHist", expRetainedHist)
rm.Set("RetainedCurrent", expRetainedCurrent)
rm.Set("RetainedSize", expRetainedSize)
rm.Set("SizeHist", expSizeHist)
metric.AddTickerFunc(func() {
expRetentionDeletesHist.Set(metric.Push(retentionDeletesHist, expRetentionDeletesTotal))
expRetainedHist.Set(metric.Push(retainedHist, expRetainedCurrent))
expSizeHist.Set(metric.Push(sizeHist, expRetainedSize))
})
}
// 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
ds Store
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()
// NewRetentionScanner configures a new RententionScanner.
func NewRetentionScanner(
cfg config.Storage,
ds Store,
shutdownChannel chan bool,
) *RetentionScanner {
rs := &RetentionScanner{
globalShutdown: shutdownChannel,
retentionShutdown: make(chan bool),
ds: ds,
retentionPeriod: time.Duration(cfg.RetentionMinutes) * time.Minute,
retentionSleep: time.Duration(cfg.RetentionSleep) * time.Millisecond,
retentionPeriod: cfg.RetentionPeriod,
retentionSleep: cfg.RetentionSleep,
}
// expRetentionPeriod is displayed on the status page
expRetentionPeriod.Set(int64(cfg.RetentionMinutes * 60))
expRetentionPeriod.Set(int64(cfg.RetentionPeriod / time.Second))
return rs
}
// Start up the retention scanner if retention period > 0
func (rs *RetentionScanner) Start() {
if rs.retentionPeriod <= 0 {
log.Infof("Retention scanner disabled")
log.Info().Str("phase", "startup").Str("module", "storage").Msg("Retention scanner disabled")
close(rs.retentionShutdown)
return
}
log.Infof("Retention configured for %v", rs.retentionPeriod)
log.Info().Str("phase", "startup").Str("module", "storage").
Msgf("Retention configured for %v", rs.retentionPeriod)
go rs.run()
}
// run loops to kick off the scanner on the correct schedule
func (rs *RetentionScanner) run() {
slog := log.With().Str("module", "storage").Logger()
start := time.Now()
retentionLoop:
for {
@@ -83,80 +99,76 @@ retentionLoop:
since := time.Since(start)
if since < time.Minute {
dur := time.Minute - since
log.Tracef("Retention scanner sleeping for %v", dur)
slog.Debug().Msgf("Retention scanner sleeping for %v", dur)
select {
case _ = <-rs.globalShutdown:
case <-rs.globalShutdown:
break retentionLoop
case _ = <-time.After(dur):
case <-time.After(dur):
}
}
// Kickoff scan
start = time.Now()
if err := rs.doScan(); err != nil {
log.Errorf("Error during retention scan: %v", err)
if err := rs.DoScan(); err != nil {
slog.Error().Err(err).Msg("Error during retention scan")
}
// Check for global shutdown
select {
case _ = <-rs.globalShutdown:
case <-rs.globalShutdown:
break retentionLoop
default:
}
}
log.Tracef("Retention scanner shut down")
slog.Debug().Str("phase", "shutdown").Msg("Retention scanner shut down")
close(rs.retentionShutdown)
}
// 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")
// DoScan does a single pass of all mailboxes looking for messages that can be purged.
func (rs *RetentionScanner) DoScan() error {
slog := log.With().Str("module", "storage").Logger()
slog.Debug().Msg("Starting retention scan")
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
storeSize := int64(0)
// Loop over all mailboxes.
err := rs.ds.VisitMailboxes(func(messages []Message) bool {
for _, msg := range messages {
if msg.Date().Before(cutoff) {
log.Tracef("Purging expired message %v", msg.ID())
err = msg.Delete()
if err != nil {
// Log but don't abort
log.Errorf("Failed to purge message %v: %v", msg.ID(), err)
slog.Debug().Str("mailbox", msg.Mailbox()).
Msgf("Purging expired message %v", msg.ID())
if err := rs.ds.RemoveMessage(msg.Mailbox(), msg.ID()); err != nil {
slog.Error().Str("mailbox", msg.Mailbox()).Err(err).
Msgf("Failed to purge message %v", msg.ID())
} else {
expRetentionDeletesTotal.Add(1)
}
} else {
retained++
storeSize += msg.Size()
}
}
// Sleep after completing a mailbox
select {
case <-rs.globalShutdown:
log.Tracef("Retention scan aborted due to shutdown")
return nil
slog.Debug().Str("phase", "shutdown").Msg("Retention scan aborted due to shutdown")
return false
case <-time.After(rs.retentionSleep):
// Reduce disk thrashing
}
return true
})
if err != nil {
return err
}
// Update metrics
setRetentionScanCompleted(time.Now())
expRetainedCurrent.Set(int64(retained))
expRetainedSize.Set(storeSize)
return nil
}
// Join does not retun until the retention scanner has shut down
// Join does not return until the retention scanner has shut down.
func (rs *RetentionScanner) Join() {
if rs.retentionShutdown != nil {
select {
case <-rs.retentionShutdown:
}
<-rs.retentionShutdown
}
}

View File

@@ -0,0 +1,62 @@
package storage_test
import (
"fmt"
"testing"
"time"
"github.com/jhillyerd/inbucket/pkg/config"
"github.com/jhillyerd/inbucket/pkg/message"
"github.com/jhillyerd/inbucket/pkg/storage"
"github.com/jhillyerd/inbucket/pkg/test"
)
func TestDoRetentionScan(t *testing.T) {
ds := test.NewStore()
// Mockup some different aged messages (num is in hours)
new1 := stubMessage("mb1", 0)
new2 := stubMessage("mb2", 1)
new3 := stubMessage("mb3", 2)
old1 := stubMessage("mb1", 4)
old2 := stubMessage("mb1", 12)
old3 := stubMessage("mb2", 24)
ds.AddMessage(new1)
ds.AddMessage(old1)
ds.AddMessage(old2)
ds.AddMessage(old3)
ds.AddMessage(new2)
ds.AddMessage(new3)
// Test 4 hour retention
cfg := config.Storage{
RetentionPeriod: 239 * time.Minute,
RetentionSleep: 0,
}
shutdownChan := make(chan bool)
rs := storage.NewRetentionScanner(cfg, ds, shutdownChan)
if err := rs.DoScan(); err != nil {
t.Error(err)
}
// Delete should not have been called on new messages
for _, m := range []storage.Message{new1, new2, new3} {
if ds.MessageDeleted(m) {
t.Errorf("Expected %v to be present, was deleted", m.ID())
}
}
// Delete should have been called once on old messages
for _, m := range []storage.Message{old1, old2, old3} {
if !ds.MessageDeleted(m) {
t.Errorf("Expected %v to be deleted, was present", m.ID())
}
}
}
// stubMessage creates a message stub of a specific age
func stubMessage(mailbox string, ageHours int) storage.Message {
return &message.Delivery{
Meta: message.Metadata{
Mailbox: mailbox,
ID: fmt.Sprintf("MSG[age=%vh]", ageHours),
Date: time.Now().Add(time.Duration(ageHours*-1) * time.Hour),
},
}
}

56
pkg/storage/storage.go Normal file
View File

@@ -0,0 +1,56 @@
// Package storage contains implementation independent datastore logic
package storage
import (
"errors"
"fmt"
"io"
"net/mail"
"time"
"github.com/jhillyerd/inbucket/pkg/config"
)
var (
// ErrNotExist indicates the requested message does not exist.
ErrNotExist = errors.New("message does not exist")
// ErrNotWritable indicates the message is closed; no longer writable
ErrNotWritable = errors.New("Message not writable")
// Constructors tracks registered storage constructors
Constructors = make(map[string]func(config.Storage) (Store, error))
)
// Store is the interface Inbucket uses to interact with storage implementations.
type Store interface {
// AddMessage stores the message, message ID and Size will be ignored.
AddMessage(message Message) (id string, err error)
GetMessage(mailbox, id string) (Message, error)
GetMessages(mailbox string) ([]Message, error)
MarkSeen(mailbox, id string) error
PurgeMessages(mailbox string) error
RemoveMessage(mailbox, id string) error
VisitMailboxes(f func([]Message) (cont bool)) error
}
// Message represents a message to be stored, or returned from a storage implementation.
type Message interface {
Mailbox() string
ID() string
From() *mail.Address
To() []*mail.Address
Date() time.Time
Subject() string
Source() (io.ReadCloser, error)
Size() int64
Seen() bool
}
// FromConfig creates an instance of the Store based on the provided configuration.
func FromConfig(c config.Storage) (store Store, err error) {
if cf := Constructors[c.Type]; cf != nil {
return cf(c)
}
return nil, fmt.Errorf("unknown storage type configured: %q", c.Type)
}

48
pkg/stringutil/utils.go Normal file
View File

@@ -0,0 +1,48 @@
package stringutil
import (
"crypto/sha1"
"fmt"
"io"
"net/mail"
"strings"
)
// 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()
if _, err := io.WriteString(h, mailbox); err != nil {
// This shouldn't ever happen
return ""
}
return fmt.Sprintf("%x", h.Sum(nil))
}
// StringAddressList converts a list of addresses to a list of strings
func StringAddressList(addrs []*mail.Address) []string {
s := make([]string, len(addrs))
for i, a := range addrs {
if a != nil {
s[i] = a.String()
}
}
return s
}
// SliceContains returns true if s is present in slice.
func SliceContains(slice []string, s string) bool {
for _, v := range slice {
if s == v {
return true
}
}
return false
}
// SliceToLower lowercases the contents of slice of strings.
func SliceToLower(slice []string) {
for i, s := range slice {
slice[i] = strings.ToLower(s)
}
}

View File

@@ -0,0 +1,33 @@
package stringutil_test
import (
"net/mail"
"testing"
"github.com/jhillyerd/inbucket/pkg/stringutil"
)
func TestHashMailboxName(t *testing.T) {
want := "1d6e1cf70ec6f9ab28d3ea4b27a49a77654d370e"
got := stringutil.HashMailboxName("mail")
if got != want {
t.Errorf("Got %q, want %q", got, want)
}
}
func TestStringAddressList(t *testing.T) {
input := []*mail.Address{
{Name: "Fred B. Fish", Address: "fred@fish.org"},
{Name: "User", Address: "user@domain.org"},
}
want := []string{`"Fred B. Fish" <fred@fish.org>`, `"User" <user@domain.org>`}
output := stringutil.StringAddressList(input)
if len(output) != len(want) {
t.Fatalf("Got %v strings, want: %v", len(output), len(want))
}
for i, got := range output {
if got != want[i] {
t.Errorf("Got %q, want: %q", got, want[i])
}
}
}

77
pkg/test/manager.go Normal file
View File

@@ -0,0 +1,77 @@
package test
import (
"errors"
"github.com/jhillyerd/inbucket/pkg/config"
"github.com/jhillyerd/inbucket/pkg/message"
"github.com/jhillyerd/inbucket/pkg/policy"
"github.com/jhillyerd/inbucket/pkg/storage"
)
// ManagerStub is a test stub for message.Manager
type ManagerStub struct {
message.Manager
mailboxes map[string][]*message.Message
}
// NewManager creates a new ManagerStub.
func NewManager() *ManagerStub {
return &ManagerStub{
mailboxes: make(map[string][]*message.Message),
}
}
// AddMessage adds a message to the specified mailbox.
func (m *ManagerStub) AddMessage(mailbox string, msg *message.Message) {
messages := m.mailboxes[mailbox]
m.mailboxes[mailbox] = append(messages, msg)
}
// GetMessage gets a message by ID from the specified mailbox.
func (m *ManagerStub) GetMessage(mailbox, id string) (*message.Message, error) {
if mailbox == "messageerr" {
return nil, errors.New("internal error")
}
for _, msg := range m.mailboxes[mailbox] {
if msg.ID == id {
return msg, nil
}
}
return nil, storage.ErrNotExist
}
// GetMetadata gets all the metadata for the specified mailbox.
func (m *ManagerStub) GetMetadata(mailbox string) ([]*message.Metadata, error) {
if mailbox == "messageserr" {
return nil, errors.New("internal error")
}
messages := m.mailboxes[mailbox]
metas := make([]*message.Metadata, len(messages))
for i, msg := range messages {
metas[i] = &msg.Metadata
}
return metas, nil
}
// MailboxForAddress invokes policy.ParseMailboxName.
func (m *ManagerStub) MailboxForAddress(address string) (string, error) {
addrPolicy := &policy.Addressing{Config: &config.Root{
MailboxNaming: config.FullNaming,
}}
return addrPolicy.ExtractMailbox(address)
}
// MarkSeen marks a message as having been read.
func (m *ManagerStub) MarkSeen(mailbox, id string) error {
if mailbox == "messageerr" {
return errors.New("internal error")
}
for _, msg := range m.mailboxes[mailbox] {
if msg.ID == id {
msg.Metadata.Seen = true
return nil
}
}
return storage.ErrNotExist
}

88
pkg/test/storage.go Normal file
View File

@@ -0,0 +1,88 @@
package test
import (
"errors"
"github.com/jhillyerd/inbucket/pkg/storage"
)
// StoreStub stubs storage.Store for testing.
type StoreStub struct {
storage.Store
mailboxes map[string][]storage.Message
deleted map[storage.Message]struct{}
}
// NewStore creates a new StoreStub.
func NewStore() *StoreStub {
return &StoreStub{
mailboxes: make(map[string][]storage.Message),
deleted: make(map[storage.Message]struct{}),
}
}
// AddMessage adds a message to the specified mailbox.
func (s *StoreStub) AddMessage(m storage.Message) (id string, err error) {
mb := m.Mailbox()
msgs := s.mailboxes[mb]
s.mailboxes[mb] = append(msgs, m)
return m.ID(), nil
}
// GetMessage gets a message by ID from the specified mailbox.
func (s *StoreStub) GetMessage(mailbox, id string) (storage.Message, error) {
if mailbox == "messageerr" {
return nil, errors.New("internal error")
}
for _, m := range s.mailboxes[mailbox] {
if m.ID() == id {
return m, nil
}
}
return nil, storage.ErrNotExist
}
// GetMessages gets all the messages for the specified mailbox.
func (s *StoreStub) GetMessages(mailbox string) ([]storage.Message, error) {
if mailbox == "messageserr" {
return nil, errors.New("internal error")
}
return s.mailboxes[mailbox], nil
}
// RemoveMessage deletes a message by ID from the specified mailbox.
func (s *StoreStub) RemoveMessage(mailbox, id string) error {
mb, ok := s.mailboxes[mailbox]
if ok {
var msg storage.Message
for i, m := range mb {
if m.ID() == id {
msg = m
s.mailboxes[mailbox] = append(mb[:i], mb[i+1:]...)
break
}
}
if msg != nil {
s.deleted[msg] = struct{}{}
return nil
}
}
return storage.ErrNotExist
}
// VisitMailboxes accepts a function that will be called with the messages in each mailbox while it
// continues to return true.
func (s *StoreStub) VisitMailboxes(f func([]storage.Message) (cont bool)) error {
for _, v := range s.mailboxes {
if !f(v) {
return nil
}
}
return nil
}
// MessageDeleted returns true if the specified message was deleted
func (s *StoreStub) MessageDeleted(m storage.Message) bool {
_, ok := s.deleted[m]
return ok
}

420
pkg/test/storage_suite.go Normal file
View File

@@ -0,0 +1,420 @@
package test
import (
"bytes"
"fmt"
"io/ioutil"
"net/mail"
"strings"
"testing"
"time"
"github.com/jhillyerd/inbucket/pkg/config"
"github.com/jhillyerd/inbucket/pkg/message"
"github.com/jhillyerd/inbucket/pkg/storage"
)
// StoreFactory returns a new store for the test suite.
type StoreFactory func(config.Storage) (store storage.Store, destroy func(), err error)
// StoreSuite runs a set of general tests on the provided Store.
func StoreSuite(t *testing.T, factory StoreFactory) {
testCases := []struct {
name string
test func(*testing.T, storage.Store)
conf config.Storage
}{
{"metadata", testMetadata, config.Storage{}},
{"content", testContent, config.Storage{}},
{"delivery order", testDeliveryOrder, config.Storage{}},
{"naming", testNaming, config.Storage{}},
{"size", testSize, config.Storage{}},
{"seen", testSeen, config.Storage{}},
{"delete", testDelete, config.Storage{}},
{"purge", testPurge, config.Storage{}},
{"cap=10", testMsgCap, config.Storage{MailboxMsgCap: 10}},
{"cap=0", testNoMsgCap, config.Storage{MailboxMsgCap: 0}},
{"visit mailboxes", testVisitMailboxes, config.Storage{}},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
store, destroy, err := factory(tc.conf)
if err != nil {
t.Fatal(err)
}
tc.test(t, store)
destroy()
})
}
}
// testMetadata verifies message metadata is stored and retrieved correctly.
func testMetadata(t *testing.T, store storage.Store) {
mailbox := "testmailbox"
from := &mail.Address{Name: "From Person", Address: "from@person.com"}
to := []*mail.Address{
{Name: "One Person", Address: "one@a.person.com"},
{Name: "Two Person", Address: "two@b.person.com"},
}
date := time.Now()
subject := "fantastic test subject line"
content := "doesn't matter"
delivery := &message.Delivery{
Meta: message.Metadata{
// ID and Size will be determined by the Store.
Mailbox: mailbox,
From: from,
To: to,
Date: date,
Subject: subject,
Seen: false,
},
Reader: strings.NewReader(content),
}
id, err := store.AddMessage(delivery)
if err != nil {
t.Fatal(err)
}
if id == "" {
t.Fatal("Expected AddMessage() to return non-empty ID string")
}
// Retrieve and validate the message.
sm, err := store.GetMessage(mailbox, id)
if err != nil {
t.Fatal(err)
}
if sm.Mailbox() != mailbox {
t.Errorf("got mailbox %q, want: %q", sm.Mailbox(), mailbox)
}
if sm.ID() != id {
t.Errorf("got id %q, want: %q", sm.ID(), id)
}
if *sm.From() != *from {
t.Errorf("got from %v, want: %v", sm.From(), from)
}
if len(sm.To()) != len(to) {
t.Errorf("got len(to) = %v, want: %v", len(sm.To()), len(to))
} else {
for i, got := range sm.To() {
if *to[i] != *got {
t.Errorf("got to[%v] %v, want: %v", i, got, to[i])
}
}
}
if !sm.Date().Equal(date) {
t.Errorf("got date %v, want: %v", sm.Date(), date)
}
if sm.Subject() != subject {
t.Errorf("got subject %q, want: %q", sm.Subject(), subject)
}
if sm.Size() != int64(len(content)) {
t.Errorf("got size %v, want: %v", sm.Size(), len(content))
}
if sm.Seen() {
t.Errorf("got seen %v, want: false", sm.Seen())
}
}
// testContent generates some binary content and makes sure it is correctly retrieved.
func testContent(t *testing.T, store storage.Store) {
content := make([]byte, 5000)
for i := 0; i < len(content); i++ {
content[i] = byte(i % 256)
}
mailbox := "testmailbox"
from := &mail.Address{Name: "From Person", Address: "from@person.com"}
to := []*mail.Address{
{Name: "One Person", Address: "one@a.person.com"},
}
date := time.Now()
subject := "fantastic test subject line"
delivery := &message.Delivery{
Meta: message.Metadata{
// ID and Size will be determined by the Store.
Mailbox: mailbox,
From: from,
To: to,
Date: date,
Subject: subject,
},
Reader: bytes.NewReader(content),
}
id, err := store.AddMessage(delivery)
if err != nil {
t.Fatal(err)
}
// Get and check.
m, err := store.GetMessage(mailbox, id)
if err != nil {
t.Fatal(err)
}
r, err := m.Source()
if err != nil {
t.Fatal(err)
}
got, err := ioutil.ReadAll(r)
if err != nil {
t.Fatal(err)
}
if len(got) != len(content) {
t.Errorf("Got len(content) == %v, want: %v", len(got), len(content))
}
errors := 0
for i, b := range got {
if b != content[i] {
t.Errorf("Got content[%v] == %v, want: %v", i, b, content[i])
errors++
}
if errors > 5 {
t.Fatalf("Too many content errors, aborting test.")
break
}
}
}
// testDeliveryOrder delivers several messages to the same mailbox, meanwhile querying its contents
// with a new GetMessages call each cycle.
func testDeliveryOrder(t *testing.T, store storage.Store) {
mailbox := "fred"
subjects := []string{"alpha", "bravo", "charlie", "delta", "echo"}
for i, subj := range subjects {
// Check mailbox count.
GetAndCountMessages(t, store, mailbox, i)
DeliverToStore(t, store, mailbox, subj, time.Now())
}
// Confirm delivery order.
msgs := GetAndCountMessages(t, store, mailbox, 5)
for i, want := range subjects {
got := msgs[i].Subject()
if got != want {
t.Errorf("Got subject %q, want %q", got, want)
}
}
}
// testNaming ensures the store does not enforce local part mailbox naming.
func testNaming(t *testing.T, store storage.Store) {
DeliverToStore(t, store, "fred@fish.net", "disk #27", time.Now())
GetAndCountMessages(t, store, "fred", 0)
GetAndCountMessages(t, store, "fred@fish.net", 1)
}
// testSize verifies message contnet size metadata values.
func testSize(t *testing.T, store storage.Store) {
mailbox := "fred"
subjects := []string{"a", "br", "much longer than the others"}
sentIds := make([]string, len(subjects))
sentSizes := make([]int64, len(subjects))
for i, subj := range subjects {
id, size := DeliverToStore(t, store, mailbox, subj, time.Now())
sentIds[i] = id
sentSizes[i] = size
}
for i, id := range sentIds {
msg, err := store.GetMessage(mailbox, id)
if err != nil {
t.Fatal(err)
}
want := sentSizes[i]
got := msg.Size()
if got != want {
t.Errorf("Got size %v, want: %v", got, want)
}
}
}
// testSeen verifies a message can be marked as seen.
func testSeen(t *testing.T, store storage.Store) {
mailbox := "lisa"
id1, _ := DeliverToStore(t, store, mailbox, "whatever", time.Now())
id2, _ := DeliverToStore(t, store, mailbox, "hello?", time.Now())
// Confirm unseen.
msg, err := store.GetMessage(mailbox, id1)
if err != nil {
t.Fatal(err)
}
if msg.Seen() {
t.Errorf("got seen %v, want: false", msg.Seen())
}
// Mark id1 seen.
err = store.MarkSeen(mailbox, id1)
if err != nil {
t.Fatal(err)
}
// Verify id1 seen.
msg, err = store.GetMessage(mailbox, id1)
if err != nil {
t.Fatal(err)
}
if !msg.Seen() {
t.Errorf("id1 got seen %v, want: true", msg.Seen())
}
// Verify id2 still unseen.
msg, err = store.GetMessage(mailbox, id2)
if err != nil {
t.Fatal(err)
}
if msg.Seen() {
t.Errorf("id2 got seen %v, want: false", msg.Seen())
}
}
// testDelete creates and deletes some messages.
func testDelete(t *testing.T, store storage.Store) {
mailbox := "fred"
subjects := []string{"alpha", "bravo", "charlie", "delta", "echo"}
for _, subj := range subjects {
DeliverToStore(t, store, mailbox, subj, time.Now())
}
msgs := GetAndCountMessages(t, store, mailbox, len(subjects))
// Delete a couple messages.
err := store.RemoveMessage(mailbox, msgs[1].ID())
if err != nil {
t.Fatal(err)
}
err = store.RemoveMessage(mailbox, msgs[3].ID())
if err != nil {
t.Fatal(err)
}
// Confirm deletion.
subjects = []string{"alpha", "charlie", "echo"}
msgs = GetAndCountMessages(t, store, mailbox, len(subjects))
for i, want := range subjects {
got := msgs[i].Subject()
if got != want {
t.Errorf("Got subject %q, want %q", got, want)
}
}
// Try appending one more.
DeliverToStore(t, store, mailbox, "foxtrot", time.Now())
subjects = []string{"alpha", "charlie", "echo", "foxtrot"}
msgs = GetAndCountMessages(t, store, mailbox, len(subjects))
for i, want := range subjects {
got := msgs[i].Subject()
if got != want {
t.Errorf("Got subject %q, want %q", got, want)
}
}
}
// testPurge makes sure mailboxes can be purged.
func testPurge(t *testing.T, store storage.Store) {
mailbox := "fred"
subjects := []string{"alpha", "bravo", "charlie", "delta", "echo"}
for _, subj := range subjects {
DeliverToStore(t, store, mailbox, subj, time.Now())
}
GetAndCountMessages(t, store, mailbox, len(subjects))
// Purge and verify.
err := store.PurgeMessages(mailbox)
if err != nil {
t.Fatal(err)
}
GetAndCountMessages(t, store, mailbox, 0)
}
// testMsgCap verifies the message cap is enforced.
func testMsgCap(t *testing.T, store storage.Store) {
mbCap := 10
mailbox := "captain"
for i := 0; i < 20; i++ {
subj := fmt.Sprintf("subject %v", i)
DeliverToStore(t, store, mailbox, subj, time.Now())
msgs, err := store.GetMessages(mailbox)
if err != nil {
t.Fatalf("Failed to GetMessages for %q: %v", mailbox, err)
}
if len(msgs) > mbCap {
t.Errorf("Mailbox has %v messages, should be capped at %v", len(msgs), mbCap)
break
}
// Check that the first message is correct.
first := i - mbCap + 1
if first < 0 {
first = 0
}
firstSubj := fmt.Sprintf("subject %v", first)
if firstSubj != msgs[0].Subject() {
t.Errorf("Got subject %q, wanted first subject: %q", msgs[0].Subject(), firstSubj)
}
}
}
// testNoMsgCap verfies a cap of 0 is not enforced.
func testNoMsgCap(t *testing.T, store storage.Store) {
mailbox := "captain"
for i := 0; i < 20; i++ {
subj := fmt.Sprintf("subject %v", i)
DeliverToStore(t, store, mailbox, subj, time.Now())
GetAndCountMessages(t, store, mailbox, i+1)
}
}
// testVisitMailboxes creates some mailboxes and confirms the VisitMailboxes method visits all of
// them.
func testVisitMailboxes(t *testing.T, ds storage.Store) {
boxes := []string{"abby", "bill", "christa", "donald", "evelyn"}
for _, name := range boxes {
DeliverToStore(t, ds, name, "Old Message", time.Now().Add(-24*time.Hour))
DeliverToStore(t, ds, name, "New Message", time.Now())
}
seen := 0
err := ds.VisitMailboxes(func(messages []storage.Message) bool {
seen++
count := len(messages)
if count != 2 {
t.Errorf("got: %v messages, want: 2", count)
}
return true
})
if err != nil {
t.Error(err)
}
if seen != 5 {
t.Errorf("saw %v messages in total, want: 5", seen)
}
}
// DeliverToStore creates and delivers a message to the specific mailbox, returning the size of the
// generated message.
func DeliverToStore(
t *testing.T,
store storage.Store,
mailbox string,
subject string,
date time.Time,
) (string, int64) {
t.Helper()
meta := message.Metadata{
Mailbox: mailbox,
To: []*mail.Address{{Name: "Some Body", Address: "somebody@host"}},
From: &mail.Address{Name: "Some B. Else", Address: "somebodyelse@host"},
Subject: subject,
Date: date,
}
testMsg := fmt.Sprintf("To: %s\r\nFrom: %s\r\nSubject: %s\r\n\r\nTest Body\r\n",
meta.To[0].Address, meta.From.Address, subject)
delivery := &message.Delivery{
Meta: meta,
Reader: ioutil.NopCloser(strings.NewReader(testMsg)),
}
id, err := store.AddMessage(delivery)
if err != nil {
t.Fatal(err)
}
return id, int64(len(testMsg))
}
// GetAndCountMessages is a test helper that expects to receive count messages or fails the test, it
// also checks return error.
func GetAndCountMessages(t *testing.T, s storage.Store, mailbox string, count int) []storage.Message {
t.Helper()
msgs, err := s.GetMessages(mailbox)
if err != nil {
t.Fatalf("Failed to GetMessages for %q: %v", mailbox, err)
}
if len(msgs) != count {
t.Errorf("Got %v messages for %q, want: %v", len(msgs), mailbox, count)
}
return msgs
}

View File

@@ -7,27 +7,28 @@ import (
"net/http"
"strconv"
"github.com/jhillyerd/inbucket/httpd"
"github.com/jhillyerd/inbucket/log"
"github.com/jhillyerd/inbucket/smtpd"
"github.com/jhillyerd/inbucket/pkg/server/web"
"github.com/jhillyerd/inbucket/pkg/storage"
"github.com/jhillyerd/inbucket/pkg/webui/sanitize"
"github.com/rs/zerolog/log"
)
// MailboxIndex renders the index page for a particular mailbox
func MailboxIndex(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) (err error) {
func MailboxIndex(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
// Form values must be validated manually
name := req.FormValue("name")
selected := req.FormValue("id")
if len(name) == 0 {
ctx.Session.AddFlash("Account name is required", "errors")
_ = ctx.Session.Save(req, w)
http.Redirect(w, req, httpd.Reverse("RootIndex"), http.StatusSeeOther)
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
return nil
}
name, err = smtpd.ParseMailboxName(name)
name, err = ctx.Manager.MailboxForAddress(name)
if err != nil {
ctx.Session.AddFlash(err.Error(), "errors")
_ = ctx.Session.Save(req, w)
http.Redirect(w, req, httpd.Reverse("RootIndex"), http.StatusSeeOther)
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
return nil
}
// Remember this mailbox was visited
@@ -38,7 +39,7 @@ func MailboxIndex(w http.ResponseWriter, req *http.Request, ctx *httpd.Context)
return err
}
// Render template
return httpd.RenderTemplate("mailbox/index.html", w, map[string]interface{}{
return web.RenderTemplate("mailbox/index.html", w, map[string]interface{}{
"ctx": ctx,
"errorFlash": errorFlash,
"name": name,
@@ -46,43 +47,52 @@ func MailboxIndex(w http.ResponseWriter, req *http.Request, ctx *httpd.Context)
})
}
// MailboxLink handles pretty links to a particular message. Renders a redirect
func MailboxLink(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) (err error) {
// Don't have to validate these aren't empty, Gorilla returns 404
id := ctx.Vars["id"]
name, err := smtpd.ParseMailboxName(ctx.Vars["name"])
// MailboxIndexFriendly handles pretty links to a particular mailbox. Renders a redirect
func MailboxIndexFriendly(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
if err != nil {
ctx.Session.AddFlash(err.Error(), "errors")
_ = ctx.Session.Save(req, w)
http.Redirect(w, req, httpd.Reverse("RootIndex"), http.StatusSeeOther)
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
return nil
}
// Build redirect
uri := fmt.Sprintf("%s?name=%s&id=%s", httpd.Reverse("MailboxIndex"), name, id)
uri := fmt.Sprintf("%s?name=%s", web.Reverse("MailboxIndex"), name)
http.Redirect(w, req, uri, http.StatusSeeOther)
return nil
}
// MailboxLink handles pretty links to a particular message. Renders a redirect
func MailboxLink(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
// Don't have to validate these aren't empty, Gorilla returns 404
id := ctx.Vars["id"]
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
if err != nil {
ctx.Session.AddFlash(err.Error(), "errors")
_ = ctx.Session.Save(req, w)
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
return nil
}
// Build redirect
uri := fmt.Sprintf("%s?name=%s&id=%s", web.Reverse("MailboxIndex"), name, id)
http.Redirect(w, req, uri, http.StatusSeeOther)
return nil
}
// MailboxList renders a list of messages in a mailbox. Renders a partial
func MailboxList(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) (err error) {
func MailboxList(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
// Don't have to validate these aren't empty, Gorilla returns 404
name, err := smtpd.ParseMailboxName(ctx.Vars["name"])
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
if err != nil {
return err
}
mb, err := ctx.DataStore.MailboxFor(name)
if err != nil {
// This doesn't indicate not found, likely an IO error
return fmt.Errorf("Failed to get mailbox for %q: %v", name, err)
}
messages, err := mb.GetMessages()
messages, err := ctx.Manager.GetMetadata(name)
if err != nil {
// This doesn't indicate empty, likely an IO error
return fmt.Errorf("Failed to get messages for %v: %v", name, err)
}
log.Tracef("Got %v messsages", len(messages))
// Render partial template
return httpd.RenderPartial("mailbox/_list.html", w, map[string]interface{}{
return web.RenderPartial("mailbox/_list.html", w, map[string]interface{}{
"ctx": ctx,
"name": name,
"messages": messages,
@@ -90,20 +100,15 @@ func MailboxList(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) (
}
// MailboxShow renders a particular message from a mailbox. Renders an HTML partial
func MailboxShow(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) (err error) {
func MailboxShow(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
// Don't have to validate these aren't empty, Gorilla returns 404
id := ctx.Vars["id"]
name, err := smtpd.ParseMailboxName(ctx.Vars["name"])
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
if err != nil {
return err
}
mb, err := ctx.DataStore.MailboxFor(name)
if err != nil {
// This doesn't indicate not found, likely an IO error
return fmt.Errorf("Failed to get mailbox for %q: %v", name, err)
}
msg, err := mb.GetMessage(id)
if err == smtpd.ErrNotExist {
msg, err := ctx.Manager.GetMessage(name, id)
if err == storage.ErrNotExist {
http.NotFound(w, req)
return nil
}
@@ -111,105 +116,91 @@ func MailboxShow(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) (
// This doesn't indicate empty, likely an IO error
return fmt.Errorf("GetMessage(%q) failed: %v", id, err)
}
mime, err := msg.ReadBody()
if err != nil {
return fmt.Errorf("ReadBody(%q) failed: %v", id, err)
body := template.HTML(web.TextToHTML(msg.Text()))
htmlAvailable := msg.HTML() != ""
var htmlBody template.HTML
if htmlAvailable {
if str, err := sanitize.HTML(msg.HTML()); err == nil {
htmlBody = template.HTML(str)
} else {
// Soft failure, render empty tab.
log.Warn().Str("module", "webui").Str("mailbox", name).Str("id", id).Err(err).
Msg("HTML sanitizer failed")
}
}
body := template.HTML(httpd.TextToHTML(mime.Text))
htmlAvailable := mime.HTML != ""
// Render partial template
return httpd.RenderPartial("mailbox/_show.html", w, map[string]interface{}{
return web.RenderPartial("mailbox/_show.html", w, map[string]interface{}{
"ctx": ctx,
"name": name,
"message": msg,
"body": body,
"htmlAvailable": htmlAvailable,
"mimeErrors": mime.Errors,
"attachments": mime.Attachments,
"htmlBody": htmlBody,
"mimeErrors": msg.MIMEErrors(),
"attachments": msg.Attachments(),
})
}
// MailboxHTML displays the HTML content of a message. Renders a partial
func MailboxHTML(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) (err error) {
func MailboxHTML(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
// Don't have to validate these aren't empty, Gorilla returns 404
id := ctx.Vars["id"]
name, err := smtpd.ParseMailboxName(ctx.Vars["name"])
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
if err != nil {
return err
}
mb, err := ctx.DataStore.MailboxFor(name)
if err != nil {
// This doesn't indicate not found, likely an IO error
return fmt.Errorf("Failed to get mailbox for %q: %v", name, err)
}
message, err := mb.GetMessage(id)
if err == smtpd.ErrNotExist {
msg, err := ctx.Manager.GetMessage(name, id)
if err == storage.ErrNotExist {
http.NotFound(w, req)
return nil
}
if err != nil {
// This doesn't indicate missing, likely an IO error
// This doesn't indicate empty, likely an IO error
return fmt.Errorf("GetMessage(%q) failed: %v", id, err)
}
mime, err := message.ReadBody()
if err != nil {
return fmt.Errorf("ReadBody(%q) failed: %v", id, err)
}
// Render partial template
w.Header().Set("Content-Type", "text/html; charset=UTF-8")
return httpd.RenderPartial("mailbox/_html.html", w, map[string]interface{}{
return web.RenderPartial("mailbox/_html.html", w, map[string]interface{}{
"ctx": ctx,
"name": name,
"message": message,
// TODO It is not really safe to render, need to sanitize, issue #5
"body": template.HTML(mime.HTML),
"message": msg,
"body": template.HTML(msg.HTML()),
})
}
// MailboxSource displays the raw source of a message, including headers. Renders text/plain
func MailboxSource(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) (err error) {
func MailboxSource(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
// Don't have to validate these aren't empty, Gorilla returns 404
id := ctx.Vars["id"]
name, err := smtpd.ParseMailboxName(ctx.Vars["name"])
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
if err != nil {
return err
}
mb, err := ctx.DataStore.MailboxFor(name)
if err != nil {
// This doesn't indicate not found, likely an IO error
return fmt.Errorf("Failed to get mailbox for %q: %v", name, err)
}
message, err := mb.GetMessage(id)
if err == smtpd.ErrNotExist {
r, err := ctx.Manager.SourceReader(name, id)
if err == storage.ErrNotExist {
http.NotFound(w, req)
return nil
}
if err != nil {
// This doesn't indicate missing, likely an IO error
return fmt.Errorf("GetMessage(%q) failed: %v", id, err)
}
raw, err := message.ReadRaw()
if err != nil {
return fmt.Errorf("ReadRaw(%q) failed: %v", id, err)
return fmt.Errorf("SourceReader(%q) failed: %v", id, err)
}
// Output message source
w.Header().Set("Content-Type", "text/plain")
if _, err := io.WriteString(w, *raw); err != nil {
return err
}
return nil
_, err = io.Copy(w, r)
return err
}
// MailboxDownloadAttach sends the attachment to the client; disposition:
// attachment, type: application/octet-stream
func MailboxDownloadAttach(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) (err error) {
func MailboxDownloadAttach(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
// Don't have to validate these aren't empty, Gorilla returns 404
id := ctx.Vars["id"]
name, err := smtpd.ParseMailboxName(ctx.Vars["name"])
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
if err != nil {
ctx.Session.AddFlash(err.Error(), "errors")
_ = ctx.Session.Save(req, w)
http.Redirect(w, req, httpd.Reverse("RootIndex"), http.StatusSeeOther)
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
return nil
}
numStr := ctx.Vars["num"]
@@ -217,51 +208,39 @@ func MailboxDownloadAttach(w http.ResponseWriter, req *http.Request, ctx *httpd.
if err != nil {
ctx.Session.AddFlash("Attachment number must be unsigned numeric", "errors")
_ = ctx.Session.Save(req, w)
http.Redirect(w, req, httpd.Reverse("RootIndex"), http.StatusSeeOther)
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
return nil
}
mb, err := ctx.DataStore.MailboxFor(name)
if err != nil {
// This doesn't indicate not found, likely an IO error
return fmt.Errorf("Failed to get mailbox for %q: %v", name, err)
}
message, err := mb.GetMessage(id)
if err == smtpd.ErrNotExist {
msg, err := ctx.Manager.GetMessage(name, id)
if err == storage.ErrNotExist {
http.NotFound(w, req)
return nil
}
if err != nil {
// This doesn't indicate missing, likely an IO error
// This doesn't indicate empty, likely an IO error
return fmt.Errorf("GetMessage(%q) failed: %v", id, err)
}
body, err := message.ReadBody()
if err != nil {
return err
}
if int(num) >= len(body.Attachments) {
if int(num) >= len(msg.Attachments()) {
ctx.Session.AddFlash("Attachment number too high", "errors")
_ = ctx.Session.Save(req, w)
http.Redirect(w, req, httpd.Reverse("RootIndex"), http.StatusSeeOther)
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
return nil
}
part := body.Attachments[num]
// Output attachment
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", "attachment")
if _, err := io.Copy(w, part); err != nil {
return err
}
return nil
_, err = w.Write(msg.Attachments()[num].Content)
return err
}
// MailboxViewAttach sends the attachment to the client for online viewing
func MailboxViewAttach(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) (err error) {
func MailboxViewAttach(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
// Don't have to validate these aren't empty, Gorilla returns 404
name, err := smtpd.ParseMailboxName(ctx.Vars["name"])
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
if err != nil {
ctx.Session.AddFlash(err.Error(), "errors")
_ = ctx.Session.Save(req, w)
http.Redirect(w, req, httpd.Reverse("RootIndex"), http.StatusSeeOther)
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
return nil
}
id := ctx.Vars["id"]
@@ -270,38 +249,27 @@ func MailboxViewAttach(w http.ResponseWriter, req *http.Request, ctx *httpd.Cont
if err != nil {
ctx.Session.AddFlash("Attachment number must be unsigned numeric", "errors")
_ = ctx.Session.Save(req, w)
http.Redirect(w, req, httpd.Reverse("RootIndex"), http.StatusSeeOther)
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
return nil
}
mb, err := ctx.DataStore.MailboxFor(name)
if err != nil {
// This doesn't indicate not found, likely an IO error
return fmt.Errorf("Failed to get mailbox for %q: %v", name, err)
}
message, err := mb.GetMessage(id)
if err == smtpd.ErrNotExist {
msg, err := ctx.Manager.GetMessage(name, id)
if err == storage.ErrNotExist {
http.NotFound(w, req)
return nil
}
if err != nil {
// This doesn't indicate missing, likely an IO error
// This doesn't indicate empty, likely an IO error
return fmt.Errorf("GetMessage(%q) failed: %v", id, err)
}
body, err := message.ReadBody()
if err != nil {
return err
}
if int(num) >= len(body.Attachments) {
if int(num) >= len(msg.Attachments()) {
ctx.Session.AddFlash("Attachment number too high", "errors")
_ = ctx.Session.Save(req, w)
http.Redirect(w, req, httpd.Reverse("RootIndex"), http.StatusSeeOther)
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
return nil
}
part := body.Attachments[num]
// Output attachment
part := msg.Attachments()[num]
w.Header().Set("Content-Type", part.ContentType)
if _, err := io.Copy(w, part); err != nil {
return err
}
return nil
_, err = w.Write(part.Content)
return err
}

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