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

Compare commits

...

216 Commits

Author SHA1 Message Date
James Hillyerd
f58e51d921 Fix change log tag format 2018-02-28 14:21:39 -08:00
James Hillyerd
5f5a7eecd3 Release v1.3.0 2018-02-28 14:18:04 -08:00
James Hillyerd
1ff8ffe9bd Release prep for 1.3.0 2018-02-28 12:50:39 -08:00
James Hillyerd
b4abdb6675 Change to trash glyph for delete mailbox 2018-02-28 12:37:56 -08:00
James Hillyerd
ffa756d895 gcloud: removed
- Dockerized and moved to https://github.com/jhillyerd/demo.inbucket.org
- Merge master changelog entry
2018-02-27 21:20:23 -08:00
James Hillyerd
d5aea4d635 Merge branch 'feature/sanitize'
- Closes #5
- Closes #70
2018-02-27 20:53:38 -08:00
James Hillyerd
3c19e0820b Add Makefile for developer convenience. 2018-02-27 20:50:09 -08:00
James Hillyerd
3b9af85924 sanitize: naive CSS sanitizer implementation
- CSS sanitizer allows a limited set of properties in a style attribute.
- Added a CSS inlined version of the tutsplus responsive test mail.
- Linter fixes in inbucket.go
2018-02-27 20:37:24 -08:00
James Hillyerd
26c38b1148 Simple HTML sanitizer implementation 2018-01-06 16:45:12 -08:00
James Hillyerd
3062b70ea0 Merge branch 'release/1.2.0' 2017-12-27 13:29:06 -08:00
James Hillyerd
01d51302c4 Prepare release 1.2.0 2017-12-27 13:18:11 -08:00
James Hillyerd
dedd0eacff Merge branch 'feature/filestore' into develop #67 2017-12-26 23:17:01 -08:00
James Hillyerd
6431b71cfe Refactor filestore into a dedicated pkg, closes #67 2017-12-26 23:04:39 -08:00
James Hillyerd
25815767a7 Move smtpd/utils.go into dedicated stringutil pkg 2017-12-26 22:55:20 -08:00
James Hillyerd
06165cb3d3 Many linter fixes for smtpd pkg 2017-12-26 22:16:47 -08:00
James Hillyerd
ac21675bd7 Clean up datastore related linter findings 2017-12-26 18:54:02 -08:00
James Hillyerd
f62eaa31b9 Move retention scanner into datastore pkg for #67 2017-12-26 18:33:56 -08:00
James Hillyerd
fcc0848bc0 Move metrics ticker to log pkg for #67 2017-12-26 18:25:11 -08:00
James Hillyerd
dec67622ba Move handler tests to shared datastore mocks for #48 2017-12-26 16:42:25 -08:00
James Hillyerd
11033a5359 Move datastore mocks into correct package
- Start of work for #48
- Continues #67
2017-12-26 15:45:18 -08:00
James Hillyerd
3a4fd3f093 Refactor datastore into it's own package for #67 2017-12-26 14:54:49 -08:00
James Hillyerd
cc47895d71 Pass cfg and ds as params, helps #26 #67 2017-12-26 13:57:04 -08:00
adrium
76a77beca9 Reverse message display sort order (#59)
Closes #60
2017-12-24 13:59:04 -08:00
James Hillyerd
81eba8f51a Only deploy with one version of Go 2017-12-24 13:43:22 -08:00
James Hillyerd
c750dcff81 Merge branch 'hotfix/build' to prevent dup deploys 2017-12-24 13:40:30 -08:00
James Hillyerd
de75b778c0 Only deploy with one version of Go 2017-12-24 13:37:47 -08:00
James Hillyerd
0e72b414c4 Add fauxmailer to gcloud, custom greeting 2017-12-24 13:22:51 -08:00
James Hillyerd
52de1b2bfe Initial gcloud setup.sh, not yet tested as metadata 2017-12-23 23:22:51 -08:00
James Hillyerd
b28e1d86d8 Include version for final goxc release 2017-12-18 19:15:51 -08:00
James Hillyerd
f4fadd7e44 Docker version will now fall back to commit if no tag 2017-12-18 19:12:47 -08:00
James Hillyerd
28b40eb94d Fetch tags during docker build 2017-12-18 19:12:32 -08:00
James Hillyerd
0f67e51e56 Fix version & date in Docker containers for #64 2017-12-18 19:11:08 -08:00
James Hillyerd
9d68e2c0a5 Docker version will now fall back to commit if no tag 2017-12-17 21:44:20 -08:00
James Hillyerd
5bca2ae738 Fetch tags during docker build 2017-12-17 21:29:57 -08:00
James Hillyerd
10cce5c751 Fix version & date in Docker containers for #64 2017-12-17 21:05:48 -08:00
Carlos Tadeu Panato Junior
8040b07c28 Button to delete the mailbox from the UI (#65), closes #55 2017-12-17 20:36:14 -08:00
James Hillyerd
4e8c287608 Migrate from goxc to goreleaser, closes #64 2017-12-17 20:18:51 -08:00
James Hillyerd
6f57c51934 Update release procedures, cleanup goxc config 2017-12-17 20:13:14 -08:00
James Hillyerd
a457b65603 Add cmd/client to release builds 2017-12-17 20:05:07 -08:00
James Hillyerd
890d8e0202 Rename link variables, setup travis tag releases 2017-12-17 19:32:05 -08:00
James Hillyerd
9f6dee640e Customize goreleaser to get a working build 2017-12-17 19:10:59 -08:00
James Hillyerd
095796c8a1 Default config from goreleaser init 2017-12-17 12:33:09 -08:00
James Hillyerd
db358fea8c Merge tag '1.2.0-rc2' into develop 2017-12-15 20:41:02 -08:00
James Hillyerd
86554a63b8 Merge branch 'release/1.2.0-rc2' 2017-12-15 20:39:37 -08:00
James Hillyerd
1efe2ba48f Prepare release 1.2.0-rc2 2017-12-15 20:34:27 -08:00
James Hillyerd
f597687aa3 Update CHANGELOG.md 2017-12-15 20:20:27 -08:00
Carlos Tadeu Panato Junior
6368e3a83b Add option to get the latest message using latest as request parameter (#63) 2017-12-15 17:00:09 -08:00
James Hillyerd
ef17ad9074 Update Docker base to go 1.9 2017-12-14 22:16:24 -08:00
James Hillyerd
7908e41212 Fixes #61 - monitor.history=0 panic 2017-12-14 18:54:22 -08:00
James Hillyerd
a9b174bcb6 Add tl;dr to CONTRIBUTING.md 2017-12-14 18:32:55 -08:00
James Hillyerd
dc0b9b325e Use bash for swaks tests, no pipefail in sh 2017-12-12 19:59:33 -08:00
James Hillyerd
0a967f0f21 Update golang versions 2017-12-12 19:57:20 -08:00
James Hillyerd
304a2260e8 Don't close writers with defer 2017-02-12 16:21:44 -08:00
James Hillyerd
9fc9a333a6 Run travis tests with race detector enabled 2017-02-12 14:53:42 -08:00
James Hillyerd
3e8b914f89 Merge branch 'feature/cmdline' into develop 2017-02-05 15:47:40 -08:00
James Hillyerd
5e94f7b750 Address matching should only apply to address, not name 2017-02-05 15:31:31 -08:00
James Hillyerd
64e75face8 Add maxage flag to match subcommand 2017-02-05 15:15:28 -08:00
James Hillyerd
be4675b374 Add powerful match subcommand to cmdline client
- Multiple output formats
- Signals matches via exit status for shell scripts
- Match against To, From, Subject via regular expressions
- Can optionally delete matched messages
2017-02-05 14:17:47 -08:00
James Hillyerd
6722811425 Beginnings of a command line REST client 2017-02-04 18:21:55 -08:00
James Hillyerd
56cff6296a Update changelog 2017-02-04 18:20:27 -08:00
James Hillyerd
a1e35009e0 Add convenience methods to rest/client types 2017-02-04 16:14:40 -08:00
James Hillyerd
cc0428ab9b Merge branch 'release/1.2.0-rc1' into develop 2017-01-29 13:18:19 -08:00
James Hillyerd
68e35b5eca Merge branch 'release/1.2.0-rc1' 2017-01-29 13:14:55 -08:00
James Hillyerd
5147865e55 rc1 prep 2017-01-29 13:14:27 -08:00
James Hillyerd
a3727ee436 Finalize changelog 2017-01-29 12:41:19 -08:00
James Hillyerd
9e49480482 Update bootstrap theme javascript dependencies 2017-01-28 20:20:58 -08:00
James Hillyerd
958f5a44d9 travis to Go 1.7.5 2017-01-28 19:24:01 -08:00
James Hillyerd
9b1d28fc7d Reimplement msghub as an actor 2017-01-28 19:20:06 -08:00
James Hillyerd
e6f95c9367 Make @inbucket prompt configurable, closes #31 2017-01-28 17:27:50 -08:00
James Hillyerd
de5b9a824b Remove empty intermediate directories, closes #12 2017-01-28 16:17:25 -08:00
James Hillyerd
9ac3c90036 Add mutex to protect directory operations 2017-01-22 22:03:56 -08:00
James Hillyerd
85e3a77fe5 Extract FileMessage into filemsg.go 2017-01-22 21:26:47 -08:00
James Hillyerd
32631daeae Refactor retention scanner prior to starting #12 2017-01-22 20:59:59 -08:00
James Hillyerd
62b77dfe5e Refactor configuration parser to use tables 2017-01-22 18:57:03 -08:00
James Hillyerd
fa28fa57f8 Note Golang 1.7 requirement in CHANGELOG 2017-01-22 14:02:31 -08:00
James Hillyerd
00e4d3791c Allow monitoring of a particular mailbox for #44
- No UI to access, just append /mailbox to /monitor URL
- Changed API URLs:
  - /api/v1/monitor/messages - all
  - /api/v1/monitor/messages/{name} - specific
2017-01-22 13:51:55 -08:00
James Hillyerd
cf7bdee925 Add goroutine count to metrics 2017-01-22 12:45:53 -08:00
James Hillyerd
83b71334c2 Add screenshot to README 2017-01-22 12:02:28 -08:00
James Hillyerd
aa0edff398 Update CHANGELOG with Monitor feature 2017-01-21 21:34:01 -08:00
James Hillyerd
f09a4558a9 Bump go versions for Docker and TravisCI 2017-01-21 21:22:29 -08:00
James Hillyerd
1137912e1d Merge in branch for msg monitor feature, closes #44 2017-01-21 21:19:44 -08:00
James Hillyerd
e14e97919f Add connection status indicator for #44 2017-01-21 20:47:10 -08:00
James Hillyerd
9ae428ca44 Make monitor configurable for #44 2017-01-21 20:23:04 -08:00
James Hillyerd
c346372c85 Fix for flashes not clearing bug 2017-01-21 19:31:39 -08:00
James Hillyerd
63a76696bf Add user interface for monitor, #44 2017-01-16 21:30:40 -08:00
James Hillyerd
86365a047c Add open WebSockets to metrics 2017-01-16 18:53:24 -08:00
James Hillyerd
e5aad9f5d0 Implement server side of message monitor for #44 2017-01-16 18:12:27 -08:00
James Hillyerd
e32e6d00d6 Remove httpbuf, manually save sessions
- httpbuf prevents connections being upgraded to websockets
2017-01-16 16:14:01 -08:00
James Hillyerd
b3db619db9 Broadcast deliveries into msghub for #44 2017-01-16 13:09:50 -08:00
James Hillyerd
6ca2c27747 Pull message delivery into its own method 2017-01-16 10:50:28 -08:00
James Hillyerd
88ccb19360 Implement pub/sub message hub as a base for #44 2017-01-15 22:06:39 -08:00
James Hillyerd
a222b7c428 Make use of pkg context
- Use context inside of servers for shutdown
- Remove unnecessary localShutdown related code
2017-01-15 22:00:58 -08:00
James Hillyerd
0e02061c4a Merge branch 'feature/rest-client' into develop 2017-01-08 22:16:26 +00:00
James Hillyerd
c8fd56ca90 Complete REST client for #43
- Add source, delete and purge
2017-01-08 22:11:22 +00:00
James Hillyerd
d8255382da Start of V1 REST client for #43
- List mailbox contents
- Get message
2017-01-08 04:25:50 +00:00
James Hillyerd
61e9b91637 Generic REST client (HTTP GET only) for #43 2017-01-08 04:11:06 +00:00
James Hillyerd
fa6b0a3227 Move REST JSON model into its own package for #43 2017-01-05 05:59:12 +00:00
James Hillyerd
61c6e7c2e9 Create systemd service unit file for #47
- Switch ubuntu from upstart to systemd
- Switch redhat from init.d to systemd
2017-01-05 05:29:21 +00:00
James Hillyerd
dcc0f36f48 Increase max local-part length to 128
- Improves compatibility with Mailgun
- Closes #41
- Update CHANGELOG
2016-12-31 05:09:12 +00:00
James Hillyerd
c1e7de5e14 Remove old REST API, closes #28 2016-12-31 03:39:32 +00:00
James Hillyerd
493efb04cd Remove legacy theme 'integral' 2016-12-31 01:41:13 +00:00
James Hillyerd
ff481c56c6 Add contributing guide 2016-12-31 00:35:59 +00:00
WiszniewskiMateusz
2f5d80a521 Added data about message attachments to REST API (#46) 2016-12-29 10:13:27 -08:00
James Hillyerd
364e7a0b80 Track enmime API changes
- Part accessors: 196b2ad725
- IsTextFromHTML: 2bd44ac6cc
2016-11-21 22:32:29 -08:00
James Hillyerd
26a9903492 Track enmime API change
0af1249adf
2016-11-21 20:29:45 -08:00
James Hillyerd
264fa9e11d Update travis config for go master 2016-11-17 18:37:37 -08:00
James Hillyerd
1906a147f0 Migrate from pkg go.enmime to enmime 2016-11-17 18:35:01 -08:00
James Hillyerd
145e71dc43 Merge branch 'feature/to-header' into develop
- Messages now store To information
2016-09-22 20:12:09 -07:00
James Hillyerd
017a097588 Switch to storing To addresses as a slice
- Changes on-disk storage format
- Changes JSON API
- To and From values are now parsed/formatted by Go's mail.ParseAddress
  function
- Fixed bug in list-entry-template, was not escaping HTML characters
- Updated tests
2016-09-21 22:12:20 -07:00
James Hillyerd
01ea89e7e2 Multi-recipient swaks test
- Add a multi-recipient test to run-tests.sh
- Removal accidental output of jq binary location when pretty-printing REST JSON
- Add To: change to CHANGELOG.md
- Fix comment typo
2016-09-18 17:48:21 -07:00
Tomasz Wojtuń
8f14ba8359 corrected webui/rest_test.go 2016-09-18 17:48:21 -07:00
Tomasz Wojtuń
8d36aa9750 revert webui stuff 2016-09-18 17:48:21 -07:00
Tomasz Wojtuń
02eee0a608 corrected tests 2016-09-18 17:48:21 -07:00
Tomasz Wojtuń
124f830478 Added "To:" header 2016-09-18 17:48:21 -07:00
James Hillyerd
1856deae46 SMTP handler is now more forgiving of line endings, a la Postfix 2016-09-18 17:45:03 -07:00
James Hillyerd
a939605d4a Cache message in memory during receipt, closes #23 2016-09-18 16:35:13 -07:00
James Hillyerd
f84b36039e Merge branch 'release/1.1.0' into develop 2016-09-03 11:29:39 -07:00
James Hillyerd
5ef3adc88e Merge branch 'release/1.1.0' 2016-09-03 11:27:24 -07:00
James Hillyerd
6d2c2c8dad Prep for 1.1.0 2016-09-03 11:23:09 -07:00
James Hillyerd
ebde99949e Update travis build to current Go versions 2016-09-03 10:25:44 -07:00
James Hillyerd
0afaf5109e Merge pull request #36 from stepstone-tech/issue-35
Log error and continue when deleting oldest message fails - closes #35
2016-04-27 12:30:12 -07:00
Tomasz Wojtuń
7adf3741d3 #35 Log error and continue when deleting oldest message fails. 2016-04-27 17:54:17 +02:00
James Hillyerd
9821095977 Move homebrew to its own tap repository. 2016-03-21 20:08:31 -07:00
James Hillyerd
221a65cbe6 Merge branch 'feature/brew' into develop 2016-03-11 18:35:56 -08:00
James Hillyerd
c421e4e0eb First attempt at a homebrew formula 2016-03-11 18:31:53 -08:00
James Hillyerd
0068937d58 Add homebrew specific inbucket.conf 2016-03-08 23:11:03 -08:00
James Hillyerd
268f950e01 Merge branch 'release/1.1.0-rc2' into develop 2016-03-06 16:48:24 -08:00
James Hillyerd
1742bf9a34 Merge branch 'release/1.1.0-rc2' 2016-03-06 16:47:45 -08:00
James Hillyerd
470ef9b496 Prep for 1.1.0-rc2 2016-03-06 16:47:19 -08:00
James Hillyerd
f16debebbf Handle empty subject lines in bootstrap 2016-03-06 16:31:55 -08:00
James Hillyerd
ff460309e5 Merge branch 'feature/filter' into develop 2016-03-06 15:39:33 -08:00
James Hillyerd
d13ebe9149 Add message list search feature
- Search as you type (client side)
- Add makeDelay to reduce resize and search callback frequency
- Rename class listEntry to message-list-entry
- Move Refresh button into search button bar
2016-03-06 15:35:36 -08:00
James Hillyerd
6fd9f1f98c Load message list over JSON
- Add jquery-load-template to bower
- Add moment (date rendering) to bower
- Load message list JSON via /api/v1/mailbox
- Render message list using jquery template
- Fix resize related problems with message list height caused by
  2092949dbc
2016-03-06 13:15:13 -08:00
James Hillyerd
3481a89533 Display message cap on status page 2016-03-06 10:25:25 -08:00
James Hillyerd
e99baf766b RetentionJoin() could block if scanner never started 2016-03-05 19:19:57 -08:00
James Hillyerd
629bb65cec Host assets for responsive email test 2016-03-05 19:00:07 -08:00
James Hillyerd
42b3ba35cb Merge branch 'release/1.1.0-rc1' into develop 2016-03-03 21:41:27 -08:00
James Hillyerd
c2779ff054 Merge branch 'release/1.1.0-rc1' 2016-03-03 21:41:09 -08:00
James Hillyerd
22eb793f61 Release 1.1.0-rc1 2016-03-03 21:40:25 -08:00
James Hillyerd
d566da0d86 README updates 2016-03-03 21:27:50 -08:00
James Hillyerd
c172ea4dd7 Add a CHANGELOG.md file 2016-03-03 21:13:13 -08:00
James Hillyerd
511e014a90 Support down-converting of HTML to plain text
- Display a warning when text was generated from HTML
- Add a semi complicated, responsive HTML email for future testing
- Closes #20
2016-03-03 19:44:48 -08:00
James Hillyerd
86861eb747 Switch travis to container based builds 2016-03-01 20:31:16 -08:00
James Hillyerd
2092949dbc Cache message list scroll status for smoother resizing 2016-03-01 13:09:36 -08:00
James Hillyerd
798b320769 Reorganize bootstrap mailbox.js 2016-03-01 12:45:27 -08:00
James Hillyerd
fd59aad4f0 Fix "Link" button on messages
- Add clipboard.js to bower
- Add pop-out section that populates with URL
- Add copy to clipboard button, hover confirmation
2016-03-01 12:10:26 -08:00
James Hillyerd
ee5f75631a Go tip travis builds take too long, use 1.6
- Add go vet as a before_script
2016-02-29 22:05:22 -08:00
James Hillyerd
8e66be63f5 Retention scanner respects global shutdown
- Clean up vars in retention.go
- Check globalShutdown in several parts of the retention scanner
- smtpd.Drain() now waits for scanner to shut down
- Closes #22
2016-02-29 21:56:29 -08:00
James Hillyerd
28adcf0437 Graceful shutdown on error for httpd, smtpd, pop3d 2016-02-29 20:21:49 -08:00
James Hillyerd
4b4121bb3a Use channels to communicate shutdown request
- httpd now uses shutdown channel
- smtpd now uses shutdown channel
- pop3d now uses shutdown channel
- timedExit now removes PID file
- tidy up some struct instantiations, var blocks
2016-02-28 23:48:09 -08:00
James Hillyerd
3a7be7d89c Reorganize logging
- Move the opening/closing of logs to the log package
- Use conditional compilation for redirecting stdout/err to logfile,
  Unix allows a superior method to Windows
- Panic output will now to go log file on Unix platforms
2016-02-28 19:45:27 -08:00
James Hillyerd
e4d12e60aa Add SIGINT to traps, triggers clean shutdown 2016-02-28 16:25:35 -08:00
James Hillyerd
982ad857e8 Take care of more TODO flagged code
- Improve TODO comments, mention related issues
- Export ErrNotWritable, move it to datastore.go
- Improve logging of corrupt mailbox GOB file
2016-02-28 16:14:37 -08:00
James Hillyerd
075aa0dd38 Remove PID file during shutdown 2016-02-28 14:41:15 -08:00
James Hillyerd
3f654e48be Fix retention tests on Windows 2016-02-28 13:42:17 -08:00
James Hillyerd
bbfdd4216f Session cookie key is now configurable
- Added [web] cookie.auth.key to configuration
- Inbucket generates a random key if none is configured
- Added [default] default.domain to be reference by SMTP and POP3
  configs
- Updated default/sample config files
2016-02-27 15:43:44 -08:00
James Hillyerd
5e15300d02 Bowerize more components
- Move jquery-color into bower
- Move jquery-sparklines into bower
- Make swaks-tests/run-tests.sh discover its directory
- Update _base.html public resource includes to /public
2016-02-26 21:57:15 -08:00
James Hillyerd
5daa40b081 go.enmime API now uses HTML instead of Html 2016-02-26 20:35:28 -08:00
James Hillyerd
3eb2b5ce19 Add "No-Store Domain" info to status page 2016-02-25 20:29:21 -08:00
James Hillyerd
f36e21a65c REST APIv1 now uses lowercase JSON property names
- Updated rest-apiv1.sh to pretty print JSON with jq if available
- Fixed some missing checks on JSON testutils
2016-02-25 19:21:47 -08:00
James Hillyerd
5da5d3e509 Move mocks and utility functions out of apiv1 tests 2016-02-25 18:38:01 -08:00
James Hillyerd
7b8161042c Redesign REST API tests
- Unmarshall JSON into maps instead of structs, this allows for better
  detection of incorrect output
- JSON tests are now case sensitive
2016-02-25 18:28:22 -08:00
James Hillyerd
b9535c126c Implement REST API v1
- Add rest package with REST-only controller, tests
- Remove individual shell scripts in favor of rest-apivh1.sh
2016-02-25 14:42:10 -08:00
James Hillyerd
8e084b5697 Refactor web package into two packages: httpd and webui 2016-02-24 22:38:14 -08:00
James Hillyerd
0b32af5495 Homogenize HTTP logging and error handling
- Prefix many web package log messages with HTTP
- Return 404 in cases where it makes sense
- Improve error messages where possible
- Improve comments
- Closes #18
2016-02-24 20:51:43 -08:00
James Hillyerd
f996fa2ae7 Docker best practices
- Install and remove OS and Go build deps in a single layer
- Use COPY instead of ADD
- WORKDIR now resolves ENV variables, use one
- Use VOLUMES for configuration and datastore
- Added launcher script start-inbucket.sh
- Made docker-run.sh more powerful
2016-02-23 22:09:39 -08:00
James Hillyerd
9fafbf73d0 Dockerfile now builds from golang:1.6-alpine 2016-02-22 21:10:48 -08:00
James Hillyerd
a2606a14f6 Update default greeting.html files 2016-02-22 20:11:39 -08:00
James Hillyerd
30fe43dcc7 Add favicon to bootstrap theme 2016-02-22 19:24:37 -08:00
James Hillyerd
bcc36ee965 Update bootstrap and jquery versions for bootstrap theme 2016-02-22 19:15:37 -08:00
James Hillyerd
44f6407de8 Swaks updates 2016-02-22 12:17:59 -08:00
James Hillyerd
e6b7e335cb Follow meta-linter recommendations for all of Inbucket
- Rename BUILD_DATE to BUILDDATE in goxc
- Update travis config
- Follow linter recommendations for inbucket.go
- Follow linter recommendations for config package
- Follow linter recommendations for log package
- Follow linter recommendations for pop3d package
- Follow linter recommendations for smtpd package
- Follow linter recommendations for web package
- Fix Id -> ID in templates
- Add shebang to REST demo scripts
- Add or refine many comments
2016-02-22 00:16:45 -08:00
James Hillyerd
83f9c6aa49 Minor goxc tweaks 2016-02-20 22:14:45 -08:00
James Hillyerd
ef5a10457e Implement recent mailboxes feature 2016-02-20 20:55:02 -08:00
James Hillyerd
e72c5c4b92 Version going to 1.1.0-snapshot, Go 1.5 2015-08-25 22:14:54 -07:00
James Hillyerd
0f5ba4a7a9 Make message-list scroll on some devices 2015-08-25 21:54:02 -07:00
James Hillyerd
c263129711 Add test message from Outlook 2015-08-24 13:15:41 -07:00
James Hillyerd
b107cb8787 Tweak attachment display 2015-08-24 12:49:48 -07:00
James Hillyerd
44ff0be01e Add a gmail message to test with 2015-08-24 12:33:50 -07:00
James Hillyerd
3b0d17867e Docker updates
- Build from official golang image
- Modernize docker build script
- Remove sudo from docker-run.sh, relocate
2015-08-23 23:39:07 -07:00
James Hillyerd
4d8aa340ff Make replace integral with bootstrap as default theme 2015-08-23 21:57:29 -07:00
James Hillyerd
5760d72bcd Extract mailbox javascript 2015-08-23 19:15:13 -07:00
James Hillyerd
8e6745b8b7 Add in bootstrap-theme and switch to minified assets 2015-08-23 19:00:53 -07:00
James Hillyerd
517c68a6b7 Columnize status for mobile 2015-08-23 15:28:49 -07:00
James Hillyerd
4144e2b6f0 Panel-ize status, move js to public 2015-08-23 12:26:04 -07:00
James Hillyerd
5623ac1e8e More bootstrap work, status page now updates 2015-08-18 22:58:29 -07:00
James Hillyerd
da28a8ee55 Message formatting checkpoint 2015-08-18 21:20:42 -07:00
James Hillyerd
f48704b6a6 Bootstrap navbar and message list 2015-08-16 23:05:13 -07:00
James Hillyerd
7b8b872ef0 Copy integral templates into bootstrap theme 2015-08-16 19:52:35 -07:00
James Hillyerd
028ac2994b Use bower to add bootstrap and jquery for bootstrap theme 2015-08-16 19:46:11 -07:00
James Hillyerd
834efefe46 Update travis golang to 1.4.2, run gofmt -s 2015-08-16 15:37:32 -07:00
James Hillyerd
affcc01d19 Convert test message lengths to int64 to fix tests 2015-05-02 10:34:33 -07:00
James Hillyerd
eadc61605a Ignore *.swo files 2015-05-02 10:33:50 -07:00
James Hillyerd
9ca1711252 Update website links 2014-05-25 21:51:55 -07:00
James Hillyerd
bb398498d4 More Docker work
- Dockerfile now builds Inbucket inside the container
- Add starter inbucket.conf for Docker
- Add install.sh to build inbucket inside container
- Add customized greeting.html for Docker
2014-05-25 16:10:05 -07:00
James Hillyerd
cb487c3c7b Switch to crosbymichael/golang trusted base 2014-05-25 13:46:17 -07:00
James Hillyerd
9ebdb06a7a Fix typo in comment 2014-05-25 13:44:08 -07:00
James Hillyerd
8f5ac7ba5b Add Dockerfile (experimental)
- Does not build inside of Docker, just copies from local machine
2014-05-19 23:09:37 -07:00
James Hillyerd
88ae99abb0 Reformat *.go with goimports command 2014-05-19 19:40:33 -07:00
James Hillyerd
63084c67b9 Support numeric domain-parts, closes #24 2014-05-19 19:08:39 -07:00
James Hillyerd
428dc6a286 README and Travis CI tweaks
- Update links to RedHat & Ubuntu install guides
- Travis CI now tests against Go 1.2 and tip
2014-05-09 19:14:10 -07:00
James Hillyerd
9791039aea Merge pull request #25 from hotei/master
Update inbucket.conf to remove dup config entry
2014-05-09 19:09:55 -07:00
Hotei
dcb6a6f845 Update README 2014-05-05 22:13:33 -04:00
Hotei
7433e9ac36 Update inbucket.conf
Remove duped mailbox.message.cap= setting
2014-05-05 22:06:16 -04:00
James Hillyerd
c34549e783 1.0 release, no changes from rc4 2014-04-14 12:34:21 -07:00
James Hillyerd
13868d85d4 HTML view encoding fix
- HTML popup now specifies UTF8 encoding
- Version and build date are captured from goxc
- Version is displayed on status page, and initial log entry
2014-03-12 09:09:47 -07:00
James Hillyerd
8f10e18fef Fix a couple defered Close() operations 2014-01-20 14:32:58 -08:00
James Hillyerd
b105bbf87f Merge branch 'master' of https://github.com/jhillyerd/inbucket 2013-11-15 12:03:44 -08:00
James Hillyerd
ad85a1db93 Set content-type properly on MailboxHtml handler
- Changes to upstream go.enmime help with #20
- Setting content-type is required so that browsers correclty render
  docs that don't include an <html> element.
2013-11-15 12:03:27 -08:00
James Hillyerd
d418f4ba29 Update README and template for inbucket.org site 2013-11-14 20:28:29 -08:00
James Hillyerd
d98e6a2b58 Missing message is now 404 instead of 500
- Added smtpd.ErrNotExist to make detecting missing message easier
- Return 404 instead of 500 when requesting a non-extistant message
- More REST unit tests
2013-11-14 15:08:46 -08:00
James Hillyerd
8b7fbfda6a Unit test MailboxList JSON output 2013-11-13 15:22:03 -08:00
James Hillyerd
46fa714cc7 Add configurable mailbox message cap
- Add new configuration option [datastore]mailbox.message.cap
- Modify filestore to enforce message cap if value > 0
- Filestore unit tests for message cap when enabled & disabled
- Change to DataStore.Mailbox.NewMessage() interface to allow error
  return
2013-11-12 10:42:39 -08:00
James Hillyerd
414ed44882 Refactoring to support REST unit tests 2013-11-11 10:50:41 -08:00
James Hillyerd
2e1c937d23 Now adds a Recieved: header to raw message 2013-11-09 12:57:45 -08:00
James Hillyerd
df11575b3a Handle missing .raw better, closes #19
- Add filestore unit tests to exercise #19
- Move deferred file close under error check
- Handle error message from server gracefully on message click
- Scroll to top of page when message loads successfully
2013-11-09 08:47:20 -08:00
780 changed files with 289581 additions and 3109 deletions

8
.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
.dockerignore
.goxc.json
.goxc.local.json
.travis.yml
inbucket
inbucket.exe
swaks-tests
target

9
.gitignore vendored
View File

@@ -23,10 +23,11 @@ _testmain.go
# vim swp files
*.swp
*.swo
# our binary
# our binaries
/inbucket
/inbucket.exe
# local goxc config
.goxc.local.json
/dist/**
/cmd/client/client
/cmd/client/client.exe

60
.goreleaser.yml Normal file
View File

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

View File

@@ -1,13 +0,0 @@
{
"TasksExclude": [
"pkg-build"
],
"Arch": "amd64",
"Os": "darwin freebsd linux windows",
"Resources": {
"Include": "README*,LICENSE*,inbucket.bat,etc,themes"
},
"PackageVersion": "1.0",
"PrereleaseInfo": "rc2",
"FormatVersion": "0.8"
}

View File

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

125
CHANGELOG.md Normal file
View File

@@ -0,0 +1,125 @@
Change Log
==========
All notable changes to this project will be documented in this file.
This project adheres to [Semantic Versioning](http://semver.org/).
## [v1.3.0] - 2018-02-28
### Added
- Button to purge mailbox contents from the UI.
- Simple HTML/CSS sanitization; `Safe HTML` and `Plain Text` UI tabs.
### Changed
- Reverse message display sort order in the UI; now newest first.
## [v1.2.0] - 2017-12-27
### Changed
- No significant code changes from rc2
### Added
- `rest/client` types `MessageHeader` and `Message` with convenience methods;
provides a more natural API
- Powerful command line REST
[client](https://github.com/jhillyerd/inbucket/wiki/cmd-client)
- Allow use of `latest` as a message ID in REST calls
### Changed
- `rest/client.NewV1` renamed to `New`
- `rest/client` package now embeds the shared `rest/model` structs into its own
types
- Fixed panic when `monitor.history` set to 0
## [v1.2.0-rc1] - 2017-01-29
### Added
- Storage of `To:` header in messages (likely breaks existing datastores)
- Attachment list to [GET message
JSON](https://github.com/jhillyerd/inbucket/wiki/REST-GET-message)
- [Go client for REST
API](https://godoc.org/github.com/jhillyerd/inbucket/rest/client)
- Monitor feature: lists messages as they arrive, regardless of their
destination mailbox
- Make `@inbucket` mailbox prompt configurable
- Warnings and errors from MIME parser are displayed with message
### Fixed
- No longer run out of file handles when dealing with a large number of
recipients for a single message.
- Empty intermediate directories are now removed when a mailbox is deleted,
leaving less junk on your filesystem.
### Changed
- Build now requires Go 1.7 or later
- Removed legacy `integral` theme, as most new features only in `bootstrap`
- Removed old RESTful APIs, must use `/api/v1` base URI now
- Allow increased local-part length of 128 chars for Mailgun
- RedHat and Ubuntu now use systemd instead of legacy init systems
## [v1.1.0] - 2016-09-03
### Added
- Homebrew inbucket.conf and formula (see README)
### Fixed
- Log and continue when unable to delete oldest message during cap enforcement
## [v1.1.0-rc2] - 2016-03-06
### Added
- Message Cap to status page
- Search-while-you-type message list filter
### Fixed
- Shutdown hang in retention scanner
- Display empty subject as `(No Subject)`
## [v1.1.0-rc1] - 2016-03-04
### Added
- Inbucket now builds with Go 1.5 or 1.6
- Project can build & run inside a Docker container
- Add new default theme based on Bootstrap
- Your recently accessed mailboxes are listed in the GUI
- HTML-only messages now get down-converted to plain text automatically
- This change log
### Changed
- RESTful API moved to `/api/v1` base URI
- More graceful shutdown on Ctrl-C or when errors encountered
## [v1.0] - 2014-04-14
### Added
- Add new configuration option `mailbox.message.cap` to prevent individual
mailboxes from growing too large.
- Add Link button to messages, allows for directing another person to a
specific message.
[Unreleased]: https://github.com/jhillyerd/inbucket/compare/master...develop
[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
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. Run tests
4. Test cross-compile: `goreleaser --snapshot`
5. Commit changes and merge release: `git flow release finish`
6. Push tags and wait for https://travis-ci.org/jhillyerd/inbucket build to
complete
7. Update `binary_versions` option in `inbucket-site/_config.yml`
See http://keepachangelog.com/ for additional instructions on how to update this file.

44
CONTRIBUTING.md Normal file
View File

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

25
Dockerfile Normal file
View File

@@ -0,0 +1,25 @@
# Docker build file for Inbucket, see https://www.docker.io/
# Inbucket website: http://www.inbucket.org/
FROM golang:1.9-alpine
MAINTAINER James Hillyerd, @jameshillyerd
# 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"]
# Ports: SMTP, HTTP, POP3
EXPOSE 10025 10080 10110
# 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
# Build Inbucket
COPY . $INBUCKET_SRC/
RUN "$INBUCKET_SRC/etc/docker/install.sh"

35
Makefile Normal file
View File

@@ -0,0 +1,35 @@
PKG := inbucket
SHELL := /bin/sh
SRC := $(shell find . -type f -name '*.go' -not -path "./vendor/*")
PKGS := $$(go list ./... | grep -v /vendor/)
.PHONY: all build clean fmt install lint simplify test
all: test lint build
clean:
go clean
deps:
go get -t ./...
build: clean deps
go build
install: build
go install
test: clean deps
go test -race ./...
fmt:
@gofmt -l -w $(SRC)
simplify:
@gofmt -s -l -w $(SRC)
lint:
@test -z "$(shell gofmt -l . | tee /dev/stderr)" || echo "[WARN] Fix formatting issues with 'make fmt'"
@golint -set_exit_status $${PKGS}
@go vet $${PKGS}

View File

@@ -1,35 +1,33 @@
Inbucket [![Build Status](https://travis-ci.org/jhillyerd/inbucket.png?branch=master)](https://travis-ci.org/jhillyerd/inbucket)
========
Inbucket
=============================================================================
[![Build Status](https://travis-ci.org/jhillyerd/inbucket.png?branch=master)][Build Status]
Inbucket is an email testing service; it will accept messages for any email
address and make them available to view via a web interface.
address and make them available via web, REST and POP3. Once compiled,
Inbucket does not have any external dependencies (HTTP, SMTP, POP3 and storage
are all built in).
It allows web developers, software engineers and system administrators to
quickly see the emailed output of ther applications. No per-account setup is
required! Mailboxes are created on the fly as mail is received for them, and
no password is required to browse the content of the mailboxes.
Read more at the [Inbucket Website]
Inbucket has a built-in SMTP server and stores incoming mail as flat files on
disk - no external SMTP or database daemons required.
![Screenshot](http://www.inbucket.org/images/inbucket-ss1.png "Viewing a message")
There is also an embedded POP3 server, which allows message rendering to be
checked in multiple email programs or to verify message delivery as part of
an integration test suite.
## Development Status
Read more at the [Inbucket website](http://jhillyerd.github.io/inbucket/).
Inbucket is currently production quality: it is being used for real work.
Development Status
------------------
Please see the [Change Log] and [Issues List] for more details. If you'd like
to contribute code to the project check out [CONTRIBUTING.md].
Inbucket is currently release-candidate quality: it is being used for real work.
Please check the [issues list](https://github.com/jhillyerd/inbucket/issues?state=open)
for more details.
## Homebrew Tap
Installation from Source
------------------------
Inbucket has an OS X [Homebrew] tap available as [jhillyerd/inbucket][Homebrew Tap],
see the `README.md` there for installation instructions.
You will need a functioning [Go installation][1] for this to work.
## Building from Source
You will need a functioning [Go installation][Google Go] for this to work.
Grab the Inbucket source code and compile the daemon:
@@ -43,14 +41,23 @@ Unix and OS X machines as is. Launch the daemon:
By default the SMTP server will be listening on localhost port 2500 and
the web interface will be available at [localhost:9000](http://localhost:9000/).
There are RedHat EL6 init, logrotate and httpd proxy configs provided.
The Inbucket website has a more complete guide to
[installing from source][From Source]
About
-----
Inbucket is written in [Google Go][1].
## About
Inbucket is written in [Google Go]
Inbucket is open source software released under the MIT License. The latest
version can be found at https://github.com/jhillyerd/inbucket
[1]: http://golang.org/
[Build Status]: https://travis-ci.org/jhillyerd/inbucket
[Change Log]: https://github.com/jhillyerd/inbucket/blob/master/CHANGELOG.md
[CONTRIBUTING.md]: https://github.com/jhillyerd/inbucket/blob/develop/CONTRIBUTING.md
[From Source]: http://www.inbucket.org/installation/from-source.html
[Google Go]: http://golang.org/
[Homebrew]: http://brew.sh/
[Homebrew Tap]: https://github.com/jhillyerd/homebrew-inbucket
[Inbucket Website]: http://www.inbucket.org/
[Issues List]: https://github.com/jhillyerd/inbucket/issues?state=open

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

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

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

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

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

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

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

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

View File

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

53
datastore/datastore.go Normal file
View File

@@ -0,0 +1,53 @@
// Package datastore contains implementation independent datastore logic
package datastore
import (
"errors"
"io"
"net/mail"
"time"
"github.com/jhillyerd/enmime"
)
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")
)
// DataStore is an interface to get Mailboxes stored in Inbucket
type DataStore interface {
MailboxFor(emailAddress string) (Mailbox, error)
AllMailboxes() ([]Mailbox, error)
}
// Mailbox is an interface to get and manipulate messages in a DataStore
type Mailbox interface {
GetMessages() ([]Message, error)
GetMessage(id string) (Message, error)
Purge() error
NewMessage() (Message, error)
Name() string
String() string
}
// Message is an interface for a single message in a Mailbox
type Message interface {
ID() string
From() string
To() []string
Date() time.Time
Subject() string
RawReader() (reader io.ReadCloser, err error)
ReadHeader() (msg *mail.Message, err error)
ReadBody() (body *enmime.Envelope, err error)
ReadRaw() (raw *string, err error)
Append(data []byte) error
Close() error
Delete() error
String() string
Size() int64
}

180
datastore/retention.go Normal file
View File

@@ -0,0 +1,180 @@
package datastore
import (
"container/list"
"expvar"
"sync"
"time"
"github.com/jhillyerd/inbucket/config"
"github.com/jhillyerd/inbucket/log"
)
var (
retentionScanCompleted = time.Now()
retentionScanCompletedMu sync.RWMutex
// History counters
expRetentionDeletesTotal = new(expvar.Int)
expRetentionPeriod = new(expvar.Int)
expRetainedCurrent = new(expvar.Int)
// History of certain stats
retentionDeletesHist = list.New()
retainedHist = list.New()
// History rendered as comma delimited string
expRetentionDeletesHist = new(expvar.String)
expRetainedHist = new(expvar.String)
)
func init() {
rm := expvar.NewMap("retention")
rm.Set("SecondsSinceScanCompleted", expvar.Func(secondsSinceRetentionScanCompleted))
rm.Set("DeletesHist", expRetentionDeletesHist)
rm.Set("DeletesTotal", expRetentionDeletesTotal)
rm.Set("Period", expRetentionPeriod)
rm.Set("RetainedHist", expRetainedHist)
rm.Set("RetainedCurrent", expRetainedCurrent)
log.AddTickerFunc(func() {
expRetentionDeletesHist.Set(log.PushMetric(retentionDeletesHist, expRetentionDeletesTotal))
expRetainedHist.Set(log.PushMetric(retainedHist, expRetainedCurrent))
})
}
// RetentionScanner looks for messages older than the configured retention period and deletes them.
type RetentionScanner struct {
globalShutdown chan bool // Closes when Inbucket needs to shut down
retentionShutdown chan bool // Closed after the scanner has shut down
ds DataStore
retentionPeriod time.Duration
retentionSleep time.Duration
}
// NewRetentionScanner launches a go-routine that scans for expired
// messages, following the configured interval
func NewRetentionScanner(ds DataStore, shutdownChannel chan bool) *RetentionScanner {
cfg := config.GetDataStoreConfig()
rs := &RetentionScanner{
globalShutdown: shutdownChannel,
retentionShutdown: make(chan bool),
ds: ds,
retentionPeriod: time.Duration(cfg.RetentionMinutes) * time.Minute,
retentionSleep: time.Duration(cfg.RetentionSleep) * time.Millisecond,
}
// expRetentionPeriod is displayed on the status page
expRetentionPeriod.Set(int64(cfg.RetentionMinutes * 60))
return rs
}
// Start up the retention scanner if retention period > 0
func (rs *RetentionScanner) Start() {
if rs.retentionPeriod <= 0 {
log.Infof("Retention scanner disabled")
close(rs.retentionShutdown)
return
}
log.Infof("Retention configured for %v", rs.retentionPeriod)
go rs.run()
}
// run loops to kick off the scanner on the correct schedule
func (rs *RetentionScanner) run() {
start := time.Now()
retentionLoop:
for {
// Prevent scanner from starting more than once a minute
since := time.Since(start)
if since < time.Minute {
dur := time.Minute - since
log.Tracef("Retention scanner sleeping for %v", dur)
select {
case <-rs.globalShutdown:
break retentionLoop
case <-time.After(dur):
}
}
// Kickoff scan
start = time.Now()
if err := rs.doScan(); err != nil {
log.Errorf("Error during retention scan: %v", err)
}
// Check for global shutdown
select {
case <-rs.globalShutdown:
break retentionLoop
default:
}
}
log.Tracef("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")
cutoff := time.Now().Add(-1 * rs.retentionPeriod)
mboxes, err := rs.ds.AllMailboxes()
if err != nil {
return err
}
retained := 0
// Loop over all mailboxes
for _, mb := range mboxes {
messages, err := mb.GetMessages()
if err != nil {
return err
}
// Loop over all messages in mailbox
for _, msg := range messages {
if msg.Date().Before(cutoff) {
log.Tracef("Purging expired message %v", msg.ID())
err = msg.Delete()
if err != nil {
// Log but don't abort
log.Errorf("Failed to purge message %v: %v", msg.ID(), err)
} else {
expRetentionDeletesTotal.Add(1)
}
} else {
retained++
}
}
// Sleep after completing a mailbox
select {
case <-rs.globalShutdown:
log.Tracef("Retention scan aborted due to shutdown")
return nil
case <-time.After(rs.retentionSleep):
// Reduce disk thrashing
}
}
// Update metrics
setRetentionScanCompleted(time.Now())
expRetainedCurrent.Set(int64(retained))
return nil
}
// Join does not retun until the retention scanner has shut down
func (rs *RetentionScanner) Join() {
if rs.retentionShutdown != nil {
<-rs.retentionShutdown
}
}
func setRetentionScanCompleted(t time.Time) {
retentionScanCompletedMu.Lock()
defer retentionScanCompletedMu.Unlock()
retentionScanCompleted = t
}
func getRetentionScanCompleted() time.Time {
retentionScanCompletedMu.RLock()
defer retentionScanCompletedMu.RUnlock()
return retentionScanCompleted
}
func secondsSinceRetentionScanCompleted() interface{} {
return time.Since(getRetentionScanCompleted()) / time.Second
}

View File

@@ -0,0 +1,67 @@
package datastore
import (
"fmt"
"testing"
"time"
)
func TestDoRetentionScan(t *testing.T) {
// Create mock objects
mds := &MockDataStore{}
mb1 := &MockMailbox{}
mb2 := &MockMailbox{}
mb3 := &MockMailbox{}
// Mockup some different aged messages (num is in hours)
new1 := mockMessage(0)
new2 := mockMessage(1)
new3 := mockMessage(2)
old1 := mockMessage(4)
old2 := mockMessage(12)
old3 := mockMessage(24)
// First it should ask for all mailboxes
mds.On("AllMailboxes").Return([]Mailbox{mb1, mb2, mb3}, nil)
// Then for all messages on each box
mb1.On("GetMessages").Return([]Message{new1, old1, old2}, nil)
mb2.On("GetMessages").Return([]Message{old3, new2}, nil)
mb3.On("GetMessages").Return([]Message{new3}, nil)
// Test 4 hour retention
rs := &RetentionScanner{
ds: mds,
retentionPeriod: 4*time.Hour - time.Minute,
retentionSleep: 0,
}
if err := rs.doScan(); err != nil {
t.Error(err)
}
// Check our assertions
mds.AssertExpectations(t)
mb1.AssertExpectations(t)
mb2.AssertExpectations(t)
mb3.AssertExpectations(t)
// Delete should not have been called on new messages
new1.AssertNotCalled(t, "Delete")
new2.AssertNotCalled(t, "Delete")
new3.AssertNotCalled(t, "Delete")
// Delete should have been called once on old messages
old1.AssertNumberOfCalls(t, "Delete", 1)
old2.AssertNumberOfCalls(t, "Delete", 1)
old3.AssertNumberOfCalls(t, "Delete", 1)
}
// Make a MockMessage of a specific age
func mockMessage(ageHours int) *MockMessage {
msg := &MockMessage{}
msg.On("ID").Return(fmt.Sprintf("MSG[age=%vh]", ageHours))
msg.On("Date").Return(time.Now().Add(time.Duration(ageHours*-1) * time.Hour))
msg.On("Delete").Return(nil)
return msg
}

View File

@@ -1,178 +1,156 @@
package smtpd
package datastore
import (
"fmt"
"github.com/jhillyerd/go.enmime"
"github.com/stretchr/testify/mock"
"io"
"net/mail"
"testing"
"time"
"github.com/jhillyerd/enmime"
"github.com/stretchr/testify/mock"
)
func TestDoRetentionScan(t *testing.T) {
// Create mock objects
mds := &MockDataStore{}
mb1 := &MockMailbox{}
mb2 := &MockMailbox{}
mb3 := &MockMailbox{}
// Mockup some different aged messages (num is in hours)
new1 := mockMessage(0)
new2 := mockMessage(1)
new3 := mockMessage(2)
old1 := mockMessage(4)
old2 := mockMessage(12)
old3 := mockMessage(24)
// First it should ask for all mailboxes
mds.On("AllMailboxes").Return([]Mailbox{mb1, mb2, mb3}, nil)
// Then for all messages on each box
mb1.On("GetMessages").Return([]Message{new1, old1, old2}, nil)
mb2.On("GetMessages").Return([]Message{old3, new2}, nil)
mb3.On("GetMessages").Return([]Message{new3}, nil)
// Test 4 hour retention
doRetentionScan(mds, 4*time.Hour, 0)
// Check our assertions
mds.AssertExpectations(t)
mb1.AssertExpectations(t)
mb2.AssertExpectations(t)
mb3.AssertExpectations(t)
// Delete should not have been called on new messages
new1.AssertNotCalled(t, "Delete")
new2.AssertNotCalled(t, "Delete")
new3.AssertNotCalled(t, "Delete")
// Delete should have been called once on old messages
old1.AssertNumberOfCalls(t, "Delete", 1)
old2.AssertNumberOfCalls(t, "Delete", 1)
old3.AssertNumberOfCalls(t, "Delete", 1)
}
// Make a MockMessage of a specific age
func mockMessage(ageHours int) *MockMessage {
msg := &MockMessage{}
msg.On("Id").Return(fmt.Sprintf("MSG[age=%vh]", ageHours))
msg.On("Date").Return(time.Now().Add(time.Duration(ageHours*-1) * time.Hour))
msg.On("Delete").Return(nil)
return msg
}
// Mock DataStore object
// MockDataStore is a shared mock for unit testing
type MockDataStore struct {
mock.Mock
}
// MailboxFor mock function
func (m *MockDataStore) MailboxFor(name string) (Mailbox, error) {
args := m.Called()
args := m.Called(name)
return args.Get(0).(Mailbox), args.Error(1)
}
// AllMailboxes mock function
func (m *MockDataStore) AllMailboxes() ([]Mailbox, error) {
args := m.Called()
return args.Get(0).([]Mailbox), args.Error(1)
}
// Mock Mailbox object
// MockMailbox is a shared mock for unit testing
type MockMailbox struct {
mock.Mock
}
// GetMessages mock function
func (m *MockMailbox) GetMessages() ([]Message, error) {
args := m.Called()
return args.Get(0).([]Message), args.Error(1)
}
// GetMessage mock function
func (m *MockMailbox) GetMessage(id string) (Message, error) {
args := m.Called(id)
return args.Get(0).(Message), args.Error(1)
}
// Purge mock function
func (m *MockMailbox) Purge() error {
args := m.Called()
return args.Error(0)
}
func (m *MockMailbox) NewMessage() Message {
// NewMessage mock function
func (m *MockMailbox) NewMessage() (Message, error) {
args := m.Called()
return args.Get(0).(Message)
return args.Get(0).(Message), args.Error(1)
}
// Name mock function
func (m *MockMailbox) Name() string {
args := m.Called()
return args.String(0)
}
// String mock function
func (m *MockMailbox) String() string {
args := m.Called()
return args.String(0)
}
// Mock Message object
// MockMessage is a shared mock for unit testing
type MockMessage struct {
mock.Mock
}
func (m *MockMessage) Id() string {
// ID mock function
func (m *MockMessage) ID() string {
args := m.Called()
return args.String(0)
}
// From mock function
func (m *MockMessage) From() string {
args := m.Called()
return args.String(0)
}
// To mock function
func (m *MockMessage) To() []string {
args := m.Called()
return args.Get(0).([]string)
}
// Date mock function
func (m *MockMessage) Date() time.Time {
args := m.Called()
return args.Get(0).(time.Time)
}
// Subject mock function
func (m *MockMessage) Subject() string {
args := m.Called()
return args.String(0)
}
// ReadHeader mock function
func (m *MockMessage) ReadHeader() (msg *mail.Message, err error) {
args := m.Called()
return args.Get(0).(*mail.Message), args.Error(1)
}
func (m *MockMessage) ReadBody() (body *enmime.MIMEBody, err error) {
// ReadBody mock function
func (m *MockMessage) ReadBody() (body *enmime.Envelope, err error) {
args := m.Called()
return args.Get(0).(*enmime.MIMEBody), args.Error(1)
return args.Get(0).(*enmime.Envelope), args.Error(1)
}
// ReadRaw mock function
func (m *MockMessage) ReadRaw() (raw *string, err error) {
args := m.Called()
return args.Get(0).(*string), args.Error(1)
}
// RawReader mock function
func (m *MockMessage) RawReader() (reader io.ReadCloser, err error) {
args := m.Called()
return args.Get(0).(io.ReadCloser), args.Error(1)
}
// Size mock function
func (m *MockMessage) Size() int64 {
args := m.Called()
return int64(args.Int(0))
}
// Append mock function
func (m *MockMessage) Append(data []byte) error {
// []byte arg seems to mess up testify/mock
return nil
}
// Close mock function
func (m *MockMessage) Close() error {
args := m.Called()
return args.Error(0)
}
// Delete mock function
func (m *MockMessage) Delete() error {
args := m.Called()
return args.Error(0)
}
// String mock function
func (m *MockMessage) String() string {
args := m.Called()
return args.String(0)

View File

@@ -4,8 +4,9 @@
#############################################################################
[DEFAULT]
# Not used by directly, but is typically referenced below in %()s format.
# Not used directly, but is typically referenced below in %()s format.
install.dir=.
default.domain=inbucket.local
#############################################################################
[logging]
@@ -23,7 +24,7 @@ ip4.address=0.0.0.0
ip4.port=2500
# used in SMTP greeting
domain=inbucket.local
domain=%(default.domain)s
# optional: mail sent to accounts at this domain will not be stored,
# for mixed use (content and load testing)
@@ -54,7 +55,7 @@ ip4.address=0.0.0.0
ip4.port=1100
# used in POP3 greeting
domain=inbucket.local
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).
@@ -70,7 +71,11 @@ ip4.address=0.0.0.0
ip4.port=9000
# Name of web theme to use
theme=integral
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
@@ -81,10 +86,27 @@ 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
# 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]
@@ -100,3 +122,8 @@ retention.minutes=0
# 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

@@ -0,0 +1,15 @@
<p>Inbucket is an email testing service; it will accept email for any email
address and make it available to view without a password.</p>
<p>To view email for a particular address, enter the username portion
of the address into the box on the upper right and click <em>View</em>.</p>
<p>This instance of Inbucket is running inside of a <a
href="https://www.docker.com/" target="_blank">Docker</a> container. It is
configured to retain messages for a maximum of 3 days, and will enforce a limit
of 300 messages per mailbox - the oldest messages will be deleted to stay under
that limit.</p>
<p>Messages addressed to any recipient in the <code>@bitbucket.local</code>
domain will be accepted but not written to disk. Use this domain for load or
soak testing your application.</p>

View File

@@ -0,0 +1,131 @@
# 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

@@ -0,0 +1,24 @@
#!/bin/sh
# start-inbucket.sh
# description: start inbucket (runs within a docker container)
CONF_SOURCE="$INBUCKET_HOME/defaults"
CONF_TARGET="/con/configuration"
set -eo pipefail
install_default_config() {
local file="$1"
local source="$CONF_SOURCE/$file"
local target="$CONF_TARGET/$file"
if [ ! -e "$target" ]; then
echo "Installing default $file to $CONF_TARGET"
install "$source" "$target"
fi
}
install_default_config "inbucket.conf"
install_default_config "greeting.html"
exec "$INBUCKET_HOME/bin/inbucket" $*

62
etc/docker/docker-run.sh Executable file
View File

@@ -0,0 +1,62 @@
#!/bin/sh
# docker-run.sh
# description: Launch Inbucket's docker image
# Docker Image Tag
IMAGE="jhillyerd/inbucket"
# Ports exposed on host:
PORT_HTTP=9000
PORT_SMTP=2500
PORT_POP3=1100
# Volumes exposed on host:
VOL_CONFIG="/tmp/inbucket/config"
VOL_DATA="/tmp/inbucket/data"
set -eo pipefail
main() {
local run_opts=""
for arg in $*; do
case "$arg" in
-h)
usage
exit
;;
-r)
reset
;;
-d)
run_opts="$run_opts -d"
;;
*)
usage
exit 1
;;
esac
done
docker run $run_opts \
-p $PORT_HTTP:10080 \
-p $PORT_SMTP:10025 \
-p $PORT_POP3:10110 \
-v "$VOL_CONFIG:/con/configuration" \
-v "$VOL_DATA:/con/data" \
"$IMAGE"
}
usage() {
echo "$0 [options]" 2>&1
echo " -d detach - detach and print container ID" 2>&1
echo " -r reset - purge config and data before startup" 2>&1
echo " -h help - print this message" 2>&1
}
reset() {
/bin/rm -rf "$VOL_CONFIG"
/bin/rm -rf "$VOL_DATA"
}
main $*

51
etc/docker/install.sh Executable file
View File

@@ -0,0 +1,51 @@
#!/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"
cd "$srcdir"
# Fetch tags for describe
git fetch -t
builddate="$(date -Iseconds)"
buildver="$(git describe --tags --always)"
# Build
go clean
echo "### Fetching Dependencies"
go get -t -v ./...
echo "### Testing Inbucket"
go test ./...
echo "### Building Inbucket"
go build -o inbucket -ldflags "-X 'main.version=$buildver' -X 'main.date=$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"

131
etc/homebrew/inbucket.conf Normal file
View File

@@ -0,0 +1,131 @@
# 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

@@ -4,8 +4,9 @@
#############################################################################
[DEFAULT]
# Not used by directly, but is typically referenced below in %()s format.
# Not used directly, but is typically referenced below in %()s format.
install.dir=.
default.domain=inbucket.local
#############################################################################
[logging]
@@ -23,7 +24,7 @@ ip4.address=0.0.0.0
ip4.port=2500
# used in SMTP greeting
domain=inbucket.local
domain=%(default.domain)s
# optional: mail sent to accounts at this domain will not be stored,
# for mixed use (content and load testing)
@@ -54,7 +55,7 @@ ip4.address=0.0.0.0
ip4.port=1100
# used in POP3 greeting
domain=inbucket.local
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).
@@ -70,7 +71,11 @@ ip4.address=0.0.0.0
ip4.port=9000
# Name of web theme to use
theme=integral
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
@@ -81,10 +86,27 @@ 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
# 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]
@@ -100,3 +122,8 @@ retention.minutes=240
# 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 @@
curl -i -H "Accept: application/json" --noproxy localhost http://localhost:9000/mailbox/$1

View File

@@ -1 +0,0 @@
curl -i -H "Accept: application/json" --noproxy localhost -X DELETE http://localhost:9000/mailbox/$1

View File

@@ -1 +0,0 @@
curl -i -H "Accept: application/json" --noproxy localhost http://localhost:9000/mailbox/$1/$2

View File

@@ -1 +0,0 @@
curl -i -H "Accept: application/json" --noproxy localhost -X DELETE http://localhost:9000/mailbox/$1/$2

View File

@@ -1 +0,0 @@
curl -i -H "Accept: application/json" --noproxy localhost http://localhost:9000/mailbox/$1/$2/source

View File

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

3
etc/redhat/README Normal file
View File

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

View File

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

View File

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

118
etc/rest-apiv1.sh Executable file
View File

@@ -0,0 +1,118 @@
#!/bin/bash
# rest-apiv1.sh
# description: Script to access Inbucket REST API version 1
API_HOST="localhost"
URL_ROOT="http://$API_HOST:9000/api/v1"
set -eo pipefail
[ $TRACE ] && set -x
usage() {
echo "Usage: $0 <command> [argument1 [argument2 [..]]]" >&2
echo >&2
echo "Options:" >&2
echo " -h - show this help" >&2
echo " -i - show HTTP headers" >&2
echo >&2
echo "Commands:" >&2
echo " list <mailbox> - list mailbox contents" >&2
echo " body <mailbox> <id> - print message body" >&2
echo " source <mailbox> <id> - print message source" >&2
echo " delete <mailbox> <id> - delete message" >&2
echo " purge <mailbox> - delete all messages in mailbox" >&2
}
arg_check() {
declare command="$1" expected="$2" received="$3"
if [ $expected != $received ]; then
echo "Error: Command '$command' requires $expected arguments, but received $received" >&2
echo >&2
usage
exit 1
fi
}
main() {
# Process options
local curl_opts=""
local pretty="true"
for arg in $*; do
if [[ $arg == -* ]]; then
case "$arg" in
-h)
usage
exit
;;
-i)
curl_opts="$curl_opts -i"
pretty=""
;;
**)
echo "Unknown option: $arg" >&2
echo
usage
exit 1
;;
esac
shift
else
break
fi
done
# Store command
declare command="$1"
shift
local url=""
local method="GET"
local is_json=""
case "$command" in
body)
arg_check "$command" 2 $#
url="$URL_ROOT/mailbox/$1/$2"
is_json="true"
;;
delete)
arg_check "$command" 2 $#
method=DELETE
url="$URL_ROOT/mailbox/$1/$2"
;;
list)
arg_check "$command" 1 $#
url="$URL_ROOT/mailbox/$1"
is_json="true"
;;
purge)
arg_check "$command" 1 $#
method=DELETE
url="$URL_ROOT/mailbox/$1"
;;
source)
arg_check "$command" 2 $#
url="$URL_ROOT/mailbox/$1/$2/source"
;;
*)
echo "Unknown command $command" >&2
echo >&2
usage
exit 1
;;
esac
# Use jq to pretty-print if installed and we are expecting JSON output
if [ $pretty ] && [ $is_json ] && type -P jq >/dev/null; then
curl -s $curl_opts -H "Accept: application/json" --noproxy "$API_HOST" -X "$method" "$url" | jq .
else
curl -s $curl_opts -H "Accept: application/json" --noproxy "$API_HOST" -X "$method" "$url"
fi
}
if [ $# -lt 1 ]; then
usage
exit 1
fi
main $*

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://jhillyerd.github.com/inbucket/installation/ubuntu.html

3
etc/ubuntu/README Normal file
View File

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

View File

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

View File

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

View File

@@ -4,8 +4,9 @@
#############################################################################
[DEFAULT]
# Not used by directly, but is typically referenced below in %()s format.
# Not used directly, but is typically referenced below in %()s format.
install.dir=/opt/inbucket
default.domain=inbucket.local
#############################################################################
[logging]
@@ -23,7 +24,7 @@ ip4.address=0.0.0.0
ip4.port=25
# used in SMTP greeting
domain=inbucket.local
domain=%(default.domain)s
# optional: mail sent to accounts at this domain will not be stored,
# for mixed use (content and load testing)
@@ -54,7 +55,7 @@ ip4.address=0.0.0.0
ip4.port=110
# used in POP3 greeting
domain=inbucket.local
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).
@@ -70,7 +71,11 @@ ip4.address=0.0.0.0
ip4.port=80
# Name of web theme to use
theme=integral
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
@@ -81,10 +86,27 @@ 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
# 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]
@@ -100,3 +122,8 @@ retention.minutes=240
# 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

@@ -4,8 +4,9 @@
#############################################################################
[DEFAULT]
# Not used by directly, but is typically referenced below in %()s format.
# Not used directly, but is typically referenced below in %()s format.
install.dir=.
default.domain=inbucket.local
#############################################################################
[logging]
@@ -23,7 +24,7 @@ ip4.address=0.0.0.0
ip4.port=2500
# used in SMTP greeting
domain=inbucket.local
domain=%(default.domain)s
# optional: mail sent to accounts at this domain will not be stored,
# for mixed use (content and load testing)
@@ -54,7 +55,7 @@ ip4.address=0.0.0.0
ip4.port=1100
# used in POP3 greeting
domain=inbucket.local
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).
@@ -70,7 +71,11 @@ ip4.address=0.0.0.0
ip4.port=9000
# Name of web theme to use
theme=integral
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
@@ -81,10 +86,27 @@ 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
# 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]
@@ -100,3 +122,8 @@ retention.minutes=240
# 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

270
filestore/fmessage.go Normal file
View File

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

357
filestore/fstore.go Normal file
View File

@@ -0,0 +1,357 @@
package filestore
import (
"bufio"
"encoding/gob"
"fmt"
"io"
"io/ioutil"
"os"
"path/filepath"
"sync"
"time"
"github.com/jhillyerd/inbucket/config"
"github.com/jhillyerd/inbucket/datastore"
"github.com/jhillyerd/inbucket/log"
"github.com/jhillyerd/inbucket/stringutil"
)
// Name of index file in each mailbox
const indexFileName = "index.gob"
var (
// indexMx is locked while reading/writing an index file
//
// NOTE: This is a bottleneck because it's a single lock even if we have a
// million index files
indexMx = new(sync.RWMutex)
// dirMx is locked while creating/removing directories
dirMx = new(sync.Mutex)
// countChannel is filled with a sequential numbers (0000..9999), which are
// used by generateID() to generate unique message IDs. It's global
// 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
}
}
// FileDataStore implements DataStore aand is the root of the mail storage
// hiearchy. It provides access to Mailbox objects
type FileDataStore struct {
path string
mailPath string
messageCap int
}
// NewFileDataStore creates a new DataStore object using the specified path
func NewFileDataStore(cfg config.DataStoreConfig) datastore.DataStore {
path := cfg.Path
if path == "" {
log.Errorf("No value configured for datastore path")
return nil
}
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.Errorf("Error creating dir %q: %v", mailPath, err)
}
}
return &FileDataStore{path: path, mailPath: mailPath, messageCap: cfg.MailboxMsgCap}
}
// DefaultFileDataStore creates a new DataStore object. It uses the inbucket.Config object to
// construct it's path.
func DefaultFileDataStore() datastore.DataStore {
cfg := config.GetDataStoreConfig()
return NewFileDataStore(cfg)
}
// MailboxFor retrieves the Mailbox object for a specified email address, if the mailbox
// does not exist, it will attempt to create it.
func (ds *FileDataStore) MailboxFor(emailAddress string) (datastore.Mailbox, error) {
name, err := stringutil.ParseMailboxName(emailAddress)
if err != nil {
return nil, err
}
dir := stringutil.HashMailboxName(name)
s1 := dir[0:3]
s2 := dir[0:6]
path := filepath.Join(ds.mailPath, s1, s2, dir)
indexPath := filepath.Join(path, indexFileName)
return &FileMailbox{store: ds, name: name, dirName: dir, path: path,
indexPath: indexPath}, nil
}
// AllMailboxes returns a slice with all Mailboxes
func (ds *FileDataStore) AllMailboxes() ([]datastore.Mailbox, error) {
mailboxes := make([]datastore.Mailbox, 0, 100)
infos1, err := ioutil.ReadDir(ds.mailPath)
if err != nil {
return nil, err
}
// Loop over level 1 directories
for _, inf1 := range infos1 {
if inf1.IsDir() {
l1 := inf1.Name()
infos2, err := ioutil.ReadDir(filepath.Join(ds.mailPath, l1))
if err != nil {
return nil, err
}
// Loop over level 2 directories
for _, inf2 := range infos2 {
if inf2.IsDir() {
l2 := inf2.Name()
infos3, err := ioutil.ReadDir(filepath.Join(ds.mailPath, l1, l2))
if err != nil {
return nil, err
}
// Loop over mailboxes
for _, inf3 := range infos3 {
if inf3.IsDir() {
mbdir := inf3.Name()
mbpath := filepath.Join(ds.mailPath, l1, l2, mbdir)
idx := filepath.Join(mbpath, indexFileName)
mb := &FileMailbox{store: ds, dirName: mbdir, path: mbpath,
indexPath: idx}
mailboxes = append(mailboxes, mb)
}
}
}
}
}
}
return mailboxes, nil
}
// FileMailbox implements Mailbox, manages the mail for a specific user and
// correlates to a particular directory on disk.
type FileMailbox struct {
store *FileDataStore
name string
dirName string
path string
indexLoaded bool
indexPath string
messages []*FileMessage
}
// Name of the mailbox
func (mb *FileMailbox) Name() string {
return mb.name
}
// String renders the name and directory path of the mailbox
func (mb *FileMailbox) String() string {
return mb.name + "[" + mb.dirName + "]"
}
// GetMessages scans the mailbox directory for .gob files and decodes them into
// a slice of Message objects.
func (mb *FileMailbox) GetMessages() ([]datastore.Message, error) {
if !mb.indexLoaded {
if err := mb.readIndex(); err != nil {
return nil, err
}
}
messages := make([]datastore.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 *FileMailbox) GetMessage(id string) (datastore.Message, error) {
if !mb.indexLoaded {
if err := mb.readIndex(); err != nil {
return nil, err
}
}
if id == "latest" && len(mb.messages) != 0 {
return mb.messages[len(mb.messages)-1], nil
}
for _, m := range mb.messages {
if m.Fid == id {
return m, nil
}
}
return nil, datastore.ErrNotExist
}
// Purge deletes all messages in this mailbox
func (mb *FileMailbox) Purge() error {
mb.messages = mb.messages[:0]
return mb.writeIndex()
}
// readIndex loads the mailbox index data from disk
func (mb *FileMailbox) readIndex() error {
// Clear message slice, open index
mb.messages = mb.messages[:0]
// Lock for reading
indexMx.RLock()
defer indexMx.RUnlock()
// Check if index exists
if _, err := os.Stat(mb.indexPath); err != nil {
// Does not exist, but that's not an error in our world
log.Tracef("Index %v does not exist (yet)", mb.indexPath)
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.Errorf("Failed to close %q: %v", mb.indexPath, err)
}
}()
// Decode gob data
dec := gob.NewDecoder(bufio.NewReader(file))
for {
msg := new(FileMessage)
if err = dec.Decode(msg); err != nil {
if err == io.EOF {
// It's OK to get an EOF here
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 *FileMailbox) writeIndex() error {
// Lock for writing
indexMx.Lock()
defer indexMx.Unlock()
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)
for _, m := range mb.messages {
err = enc.Encode(m)
if err != nil {
_ = file.Close()
return err
}
}
if err := writer.Flush(); err != nil {
_ = file.Close()
return err
}
if err := file.Close(); err != nil {
log.Errorf("Failed to close %q: %v", mb.indexPath, err)
return err
}
} else {
// No messages, delete index+maildir
log.Tracef("Removing mailbox %v", mb.path)
return mb.removeDir()
}
return nil
}
// createDir checks for the presence of the path for this mailbox, creates it if needed
func (mb *FileMailbox) createDir() error {
dirMx.Lock()
defer dirMx.Unlock()
if _, err := os.Stat(mb.path); err != nil {
if err := os.MkdirAll(mb.path, 0770); err != nil {
log.Errorf("Failed to create directory %v, %v", mb.path, err)
return err
}
}
return nil
}
// removeDir removes the mailbox, plus empty higher level directories
func (mb *FileMailbox) removeDir() error {
dirMx.Lock()
defer dirMx.Unlock()
// remove mailbox dir, including index file
if err := os.RemoveAll(mb.path); err != nil {
return err
}
// 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.
// Caller should hold dirMx. Returns true if dir was removed.
func removeDirIfEmpty(path string) (removed bool) {
f, err := os.Open(path)
if err != nil {
return false
}
files, err := f.Readdirnames(0)
_ = f.Close()
if err != nil {
return false
}
if len(files) > 0 {
// Dir not empty
return false
}
log.Tracef("Removing dir %v", path)
err = os.Remove(path)
if err != nil {
log.Errorf("Failed to remove %q: %v", path, err)
return false
}
return true
}
// generatePrefix converts a Time object into the ISO style format we use
// 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

@@ -1,9 +1,8 @@
package smtpd
package filestore
import (
"bytes"
"fmt"
"github.com/stretchr/testify/assert"
"io"
"io/ioutil"
"log"
@@ -11,11 +10,14 @@ import (
"path/filepath"
"testing"
"time"
"github.com/jhillyerd/inbucket/config"
"github.com/stretchr/testify/assert"
)
// Test directory structure created by filestore
func TestFSDirStructure(t *testing.T) {
ds, logbuf := setupDataStore()
ds, logbuf := setupDataStore(config.DataStoreConfig{})
defer teardownDataStore(ds)
root := ds.path
@@ -93,13 +95,13 @@ func TestFSDirStructure(t *testing.T) {
// 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)
_, _ = io.Copy(os.Stderr, logbuf)
}
}
// Test FileDataStore.AllMailboxes()
func TestFSAllMailboxes(t *testing.T) {
ds, logbuf := setupDataStore()
ds, logbuf := setupDataStore(config.DataStoreConfig{})
defer teardownDataStore(ds)
for _, name := range []string{"abby", "bill", "christa", "donald", "evelyn"} {
@@ -120,14 +122,14 @@ func TestFSAllMailboxes(t *testing.T) {
// 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)
_, _ = io.Copy(os.Stderr, logbuf)
}
}
// Test delivering several messages to the same mailbox, meanwhile querying its
// contents with a new mailbox object each time
func TestFSDeliverMany(t *testing.T) {
ds, logbuf := setupDataStore()
ds, logbuf := setupDataStore(config.DataStoreConfig{})
defer teardownDataStore(ds)
mbName := "fred"
@@ -170,13 +172,13 @@ func TestFSDeliverMany(t *testing.T) {
// 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)
_, _ = io.Copy(os.Stderr, logbuf)
}
}
// Test deleting messages
func TestFSDelete(t *testing.T) {
ds, logbuf := setupDataStore()
ds, logbuf := setupDataStore(config.DataStoreConfig{})
defer teardownDataStore(ds)
mbName := "fred"
@@ -199,8 +201,8 @@ func TestFSDelete(t *testing.T) {
len(subjects), len(msgs))
// Delete a couple messages
msgs[1].Delete()
msgs[3].Delete()
_ = msgs[1].Delete()
_ = msgs[3].Delete()
// Confirm deletion
mb, err = ds.MailboxFor(mbName)
@@ -244,13 +246,13 @@ func TestFSDelete(t *testing.T) {
// 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)
_, _ = io.Copy(os.Stderr, logbuf)
}
}
// Test purging a mailbox
func TestFSPurge(t *testing.T) {
ds, logbuf := setupDataStore()
ds, logbuf := setupDataStore(config.DataStoreConfig{})
defer teardownDataStore(ds)
mbName := "fred"
@@ -292,19 +294,19 @@ func TestFSPurge(t *testing.T) {
// 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)
_, _ = io.Copy(os.Stderr, logbuf)
}
}
// Test message size calculation
func TestFSSize(t *testing.T) {
ds, logbuf := setupDataStore()
ds, logbuf := setupDataStore(config.DataStoreConfig{})
defer teardownDataStore(ds)
mbName := "fred"
subjects := []string{"a", "br", "much longer than the others"}
sentIds := make([]string, len(subjects))
sentSizes := make([]int, len(subjects))
sentSizes := make([]int64, len(subjects))
for i, subj := range subjects {
// Add a message
@@ -330,12 +332,183 @@ func TestFSSize(t *testing.T) {
// 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)
_, _ = io.Copy(os.Stderr, logbuf)
}
}
// Test missing files
func TestFSMissing(t *testing.T) {
ds, logbuf := setupDataStore(config.DataStoreConfig{})
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
}
mb, err := ds.MailboxFor(mbName)
if err != nil {
t.Fatalf("Failed to MailboxFor(%q): %v", mbName, err)
}
// Delete a message file without removing it from index
msg, err := mb.GetMessage(sentIds[1])
assert.Nil(t, err)
fmsg := msg.(*FileMessage)
_ = os.Remove(fmsg.rawPath())
msg, err = mb.GetMessage(sentIds[1])
assert.Nil(t, err)
// Try to read parts of message
_, err = msg.ReadHeader()
assert.Error(t, err)
_, err = msg.ReadBody()
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 delivering several messages to the same mailbox, see if message cap works
func TestFSMessageCap(t *testing.T) {
mbCap := 10
ds, logbuf := setupDataStore(config.DataStoreConfig{MailboxMsgCap: mbCap})
defer teardownDataStore(ds)
mbName := "captain"
for i := 0; i < 20; i++ {
// Add a message
subj := fmt.Sprintf("subject %v", i)
deliverMessage(ds, mbName, subj, time.Now())
t.Logf("Delivered %q", subj)
// Check number of messages
mb, err := ds.MailboxFor(mbName)
if err != nil {
t.Fatalf("Failed to MailboxFor(%q): %v", mbName, err)
}
msgs, err := mb.GetMessages()
if err != nil {
t.Fatalf("Failed to GetMessages for %q: %v", mbName, err)
}
if len(msgs) > mbCap {
t.Errorf("Mailbox should be capped at %v messages, but has %v", mbCap, len(msgs))
}
// 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("Expected first subject to be %q, got %q", firstSubj, msgs[0].Subject())
}
}
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 delivering several messages to the same mailbox, see if no message cap works
func TestFSNoMessageCap(t *testing.T) {
mbCap := 0
ds, logbuf := setupDataStore(config.DataStoreConfig{MailboxMsgCap: mbCap})
defer teardownDataStore(ds)
mbName := "captain"
for i := 0; i < 20; i++ {
// Add a message
subj := fmt.Sprintf("subject %v", i)
deliverMessage(ds, mbName, subj, time.Now())
t.Logf("Delivered %q", subj)
// Check number of messages
mb, err := ds.MailboxFor(mbName)
if err != nil {
t.Fatalf("Failed to MailboxFor(%q): %v", mbName, err)
}
msgs, err := mb.GetMessages()
if err != nil {
t.Fatalf("Failed to GetMessages for %q: %v", mbName, err)
}
if len(msgs) != i+1 {
t.Errorf("Expected %v messages, got %v", i+1, len(msgs))
}
}
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.DataStoreConfig{})
defer teardownDataStore(ds)
// james hashes to 474ba67bdb289c6263b36dfd8a7bed6c85b04943
mbName := "james"
// Test empty mailbox
mb, err := ds.MailboxFor(mbName)
assert.Nil(t, err)
msg, err := mb.GetMessage("latest")
assert.Nil(t, msg)
assert.Error(t, err)
// Deliver test message
deliverMessage(ds, mbName, "test", time.Now())
// Deliver test message 2
id2, _ := deliverMessage(ds, mbName, "test 2", time.Now())
// Test get the latest message
mb, err = ds.MailboxFor(mbName)
assert.Nil(t, err)
msg, err = mb.GetMessage("latest")
assert.Nil(t, err)
assert.True(t, msg.ID() == id2, "Expected %q to be equal to %q", msg.ID(), id2)
// Deliver test message 3
id3, _ := deliverMessage(ds, mbName, "test 3", time.Now())
mb, err = ds.MailboxFor(mbName)
assert.Nil(t, err)
msg, err = mb.GetMessage("latest")
assert.Nil(t, err)
assert.True(t, msg.ID() == id3, "Expected %q to be equal to %q", msg.ID(), id3)
// Test wrong id
_, err = mb.GetMessage("wrongid")
assert.Error(t, err)
if t.Failed() {
// Wait for handler to finish logging
time.Sleep(2 * time.Second)
// Dump buffered log data if there was a failure
_, _ = io.Copy(os.Stderr, logbuf)
}
}
// setupDataStore creates a new FileDataStore in a temporary directory
func setupDataStore() (*FileDataStore, *bytes.Buffer) {
func setupDataStore(cfg config.DataStoreConfig) (*FileDataStore, *bytes.Buffer) {
path, err := ioutil.TempDir("", "inbucket")
if err != nil {
panic(err)
@@ -345,12 +518,14 @@ func setupDataStore() (*FileDataStore, *bytes.Buffer) {
buf := new(bytes.Buffer)
log.SetOutput(buf)
return NewFileDataStore(path).(*FileDataStore), buf
cfg.Path = path
return NewFileDataStore(cfg).(*FileDataStore), buf
}
// deliverMessage creates and delivers a message to the specific mailbox, returning
// the size of the generated message.
func deliverMessage(ds *FileDataStore, mbName string, subject string, date time.Time) (id string, size int) {
func deliverMessage(ds *FileDataStore, mbName string, subject string,
date time.Time) (id string, size int64) {
// Build fake SMTP message for delivery
testMsg := make([]byte, 0, 300)
testMsg = append(testMsg, []byte("To: somebody@host\r\n")...)
@@ -364,19 +539,22 @@ func deliverMessage(ds *FileDataStore, mbName string, subject string, date time.
panic(err)
}
// Create message object
id = generateId(date)
msg := &FileMessage{
mailbox: mb.(*FileMailbox),
writable: true,
Fdate: date,
Fid: id,
id = generateID(date)
msg, err := mb.NewMessage()
if err != nil {
panic(err)
}
fmsg := msg.(*FileMessage)
fmsg.Fdate = date
fmsg.Fid = id
if err = msg.Append(testMsg); err != nil {
panic(err)
}
msg.Append(testMsg)
if err = msg.Close(); err != nil {
panic(err)
}
return id, len(testMsg)
return id, int64(len(testMsg))
}
func teardownDataStore(ds *FileDataStore) {

View File

@@ -1,20 +1,27 @@
package web
package httpd
import (
"github.com/gorilla/mux"
"github.com/gorilla/sessions"
"github.com/jhillyerd/inbucket/smtpd"
"net/http"
"strings"
"github.com/gorilla/mux"
"github.com/gorilla/sessions"
"github.com/jhillyerd/inbucket/config"
"github.com/jhillyerd/inbucket/datastore"
"github.com/jhillyerd/inbucket/msghub"
)
// Context is passed into every request handler function
type Context struct {
Vars map[string]string
Session *sessions.Session
DataStore smtpd.DataStore
IsJson bool
DataStore datastore.DataStore
MsgHub *msghub.Hub
WebConfig config.WebConfig
IsJSON bool
}
// Close the Context (currently does nothing)
func (c *Context) Close() {
// Do nothing
}
@@ -36,17 +43,26 @@ func headerMatch(req *http.Request, name string, value string) bool {
return false
}
// NewContext returns a Context for the given HTTP Request
func NewContext(req *http.Request) (*Context, error) {
vars := mux.Vars(req)
sess, err := sessionStore.Get(req, "inbucket")
if err != nil {
if sess == nil {
// No session, must fail
return nil, err
}
// The session cookie was probably signed by an old key, ignore it
// gorilla created an empty session for us
err = nil
}
ctx := &Context{
Vars: vars,
Session: sess,
DataStore: DataStore,
IsJson: headerMatch(req, "Accept", "application/json"),
}
if err != nil {
return ctx, err
MsgHub: msgHub,
WebConfig: webConfig,
IsJSON: headerMatch(req, "Accept", "application/json"),
}
return ctx, err
}

View File

@@ -1,26 +1,29 @@
package web
package httpd
import (
"fmt"
"github.com/jhillyerd/inbucket/log"
"html"
"html/template"
"regexp"
"strings"
"time"
"github.com/jhillyerd/inbucket/log"
)
// TemplateFuncs declares functions made available to all templates (including partials)
var TemplateFuncs = template.FuncMap{
"friendlyTime": friendlyTime,
"reverse": reverse,
"textToHtml": textToHtml,
"friendlyTime": FriendlyTime,
"reverse": Reverse,
"textToHtml": TextToHTML,
}
// From http://daringfireball.net/2010/07/improved_regex_for_matching_urls
var urlRE = regexp.MustCompile("(?i)\\b((?:[a-z][\\w-]+:(?:/{1,3}|[a-z0-9%])|www\\d{0,3}[.]|[a-z0-9.\\-]+[.][a-z]{2,4}/)(?:[^\\s()<>]+|\\(([^\\s()<>]+|(\\([^\\s()<>]+\\)))*\\))+(?:\\(([^\\s()<>]+|(\\([^\\s()<>]+\\)))*\\)|[^\\s`!()\\[\\]{};:'\".,<>?«»“”‘’]))")
// Friendly date & time rendering
func friendlyTime(t time.Time) template.HTML {
// FriendlyTime renders a timestamp in a friendly fashion: 03:04:05 PM if same day,
// otherwise Mon Jan 2, 2006
func FriendlyTime(t time.Time) template.HTML {
ty, tm, td := t.Date()
ny, nm, nd := time.Now().Date()
if (ty == ny) && (tm == nm) && (td == nd) {
@@ -29,8 +32,8 @@ func friendlyTime(t time.Time) template.HTML {
return template.HTML(t.Format("Mon Jan 2, 2006"))
}
// Reversable routing function (shared with templates)
func reverse(name string, things ...interface{}) string {
// Reverse routing function (shared with templates)
func Reverse(name string, things ...interface{}) string {
// Convert the things to strings
strs := make([]string, len(things))
for i, th := range things {
@@ -39,23 +42,23 @@ func reverse(name string, things ...interface{}) string {
// Grab the route
u, err := Router.Get(name).URL(strs...)
if err != nil {
log.LogError("Failed to reverse route: %v", err)
log.Errorf("Failed to reverse route: %v", err)
return "/ROUTE-ERROR"
}
return u.Path
}
// textToHtml takes plain text, escapes it and tries to pretty it up for
// TextToHTML takes plain text, escapes it and tries to pretty it up for
// HTML display
func textToHtml(text string) template.HTML {
func TextToHTML(text string) template.HTML {
text = html.EscapeString(text)
text = urlRE.ReplaceAllStringFunc(text, wrapUrl)
text = urlRE.ReplaceAllStringFunc(text, WrapURL)
replacer := strings.NewReplacer("\r\n", "<br/>\n", "\r", "<br/>\n", "\n", "<br/>\n")
return template.HTML(replacer.Replace(text))
}
// wrapUrl wraps a <a href> tag around the provided URL
func wrapUrl(url string) string {
// WrapURL wraps a <a href> tag around the provided URL
func WrapURL(url string) string {
unescaped := strings.Replace(url, "&amp;", "&", -1)
return fmt.Sprintf("<a href=\"%s\" target=\"_blank\">%s</a>", unescaped, url)
}

View File

@@ -1,29 +1,30 @@
package web
package httpd
import (
"github.com/stretchr/testify/assert"
"html/template"
"testing"
"github.com/stretchr/testify/assert"
)
func TestTextToHtml(t *testing.T) {
// Identity
assert.Equal(t, textToHtml("html"), template.HTML("html"))
assert.Equal(t, TextToHTML("html"), template.HTML("html"))
// Check it escapes
assert.Equal(t, textToHtml("<html>"), template.HTML("&lt;html&gt;"))
assert.Equal(t, TextToHTML("<html>"), template.HTML("&lt;html&gt;"))
// Check for linebreaks
assert.Equal(t, textToHtml("line\nbreak"), template.HTML("line<br/>\nbreak"))
assert.Equal(t, textToHtml("line\r\nbreak"), template.HTML("line<br/>\nbreak"))
assert.Equal(t, textToHtml("line\rbreak"), template.HTML("line<br/>\nbreak"))
assert.Equal(t, TextToHTML("line\nbreak"), template.HTML("line<br/>\nbreak"))
assert.Equal(t, TextToHTML("line\r\nbreak"), template.HTML("line<br/>\nbreak"))
assert.Equal(t, TextToHTML("line\rbreak"), template.HTML("line<br/>\nbreak"))
}
func TestURLDetection(t *testing.T) {
assert.Equal(t,
textToHtml("http://google.com/"),
TextToHTML("http://google.com/"),
template.HTML("<a href=\"http://google.com/\" target=\"_blank\">http://google.com/</a>"))
assert.Equal(t,
textToHtml("http://a.com/?q=a&n=v"),
TextToHTML("http://a.com/?q=a&n=v"),
template.HTML("<a href=\"http://a.com/?q=a&n=v\" target=\"_blank\">http://a.com/?q=a&amp;n=v</a>"))
}

15
httpd/rest.go Normal file
View File

@@ -0,0 +1,15 @@
package httpd
import (
"encoding/json"
"net/http"
)
// RenderJSON sets the correct HTTP headers for JSON, then writes the specified
// data (typically a struct) encoded in JSON
func RenderJSON(w http.ResponseWriter, data interface{}) error {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Expires", "-1")
enc := json.NewEncoder(w)
return enc.Encode(data)
}

159
httpd/server.go Normal file
View File

@@ -0,0 +1,159 @@
// Package httpd provides the plumbing for Inbucket's web GUI and RESTful API
package httpd
import (
"context"
"expvar"
"fmt"
"net"
"net/http"
"time"
"github.com/gorilla/mux"
"github.com/gorilla/securecookie"
"github.com/gorilla/sessions"
"github.com/jhillyerd/inbucket/config"
"github.com/jhillyerd/inbucket/datastore"
"github.com/jhillyerd/inbucket/log"
"github.com/jhillyerd/inbucket/msghub"
)
// 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 datastore.DataStore
// msgHub holds a reference to the message pub/sub system
msgHub *msghub.Hub
// Router is shared between httpd, webui and rest packages. It sends
// incoming requests to the correct handler function
Router = mux.NewRouter()
webConfig config.WebConfig
server *http.Server
listener net.Listener
sessionStore sessions.Store
globalShutdown chan bool
// ExpWebSocketConnectsCurrent tracks the number of open WebSockets
ExpWebSocketConnectsCurrent = new(expvar.Int)
)
func init() {
m := expvar.NewMap("http")
m.Set("WebSocketConnectsCurrent", ExpWebSocketConnectsCurrent)
}
// Initialize sets up things for unit tests or the Start() method
func Initialize(
cfg config.WebConfig,
shutdownChan chan bool,
ds datastore.DataStore,
mh *msghub.Hub) {
webConfig = cfg
globalShutdown = shutdownChan
// NewContext() will use this DataStore for the web handlers
DataStore = ds
msgHub = mh
// Content Paths
log.Infof("HTTP templates mapped to %q", cfg.TemplateDir)
log.Infof("HTTP static content mapped to %q", cfg.PublicDir)
Router.PathPrefix("/public/").Handler(http.StripPrefix("/public/",
http.FileServer(http.Dir(cfg.PublicDir))))
http.Handle("/", Router)
// Session cookie setup
if cfg.CookieAuthKey == "" {
log.Infof("HTTP 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))
}
}
// 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,
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)
var err error
listener, err = net.Listen("tcp", addr)
if err != nil {
log.Errorf("HTTP failed to start TCP4 listener: %v", err)
emergencyShutdown()
return
}
// Listener go routine
go serve(ctx)
// Wait for shutdown
select {
case _ = <-ctx.Done():
log.Tracef("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)
}
}
// serve begins serving HTTP requests
func serve(ctx context.Context) {
// server.Serve blocks until we close the listener
err := server.Serve(listener)
select {
case _ = <-ctx.Done():
// Nop
default:
log.Errorf("HTTP server failed: %v", err)
emergencyShutdown()
return
}
}
// ServeHTTP builds the context and passes onto the real handler
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)
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)
err = h(w, req, ctx)
if err != nil {
log.Errorf("HTTP error handling %q: %v", req.RequestURI, err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
func emergencyShutdown() {
// Shutdown Inbucket
select {
case _ = <-globalShutdown:
default:
close(globalShutdown)
}
}

View File

@@ -1,14 +1,14 @@
package web
package httpd
import (
"github.com/jhillyerd/inbucket/config"
"github.com/jhillyerd/inbucket/log"
"html/template"
"net/http"
"path"
"path/filepath"
"strings"
"sync"
"github.com/jhillyerd/inbucket/log"
)
var cachedMutex sync.Mutex
@@ -20,7 +20,7 @@ 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.LogError("Error in template '%v': %v", name, err)
log.Errorf("Error in template '%v': %v", name, err)
return err
}
w.Header().Set("Expires", "-1")
@@ -32,7 +32,7 @@ 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.LogError("Error in template '%v': %v", name, err)
log.Errorf("Error in template '%v': %v", name, err)
return err
}
w.Header().Set("Expires", "-1")
@@ -49,10 +49,9 @@ func ParseTemplate(name string, partial bool) (*template.Template, error) {
return t, nil
}
cfg := config.GetWebConfig()
tempPath := strings.Replace(name, "/", string(filepath.Separator), -1)
tempFile := filepath.Join(cfg.TemplateDir, tempPath)
log.LogTrace("Parsing template %v", tempFile)
tempFile := filepath.Join(webConfig.TemplateDir, tempPath)
log.Tracef("Parsing template %v", tempFile)
var err error
var t *template.Template
@@ -63,19 +62,19 @@ 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(cfg.TemplateDir, "_base.html"), tempFile)
t, err = t.ParseFiles(filepath.Join(webConfig.TemplateDir, "_base.html"), tempFile)
}
if err != nil {
return nil, err
}
// Allows us to disable caching for theme development
if cfg.TemplateCache {
if webConfig.TemplateCache {
if partial {
log.LogTrace("Caching partial %v", name)
log.Tracef("Caching partial %v", name)
cachedTemplates[name] = t
} else {
log.LogTrace("Caching template %v", name)
log.Tracef("Caching template %v", name)
cachedTemplates[name] = t
}
}

View File

@@ -1,45 +1,79 @@
/*
This is the inbucket daemon launcher
*/
// main is the inbucket daemon launcher
package main
import (
"context"
"expvar"
"flag"
"fmt"
"github.com/jhillyerd/inbucket/config"
"github.com/jhillyerd/inbucket/log"
"github.com/jhillyerd/inbucket/pop3d"
"github.com/jhillyerd/inbucket/smtpd"
"github.com/jhillyerd/inbucket/web"
golog "log"
"os"
"os/signal"
"runtime"
"syscall"
"time"
"github.com/jhillyerd/inbucket/config"
"github.com/jhillyerd/inbucket/filestore"
"github.com/jhillyerd/inbucket/httpd"
"github.com/jhillyerd/inbucket/log"
"github.com/jhillyerd/inbucket/msghub"
"github.com/jhillyerd/inbucket/pop3d"
"github.com/jhillyerd/inbucket/rest"
"github.com/jhillyerd/inbucket/smtpd"
"github.com/jhillyerd/inbucket/webui"
)
// Command line flags
var help = flag.Bool("help", false, "Displays this help")
var pidfile = flag.String("pidfile", "none", "Write our PID into the specified file")
var logfile = flag.String("logfile", "stderr", "Write out log into the specified file")
var (
// version contains the build version number, populated during linking
version = "undefined"
// startTime is used to calculate uptime of Inbucket
var startTime = time.Now()
// date contains the build date, populated during linking
date = "undefined"
// The file we send log output to, will be nil for stderr or stdout
var logf *os.File
// 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")
var smtpServer *smtpd.Server
var pop3Server *pop3d.Server
// 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 = date
flag.Parse()
if *help {
flag.Usage()
return
}
// Root context
rootCtx, rootCancel := context.WithCancel(context.Background())
// Load & Parse config
if flag.NArg() != 1 {
flag.Usage()
@@ -52,131 +86,98 @@ func main() {
}
// Setup signal handler
sigChan := make(chan os.Signal)
signal.Notify(sigChan, syscall.SIGHUP, syscall.SIGTERM)
go signalProcessor(sigChan)
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGHUP, syscall.SIGTERM, syscall.SIGINT)
// Configure logging, close std* fds
level, _ := config.Config.String("logging", "level")
log.SetLogLevel(level)
if *logfile != "stderr" {
// stderr is the go logging default
if *logfile == "stdout" {
// set to stdout
golog.SetOutput(os.Stdout)
} else {
err = openLogFile()
if err != nil {
fmt.Fprintf(os.Stderr, "%v", err)
os.Exit(1)
}
defer closeLogFile()
// close std* streams
os.Stdout.Close()
os.Stderr.Close() // Warning: this will hide panic() output
os.Stdin.Close()
os.Stdout = logf
os.Stderr = logf
}
// 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
// TODO: Probably supposed to remove pidfile during shutdown
if *pidfile != "none" {
pidf, err := os.Create(*pidfile)
if err != nil {
log.LogError("Failed to create %v: %v", *pidfile, err)
log.Errorf("Failed to create %q: %v", *pidfile, err)
os.Exit(1)
}
defer pidf.Close()
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()
ds := filestore.DefaultFileDataStore()
// Start HTTP server
go web.Start()
httpd.Initialize(config.GetWebConfig(), shutdownChan, ds, msgHub)
webui.SetupRoutes(httpd.Router)
rest.SetupRoutes(httpd.Router)
go httpd.Start(rootCtx)
// Start POP3 server
pop3Server = pop3d.New()
go pop3Server.Start()
pop3Server = pop3d.New(config.GetPOP3Config(), shutdownChan, ds)
go pop3Server.Start(rootCtx)
// Startup SMTP server, block until it exits
smtpServer = smtpd.NewSmtpServer(config.GetSmtpConfig(), ds)
smtpServer.Start()
// 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()
}
// openLogFile creates or appends to the logfile passed on commandline
func openLogFile() error {
// use specified log file
var err error
logf, err = os.OpenFile(*logfile, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0666)
if err != nil {
return fmt.Errorf("Failed to create %v: %v\n", *logfile, err)
}
golog.SetOutput(logf)
log.LogTrace("Opened new logfile")
return nil
}
// closeLogFile closes the current logfile
func closeLogFile() error {
log.LogTrace("Closing logfile")
return logf.Close()
}
// signalProcessor is a goroutine that handles OS signals
func signalProcessor(c <-chan os.Signal) {
for {
sig := <-c
switch sig {
case syscall.SIGHUP:
// Rotate logs if configured
if logf != nil {
log.LogInfo("Recieved SIGHUP, cycling logfile")
closeLogFile()
openLogFile()
} else {
log.LogInfo("Ignoring SIGHUP, logfile not configured")
}
case syscall.SIGTERM:
// Initiate shutdown
log.LogInfo("Received SIGTERM, shutting down")
go timedExit()
web.Stop()
if smtpServer != nil {
smtpServer.Stop()
} else {
log.LogError("smtpServer was nil during shutdown")
}
// 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
// timedExit is called as a goroutine during shutdown, it will force an exit
// after 15 seconds
func timedExit() {
time.Sleep(15 * time.Second)
log.LogError("Inbucket clean shutdown timed out, forcing exit")
log.Errorf("Clean shutdown took too long, forcing exit")
removePIDFile()
os.Exit(0)
}
func init() {
flag.Usage = func() {
fmt.Fprintln(os.Stderr, "Usage of inbucket [options] <conf file>:")
flag.PrintDefaults()
}
expvar.Publish("uptime", expvar.Func(uptime))
}
// uptime() is published as an expvar
func uptime() interface{} {
return time.Since(startTime) / time.Second
}

View File

@@ -1,65 +1,145 @@
package log
import (
"log"
"fmt"
golog "log"
"os"
"strings"
)
type LogLevel int
// Level is used to indicate the severity of a log entry
type Level int
const (
ERROR LogLevel = iota
// 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 MaxLogLevel LogLevel = TRACE
var (
// MaxLevel is the highest Level we will log (max TRACE, min ERROR)
MaxLevel = TRACE
// SetLogLevel sets MaxLogLevel based on the provided string
// 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":
MaxLogLevel = ERROR
MaxLevel = ERROR
case "WARN":
MaxLogLevel = WARN
MaxLevel = WARN
case "INFO":
MaxLogLevel = INFO
MaxLevel = INFO
case "TRACE":
MaxLogLevel = TRACE
MaxLevel = TRACE
default:
LogError("Unknown log level requested: %v", level)
Errorf("Unknown log level requested: " + level)
return false
}
return true
}
// Error logs a message to the 'standard' Logger (always)
func LogError(msg string, args ...interface{}) {
// Errorf logs a message to the 'standard' Logger (always), accepts format strings
func Errorf(msg string, args ...interface{}) {
msg = "[ERROR] " + msg
log.Printf(msg, args...)
golog.Printf(msg, args...)
}
// Warn logs a message to the 'standard' Logger if MaxLogLevel is >= WARN
func LogWarn(msg string, args ...interface{}) {
if MaxLogLevel >= WARN {
// 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
log.Printf(msg, args...)
golog.Printf(msg, args...)
}
}
// Info logs a message to the 'standard' Logger if MaxLogLevel is >= INFO
func LogInfo(msg string, args ...interface{}) {
if MaxLogLevel >= INFO {
// 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
log.Printf(msg, args...)
golog.Printf(msg, args...)
}
}
// Trace logs a message to the 'standard' Logger if MaxLogLevel is >= TRACE
func LogTrace(msg string, args ...interface{}) {
if MaxLogLevel >= TRACE {
// 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
log.Printf(msg, args...)
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()
}

62
log/metrics.go Normal file
View File

@@ -0,0 +1,62 @@
package log
import (
"container/list"
"expvar"
"strings"
"time"
)
// TickerFunc is the type of metrics function accepted by AddTickerFunc
type TickerFunc func()
var tickerFuncChan = make(chan TickerFunc)
func init() {
go metricsTicker()
}
// AddTickerFunc adds a new function callback to the list of metrics TickerFuncs that get
// called each minute.
func AddTickerFunc(f TickerFunc) {
tickerFuncChan <- f
}
// PushMetric adds the metric to the end of the list and returns a comma separated string of the
// previous 61 entries. We return 61 instead of 60 (an hour) because the chart on the client
// tracks deltas between these values - there is nothing to compare the first value against.
func PushMetric(history *list.List, ev expvar.Var) string {
history.PushBack(ev.String())
if history.Len() > 61 {
history.Remove(history.Front())
}
return joinStringList(history)
}
// joinStringList joins a List containing strings by commas
func joinStringList(listOfStrings *list.List) string {
if listOfStrings.Len() == 0 {
return ""
}
s := make([]string, 0, listOfStrings.Len())
for e := listOfStrings.Front(); e != nil; e = e.Next() {
s = append(s, e.Value.(string))
}
return strings.Join(s, ",")
}
func metricsTicker() {
funcs := make([]TickerFunc, 0)
ticker := time.NewTicker(time.Minute)
for {
select {
case <-ticker.C:
for _, f := range funcs {
f()
}
case f := <-tickerFuncChan:
funcs = append(funcs, f)
}
}
}

31
log/stdout_unix.go Normal file
View File

@@ -0,0 +1,31 @@
// +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)
}
}

37
log/stdout_windows.go Normal file
View File

@@ -0,0 +1,37 @@
// +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
}
}

110
msghub/hub.go Normal file
View File

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

255
msghub/hub_test.go Normal file
View File

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

View File

@@ -4,21 +4,26 @@ import (
"bufio"
"bytes"
"fmt"
"github.com/jhillyerd/inbucket/log"
"github.com/jhillyerd/inbucket/smtpd"
"io"
"net"
"os"
"strconv"
"strings"
"time"
"github.com/jhillyerd/inbucket/datastore"
"github.com/jhillyerd/inbucket/log"
)
// State tracks the current mode of our POP3 state machine
type State int
const (
AUTHORIZATION State = iota // The client must now identify and authenticate
TRANSACTION // Mailbox open, client may now issue commands
// 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
)
@@ -50,21 +55,23 @@ var commands = map[string]bool{
"CAPA": true,
}
// Session defines an active POP3 session
type Session struct {
server *Server // Reference to the server we belong to
id int // Session ID number
conn net.Conn // Our network connection
remoteHost string // IP address of client
sendError error // Used to bail out of read loop on send error
state State // Current session state
reader *bufio.Reader // Buffered reader for our net conn
user string // Mailbox name
mailbox smtpd.Mailbox // Mailbox instance
messages []smtpd.Message // Slice of messages in mailbox
retain []bool // Messages to retain upon UPDATE (true=retain)
msgCount int // Number of undeleted messages
server *Server // Reference to the server we belong to
id int // Session ID number
conn net.Conn // Our network connection
remoteHost string // IP address of client
sendError error // Used to bail out of read loop on send error
state State // Current session state
reader *bufio.Reader // Buffered reader for our net conn
user string // Mailbox name
mailbox datastore.Mailbox // Mailbox instance
messages []datastore.Message // Slice of messages in mailbox
retain []bool // Messages to retain upon UPDATE (true=retain)
msgCount int // Number of undeleted messages
}
// NewSession creates a new POP3 session
func NewSession(server *Server, id int, conn net.Conn) *Session {
reader := bufio.NewReader(conn)
host, _, _ := net.SplitHostPort(conn.RemoteAddr().String())
@@ -84,10 +91,12 @@ func (ses *Session) String() string {
* 5. Goto 2
*/
func (s *Server) startSession(id int, conn net.Conn) {
log.LogInfo("POP3 connection from %v, starting session <%v>", conn.RemoteAddr(), id)
log.Infof("POP3 connection from %v, starting session <%v>", conn.RemoteAddr(), id)
//expConnectsCurrent.Add(1)
defer func() {
conn.Close()
if err := conn.Close(); err != nil {
log.Errorf("Error closing POP3 connection for <%v>: %v", id, err)
}
s.waitgroup.Done()
//expConnectsCurrent.Add(-1)
}()
@@ -234,7 +243,7 @@ func (ses *Session) transactionHandler(cmd string, args []string) {
var size int64
for i, msg := range ses.messages {
if ses.retain[i] {
count += 1
count++
size += msg.Size()
}
}
@@ -305,12 +314,12 @@ func (ses *Session) transactionHandler(cmd string, args []string) {
ses.send(fmt.Sprintf("-ERR You deleted message %v", msgNum))
return
}
ses.send(fmt.Sprintf("+OK %v %v", msgNum, ses.messages[msgNum-1].Id()))
ses.send(fmt.Sprintf("+OK %v %v", msgNum, ses.messages[msgNum-1].ID()))
} else {
ses.send(fmt.Sprintf("+OK Listing %v messages", ses.msgCount))
for i, msg := range ses.messages {
if ses.retain[i] {
ses.send(fmt.Sprintf("%v %v", i+1, msg.Id()))
ses.send(fmt.Sprintf("%v %v", i+1, msg.ID()))
}
}
ses.send(".")
@@ -339,7 +348,7 @@ func (ses *Session) transactionHandler(cmd string, args []string) {
}
if ses.retain[msgNum-1] {
ses.retain[msgNum-1] = false
ses.msgCount -= 1
ses.msgCount--
ses.send(fmt.Sprintf("+OK Deleted message %v", msgNum))
} else {
ses.logWarn("Client tried to DELE an already deleted message")
@@ -423,14 +432,19 @@ func (ses *Session) transactionHandler(cmd string, args []string) {
}
// Send the contents of the message to the client
func (ses *Session) sendMessage(msg smtpd.Message) {
func (ses *Session) sendMessage(msg datastore.Message) {
reader, err := msg.RawReader()
defer reader.Close()
if err != nil {
ses.logError("Failed to read message for RETR command")
ses.send("-ERR Failed to RETR that message, internal error")
return
}
defer func() {
if err := reader.Close(); err != nil {
ses.logError("Failed to close message: %v", err)
}
}()
scanner := bufio.NewScanner(reader)
for scanner.Scan() {
line := scanner.Text()
@@ -451,14 +465,19 @@ func (ses *Session) sendMessage(msg smtpd.Message) {
}
// Send the headers plus the top N lines to the client
func (ses *Session) sendMessageTop(msg smtpd.Message, lineCount int) {
func (ses *Session) sendMessageTop(msg datastore.Message, lineCount int) {
reader, err := msg.RawReader()
defer reader.Close()
if err != nil {
ses.logError("Failed to read message for RETR command")
ses.send("-ERR Failed to RETR that message, internal error")
return
}
defer func() {
if err := reader.Close(); err != nil {
ses.logError("Failed to close message: %v", err)
}
}()
scanner := bufio.NewScanner(reader)
inBody := false
for scanner.Scan() {
@@ -472,7 +491,7 @@ func (ses *Session) sendMessageTop(msg smtpd.Message, lineCount int) {
if lineCount < 1 {
break
} else {
lineCount -= 1
lineCount--
}
} else {
if line == "" {
@@ -506,7 +525,7 @@ func (ses *Session) loadMailbox() {
// Reset retain flag to true for all messages
func (ses *Session) retainAll() {
ses.retain = make([]bool, len(ses.messages))
for i, _ := range ses.retain {
for i := range ses.retain {
ses.retain[i] = true
}
ses.msgCount = len(ses.messages)
@@ -521,7 +540,9 @@ func (ses *Session) processDeletes() {
for i, msg := range ses.messages {
if !ses.retain[i] {
ses.logTrace("Deleting %v", msg)
msg.Delete()
if err := msg.Delete(); err != nil {
ses.logWarn("Error deleting %v: %v", msg, err)
}
}
}
}
@@ -561,13 +582,17 @@ func (ses *Session) readByteLine(buf *bytes.Buffer) error {
if err != nil {
return err
}
buf.Write(line)
if _, err = buf.Write(line); err != nil {
return err
}
// Read the next byte looking for '\n'
c, err := ses.reader.ReadByte()
if err != nil {
return err
}
buf.WriteByte(c)
if err := buf.WriteByte(c); err != nil {
return err
}
if c == '\n' {
// We've reached the end of the line, return
return nil
@@ -611,21 +636,21 @@ func (ses *Session) ooSeq(cmd string) {
// Session specific logging methods
func (ses *Session) logTrace(msg string, args ...interface{}) {
log.LogTrace("POP3[%v]<%v> %v", ses.remoteHost, ses.id, fmt.Sprintf(msg, args...))
log.Tracef("POP3[%v]<%v> %v", ses.remoteHost, ses.id, fmt.Sprintf(msg, args...))
}
func (ses *Session) logInfo(msg string, args ...interface{}) {
log.LogInfo("POP3[%v]<%v> %v", ses.remoteHost, ses.id, fmt.Sprintf(msg, args...))
log.Infof("POP3[%v]<%v> %v", ses.remoteHost, ses.id, fmt.Sprintf(msg, args...))
}
func (ses *Session) logWarn(msg string, args ...interface{}) {
// Update metrics
//expWarnsTotal.Add(1)
log.LogWarn("POP3[%v]<%v> %v", ses.remoteHost, ses.id, fmt.Sprintf(msg, args...))
log.Warnf("POP3[%v]<%v> %v", ses.remoteHost, ses.id, fmt.Sprintf(msg, args...))
}
func (ses *Session) logError(msg string, args ...interface{}) {
// Update metrics
//expErrorsTotal.Add(1)
log.LogError("POP3[%v]<%v> %v", ses.remoteHost, ses.id, fmt.Sprintf(msg, args...))
log.Errorf("POP3[%v]<%v> %v", ses.remoteHost, ses.id, fmt.Sprintf(msg, args...))
}

View File

@@ -1,53 +1,74 @@
package pop3d
import (
"context"
"fmt"
"github.com/jhillyerd/inbucket/config"
"github.com/jhillyerd/inbucket/log"
"github.com/jhillyerd/inbucket/smtpd"
"net"
"sync"
"time"
"github.com/jhillyerd/inbucket/config"
"github.com/jhillyerd/inbucket/datastore"
"github.com/jhillyerd/inbucket/log"
)
// Real server code starts here
// Server defines an instance of our POP3 server
type Server struct {
host string
domain string
maxIdleSeconds int
dataStore smtpd.DataStore
dataStore datastore.DataStore
listener net.Listener
shutdown bool
globalShutdown chan bool
waitgroup *sync.WaitGroup
}
// Init a new Server object
func New() *Server {
// TODO is two filestores better/worse than sharing w/ smtpd?
ds := smtpd.DefaultFileDataStore()
cfg := config.GetPop3Config()
return &Server{domain: cfg.Domain, dataStore: ds, maxIdleSeconds: cfg.MaxIdleSeconds,
waitgroup: new(sync.WaitGroup)}
// New creates a new Server struct
func New(cfg config.POP3Config, shutdownChan chan bool, ds datastore.DataStore) *Server {
return &Server{
host: fmt.Sprintf("%v:%v", cfg.IP4address, cfg.IP4port),
domain: cfg.Domain,
dataStore: ds,
maxIdleSeconds: cfg.MaxIdleSeconds,
globalShutdown: shutdownChan,
waitgroup: new(sync.WaitGroup),
}
}
// Main listener loop
func (s *Server) Start() {
cfg := config.GetPop3Config()
addr, err := net.ResolveTCPAddr("tcp4", fmt.Sprintf("%v:%v",
cfg.Ip4address, cfg.Ip4port))
// Start the server and listen for connections
func (s *Server) Start(ctx context.Context) {
addr, err := net.ResolveTCPAddr("tcp4", s.host)
if err != nil {
log.LogError("POP3 Failed to build tcp4 address: %v", err)
// TODO More graceful early-shutdown procedure
panic(err)
log.Errorf("POP3 Failed to build tcp4 address: %v", err)
s.emergencyShutdown()
return
}
log.LogInfo("POP3 listening on TCP4 %v", addr)
log.Infof("POP3 listening on TCP4 %v", addr)
s.listener, err = net.ListenTCP("tcp4", addr)
if err != nil {
log.LogError("POP3 failed to start tcp4 listener: %v", err)
// TODO More graceful early-shutdown procedure
panic(err)
log.Errorf("POP3 failed to start tcp4 listener: %v", err)
s.emergencyShutdown()
return
}
// Listener go routine
go s.serve(ctx)
// Wait for shutdown
select {
case _ = <-ctx.Done():
}
log.Tracef("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 {
log.Errorf("Error closing POP3 listener: %v", err)
}
}
// serve is the listen/accept loop
func (s *Server) serve(ctx context.Context) {
// Handle incoming connections
var tempDelay time.Duration
for sid := 1; ; sid++ {
@@ -62,17 +83,20 @@ func (s *Server) Start() {
if max := 1 * time.Second; tempDelay > max {
tempDelay = max
}
log.LogError("POP3 accept error: %v; retrying in %v", err, tempDelay)
log.Errorf("POP3 accept error: %v; retrying in %v", err, tempDelay)
time.Sleep(tempDelay)
continue
} else {
if s.shutdown {
log.LogTrace("POP3 listener shutting down on request")
// Permanent error
select {
case <-ctx.Done():
// POP3 is shutting down
return
default:
// Something went wrong
s.emergencyShutdown()
return
}
// TODO Implement a max error counter before shutdown?
// or maybe attempt to restart POP3
panic(err)
}
} else {
tempDelay = 0
@@ -82,15 +106,18 @@ func (s *Server) Start() {
}
}
// Stop requests the POP3 server closes it's listener
func (s *Server) Stop() {
log.LogTrace("POP3 shutdown requested, connections will be drained")
s.shutdown = true
s.listener.Close()
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.waitgroup.Wait()
log.LogTrace("POP3 connections drained")
log.Tracef("POP3 connections have drained")
}

201
rest/apiv1_controller.go Normal file
View File

@@ -0,0 +1,201 @@
package rest
import (
"fmt"
"io"
"net/http"
"crypto/md5"
"encoding/hex"
"io/ioutil"
"strconv"
"github.com/jhillyerd/inbucket/datastore"
"github.com/jhillyerd/inbucket/httpd"
"github.com/jhillyerd/inbucket/log"
"github.com/jhillyerd/inbucket/rest/model"
"github.com/jhillyerd/inbucket/stringutil"
)
// MailboxListV1 renders a list of messages in a mailbox
func MailboxListV1(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) (err error) {
// Don't have to validate these aren't empty, Gorilla returns 404
name, err := stringutil.ParseMailboxName(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()
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))
jmessages := make([]*model.JSONMessageHeaderV1, len(messages))
for i, msg := range messages {
jmessages[i] = &model.JSONMessageHeaderV1{
Mailbox: name,
ID: msg.ID(),
From: msg.From(),
To: msg.To(),
Subject: msg.Subject(),
Date: msg.Date(),
Size: msg.Size(),
}
}
return httpd.RenderJSON(w, jmessages)
}
// MailboxShowV1 renders a particular message from a mailbox
func MailboxShowV1(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) (err error) {
// Don't have to validate these aren't empty, Gorilla returns 404
id := ctx.Vars["id"]
name, err := stringutil.ParseMailboxName(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 == datastore.ErrNotExist {
http.NotFound(w, req)
return nil
}
if err != nil {
// This doesn't indicate empty, likely an IO error
return fmt.Errorf("GetMessage(%q) failed: %v", id, err)
}
header, err := msg.ReadHeader()
if err != nil {
return fmt.Errorf("ReadHeader(%q) failed: %v", id, err)
}
mime, err := msg.ReadBody()
if err != nil {
return fmt.Errorf("ReadBody(%q) failed: %v", id, err)
}
attachments := make([]*model.JSONMessageAttachmentV1, len(mime.Attachments))
for i, att := range mime.Attachments {
var content []byte
content, err = ioutil.ReadAll(att)
var checksum = md5.Sum(content)
attachments[i] = &model.JSONMessageAttachmentV1{
ContentType: att.ContentType,
FileName: att.FileName,
DownloadLink: "http://" + req.Host + "/mailbox/dattach/" + name + "/" + id + "/" + strconv.Itoa(i) + "/" + att.FileName,
ViewLink: "http://" + req.Host + "/mailbox/vattach/" + name + "/" + id + "/" + strconv.Itoa(i) + "/" + att.FileName,
MD5: hex.EncodeToString(checksum[:]),
}
}
return httpd.RenderJSON(w,
&model.JSONMessageV1{
Mailbox: name,
ID: msg.ID(),
From: msg.From(),
To: msg.To(),
Subject: msg.Subject(),
Date: msg.Date(),
Size: msg.Size(),
Header: header.Header,
Body: &model.JSONMessageBodyV1{
Text: mime.Text,
HTML: mime.HTML,
},
Attachments: attachments,
})
}
// MailboxPurgeV1 deletes all messages from a mailbox
func MailboxPurgeV1(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) (err error) {
// Don't have to validate these aren't empty, Gorilla returns 404
name, err := stringutil.ParseMailboxName(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)
}
// Delete all messages
err = mb.Purge()
if err != nil {
return fmt.Errorf("Mailbox(%q) purge failed: %v", name, err)
}
log.Tracef("HTTP purged mailbox for %q", name)
return httpd.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 *httpd.Context) (err error) {
// Don't have to validate these aren't empty, Gorilla returns 404
id := ctx.Vars["id"]
name, err := stringutil.ParseMailboxName(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 == datastore.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)
}
w.Header().Set("Content-Type", "text/plain")
if _, err := io.WriteString(w, *raw); err != nil {
return err
}
return nil
}
// MailboxDeleteV1 removes a particular message from a mailbox
func MailboxDeleteV1(w http.ResponseWriter, req *http.Request, ctx *httpd.Context) (err error) {
// Don't have to validate these aren't empty, Gorilla returns 404
id := ctx.Vars["id"]
name, err := stringutil.ParseMailboxName(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 == datastore.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)
}
err = message.Delete()
if err != nil {
return fmt.Errorf("Delete(%q) failed: %v", id, err)
}
return httpd.RenderJSON(w, "OK")
}

View File

@@ -0,0 +1,266 @@
package rest
import (
"encoding/json"
"fmt"
"io"
"net/mail"
"os"
"testing"
"time"
"github.com/jhillyerd/inbucket/datastore"
)
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
ds := &datastore.MockDataStore{}
logbuf := setupWebServer(ds)
// Test invalid mailbox name
w, err := testRestGet(baseURL + "/mailbox/foo@bar")
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
emptybox := &datastore.MockMailbox{}
ds.On("MailboxFor", "empty").Return(emptybox, nil)
emptybox.On("GetMessages").Return([]datastore.Message{}, nil)
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 MailboxFor error
ds.On("MailboxFor", "error").Return(&datastore.MockMailbox{}, fmt.Errorf("Internal error"))
w, err = testRestGet(baseURL + "/mailbox/error")
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 MailboxFor error
error2box := &datastore.MockMailbox{}
ds.On("MailboxFor", "error2").Return(error2box, nil)
error2box.On("GetMessages").Return([]datastore.Message{}, fmt.Errorf("Internal error 2"))
w, err = testRestGet(baseURL + "/mailbox/error2")
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
data1 := &InputMessageData{
Mailbox: "good",
ID: "0001",
From: "from1",
To: []string{"to1"},
Subject: "subject 1",
Date: time.Date(2012, 2, 1, 10, 11, 12, 253, time.FixedZone("PST", -800)),
}
data2 := &InputMessageData{
Mailbox: "good",
ID: "0002",
From: "from2",
To: []string{"to1"},
Subject: "subject 2",
Date: time.Date(2012, 7, 1, 10, 11, 12, 253, time.FixedZone("PDT", -700)),
}
goodbox := &datastore.MockMailbox{}
ds.On("MailboxFor", "good").Return(goodbox, nil)
msg1 := data1.MockMessage()
msg2 := data2.MockMessage()
goodbox.On("GetMessages").Return([]datastore.Message{msg1, msg2}, nil)
// 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.Errorf("Expected 2 results, got %v", len(result))
}
if errors := data1.CompareToJSONHeaderMap(result[0]); len(errors) > 0 {
t.Logf("%v", result[0])
for _, e := range errors {
t.Error(e)
}
}
if errors := data2.CompareToJSONHeaderMap(result[1]); len(errors) > 0 {
t.Logf("%v", result[1])
for _, e := range errors {
t.Error(e)
}
}
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
ds := &datastore.MockDataStore{}
logbuf := setupWebServer(ds)
// Test invalid mailbox name
w, err := testRestGet(baseURL + "/mailbox/foo@bar/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
emptybox := &datastore.MockMailbox{}
ds.On("MailboxFor", "empty").Return(emptybox, nil)
emptybox.On("GetMessage", "0001").Return(&datastore.MockMessage{}, datastore.ErrNotExist)
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 MailboxFor error
ds.On("MailboxFor", "error").Return(&datastore.MockMailbox{}, fmt.Errorf("Internal error"))
w, err = testRestGet(baseURL + "/mailbox/error/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 GetMessage error
error2box := &datastore.MockMailbox{}
ds.On("MailboxFor", "error2").Return(error2box, nil)
error2box.On("GetMessage", "0001").Return(&datastore.MockMessage{}, fmt.Errorf("Internal error 2"))
w, err = testRestGet(baseURL + "/mailbox/error2/0001")
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
data1 := &InputMessageData{
Mailbox: "good",
ID: "0001",
From: "from1",
Subject: "subject 1",
Date: time.Date(2012, 2, 1, 10, 11, 12, 253, time.FixedZone("PST", -800)),
Header: mail.Header{
"To": []string{"fred@fish.com", "keyword@nsa.gov"},
"From": []string{"noreply@inbucket.org"},
},
Text: "This is some text",
HTML: "This is some HTML",
}
goodbox := &datastore.MockMailbox{}
ds.On("MailboxFor", "good").Return(goodbox, nil)
msg1 := data1.MockMessage()
goodbox.On("GetMessage", "0001").Return(msg1, nil)
// 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)
}
if errors := data1.CompareToJSONMessageMap(result); len(errors) > 0 {
t.Logf("%v", result)
for _, e := range errors {
t.Error(e)
}
}
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)
}
}

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

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

View File

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

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

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

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

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

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

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

23
rest/routes.go Normal file
View File

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

195
rest/socketv1_controller.go Normal file
View File

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

207
rest/testutils_test.go Normal file
View File

@@ -0,0 +1,207 @@
package rest
import (
"bytes"
"fmt"
"log"
"net/http"
"net/http/httptest"
"net/mail"
"time"
"github.com/jhillyerd/enmime"
"github.com/jhillyerd/inbucket/config"
"github.com/jhillyerd/inbucket/datastore"
"github.com/jhillyerd/inbucket/httpd"
"github.com/jhillyerd/inbucket/msghub"
)
type InputMessageData struct {
Mailbox, ID, From, Subject string
To []string
Date time.Time
Size int
Header mail.Header
HTML, Text string
}
func (d *InputMessageData) MockMessage() *datastore.MockMessage {
msg := &datastore.MockMessage{}
msg.On("ID").Return(d.ID)
msg.On("From").Return(d.From)
msg.On("To").Return(d.To)
msg.On("Subject").Return(d.Subject)
msg.On("Date").Return(d.Date)
msg.On("Size").Return(d.Size)
gomsg := &mail.Message{
Header: d.Header,
}
msg.On("ReadHeader").Return(gomsg, nil)
body := &enmime.Envelope{
Text: d.Text,
HTML: d.HTML,
}
msg.On("ReadBody").Return(body, nil)
return msg
}
// isJSONStringEqual is a utility function to return a nicely formatted message when
// comparing a string to a value received from a JSON map.
func isJSONStringEqual(key, expected string, received interface{}) (message string, ok bool) {
if value, ok := received.(string); ok {
if expected == value {
return "", true
}
return fmt.Sprintf("Expected value of key %v to be %q, got %q", key, expected, value), false
}
return fmt.Sprintf("Expected value of key %v to be a string, got %T", key, received), false
}
// isJSONNumberEqual is a utility function to return a nicely formatted message when
// comparing an float64 to a value received from a JSON map.
func isJSONNumberEqual(key string, expected float64, received interface{}) (message string, ok bool) {
if value, ok := received.(float64); ok {
if expected == value {
return "", true
}
return fmt.Sprintf("Expected %v to be %v, got %v", key, expected, value), false
}
return fmt.Sprintf("Expected %v to be a string, got %T", key, received), false
}
// CompareToJSONHeaderMap compares InputMessageData to a header map decoded from JSON,
// returning a list of things that did not match.
func (d *InputMessageData) CompareToJSONHeaderMap(json interface{}) (errors []string) {
if m, ok := json.(map[string]interface{}); ok {
if msg, ok := isJSONStringEqual(mailboxKey, d.Mailbox, m[mailboxKey]); !ok {
errors = append(errors, msg)
}
if msg, ok := isJSONStringEqual(idKey, d.ID, m[idKey]); !ok {
errors = append(errors, msg)
}
if msg, ok := isJSONStringEqual(fromKey, d.From, m[fromKey]); !ok {
errors = append(errors, msg)
}
for i, inputTo := range d.To {
if msg, ok := isJSONStringEqual(toKey, inputTo, m[toKey].([]interface{})[i]); !ok {
errors = append(errors, msg)
}
}
if msg, ok := isJSONStringEqual(subjectKey, d.Subject, m[subjectKey]); !ok {
errors = append(errors, msg)
}
exDate := d.Date.Format("2006-01-02T15:04:05.999999999-07:00")
if msg, ok := isJSONStringEqual(dateKey, exDate, m[dateKey]); !ok {
errors = append(errors, msg)
}
if msg, ok := isJSONNumberEqual(sizeKey, float64(d.Size), m[sizeKey]); !ok {
errors = append(errors, msg)
}
return errors
}
panic(fmt.Sprintf("Expected map[string]interface{} in json, got %T", json))
}
// CompareToJSONMessageMap compares InputMessageData to a message map decoded from JSON,
// returning a list of things that did not match.
func (d *InputMessageData) CompareToJSONMessageMap(json interface{}) (errors []string) {
// We need to check the same values as header first
errors = d.CompareToJSONHeaderMap(json)
if m, ok := json.(map[string]interface{}); ok {
// Get nested body map
if m[bodyKey] != nil {
if body, ok := m[bodyKey].(map[string]interface{}); ok {
if msg, ok := isJSONStringEqual(textKey, d.Text, body[textKey]); !ok {
errors = append(errors, msg)
}
if msg, ok := isJSONStringEqual(htmlKey, d.HTML, body[htmlKey]); !ok {
errors = append(errors, msg)
}
} else {
panic(fmt.Sprintf("Expected map[string]interface{} in json key %q, got %T",
bodyKey, m[bodyKey]))
}
} else {
errors = append(errors, fmt.Sprintf("Expected body in JSON %q but it was nil", bodyKey))
}
exDate := d.Date.Format("2006-01-02T15:04:05.999999999-07:00")
if msg, ok := isJSONStringEqual(dateKey, exDate, m[dateKey]); !ok {
errors = append(errors, msg)
}
if msg, ok := isJSONNumberEqual(sizeKey, float64(d.Size), m[sizeKey]); !ok {
errors = append(errors, msg)
}
// Get nested header map
if m[headerKey] != nil {
if header, ok := m[headerKey].(map[string]interface{}); ok {
// Loop over input (expected) header names
for name, keyInputHeaders := range d.Header {
// Make sure expected header name exists in received JSON
if keyOutputVals, ok := header[name]; ok {
if keyOutputHeaders, ok := keyOutputVals.([]interface{}); ok {
// Loop over input (expected) header values
for _, inputHeader := range keyInputHeaders {
hasValue := false
// Look for expected value in received headers
for _, outputHeader := range keyOutputHeaders {
if inputHeader == outputHeader {
hasValue = true
break
}
}
if !hasValue {
errors = append(errors, fmt.Sprintf(
"JSON %v[%q] missing value %q", headerKey, name, inputHeader))
}
}
} else {
// keyOutputValues was not a slice of interface{}
panic(fmt.Sprintf("Expected []interface{} in %v[%q], got %T", headerKey,
name, keyOutputVals))
}
} else {
errors = append(errors, fmt.Sprintf("JSON %v missing key %q", headerKey, name))
}
}
}
} else {
errors = append(errors, fmt.Sprintf("Expected header in JSON %q but it was nil", headerKey))
}
} else {
panic(fmt.Sprintf("Expected map[string]interface{} in json, got %T", json))
}
return errors
}
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()
httpd.Router.ServeHTTP(w, req)
return w, nil
}
func setupWebServer(ds datastore.DataStore) *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.WebConfig{
TemplateDir: "../themes/bootstrap/templates",
PublicDir: "../themes/bootstrap/public",
}
shutdownChan := make(chan bool)
httpd.Initialize(cfg, shutdownChan, ds, &msghub.Hub{})
SetupRoutes(httpd.Router)
return buf
}

110
sanitize/css.go Normal file
View File

@@ -0,0 +1,110 @@
package sanitize
import (
"bytes"
"strings"
"github.com/gorilla/css/scanner"
)
// propertyRule may someday allow control of what values are valid for a particular property.
type propertyRule struct{}
var allowedProperties = map[string]propertyRule{
"align": {},
"background-color": {},
"border": {},
"border-bottom": {},
"border-left": {},
"border-radius": {},
"border-right": {},
"border-top": {},
"box-sizing": {},
"clear": {},
"color": {},
"content": {},
"display": {},
"font-family": {},
"font-size": {},
"font-weight": {},
"height": {},
"line-height": {},
"margin": {},
"margin-bottom": {},
"margin-left": {},
"margin-right": {},
"margin-top": {},
"max-height": {},
"max-width": {},
"overflow": {},
"padding": {},
"padding-bottom": {},
"padding-left": {},
"padding-right": {},
"padding-top": {},
"table-layout": {},
"text-align": {},
"text-decoration": {},
"text-shadow": {},
"vertical-align": {},
"width": {},
"word-break": {},
}
// Handler Token, return next state.
type stateHandler func(b *bytes.Buffer, t *scanner.Token) stateHandler
func sanitizeStyle(input string) string {
b := &bytes.Buffer{}
scan := scanner.New(input)
state := stateStart
for {
t := scan.Next()
if t.Type == scanner.TokenEOF {
return b.String()
}
if t.Type == scanner.TokenError {
return ""
}
state = state(b, t)
if state == nil {
return ""
}
}
}
func stateStart(b *bytes.Buffer, t *scanner.Token) stateHandler {
switch t.Type {
case scanner.TokenIdent:
_, ok := allowedProperties[strings.ToLower(t.Value)]
if !ok {
return stateEat
}
b.WriteString(t.Value)
return stateValid
case scanner.TokenS:
return stateStart
}
// Unexpected type.
b.WriteString("/*" + t.Type.String() + "*/")
return stateEat
}
func stateEat(b *bytes.Buffer, t *scanner.Token) stateHandler {
if t.Type == scanner.TokenChar && t.Value == ";" {
// Done eating.
return stateStart
}
// Throw away this token.
return stateEat
}
func stateValid(b *bytes.Buffer, t *scanner.Token) stateHandler {
state := stateValid
if t.Type == scanner.TokenChar && t.Value == ";" {
// End of property.
state = stateStart
}
b.WriteString(t.Value)
return state
}

34
sanitize/css_test.go Normal file
View File

@@ -0,0 +1,34 @@
package sanitize
import (
"testing"
)
func TestSanitizeStyle(t *testing.T) {
testCases := []struct {
input, want string
}{
{"", ""},
{
"color: red;",
"color: red;",
},
{
"background-color: black; color: white",
"background-color: black;color: white",
},
{
"background-color: black; invalid: true; color: white",
"background-color: black;color: white",
},
}
for _, tc := range testCases {
t.Run(tc.input, func(t *testing.T) {
got := sanitizeStyle(tc.input)
if got != tc.want {
t.Errorf("got: %q, want: %q, input: %q", got, tc.want, tc.input)
}
})
}
}

88
sanitize/html.go Normal file
View File

@@ -0,0 +1,88 @@
package sanitize
import (
"bufio"
"bytes"
"io"
"regexp"
"strings"
"github.com/microcosm-cc/bluemonday"
"golang.org/x/net/html"
)
var (
cssSafe = regexp.MustCompile(".*")
policy = bluemonday.UGCPolicy().
AllowElements("center").
AllowAttrs("style").Matching(cssSafe).Globally()
)
func HTML(html string) (output string, err error) {
output, err = sanitizeStyleTags(html)
if err != nil {
return "", err
}
output = policy.Sanitize(output)
return
}
func sanitizeStyleTags(input string) (string, error) {
r := strings.NewReader(input)
b := &bytes.Buffer{}
if err := styleTagFilter(b, r); err != nil {
return "", err
}
return b.String(), nil
}
func styleTagFilter(w io.Writer, r io.Reader) error {
bw := bufio.NewWriter(w)
b := make([]byte, 256)
z := html.NewTokenizer(r)
for {
b = b[:0]
tt := z.Next()
switch tt {
case html.ErrorToken:
err := z.Err()
if err == io.EOF {
return bw.Flush()
}
return err
case html.StartTagToken, html.SelfClosingTagToken:
name, hasAttr := z.TagName()
if !hasAttr {
bw.Write(z.Raw())
continue
}
b = append(b, '<')
b = append(b, name...)
for {
key, val, more := z.TagAttr()
strval := string(val)
style := false
if strings.ToLower(string(key)) == "style" {
style = true
strval = sanitizeStyle(strval)
}
if !style || strval != "" {
b = append(b, ' ')
b = append(b, key...)
b = append(b, '=', '"')
b = append(b, []byte(html.EscapeString(strval))...)
b = append(b, '"')
}
if !more {
break
}
}
if tt == html.SelfClosingTagToken {
b = append(b, '/')
}
bw.Write(append(b, '>'))
default:
bw.Write(z.Raw())
}
}
}

171
sanitize/html_test.go Normal file
View File

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

View File

@@ -1,37 +0,0 @@
package smtpd
import (
"github.com/jhillyerd/go.enmime"
"io"
"net/mail"
"time"
)
type DataStore interface {
MailboxFor(emailAddress string) (Mailbox, error)
AllMailboxes() ([]Mailbox, error)
}
type Mailbox interface {
GetMessages() ([]Message, error)
GetMessage(id string) (Message, error)
Purge() error
NewMessage() Message
String() string
}
type Message interface {
Id() string
From() string
Date() time.Time
Subject() string
RawReader() (reader io.ReadCloser, err error)
ReadHeader() (msg *mail.Message, err error)
ReadBody() (body *enmime.MIMEBody, err error)
ReadRaw() (raw *string, err error)
Append(data []byte) error
Close() error
Delete() error
String() string
Size() int64
}

View File

@@ -1,487 +0,0 @@
package smtpd
import (
"bufio"
"encoding/gob"
"errors"
"fmt"
"github.com/jhillyerd/go.enmime"
"github.com/jhillyerd/inbucket/config"
"github.com/jhillyerd/inbucket/log"
"io"
"io/ioutil"
"net/mail"
"os"
"path/filepath"
"sync"
"time"
)
// Name of index file in each mailbox
const INDEX_FILE = "index.gob"
// We lock this when reading/writing an index file, this is a bottleneck because
// it's a single lock even if we have a million index files
var indexLock = new(sync.RWMutex)
var ErrNotWritable = errors.New("Message not writable")
// Global because we only want one regardless of the number of DataStore objects
var 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
}
}
// A DataStore is the root of the mail storage hiearchy. It provides access to
// Mailbox objects
type FileDataStore struct {
path string
mailPath string
}
// NewFileDataStore creates a new DataStore object using the specified path
func NewFileDataStore(path string) DataStore {
mailPath := filepath.Join(path, "mail")
if _, err := os.Stat(mailPath); err != nil {
// Mail datastore does not yet exist
os.MkdirAll(mailPath, 0770)
}
return &FileDataStore{path: path, mailPath: mailPath}
}
// DefaultFileDataStore creates a new DataStore object. It uses the inbucket.Config object to
// construct it's path.
func DefaultFileDataStore() DataStore {
path, err := config.Config.String("datastore", "path")
if err != nil {
log.LogError("Error getting datastore path: %v", err)
return nil
}
if path == "" {
log.LogError("No value configured for datastore path")
return nil
}
return NewFileDataStore(path)
}
// Retrieves the Mailbox object for a specified email address, if the mailbox
// does not exist, it will attempt to create it.
func (ds *FileDataStore) MailboxFor(emailAddress string) (Mailbox, error) {
name, err := ParseMailboxName(emailAddress)
if err != nil {
return nil, err
}
dir := HashMailboxName(name)
s1 := dir[0:3]
s2 := dir[0:6]
path := filepath.Join(ds.mailPath, s1, s2, dir)
indexPath := filepath.Join(path, INDEX_FILE)
return &FileMailbox{store: ds, name: name, dirName: dir, path: path,
indexPath: indexPath}, nil
}
// AllMailboxes returns a slice with all Mailboxes
func (ds *FileDataStore) AllMailboxes() ([]Mailbox, error) {
mailboxes := make([]Mailbox, 0, 100)
infos1, err := ioutil.ReadDir(ds.mailPath)
if err != nil {
return nil, err
}
// Loop over level 1 directories
for _, inf1 := range infos1 {
if inf1.IsDir() {
l1 := inf1.Name()
infos2, err := ioutil.ReadDir(filepath.Join(ds.mailPath, l1))
if err != nil {
return nil, err
}
// Loop over level 2 directories
for _, inf2 := range infos2 {
if inf2.IsDir() {
l2 := inf2.Name()
infos3, err := ioutil.ReadDir(filepath.Join(ds.mailPath, l1, l2))
if err != nil {
return nil, err
}
// Loop over mailboxes
for _, inf3 := range infos3 {
if inf3.IsDir() {
mbdir := inf3.Name()
mbpath := filepath.Join(ds.mailPath, l1, l2, mbdir)
idx := filepath.Join(mbpath, INDEX_FILE)
mb := &FileMailbox{store: ds, dirName: mbdir, path: mbpath,
indexPath: idx}
mailboxes = append(mailboxes, mb)
}
}
}
}
}
}
return mailboxes, nil
}
// A Mailbox manages the mail for a specific user and correlates to a particular
// directory on disk.
type FileMailbox struct {
store *FileDataStore
name string
dirName string
path string
indexLoaded bool
indexPath string
messages []*FileMessage
}
func (mb *FileMailbox) String() string {
return mb.name + "[" + mb.dirName + "]"
}
// GetMessages scans the mailbox directory for .gob files and decodes them into
// a slice of Message objects.
func (mb *FileMailbox) GetMessages() ([]Message, error) {
if !mb.indexLoaded {
if err := mb.readIndex(); err != nil {
return nil, err
}
}
messages := make([]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 *FileMailbox) GetMessage(id string) (Message, error) {
if !mb.indexLoaded {
if err := mb.readIndex(); err != nil {
return nil, err
}
}
for _, m := range mb.messages {
if m.Fid == id {
return m, nil
}
}
return nil, fmt.Errorf("Message %s not in index", id)
}
// Delete all messages in this mailbox
func (mb *FileMailbox) Purge() error {
mb.messages = mb.messages[:0]
return mb.writeIndex()
}
// readIndex loads the mailbox index data from disk
func (mb *FileMailbox) readIndex() error {
// Clear message slice, open index
mb.messages = mb.messages[:0]
// Lock for reading
indexLock.RLock()
defer indexLock.RUnlock()
// Check if index exists
if _, err := os.Stat(mb.indexPath); err != nil {
// Does not exist, but that's not an error in our world
log.LogTrace("Index %v does not exist (yet)", mb.indexPath)
mb.indexLoaded = true
return nil
}
file, err := os.Open(mb.indexPath)
if err != nil {
return err
}
defer file.Close()
// Decode gob data
dec := gob.NewDecoder(bufio.NewReader(file))
for {
// TODO Detect EOF
msg := new(FileMessage)
if err = dec.Decode(msg); err != nil {
if err == io.EOF {
// It's OK to get an EOF here
break
}
return fmt.Errorf("While decoding message: %v", err)
}
msg.mailbox = mb
log.LogTrace("Found: %v", msg)
mb.messages = append(mb.messages, msg)
}
mb.indexLoaded = true
return nil
}
// createDir checks for the presence of the path for this mailbox, creates it if needed
func (mb *FileMailbox) createDir() error {
if _, err := os.Stat(mb.path); err != nil {
if err := os.MkdirAll(mb.path, 0770); err != nil {
log.LogError("Failed to create directory %v, %v", mb.path, err)
return err
}
}
return nil
}
// writeIndex overwrites the index on disk with the current mailbox data
func (mb *FileMailbox) writeIndex() error {
// Lock for writing
indexLock.Lock()
defer indexLock.Unlock()
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
}
defer file.Close()
writer := bufio.NewWriter(file)
// Write each message and then flush
enc := gob.NewEncoder(writer)
for _, m := range mb.messages {
err = enc.Encode(m)
if err != nil {
return err
}
}
writer.Flush()
} else {
// No messages, delete index+maildir
log.LogTrace("Removing mailbox %v", mb.path)
return os.RemoveAll(mb.path)
}
return nil
}
// Message contains a little bit of data about a particular email message, and
// methods to retrieve the rest of it from disk.
type FileMessage struct {
mailbox *FileMailbox
// Stored in GOB
Fid string
Fdate time.Time
Ffrom string
Fsubject string
Fsize int64
// These are for creating new messages only
writable bool
writerFile *os.File
writer *bufio.Writer
}
// NewMessage creates a new Message object and sets the Date and Id fields.
func (mb *FileMailbox) NewMessage() Message {
date := time.Now()
id := generateId(date)
return &FileMessage{mailbox: mb, Fid: id, Fdate: date, writable: true}
}
func (m *FileMessage) Id() string {
return m.Fid
}
func (m *FileMessage) Date() time.Time {
return m.Fdate
}
func (m *FileMessage) From() string {
return m.Ffrom
}
func (m *FileMessage) Subject() string {
return m.Fsubject
}
func (m *FileMessage) String() string {
return fmt.Sprintf("\"%v\" from %v", m.Fsubject, m.Ffrom)
}
func (m *FileMessage) Size() int64 {
return m.Fsize
}
func (m *FileMessage) rawPath() string {
return filepath.Join(m.mailbox.path, m.Fid+".raw")
}
// ReadHeader opens the .raw portion of a Message and returns a standard Go mail.Message object
func (m *FileMessage) ReadHeader() (msg *mail.Message, err error) {
file, err := os.Open(m.rawPath())
defer file.Close()
if err != nil {
return nil, err
}
reader := bufio.NewReader(file)
msg, err = mail.ReadMessage(reader)
return msg, err
}
// ReadBody opens the .raw portion of a Message and returns a MIMEBody object
func (m *FileMessage) ReadBody() (body *enmime.MIMEBody, err error) {
file, err := os.Open(m.rawPath())
if err != nil {
return nil, err
}
defer file.Close()
reader := bufio.NewReader(file)
msg, err := mail.ReadMessage(reader)
if err != nil {
return nil, err
}
mime, err := enmime.ParseMIMEBody(msg)
if err != nil {
return nil, err
}
return mime, err
}
// RawReader opens the .raw portion of a Message as an io.ReadCloser
func (m *FileMessage) RawReader() (reader io.ReadCloser, err error) {
file, err := os.Open(m.rawPath())
if err != nil {
return nil, err
}
return file, nil
}
// ReadRaw opens the .raw portion of a Message and returns it as a string
func (m *FileMessage) ReadRaw() (raw *string, err error) {
reader, err := m.RawReader()
defer reader.Close()
if err != nil {
return nil, err
}
bodyBytes, err := ioutil.ReadAll(bufio.NewReader(reader))
if err != nil {
return nil, err
}
bodyString := string(bodyBytes)
return &bodyString, nil
}
// Append data to a newly opened Message, this will fail on a pre-existing Message and
// after Close() is called.
func (m *FileMessage) Append(data []byte) error {
// Prevent Appending to a pre-existing Message
if !m.writable {
return ErrNotWritable
}
// Open file for writing if we haven't yet
if m.writer == nil {
// Ensure mailbox directory exists
if err := m.mailbox.createDir(); err != nil {
return err
}
file, err := os.Create(m.rawPath())
if err != nil {
// Set writable false just in case something calls me a million times
m.writable = false
return err
}
m.writerFile = file
m.writer = bufio.NewWriter(file)
}
_, err := m.writer.Write(data)
m.Fsize += int64(len(data))
return err
}
// Close this Message for writing - no more data may be Appended. Close() will also
// trigger the creation of the .gob file.
func (m *FileMessage) Close() error {
// nil out the writer fields so they can't be used
writer := m.writer
writerFile := m.writerFile
m.writer = nil
m.writerFile = nil
if writer != nil {
if err := writer.Flush(); err != nil {
return err
}
}
if writerFile != nil {
if err := writerFile.Close(); err != nil {
return err
}
}
// Fetch headers
body, err := m.ReadBody()
if err != nil {
return err
}
// Only public fields are stored in gob
m.Ffrom = body.GetHeader("From")
m.Fsubject = body.GetHeader("Subject")
// Refresh the index before adding our message
err = m.mailbox.readIndex()
if err != nil {
return err
}
// Made it this far without errors, add it to the index
m.mailbox.messages = append(m.mailbox.messages, m)
return m.mailbox.writeIndex()
}
// Delete this Message from disk by removing both the gob and raw files
func (m *FileMessage) Delete() error {
messages := m.mailbox.messages
for i, mm := range messages {
if m == mm {
// Slice around message we are deleting
m.mailbox.messages = append(messages[:i], messages[i+1:]...)
break
}
}
m.mailbox.writeIndex()
if len(m.mailbox.messages) == 0 {
// This was the last message, writeIndex() has removed the entire
// directory
return nil
}
// There are still messages in the index
log.LogTrace("Deleting %v", m.rawPath())
return os.Remove(m.rawPath())
}
// 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

@@ -5,25 +5,37 @@ import (
"bytes"
"container/list"
"fmt"
"github.com/jhillyerd/inbucket/log"
"io"
"net"
"regexp"
"strconv"
"strings"
"time"
"github.com/jhillyerd/inbucket/datastore"
"github.com/jhillyerd/inbucket/log"
"github.com/jhillyerd/inbucket/msghub"
"github.com/jhillyerd/inbucket/stringutil"
)
// State tracks the current mode of our SMTP state machine
type State int
const (
GREET State = iota // Waiting for HELO
READY // Got HELO, waiting for MAIL
MAIL // Got MAIL, accepting RCPTs
DATA // Got DATA, waiting for "."
QUIT // Close session
// 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
)
const timeStampFormat = "Mon, 02 Jan 2006 15:04:05 -0700 (MST)"
func (s State) String() string {
switch s {
case GREET:
@@ -58,18 +70,27 @@ var commands = map[string]bool{
"TURN": true,
}
type Session struct {
server *Server
id int
conn net.Conn
remoteHost string
sendError error
state State
reader *bufio.Reader
from string
recipients *list.List
// recipientDetails for message delivery
type recipientDetails struct {
address, localPart, domainPart string
mailbox datastore.Mailbox
}
// Session holds the state of an SMTP session
type Session struct {
server *Server
id int
conn net.Conn
remoteDomain string
remoteHost string
sendError error
state State
reader *bufio.Reader
from string
recipients *list.List
}
// NewSession creates a new Session for the given connection
func NewSession(server *Server, id int, conn net.Conn) *Session {
reader := bufio.NewReader(conn)
host, _, _ := net.SplitHostPort(conn.RemoteAddr().String())
@@ -88,10 +109,12 @@ func (ss *Session) String() string {
* 5. Goto 2
*/
func (s *Server) startSession(id int, conn net.Conn) {
log.LogInfo("SMTP Connection from %v, starting session <%v>", conn.RemoteAddr(), id)
log.Infof("SMTP Connection from %v, starting session <%v>", conn.RemoteAddr(), id)
expConnectsCurrent.Add(1)
defer func() {
conn.Close()
if err := conn.Close(); err != nil {
log.Errorf("Error closing connection for <%v>: %v", id, err)
}
s.waitgroup.Done()
expConnectsCurrent.Add(-1)
}()
@@ -196,9 +219,21 @@ func (s *Server) startSession(id int, conn net.Conn) {
func (ss *Session) greetHandler(cmd string, arg string) {
switch cmd {
case "HELO":
domain, err := parseHelloArgument(arg)
if err != nil {
ss.send("501 Domain/address argument required for HELO")
return
}
ss.remoteDomain = domain
ss.send("250 Great, let's get this show on the road")
ss.enterState(READY)
case "EHLO":
domain, err := parseHelloArgument(arg)
if err != nil {
ss.send("501 Domain/address argument required for EHLO")
return
}
ss.remoteDomain = domain
ss.send("250-Great, let's get this show on the road")
ss.send("250-8BITMIME")
ss.send(fmt.Sprintf("250 SIZE %v", ss.server.maxMessageBytes))
@@ -208,6 +243,17 @@ func (ss *Session) greetHandler(cmd string, arg string) {
}
}
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 (ss *Session) readyHandler(cmd string, arg string) {
if cmd == "MAIL" {
@@ -221,7 +267,7 @@ func (ss *Session) readyHandler(cmd string, arg string) {
return
}
from := m[1]
if _, _, err := ParseEmailAddress(from); err != nil {
if _, _, err := stringutil.ParseEmailAddress(from); err != nil {
ss.send("501 Bad sender address syntax")
ss.logWarn("Bad address as MAIL arg: %q, %s", from, err)
return
@@ -270,7 +316,7 @@ func (ss *Session) mailHandler(cmd string, arg string) {
}
// This trim is probably too forgiving
recip := strings.Trim(arg[3:], "<> ")
if _, _, err := ParseEmailAddress(recip); err != nil {
if _, _, err := stringutil.ParseEmailAddress(recip); err != nil {
ss.send("501 Bad recipient address syntax")
ss.logWarn("Bad address as RCPT arg: %q, %s", recip, err)
return
@@ -294,27 +340,23 @@ func (ss *Session) mailHandler(cmd string, arg string) {
// We have recipients, go to accept data
ss.enterState(DATA)
return
} else {
// DATA out of sequence
ss.ooSeq(cmd)
return
}
// DATA out of sequence
ss.ooSeq(cmd)
return
}
ss.ooSeq(cmd)
}
// DATA
func (ss *Session) dataHandler() {
msgSize := 0
recipients := make([]recipientDetails, 0, ss.recipients.Len())
// Get a Mailbox and a new Message for each recipient
mailboxes := make([]Mailbox, ss.recipients.Len())
messages := make([]Message, ss.recipients.Len())
msgSize := 0
if ss.server.storeMessages {
i := 0
for e := ss.recipients.Front(); e != nil; e = e.Next() {
recip := e.Value.(string)
local, domain, err := ParseEmailAddress(recip)
local, domain, err := stringutil.ParseEmailAddress(recip)
if err != nil {
ss.logError("Failed to parse address for %q", recip)
ss.send(fmt.Sprintf("451 Failed to open mailbox for %v", recip))
@@ -325,25 +367,24 @@ func (ss *Session) dataHandler() {
// Not our "no store" domain, so store the message
mb, err := ss.server.dataStore.MailboxFor(local)
if err != nil {
ss.logError("Failed to open mailbox for %q", local)
ss.logError("Failed to open mailbox for %q: %s", local, err)
ss.send(fmt.Sprintf("451 Failed to open mailbox for %v", local))
ss.reset()
return
}
mailboxes[i] = mb
messages[i] = mb.NewMessage()
recipients = append(recipients, recipientDetails{recip, local, domain, mb})
} else {
log.LogTrace("Not storing message for %q", recip)
log.Tracef("Not storing message for %q", recip)
}
i++
}
}
ss.send("354 Start mail input; end with <CRLF>.<CRLF>")
var buf bytes.Buffer
var lineBuf bytes.Buffer
msgBuf := make([][]byte, 0, 1024)
for {
buf.Reset()
err := ss.readByteLine(&buf)
lineBuf.Reset()
err := ss.readByteLine(&lineBuf)
if err != nil {
if netErr, ok := err.(net.Error); ok {
if netErr.Timeout() {
@@ -354,17 +395,20 @@ func (ss *Session) dataHandler() {
ss.enterState(QUIT)
return
}
line := buf.Bytes()
if string(line) == ".\r\n" {
line := lineBuf.Bytes()
// ss.logTrace("DATA: %q", line)
if string(line) == ".\r\n" || string(line) == ".\n" {
// Mail data complete
if ss.server.storeMessages {
for _, m := range messages {
if m != nil {
if err := m.Close(); err != nil {
ss.logError("Error: %v while writing message", err)
// TODO Report to client?
}
// Create a message for each valid recipient
for _, r := range recipients {
if ok := ss.deliverMessage(r, msgBuf); ok {
expReceivedTotal.Add(1)
} else {
// Delivery failure
ss.send(fmt.Sprintf("451 Failed to store message for %v", r.localPart))
ss.reset()
return
}
}
} else {
@@ -379,30 +423,63 @@ func (ss *Session) dataHandler() {
if len(line) > 0 && line[0] == '.' {
line = line[1:]
}
// Second append copies line/lineBuf so we can reuse it
msgBuf = append(msgBuf, append([]byte{}, line...))
msgSize += len(line)
if msgSize > ss.server.maxMessageBytes {
// Max message size exceeded
ss.send("552 Maximum message size exceeded")
ss.logWarn("Max message size exceeded while in DATA")
ss.reset()
// TODO: Should really cleanup the crap on filesystem...
// Should really cleanup the crap on filesystem (after issue #23)
return
}
// Append to message objects
if ss.server.storeMessages {
for i, m := range messages {
if m != nil {
if err := m.Append(line); err != nil {
ss.logError("Failed to append to mailbox %v: %v", mailboxes[i], err)
ss.send("554 Something went wrong")
ss.reset()
// TODO: Should really cleanup the crap on filesystem...
return
}
}
}
} // end for
}
// deliverMessage creates and populates a new Message for the specified recipient
func (ss *Session) deliverMessage(r recipientDetails, msgBuf [][]byte) (ok bool) {
msg, err := r.mailbox.NewMessage()
if err != nil {
ss.logError("Failed to create message for %q: %s", r.localPart, err)
return false
}
// Generate Received header
stamp := time.Now().Format(timeStampFormat)
recd := fmt.Sprintf("Received: from %s ([%s]) by %s\r\n for <%s>; %s\r\n",
ss.remoteDomain, ss.remoteHost, ss.server.domain, r.address, stamp)
if err := msg.Append([]byte(recd)); err != nil {
ss.logError("Failed to write received header for %q: %s", r.localPart, err)
return false
}
// Append lines from msgBuf
for _, line := range msgBuf {
if err := msg.Append(line); err != nil {
ss.logError("Failed to append to mailbox %v: %v", r.mailbox, err)
// Should really cleanup the crap on filesystem
return false
}
}
if err := msg.Close(); err != nil {
ss.logError("Error while closing message for %v: %v", r.mailbox, err)
return false
}
// Broadcast message information
broadcast := msghub.Message{
Mailbox: r.mailbox.Name(),
ID: msg.ID(),
From: msg.From(),
To: msg.To(),
Subject: msg.Subject(),
Date: msg.Date(),
Size: msg.Size(),
}
ss.server.msgHub.Dispatch(broadcast)
return true
}
func (ss *Session) enterState(state State) {
@@ -435,29 +512,16 @@ func (ss *Session) send(msg string) {
// readByteLine reads a line of input into the provided buffer. Does
// not reset the Buffer - please do so prior to calling.
func (ss *Session) readByteLine(buf *bytes.Buffer) error {
func (ss *Session) readByteLine(buf io.Writer) error {
if err := ss.conn.SetReadDeadline(ss.nextDeadline()); err != nil {
return err
}
for {
line, err := ss.reader.ReadBytes('\r')
if err != nil {
return err
}
buf.Write(line)
// Read the next byte looking for '\n'
c, err := ss.reader.ReadByte()
if err != nil {
return err
}
buf.WriteByte(c)
if c == '\n' {
// We've reached the end of the line, return
return nil
}
// Else, keep looking
line, err := ss.reader.ReadBytes('\n')
if err != nil {
return err
}
// Should be unreachable
_, err = buf.Write(line)
return err
}
// Reads a line of input
@@ -506,7 +570,7 @@ func (ss *Session) parseCmd(line string) (cmd string, arg string, ok bool) {
// The leading space is mandatory.
func (ss *Session) parseArgs(arg string) (args map[string]string, ok bool) {
args = make(map[string]string)
re := regexp.MustCompile(" (\\w+)=(\\w+)")
re := regexp.MustCompile(` (\w+)=(\w+)`)
pm := re.FindAllStringSubmatch(arg, -1)
if pm == nil {
ss.logWarn("Failed to parse arg string: %q")
@@ -532,21 +596,21 @@ func (ss *Session) ooSeq(cmd string) {
// Session specific logging methods
func (ss *Session) logTrace(msg string, args ...interface{}) {
log.LogTrace("SMTP[%v]<%v> %v", ss.remoteHost, ss.id, fmt.Sprintf(msg, args...))
log.Tracef("SMTP[%v]<%v> %v", ss.remoteHost, ss.id, fmt.Sprintf(msg, args...))
}
func (ss *Session) logInfo(msg string, args ...interface{}) {
log.LogInfo("SMTP[%v]<%v> %v", ss.remoteHost, ss.id, fmt.Sprintf(msg, args...))
log.Infof("SMTP[%v]<%v> %v", ss.remoteHost, ss.id, fmt.Sprintf(msg, args...))
}
func (ss *Session) logWarn(msg string, args ...interface{}) {
// Update metrics
expWarnsTotal.Add(1)
log.LogWarn("SMTP[%v]<%v> %v", ss.remoteHost, ss.id, fmt.Sprintf(msg, args...))
log.Warnf("SMTP[%v]<%v> %v", ss.remoteHost, ss.id, fmt.Sprintf(msg, args...))
}
func (ss *Session) logError(msg string, args ...interface{}) {
// Update metrics
expErrorsTotal.Add(1)
log.LogError("SMTP[%v]<%v> %v", ss.remoteHost, ss.id, fmt.Sprintf(msg, args...))
log.Errorf("SMTP[%v]<%v> %v", ss.remoteHost, ss.id, fmt.Sprintf(msg, args...))
}

View File

@@ -2,16 +2,20 @@ package smtpd
import (
"bytes"
"context"
"fmt"
"github.com/jhillyerd/inbucket/config"
"io"
//"io/ioutil"
"log"
"net"
"net/textproto"
"os"
"testing"
"time"
"github.com/jhillyerd/inbucket/config"
"github.com/jhillyerd/inbucket/datastore"
"github.com/jhillyerd/inbucket/msghub"
)
type scriptStep struct {
@@ -22,17 +26,15 @@ type scriptStep struct {
// Test commands in GREET state
func TestGreetState(t *testing.T) {
// Setup mock objects
mds := &MockDataStore{}
mb1 := &MockMailbox{}
mds.On("MailboxFor").Return(mb1, nil)
mds := &datastore.MockDataStore{}
server, logbuf := setupSmtpServer(mds)
defer teardownSmtpServer(server)
var script []scriptStep
server, logbuf, teardown := setupSMTPServer(mds)
defer teardown()
// Test out some mangled HELOs
script = []scriptStep{
script := []scriptStep{
{"HELO", 501},
{"EHLO", 501},
{"HELLO", 500},
{"HELL", 500},
{"hello", 500},
@@ -43,9 +45,6 @@ func TestGreetState(t *testing.T) {
}
// Valid HELOs
if err := playSession(t, server, []scriptStep{{"HELO", 250}}); err != nil {
t.Error(err)
}
if err := playSession(t, server, []scriptStep{{"HELO mydomain", 250}}); err != nil {
t.Error(err)
}
@@ -55,29 +54,42 @@ func TestGreetState(t *testing.T) {
if err := playSession(t, server, []scriptStep{{"HelO mydom.com", 250}}); err != nil {
t.Error(err)
}
if err := playSession(t, server, []scriptStep{{"helo 127.0.0.1", 250}}); err != nil {
t.Error(err)
}
// Valid EHLOs
if err := playSession(t, server, []scriptStep{{"EHLO mydomain", 250}}); err != nil {
t.Error(err)
}
if err := playSession(t, server, []scriptStep{{"EHLO mydom.com", 250}}); err != nil {
t.Error(err)
}
if err := playSession(t, server, []scriptStep{{"EhlO mydom.com", 250}}); err != nil {
t.Error(err)
}
if err := playSession(t, server, []scriptStep{{"ehlo 127.0.0.1", 250}}); err != nil {
t.Error(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)
_, _ = io.Copy(os.Stderr, logbuf)
}
}
// Test commands in READY state
func TestReadyState(t *testing.T) {
// Setup mock objects
mds := &MockDataStore{}
mb1 := &MockMailbox{}
mds.On("MailboxFor").Return(mb1, nil)
mds := &datastore.MockDataStore{}
server, logbuf := setupSmtpServer(mds)
defer teardownSmtpServer(server)
var script []scriptStep
server, logbuf, teardown := setupSMTPServer(mds)
defer teardown()
// Test out some mangled READY commands
script = []scriptStep{
script := []scriptStep{
{"HELO localhost", 250},
{"FOOB", 500},
{"HELO", 503},
@@ -125,27 +137,32 @@ func TestReadyState(t *testing.T) {
// 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)
_, _ = io.Copy(os.Stderr, logbuf)
}
}
// 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)
mds := &datastore.MockDataStore{}
mb1 := &datastore.MockMailbox{}
msg1 := &datastore.MockMessage{}
mds.On("MailboxFor", "u1").Return(mb1, nil)
mb1.On("NewMessage").Return(msg1, nil)
mb1.On("Name").Return("u1")
msg1.On("ID").Return("")
msg1.On("From").Return("")
msg1.On("To").Return(make([]string, 0))
msg1.On("Date").Return(time.Time{})
msg1.On("Subject").Return("")
msg1.On("Size").Return(0)
msg1.On("Close").Return(nil)
server, logbuf := setupSmtpServer(mds)
defer teardownSmtpServer(server)
var script []scriptStep
server, logbuf, teardown := setupSMTPServer(mds)
defer teardown()
// Test out some mangled READY commands
script = []scriptStep{
script := []scriptStep{
{"HELO localhost", 250},
{"MAIL FROM:<john@gmail.com>", 250},
{"FOOB", 500},
@@ -235,25 +252,32 @@ func TestMailState(t *testing.T) {
// 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)
_, _ = io.Copy(os.Stderr, logbuf)
}
}
// 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)
mds := &datastore.MockDataStore{}
mb1 := &datastore.MockMailbox{}
msg1 := &datastore.MockMessage{}
mds.On("MailboxFor", "u1").Return(mb1, nil)
mb1.On("NewMessage").Return(msg1, nil)
mb1.On("Name").Return("u1")
msg1.On("ID").Return("")
msg1.On("From").Return("")
msg1.On("To").Return(make([]string, 0))
msg1.On("Date").Return(time.Time{})
msg1.On("Subject").Return("")
msg1.On("Size").Return(0)
msg1.On("Close").Return(nil)
server, logbuf := setupSmtpServer(mds)
defer teardownSmtpServer(server)
server, logbuf, teardown := setupSMTPServer(mds)
defer teardown()
var script []scriptStep
pipe := setupSmtpSession(server)
pipe := setupSMTPSession(server)
c := textproto.NewConn(pipe)
// Get us into DATA state
@@ -277,8 +301,8 @@ Subject: test
Hi!
`
dw := c.DotWriter()
io.WriteString(dw, body)
dw.Close()
_, _ = io.WriteString(dw, body)
_ = dw.Close()
if code, _, err := c.ReadCodeLine(250); err != nil {
t.Errorf("Expected a 250 greeting, got %v", code)
}
@@ -287,13 +311,13 @@ Hi!
// 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)
_, _ = io.Copy(os.Stderr, logbuf)
}
}
// playSession creates a new session, reads the greeting and then plays the script
func playSession(t *testing.T, server *Server, script []scriptStep) error {
pipe := setupSmtpSession(server)
pipe := setupSMTPSession(server)
c := textproto.NewConn(pipe)
if code, _, err := c.ReadCodeLine(220); err != nil {
@@ -302,8 +326,10 @@ func playSession(t *testing.T, server *Server, script []scriptStep) error {
err := playScriptAgainst(t, c, script)
c.Cmd("QUIT")
c.ReadCodeLine(221)
// Not all tests leave the session in a clean state, so the following two
// calls can fail
_, _ = c.Cmd("QUIT")
_, _, _ = c.ReadCodeLine(221)
return err
}
@@ -317,7 +343,7 @@ func playScriptAgainst(t *testing.T, c *textproto.Conn, script []scriptStep) err
}
c.StartResponse(id)
code, msg, err := c.ReadCodeLine(step.expect)
code, msg, err := c.ReadResponse(step.expect)
if err != nil {
err = fmt.Errorf("Step %d, sent %q, expected %v, got %v: %q",
i, step.send, step.expect, code, msg)
@@ -341,11 +367,11 @@ func (m *mockConn) SetDeadline(t time.Time) error { return nil }
func (m *mockConn) SetReadDeadline(t time.Time) error { return nil }
func (m *mockConn) SetWriteDeadline(t time.Time) error { return nil }
func setupSmtpServer(ds DataStore) (*Server, *bytes.Buffer) {
func setupSMTPServer(ds datastore.DataStore) (s *Server, buf *bytes.Buffer, teardown func()) {
// Test Server Config
cfg := config.SmtpConfig{
Ip4address: net.IPv4(127, 0, 0, 1),
Ip4port: 2500,
cfg := config.SMTPConfig{
IP4address: net.IPv4(127, 0, 0, 1),
IP4port: 2500,
Domain: "inbucket.local",
DomainNoStore: "bitbucket.local",
MaxRecipients: 5,
@@ -355,16 +381,23 @@ func setupSmtpServer(ds DataStore) (*Server, *bytes.Buffer) {
}
// Capture log output
buf := new(bytes.Buffer)
buf = new(bytes.Buffer)
log.SetOutput(buf)
// Create a server, don't start it
return NewSmtpServer(cfg, ds), buf
shutdownChan := make(chan bool)
ctx, cancel := context.WithCancel(context.Background())
teardown = func() {
close(shutdownChan)
cancel()
}
s = NewServer(cfg, shutdownChan, ds, msghub.New(ctx, 100))
return s, buf, teardown
}
var sessionNum int
func setupSmtpSession(server *Server) net.Conn {
func setupSMTPSession(server *Server) net.Conn {
// Pair of pipes to communicate
serverConn, clientConn := net.Pipe()
// Start the session
@@ -374,7 +407,3 @@ func setupSmtpSession(server *Server) net.Conn {
return clientConn
}
func teardownSmtpServer(server *Server) {
//log.SetOutput(os.Stderr)
}

View File

@@ -2,158 +2,20 @@ package smtpd
import (
"container/list"
"context"
"expvar"
"fmt"
"github.com/jhillyerd/inbucket/config"
"github.com/jhillyerd/inbucket/log"
"net"
"strings"
"sync"
"time"
"github.com/jhillyerd/inbucket/config"
"github.com/jhillyerd/inbucket/datastore"
"github.com/jhillyerd/inbucket/log"
"github.com/jhillyerd/inbucket/msghub"
)
// Real server code starts here
type Server struct {
domain string
domainNoStore string
maxRecips int
maxIdleSeconds int
maxMessageBytes int
dataStore DataStore
storeMessages bool
listener net.Listener
shutdown bool
waitgroup *sync.WaitGroup
}
// Raw stat collectors
var expConnectsTotal = new(expvar.Int)
var expConnectsCurrent = new(expvar.Int)
var expReceivedTotal = new(expvar.Int)
var expErrorsTotal = new(expvar.Int)
var expWarnsTotal = new(expvar.Int)
// History of certain stats
var deliveredHist = list.New()
var connectsHist = list.New()
var errorsHist = list.New()
var warnsHist = list.New()
// History rendered as comma delim string
var expReceivedHist = new(expvar.String)
var expConnectsHist = new(expvar.String)
var expErrorsHist = new(expvar.String)
var expWarnsHist = new(expvar.String)
// Init a new Server object
func NewSmtpServer(cfg config.SmtpConfig, ds DataStore) *Server {
return &Server{dataStore: ds, domain: cfg.Domain, maxRecips: cfg.MaxRecipients,
maxIdleSeconds: cfg.MaxIdleSeconds, maxMessageBytes: cfg.MaxMessageBytes,
storeMessages: cfg.StoreMessages, domainNoStore: strings.ToLower(cfg.DomainNoStore),
waitgroup: new(sync.WaitGroup)}
}
// Main listener loop
func (s *Server) Start() {
cfg := config.GetSmtpConfig()
addr, err := net.ResolveTCPAddr("tcp4", fmt.Sprintf("%v:%v",
cfg.Ip4address, cfg.Ip4port))
if err != nil {
log.LogError("Failed to build tcp4 address: %v", err)
// TODO More graceful early-shutdown procedure
panic(err)
}
log.LogInfo("SMTP listening on TCP4 %v", addr)
s.listener, err = net.ListenTCP("tcp4", addr)
if err != nil {
log.LogError("SMTP failed to start tcp4 listener: %v", err)
// TODO More graceful early-shutdown procedure
panic(err)
}
if !s.storeMessages {
log.LogInfo("Load test mode active, messages will not be stored")
} else if s.domainNoStore != "" {
log.LogInfo("Messages sent to domain '%v' will be discarded", s.domainNoStore)
}
// Start retention scanner
StartRetentionScanner(s.dataStore)
// 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.LogError("SMTP accept error: %v; retrying in %v", err, tempDelay)
time.Sleep(tempDelay)
continue
} else {
if s.shutdown {
log.LogTrace("SMTP listener shutting down on request")
return
}
// TODO Implement a max error counter before shutdown?
// or maybe attempt to restart smtpd
panic(err)
}
} else {
tempDelay = 0
expConnectsTotal.Add(1)
s.waitgroup.Add(1)
go s.startSession(sid, conn)
}
}
}
// Stop requests the SMTP server closes it's listener
func (s *Server) Stop() {
log.LogTrace("SMTP shutdown requested, connections will be drained")
s.shutdown = true
s.listener.Close()
}
// Drain causes the caller to block until all active SMTP sessions have finished
func (s *Server) Drain() {
s.waitgroup.Wait()
log.LogTrace("SMTP connections drained")
}
// When the provided Ticker ticks, we update our metrics history
func metricsTicker(t *time.Ticker) {
ok := true
for ok {
_, ok = <-t.C
expReceivedHist.Set(pushMetric(deliveredHist, expReceivedTotal))
expConnectsHist.Set(pushMetric(connectsHist, expConnectsTotal))
expErrorsHist.Set(pushMetric(errorsHist, expErrorsTotal))
expWarnsHist.Set(pushMetric(warnsHist, expWarnsTotal))
expRetentionDeletesHist.Set(pushMetric(retentionDeletesHist, expRetentionDeletesTotal))
expRetainedHist.Set(pushMetric(retainedHist, expRetainedCurrent))
}
}
// pushMetric adds the metric to the end of the list and returns a comma separated string of the
// previous 61 entries. We return 61 instead of 60 (an hour) because the chart on the client
// tracks deltas between these values - there is nothing to compare the first value against.
func pushMetric(history *list.List, ev expvar.Var) string {
history.PushBack(ev.String())
if history.Len() > 61 {
history.Remove(history.Front())
}
return JoinStringList(history)
}
func init() {
m := expvar.NewMap("smtp")
m.Set("ConnectsTotal", expConnectsTotal)
@@ -166,6 +28,172 @@ func init() {
m.Set("WarnsTotal", expWarnsTotal)
m.Set("WarnsHist", expWarnsHist)
t := time.NewTicker(time.Minute)
go metricsTicker(t)
log.AddTickerFunc(func() {
expReceivedHist.Set(log.PushMetric(deliveredHist, expReceivedTotal))
expConnectsHist.Set(log.PushMetric(connectsHist, expConnectsTotal))
expErrorsHist.Set(log.PushMetric(errorsHist, expErrorsTotal))
expWarnsHist.Set(log.PushMetric(warnsHist, expWarnsTotal))
})
}
// Server holds the configuration and state of our SMTP server
type Server struct {
// Configuration
host string
domain string
domainNoStore string
maxRecips int
maxIdleSeconds int
maxMessageBytes int
storeMessages bool
// Dependencies
dataStore datastore.DataStore // Mailbox/message store
globalShutdown chan bool // Shuts down Inbucket
msgHub *msghub.Hub // Pub/sub for message info
retentionScanner *datastore.RetentionScanner // Deletes expired messages
// State
listener net.Listener // Incoming network connections
waitgroup *sync.WaitGroup // Waitgroup tracks individual sessions
}
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)
)
// NewServer creates a new Server instance with the specificed config
func NewServer(
cfg config.SMTPConfig,
globalShutdown chan bool,
ds datastore.DataStore,
msgHub *msghub.Hub) *Server {
return &Server{
host: fmt.Sprintf("%v:%v", cfg.IP4address, cfg.IP4port),
domain: cfg.Domain,
domainNoStore: strings.ToLower(cfg.DomainNoStore),
maxRecips: cfg.MaxRecipients,
maxIdleSeconds: cfg.MaxIdleSeconds,
maxMessageBytes: cfg.MaxMessageBytes,
storeMessages: cfg.StoreMessages,
globalShutdown: globalShutdown,
dataStore: ds,
msgHub: msgHub,
retentionScanner: datastore.NewRetentionScanner(ds, globalShutdown),
waitgroup: new(sync.WaitGroup),
}
}
// Start the listener and handle incoming connections
func (s *Server) Start(ctx context.Context) {
addr, err := net.ResolveTCPAddr("tcp4", s.host)
if err != nil {
log.Errorf("Failed to build tcp4 address: %v", err)
s.emergencyShutdown()
return
}
log.Infof("SMTP listening on TCP4 %v", addr)
s.listener, err = net.ListenTCP("tcp4", addr)
if err != nil {
log.Errorf("SMTP failed to start tcp4 listener: %v", err)
s.emergencyShutdown()
return
}
if !s.storeMessages {
log.Infof("Load test mode active, messages will not be stored")
} else if s.domainNoStore != "" {
log.Infof("Messages sent to domain '%v' will be discarded", s.domainNoStore)
}
// Start retention scanner
s.retentionScanner.Start()
// Listener go routine
go s.serve(ctx)
// Wait for shutdown
<-ctx.Done()
log.Tracef("SMTP shutdown requested, connections will be drained")
// Closing the listener will cause the serve() go routine to exit
if err := s.listener.Close(); err != nil {
log.Errorf("Failed to close SMTP listener: %v", err)
}
}
// 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.Errorf("SMTP accept error: %v; retrying in %v", err, 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.waitgroup.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.waitgroup.Wait()
log.Tracef("SMTP connections have drained")
s.retentionScanner.Join()
}

View File

@@ -1,124 +0,0 @@
package smtpd
import (
"container/list"
"expvar"
"github.com/jhillyerd/inbucket/config"
"github.com/jhillyerd/inbucket/log"
"sync"
"time"
)
var retentionScanCompleted time.Time
var retentionScanCompletedMu sync.RWMutex
var expRetentionDeletesTotal = new(expvar.Int)
var expRetentionPeriod = new(expvar.Int)
var expRetainedCurrent = new(expvar.Int)
// History of certain stats
var retentionDeletesHist = list.New()
var retainedHist = list.New()
// History rendered as comma delim string
var expRetentionDeletesHist = new(expvar.String)
var expRetainedHist = new(expvar.String)
func StartRetentionScanner(ds DataStore) {
cfg := config.GetDataStoreConfig()
expRetentionPeriod.Set(int64(cfg.RetentionMinutes * 60))
if cfg.RetentionMinutes > 0 {
// Retention scanning enabled
log.LogInfo("Retention configured for %v minutes", cfg.RetentionMinutes)
go retentionScanner(ds, time.Duration(cfg.RetentionMinutes) * time.Minute,
time.Duration(cfg.RetentionSleep) * time.Millisecond)
} else {
log.LogInfo("Retention scanner disabled")
}
}
func retentionScanner(ds DataStore, maxAge time.Duration, sleep time.Duration) {
start := time.Now()
for {
// Prevent scanner from running more than once a minute
since := time.Since(start)
if since < time.Minute {
dur := time.Minute - since
log.LogTrace("Retention scanner sleeping for %v", dur)
time.Sleep(dur)
}
start = time.Now()
// Kickoff scan
if err := doRetentionScan(ds, maxAge, sleep); err != nil {
log.LogError("Error during retention scan: %v", err)
}
}
}
// doRetentionScan does a single pass of all mailboxes looking for messages that can be purged
func doRetentionScan(ds DataStore, maxAge time.Duration, sleep time.Duration) error {
log.LogTrace("Starting retention scan")
cutoff := time.Now().Add(-1 * maxAge)
mboxes, err := ds.AllMailboxes()
if err != nil {
return err
}
retained := 0
for _, mb := range mboxes {
messages, err := mb.GetMessages()
if err != nil {
return err
}
for _, msg := range messages {
if msg.Date().Before(cutoff) {
log.LogTrace("Purging expired message %v", msg.Id())
err = msg.Delete()
if err != nil {
// Log but don't abort
log.LogError("Failed to purge message %v: %v", msg.Id(), err)
} else {
expRetentionDeletesTotal.Add(1)
}
} else {
retained++
}
}
// Sleep after completing a mailbox
time.Sleep(sleep)
}
setRetentionScanCompleted(time.Now())
expRetainedCurrent.Set(int64(retained))
return nil
}
func setRetentionScanCompleted(t time.Time) {
retentionScanCompletedMu.Lock()
defer retentionScanCompletedMu.Unlock()
retentionScanCompleted = t
}
func getRetentionScanCompleted() time.Time {
retentionScanCompletedMu.RLock()
defer retentionScanCompletedMu.RUnlock()
return retentionScanCompleted
}
func secondsSinceRetentionScanCompleted() interface{} {
return time.Since(getRetentionScanCompleted()) / time.Second
}
func init() {
rm := expvar.NewMap("retention")
rm.Set("SecondsSinceScanCompleted", expvar.Func(secondsSinceRetentionScanCompleted))
rm.Set("DeletesHist", expRetentionDeletesHist)
rm.Set("DeletesTotal", expRetentionDeletesTotal)
rm.Set("Period", expRetentionPeriod)
rm.Set("RetainedHist", expRetainedHist)
rm.Set("RetainedCurrent", expRetainedCurrent)
}

View File

@@ -1,17 +1,17 @@
package smtpd
package stringutil
import (
"bytes"
"container/list"
"crypto/sha1"
"fmt"
"io"
"strings"
)
// Take "user+ext" and return "user", aka the mailbox we'll store it in
// Return error if it contains invalid characters, we don't accept anything
// that must be quoted according to RFC3696.
// 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")
@@ -20,7 +20,7 @@ func ParseMailboxName(localPart string) (result string, err error) {
invalid := make([]byte, 0, 10)
for i := 0; i<len(result); i++ {
for i := 0; i < len(result); i++ {
c := result[i]
switch {
case 'a' <= c && c <= 'z':
@@ -41,23 +41,15 @@ func ParseMailboxName(localPart string) (result string, err error) {
return result, nil
}
// Take a mailbox name and hash it into the directory we'll store it in
// 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()
io.WriteString(h, mailbox)
return fmt.Sprintf("%x", h.Sum(nil))
}
// JoinStringList joins a List containing strings by commas
func JoinStringList(listOfStrings *list.List) string {
if listOfStrings.Len() == 0 {
if _, err := io.WriteString(h, mailbox); err != nil {
// This shouldn't ever happen
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, ",")
return fmt.Sprintf("%x", h.Sum(nil))
}
// ValidateDomainPart returns true if the domain part complies to RFC3696, RFC1035
@@ -73,15 +65,14 @@ func ValidateDomainPart(domain string) bool {
}
prev := '.'
labelLen := 0
hasLetters := false
hasAlphaNum := false
for _, c := range domain {
switch {
case ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z') || c == '_':
case ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z') ||
('0' <= c && c <= '9') || c == '_':
// Must contain some of these to be a valid label
hasLetters = true
labelLen++
case '0' <= c && c <= '9':
hasAlphaNum = true
labelLen++
case c == '-':
if prev == '.' {
@@ -96,11 +87,11 @@ func ValidateDomainPart(domain string) bool {
if labelLen > 63 {
return false
}
if !hasLetters {
if !hasAlphaNum {
return false
}
labelLen = 0
hasLetters = false
hasAlphaNum = false
default:
// Unknown character
return false
@@ -139,15 +130,24 @@ LOOP:
switch {
case ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z'):
// Letters are OK
buf.WriteByte(c)
err = buf.WriteByte(c)
if err != nil {
return
}
inCharQuote = false
case '0' <= c && c <= '9':
// Numbers are OK
buf.WriteByte(c)
err = buf.WriteByte(c)
if err != nil {
return
}
inCharQuote = false
case bytes.IndexByte([]byte("!#$%&'*+-/=?^_`{|}~"), c) >= 0:
// These specials can be used unquoted
buf.WriteByte(c)
err = buf.WriteByte(c)
if err != nil {
return
}
inCharQuote = false
case c == '.':
// A single period is OK
@@ -155,13 +155,19 @@ LOOP:
// Sequence of periods is not permitted
return "", "", fmt.Errorf("Sequence of periods is not permitted")
}
buf.WriteByte(c)
err = buf.WriteByte(c)
if err != nil {
return
}
inCharQuote = false
case c == '\\':
inCharQuote = true
case c == '"':
if inCharQuote {
buf.WriteByte(c)
err = buf.WriteByte(c)
if err != nil {
return
}
inCharQuote = false
} else if inStringQuote {
inStringQuote = false
@@ -174,12 +180,15 @@ LOOP:
}
case c == '@':
if inCharQuote || inStringQuote {
buf.WriteByte(c)
err = buf.WriteByte(c)
if err != nil {
return
}
inCharQuote = false
} else {
// End of local-part
if i > 63 {
return "", "", fmt.Errorf("Local part must not exceed 64 characters")
if i > 128 {
return "", "", fmt.Errorf("Local part must not exceed 128 characters")
}
if prev == '.' {
return "", "", fmt.Errorf("Local part cannot end with a period")
@@ -191,7 +200,10 @@ LOOP:
return "", "", fmt.Errorf("Characters outside of US-ASCII range not permitted")
default:
if inCharQuote || inStringQuote {
buf.WriteByte(c)
err = buf.WriteByte(c)
if err != nil {
return
}
inCharQuote = false
} else {
return "", "", fmt.Errorf("Character %q must be quoted", c)

View File

@@ -1,14 +1,15 @@
package smtpd
package stringutil
import (
"github.com/stretchr/testify/assert"
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func TestParseMailboxName(t *testing.T) {
var validTable = []struct{
input string
var validTable = []struct {
input string
expect string
}{
{"mailbox", "mailbox"},
@@ -33,7 +34,7 @@ func TestParseMailboxName(t *testing.T) {
}
}
var invalidTable = []struct{
var invalidTable = []struct {
input, msg string
}{
{"", "Empty mailbox name is not permitted"},
@@ -74,9 +75,10 @@ func TestValidateDomain(t *testing.T) {
{"_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"},
{"mail.123.com", false, "Number only label not valid"},
{"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"},
@@ -97,7 +99,8 @@ func TestValidateLocal(t *testing.T) {
}{
{"", false, "Empty local is not valid"},
{"a", true, "Single letter should be fine"},
{strings.Repeat("a", 65), false, "Only valid up to 64 characters"},
{strings.Repeat("a", 128), true, "Valid up to 128 characters"},
{strings.Repeat("a", 129), false, "Only valid up to 128 characters"},
{"FirstLast", true, "Mixed case permitted"},
{"user123", true, "Numbers permitted"},
{"a!#$%&'*+-/=?^_`{|}~", true, "Any of !#$%&'*+-/=?^_`{|}~ are permitted"},

30
swaks-tests/README.md Normal file
View File

@@ -0,0 +1,30 @@
swaks-tests
===========
[Swaks](http://www.jetmore.org/john/code/swaks/) - Swiss Army Knife for SMTP
Swaks gives us an easy way to generate mail to send into Inbucket. You will need to
install Swaks before you can use the provided scripts.
## Usage
To deliver a batch of test email to the `swaks` mailbox, assuming Inbucket SMTP is listening
on localhost:2500:
./run-tests.sh
To deliver a batch of test email to the `james` mailbox:
./run-tests.sh james
You may also pass swaks options to deliver to a alternate host/port:
./run-tests --server inbucket.mydomain.com:25
To specify the mailbox with an alternate server, use `--to` with a local and host part:
./run-tests --server inbucket.mydomain.com:25 --to james@mydomain.com
## To Do
Replace Swaks with a native Go solution.

213
swaks-tests/gmail.raw Normal file
View File

@@ -0,0 +1,213 @@
MIME-Version: 1.0
Date: %DATE%
Message-ID: <CANqLHXqq1i6cOTcEHa7W9hT21ZueMJTM4QaP5DH0YXScqYCEuw@mail.gmail.com>
Subject: Test from Gmail
From: %FROM_ADDRESS%
X-ASG-Orig-Subj: Test from Gmail
To: %TO_ADDRESS%
Content-Type: multipart/mixed; boundary=001a113d2d045cd646051e1383c2
X-Barracuda-Spam-Score: 0.50
X-Barracuda-Spam-Status: No, SCORE=0.50 using global scores of TAG_LEVEL=2.0 QUARANTINE_LEVEL=1000.0 KILL_LEVEL=5.0 tests=BSF_SC5_SA210e, HTML_MESSAGE, WEIRD_PORT
X-Barracuda-Spam-Report: Code version 3.2, rules version 3.2.3.21886
Rule breakdown below
pts rule name description
---- ---------------------- --------------------------------------------------
0.50 WEIRD_PORT URI: Uses non-standard port number for HTTP
0.00 HTML_MESSAGE BODY: HTML included in message
0.00 BSF_SC5_SA210e Custom Rule SA210e
--001a113d2d045cd646051e1383c2
Content-Type: multipart/alternative; boundary=001a113d2d045cd63c051e1383c0
--001a113d2d045cd63c051e1383c0
Content-Type: text/plain; charset=UTF-8
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer at ex
urna. Nunc sollicitudin venenatis dolor, eget convallis libero convallis
eu. Nulla luctus ligula in magna condimentum, placerat lacinia nulla
varius. Curabitur quis placerat enim, et aliquam ligula. In dignissim
lectus vel pharetra posuere. Proin imperdiet augue orci, at dapibus urna
sagittis vel. In nec arcu placerat, vehicula est vel, sollicitudin erat.
Nunc tempor risus lorem, sed bibendum tellus pretium non. Sed vel tortor
ante. Donec convallis erat ac mauris mollis feugiat. Donec pharetra ex ac
tempus aliquam. Praesent ut purus tristique, pharetra arcu vulputate,
eleifend felis.
Aenean ut porttitor risus, a porta nunc. Donec ligula diam, sagittis at
luctus id, luctus sit amet risus. Sed turpis nisl, fermentum vitae nibh
non, imperdiet luctus arcu. Fusce vulputate velit porta, rutrum velit nec,
semper eros. Suspendisse in dui non nulla lacinia tincidunt. Vivamus sit
amet lectus eu velit condimentum gravida. Curabitur vestibulum felis nisl,
a sagittis odio faucibus a. Nulla fermentum, ligula in gravida hendrerit,
diam dui tempor dolor, id euismod tortor velit at massa. Phasellus et nunc
mi. Integer tristique viverra odio vitae auctor. Lorem ipsum dolor sit
amet, consectetur adipiscing elit. Nunc rutrum turpis ornare lorem ornare
lacinia. Aenean vehicula ante nunc, at dignissim dolor porta eu. In hac
habitasse platea dictumst. In hac habitasse platea dictumst. Cras ac ex
molestie, pulvinar nisi a, finibus est.
Integer a eros ut tortor convallis porta ultrices id justo. Maecenas luctus
purus id molestie molestie. Vestibulum rutrum consequat porta. Fusce
vulputate lacus sed nisl venenatis rhoncus. Aliquam erat volutpat.
Suspendisse viverra eros id erat congue, nec convallis arcu volutpat. Etiam
non sem nisi.
Integer at velit sed mauris luctus sagittis. Suspendisse aliquam diam non
enim viverra, suscipit fermentum massa accumsan. Cras eget ex justo. Aenean
non scelerisque elit. Duis et nulla quis est dignissim bibendum. Quisque
mattis dui vitae convallis pellentesque. Mauris arcu dui, aliquet non
ligula et, posuere tincidunt felis.
In id tortor sollicitudin, convallis elit ut, auctor libero. Nunc ut lorem
a quam tempor lobortis. Praesent nec dolor ut erat fermentum malesuada. Cum
sociis natoque penatibus et magnis dis parturient montes, nascetur
ridiculus mus. Nulla facilisi. Vestibulum eget ornare justo. Donec iaculis
purus eget massa mattis bibendum. Quisque commodo efficitur magna, ac
tempor ipsum ultrices eu. Suspendisse id felis molestie, consequat neque
in, vehicula velit. In a dictum dui, non tempor elit. Phasellus luctus nec
eros viverra consequat. Aliquam efficitur metus consectetur, rhoncus sem
vitae, facilisis arcu. Nullam eget nunc in urna mollis laoreet a at eros.
--001a113d2d045cd63c051e1383c0
Content-Type: text/html; charset=UTF-8
Content-Transfer-Encoding: quoted-printable
<div dir=3D"ltr"><div>Lorem ipsum dolor sit amet, consectetur adipiscing el=
it. Integer at ex urna. Nunc sollicitudin venenatis dolor, eget convallis l=
ibero convallis eu. Nulla luctus ligula in magna condimentum, placerat laci=
nia nulla varius. Curabitur quis placerat enim, et aliquam ligula. In digni=
ssim lectus vel pharetra posuere. Proin imperdiet augue orci, at dapibus ur=
na sagittis vel. In nec arcu placerat, vehicula est vel, sollicitudin erat.=
Nunc tempor risus lorem, sed bibendum tellus pretium non. Sed vel tortor a=
nte. Donec convallis erat ac mauris mollis feugiat. Donec pharetra ex ac te=
mpus aliquam. Praesent ut purus tristique, pharetra arcu vulputate, eleifen=
d felis.</div><div><br></div><div>Aenean ut porttitor risus, a porta nunc. =
Donec ligula diam, sagittis at luctus id, luctus sit amet risus. Sed turpis=
nisl, fermentum vitae nibh non, imperdiet luctus arcu. Fusce vulputate vel=
it porta, rutrum velit nec, semper eros. Suspendisse in dui non nulla lacin=
ia tincidunt. Vivamus sit amet lectus eu velit condimentum gravida. Curabit=
ur vestibulum felis nisl, a sagittis odio faucibus a. Nulla fermentum, ligu=
la in gravida hendrerit, diam dui tempor dolor, id euismod tortor velit at =
massa. Phasellus et nunc mi. Integer tristique viverra odio vitae auctor. L=
orem ipsum dolor sit amet, consectetur adipiscing elit. Nunc rutrum turpis =
ornare lorem ornare lacinia. Aenean vehicula ante nunc, at dignissim dolor =
porta eu. In hac habitasse platea dictumst. In hac habitasse platea dictums=
t. Cras ac ex molestie, pulvinar nisi a, finibus est.</div><div><br></div><=
div>Integer a eros ut tortor convallis porta ultrices id justo. Maecenas lu=
ctus purus id molestie molestie. Vestibulum rutrum consequat porta. Fusce v=
ulputate lacus sed nisl venenatis rhoncus. Aliquam erat volutpat. Suspendis=
se viverra eros id erat congue, nec convallis arcu volutpat. Etiam non sem =
nisi.</div><div><br></div><div>Integer at velit sed mauris luctus sagittis.=
Suspendisse aliquam diam non enim viverra, suscipit fermentum massa accums=
an. Cras eget ex justo. Aenean non scelerisque elit. Duis et nulla quis est=
dignissim bibendum. Quisque mattis dui vitae convallis pellentesque. Mauri=
s arcu dui, aliquet non ligula et, posuere tincidunt felis.</div><div><br><=
/div><div>In id tortor sollicitudin, convallis elit ut, auctor libero. Nunc=
ut lorem a quam tempor lobortis. Praesent nec dolor ut erat fermentum male=
suada. Cum sociis natoque penatibus et magnis dis parturient montes, nascet=
ur ridiculus mus. Nulla facilisi. Vestibulum eget ornare justo. Donec iacul=
is purus eget massa mattis bibendum. Quisque commodo efficitur magna, ac te=
mpor ipsum ultrices eu. Suspendisse id felis molestie, consequat neque in, =
vehicula velit. In a dictum dui, non tempor elit. Phasellus luctus nec eros=
viverra consequat. Aliquam efficitur metus consectetur, rhoncus sem vitae,=
facilisis arcu. Nullam eget nunc in urna mollis laoreet a at eros.</div>
</div>
--001a113d2d045cd63c051e1383c0--
--001a113d2d045cd646051e1383c2
Content-Type: text/plain; charset=US-ASCII; name="README.txt"
Content-Disposition: attachment; filename="README.txt"
Content-Transfer-Encoding: base64
X-Attachment-Id: f_idqb67l61
SW5idWNrZXQgWyFbQnVpbGQgU3RhdHVzXShodHRwczovL3RyYXZpcy1jaS5vcmcvamhpbGx5ZXJk
L2luYnVja2V0LnBuZz9icmFuY2g9bWFzdGVyKV0oaHR0cHM6Ly90cmF2aXMtY2kub3JnL2poaWxs
eWVyZC9pbmJ1Y2tldCkKPT09PT09PT0KCkluYnVja2V0IGlzIGFuIGVtYWlsIHRlc3Rpbmcgc2Vy
dmljZTsgaXQgd2lsbCBhY2NlcHQgbWVzc2FnZXMgZm9yIGFueSBlbWFpbAphZGRyZXNzIGFuZCBt
YWtlIHRoZW0gYXZhaWxhYmxlIHZpYSB3ZWIsIFJFU1QgYW5kIFBPUDMuICBPbmNlIGNvbXBpbGVk
LApJbmJ1Y2tldCBkb2VzIG5vdCBoYXZlIGFuIGV4dGVybmFsIGRlcGVuZGVuY2llcyAoSFRUUCwg
U01UUCwgUE9QMyBhbmQgc3RvcmFnZQphcmUgYWxsIGJ1aWx0IGluKS4KClJlYWQgbW9yZSBhdCB0
aGUgW0luYnVja2V0IHdlYnNpdGVdW0luYnVja2V0XQoKRGV2ZWxvcG1lbnQgU3RhdHVzCi0tLS0t
LS0tLS0tLS0tLS0tLQoKSW5idWNrZXQgaXMgY3VycmVudGx5IHByb2R1Y3Rpb24gcXVhbGl0eTog
aXQgaXMgYmVpbmcgdXNlZCBmb3IgcmVhbCB3b3JrLgoKUGxlYXNlIGNoZWNrIHRoZSBbaXNzdWVz
IGxpc3RdW0lzc3Vlc10KZm9yIG1vcmUgZGV0YWlscy4KCkJ1aWxkaW5nIGZyb20gU291cmNlCi0t
LS0tLS0tLS0tLS0tLS0tLS0tLS0tLQoKWW91IHdpbGwgbmVlZCBhIGZ1bmN0aW9uaW5nIFtHbyBp
bnN0YWxsYXRpb25dW0dvbGFuZ10gZm9yIHRoaXMgdG8gd29yay4KCkdyYWIgdGhlIEluYnVja2V0
IHNvdXJjZSBjb2RlIGFuZCBjb21waWxlIHRoZSBkYWVtb246CgogICAgZ28gZ2V0IC12IGdpdGh1
Yi5jb20vamhpbGx5ZXJkL2luYnVja2V0CgpFZGl0IGV0Yy9pbmJ1Y2tldC5jb25mIGFuZCB0YWls
b3IgdG8geW91ciBlbnZpcm9ubWVudC4gIEl0IHNob3VsZCB3b3JrIG9uIG1vc3QKVW5peCBhbmQg
T1MgWCBtYWNoaW5lcyBhcyBpcy4gIExhdW5jaCB0aGUgZGFlbW9uOgoKICAgICRHT1BBVEgvYmlu
L2luYnVja2V0ICRHT1BBVEgvc3JjL2dpdGh1Yi5jb20vamhpbGx5ZXJkL2luYnVja2V0L2V0Yy9p
bmJ1Y2tldC5jb25mCgpCeSBkZWZhdWx0IHRoZSBTTVRQIHNlcnZlciB3aWxsIGJlIGxpc3Rlbmlu
ZyBvbiBsb2NhbGhvc3QgcG9ydCAyNTAwIGFuZAp0aGUgd2ViIGludGVyZmFjZSB3aWxsIGJlIGF2
YWlsYWJsZSBhdCBbbG9jYWxob3N0OjkwMDBdKGh0dHA6Ly9sb2NhbGhvc3Q6OTAwMC8pLgoKVGhl
IEluYnVja2V0IHdlYnNpdGUgaGFzIGEgbW9yZSBjb21wbGV0ZSBndWlkZSB0bwpbaW5zdGFsbGlu
ZyBmcm9tIHNvdXJjZV1bRnJvbSBTb3VyY2VdCgpBYm91dAotLS0tLQoKSW5idWNrZXQgaXMgd3Jp
dHRlbiBpbiBbR29vZ2xlIEdvXVtHb2xhbmddLgoKSW5idWNrZXQgaXMgb3BlbiBzb3VyY2Ugc29m
dHdhcmUgcmVsZWFzZWQgdW5kZXIgdGhlIE1JVCBMaWNlbnNlLiAgVGhlIGxhdGVzdAp2ZXJzaW9u
IGNhbiBiZSBmb3VuZCBhdCBodHRwczovL2dpdGh1Yi5jb20vamhpbGx5ZXJkL2luYnVja2V0Cgpb
SW5idWNrZXRdOiBodHRwOi8vd3d3LmluYnVja2V0Lm9yZy8KW0lzc3Vlc106IGh0dHBzOi8vZ2l0
aHViLmNvbS9qaGlsbHllcmQvaW5idWNrZXQvaXNzdWVzP3N0YXRlPW9wZW4KW0Zyb20gU291cmNl
XTogaHR0cDovL3d3dy5pbmJ1Y2tldC5vcmcvaW5zdGFsbGF0aW9uL2Zyb20tc291cmNlLmh0bWwK
W0dvbGFuZ106IGh0dHA6Ly9nb2xhbmcub3JnLwo=
--001a113d2d045cd646051e1383c2
Content-Type: image/png; name="favicon.png"
Content-Disposition: attachment; filename="favicon.png"
Content-Transfer-Encoding: base64
X-Attachment-Id: f_idqb60e30
iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAC4jAAAuIwF4pT92AAAK
T2lDQ1BQaG90b3Nob3AgSUNDIHByb2ZpbGUAAHjanVNnVFPpFj333vRCS4iAlEtvUhUIIFJCi4AU
kSYqIQkQSoghodkVUcERRUUEG8igiAOOjoCMFVEsDIoK2AfkIaKOg6OIisr74Xuja9a89+bN/rXX
Pues852zzwfACAyWSDNRNYAMqUIeEeCDx8TG4eQuQIEKJHAAEAizZCFz/SMBAPh+PDwrIsAHvgAB
eNMLCADATZvAMByH/w/qQplcAYCEAcB0kThLCIAUAEB6jkKmAEBGAYCdmCZTAKAEAGDLY2LjAFAt
AGAnf+bTAICd+Jl7AQBblCEVAaCRACATZYhEAGg7AKzPVopFAFgwABRmS8Q5ANgtADBJV2ZIALC3
AMDOEAuyAAgMADBRiIUpAAR7AGDIIyN4AISZABRG8lc88SuuEOcqAAB4mbI8uSQ5RYFbCC1xB1dX
Lh4ozkkXKxQ2YQJhmkAuwnmZGTKBNA/g88wAAKCRFRHgg/P9eM4Ors7ONo62Dl8t6r8G/yJiYuP+
5c+rcEAAAOF0ftH+LC+zGoA7BoBt/qIl7gRoXgugdfeLZrIPQLUAoOnaV/Nw+H48PEWhkLnZ2eXk
5NhKxEJbYcpXff5nwl/AV/1s+X48/Pf14L7iJIEyXYFHBPjgwsz0TKUcz5IJhGLc5o9H/LcL//wd
0yLESWK5WCoU41EScY5EmozzMqUiiUKSKcUl0v9k4t8s+wM+3zUAsGo+AXuRLahdYwP2SycQWHTA
4vcAAPK7b8HUKAgDgGiD4c93/+8//UegJQCAZkmScQAAXkQkLlTKsz/HCAAARKCBKrBBG/TBGCzA
BhzBBdzBC/xgNoRCJMTCQhBCCmSAHHJgKayCQiiGzbAdKmAv1EAdNMBRaIaTcA4uwlW4Dj1wD/ph
CJ7BKLyBCQRByAgTYSHaiAFiilgjjggXmYX4IcFIBBKLJCDJiBRRIkuRNUgxUopUIFVIHfI9cgI5
h1xGupE7yAAygvyGvEcxlIGyUT3UDLVDuag3GoRGogvQZHQxmo8WoJvQcrQaPYw2oefQq2gP2o8+
Q8cwwOgYBzPEbDAuxsNCsTgsCZNjy7EirAyrxhqwVqwDu4n1Y8+xdwQSgUXACTYEd0IgYR5BSFhM
WE7YSKggHCQ0EdoJNwkDhFHCJyKTqEu0JroR+cQYYjIxh1hILCPWEo8TLxB7iEPENyQSiUMyJ7mQ
AkmxpFTSEtJG0m5SI+ksqZs0SBojk8naZGuyBzmULCAryIXkneTD5DPkG+Qh8lsKnWJAcaT4U+Io
UspqShnlEOU05QZlmDJBVaOaUt2ooVQRNY9aQq2htlKvUYeoEzR1mjnNgxZJS6WtopXTGmgXaPdp
r+h0uhHdlR5Ol9BX0svpR+iX6AP0dwwNhhWDx4hnKBmbGAcYZxl3GK+YTKYZ04sZx1QwNzHrmOeZ
D5lvVVgqtip8FZHKCpVKlSaVGyovVKmqpqreqgtV81XLVI+pXlN9rkZVM1PjqQnUlqtVqp1Q61Mb
U2epO6iHqmeob1Q/pH5Z/YkGWcNMw09DpFGgsV/jvMYgC2MZs3gsIWsNq4Z1gTXEJrHN2Xx2KruY
/R27iz2qqaE5QzNKM1ezUvOUZj8H45hx+Jx0TgnnKKeX836K3hTvKeIpG6Y0TLkxZVxrqpaXllir
SKtRq0frvTau7aedpr1Fu1n7gQ5Bx0onXCdHZ4/OBZ3nU9lT3acKpxZNPTr1ri6qa6UbobtEd79u
p+6Ynr5egJ5Mb6feeb3n+hx9L/1U/W36p/VHDFgGswwkBtsMzhg8xTVxbzwdL8fb8VFDXcNAQ6Vh
lWGX4YSRudE8o9VGjUYPjGnGXOMk423GbcajJgYmISZLTepN7ppSTbmmKaY7TDtMx83MzaLN1pk1
mz0x1zLnm+eb15vft2BaeFostqi2uGVJsuRaplnutrxuhVo5WaVYVVpds0atna0l1rutu6cRp7lO
k06rntZnw7Dxtsm2qbcZsOXYBtuutm22fWFnYhdnt8Wuw+6TvZN9un2N/T0HDYfZDqsdWh1+c7Ry
FDpWOt6azpzuP33F9JbpL2dYzxDP2DPjthPLKcRpnVOb00dnF2e5c4PziIuJS4LLLpc+Lpsbxt3I
veRKdPVxXeF60vWdm7Obwu2o26/uNu5p7ofcn8w0nymeWTNz0MPIQ+BR5dE/C5+VMGvfrH5PQ0+B
Z7XnIy9jL5FXrdewt6V3qvdh7xc+9j5yn+M+4zw33jLeWV/MN8C3yLfLT8Nvnl+F30N/I/9k/3r/
0QCngCUBZwOJgUGBWwL7+Hp8Ib+OPzrbZfay2e1BjKC5QRVBj4KtguXBrSFoyOyQrSH355jOkc5p
DoVQfujW0Adh5mGLw34MJ4WHhVeGP45wiFga0TGXNXfR3ENz30T6RJZE3ptnMU85ry1KNSo+qi5q
PNo3ujS6P8YuZlnM1VidWElsSxw5LiquNm5svt/87fOH4p3iC+N7F5gvyF1weaHOwvSFpxapLhIs
OpZATIhOOJTwQRAqqBaMJfITdyWOCnnCHcJnIi/RNtGI2ENcKh5O8kgqTXqS7JG8NXkkxTOlLOW5
hCepkLxMDUzdmzqeFpp2IG0yPTq9MYOSkZBxQqohTZO2Z+pn5mZ2y6xlhbL+xW6Lty8elQfJa7OQ
rAVZLQq2QqboVFoo1yoHsmdlV2a/zYnKOZarnivN7cyzytuQN5zvn//tEsIS4ZK2pYZLVy0dWOa9
rGo5sjxxedsK4xUFK4ZWBqw8uIq2Km3VT6vtV5eufr0mek1rgV7ByoLBtQFr6wtVCuWFfevc1+1d
T1gvWd+1YfqGnRs+FYmKrhTbF5cVf9go3HjlG4dvyr+Z3JS0qavEuWTPZtJm6ebeLZ5bDpaql+aX
Dm4N2dq0Dd9WtO319kXbL5fNKNu7g7ZDuaO/PLi8ZafJzs07P1SkVPRU+lQ27tLdtWHX+G7R7ht7
vPY07NXbW7z3/T7JvttVAVVN1WbVZftJ+7P3P66Jqun4lvttXa1ObXHtxwPSA/0HIw6217nU1R3S
PVRSj9Yr60cOxx++/p3vdy0NNg1VjZzG4iNwRHnk6fcJ3/ceDTradox7rOEH0x92HWcdL2pCmvKa
RptTmvtbYlu6T8w+0dbq3nr8R9sfD5w0PFl5SvNUyWna6YLTk2fyz4ydlZ19fi753GDborZ752PO
32oPb++6EHTh0kX/i+c7vDvOXPK4dPKy2+UTV7hXmq86X23qdOo8/pPTT8e7nLuarrlca7nuer21
e2b36RueN87d9L158Rb/1tWeOT3dvfN6b/fF9/XfFt1+cif9zsu72Xcn7q28T7xf9EDtQdlD3YfV
P1v+3Njv3H9qwHeg89HcR/cGhYPP/pH1jw9DBY+Zj8uGDYbrnjg+OTniP3L96fynQ89kzyaeF/6i
/suuFxYvfvjV69fO0ZjRoZfyl5O/bXyl/erA6xmv28bCxh6+yXgzMV70VvvtwXfcdx3vo98PT+R8
IH8o/2j5sfVT0Kf7kxmTk/8EA5jz/GMzLdsAAAAgY0hSTQAAeiUAAICDAAD5/wAAgOkAAHUwAADq
YAAAOpgAABdvkl/FRgAAAPxJREFUeNqkU0ERgzAQ3DA1EAuxgAUsYAELsYCFWKASqASQUCRQCdfP
HuxkYProzRxJLsmyd3sJZoZ/7AEAIYQ63gLo6G4vAE8AmwfMjJ/TIoAJgNFn+iKxfAcQeXDnoVix
SgKerwAKN1v6LCwGGSf+JCpAEuTIA355F/o9wQ3AoACZwSRzz3UQgOTpA8gKMAF4c9Pr4NYJgNfq
AGgkuImEayWpywimcaybm/5IAjxwvnKdeXk9tQRGoehSLUxLe8JjsZaxq1RwCUcB3yl1vGskl6z9
0f69M6oBklAu1TtwlkXYpLu3UCTvKy9ag2BmV68xSku7baz+R2vwHQC+QKj9KkHDLAAAAABJRU5E
rkJggg==
--001a113d2d045cd646051e1383c2--

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

@@ -0,0 +1,394 @@
Date: %DATE%
To: %TO_ADDRESS%
From: %FROM_ADDRESS%
Subject: tutsplus responsive external 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>
<link rel="stylesheet" type="text/css" href="http://www.inbucket.org/email-assets/responsive/styles.css" />
<!--[if (gte mso 9)|(IE)]>
<style type="text/css">
table {border-collapse: collapse;}
</style>
<![endif]-->
</head>
<body>
<center class="wrapper">
<div class="webkit">
<!--[if (gte mso 9)|(IE)]>
<table width="600" align="center">
<tr>
<td>
<![endif]-->
<table class="outer" align="center">
<tr>
<td class="full-width-image">
<img src="http://www.inbucket.org/email-assets/responsive/header.jpg" width="600" alt="" />
</td>
</tr>
<tr>
<td class="one-column">
<table width="100%">
<tr>
<td class="inner contents">
<p class="h1">Lorem ipsum dolor sit amet</p>
<p>
Compare to:
<a href="http://tutsplus.github.io/creating-a-future-proof-responsive-email-without-media-queries/index.html">
tutsplus sample</a>
</p>
<p>Copyright (c) 2015, Envato Tuts+<br/>
All rights reserved.</p>
<p>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>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">
<!--[if (gte mso 9)|(IE)]>
<table width="100%">
<tr>
<td width="50%" valign="top">
<![endif]-->
<div class="column">
<table width="100%">
<tr>
<td class="inner">
<table class="contents">
<tr>
<td>
<img src="http://www.inbucket.org/email-assets/responsive/two-column-01.jpg" width="280" alt="" />
</td>
</tr>
<tr>
<td class="text">
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">
<![endif]-->
<div class="column">
<table width="100%">
<tr>
<td class="inner">
<table class="contents">
<tr>
<td>
<img src="http://www.inbucket.org/email-assets/responsive/two-column-02.jpg" width="280" alt="" />
</td>
</tr>
<tr>
<td class="text">
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">
<!--[if (gte mso 9)|(IE)]>
<table width="100%">
<tr>
<td width="200" valign="top">
<![endif]-->
<div class="column">
<table width="100%">
<tr>
<td class="inner">
<table class="contents">
<tr>
<td>
<img src="http://www.inbucket.org/email-assets/responsive/three-column-01.jpg" width="180" alt="" />
</td>
</tr>
<tr>
<td class="text">
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">
<![endif]-->
<div class="column">
<table width="100%">
<tr>
<td class="inner">
<table class="contents">
<tr>
<td>
<img src="http://www.inbucket.org/email-assets/responsive/three-column-02.jpg" width="180" alt="" />
</td>
</tr>
<tr>
<td class="text">
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">
<![endif]-->
<div class="column">
<table width="100%">
<tr>
<td class="inner">
<table class="contents">
<tr>
<td>
<img src="http://www.inbucket.org/email-assets/responsive/three-column-03.jpg" width="180" alt="" />
</td>
</tr>
<tr>
<td class="text">
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">
<!--[if (gte mso 9)|(IE)]>
<table width="100%">
<tr>
<td width="200" valign="top">
<![endif]-->
<div class="column">
<table width="100%">
<tr>
<td class="inner contents">
<p class="h2">Fashion</p>
<p>Class eleifend aptent taciti sociosqu ad litora torquent conubia</p>
<p><a href="#">Read requirements</a></p>
</td>
</tr>
</table>
</div>
<!--[if (gte mso 9)|(IE)]>
</td><td width="200" valign="top">
<![endif]-->
<div class="column">
<table width="100%">
<tr>
<td class="inner contents">
<p class="h2">Photography</p>
<p>Maecenas sed ante pellentesque, posuere leo id, eleifend dolor</p>
<p><a href="#">See examples</a></p>
</td>
</tr>
</table>
</div>
<!--[if (gte mso 9)|(IE)]>
</td><td width="200" valign="top">
<![endif]-->
<div class="column">
<table width="100%">
<tr>
<td class="inner contents">
<p class="h2">Design</p>
<p>Class aptent taciti sociosqu eleifend ad litora per conubia nostra</p>
<p><a href="#">See the winners</a></p>
</td>
</tr>
</table>
</div>
<!--[if (gte mso 9)|(IE)]>
</td>
</tr>
<tr>
<td width="200" valign="top">
<![endif]-->
<div class="column">
<table width="100%">
<tr>
<td class="inner contents">
<p class="h2">Cooking</p>
<p>Class aptent taciti eleifend sociosqu ad litora torquent conubia</p>
<p><a href="#">Read recipes</a></p>
</td>
</tr>
</table>
</div>
<!--[if (gte mso 9)|(IE)]>
</td><td width="200" valign="top">
<![endif]-->
<div class="column">
<table width="100%">
<tr>
<td class="inner contents">
<p class="h2">Woodworking</p>
<p>Maecenas sed ante pellentesque, posuere leo id, eleifend dolor</p>
<p><a href="#">See examples</a></p>
</td>
</tr>
</table>
</div>
<!--[if (gte mso 9)|(IE)]>
</td><td width="200" valign="top">
<![endif]-->
<div class="column">
<table width="100%">
<tr>
<td class="inner contents">
<p class="h2">Craft</p>
<p>Class aptent taciti sociosqu ad eleifend litora per conubia nostra</p>
<p><a href="#">Vote now</a></p>
</td>
</tr>
</table>
</div>
<!--[if (gte mso 9)|(IE)]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
<tr>
<td class="left-sidebar">
<!--[if (gte mso 9)|(IE)]>
<table width="100%">
<tr>
<td width="100">
<![endif]-->
<div class="column left">
<table width="100%">
<tr>
<td class="inner">
<img src="http://www.inbucket.org/email-assets/responsive/sidebar-01.jpg" width="80" alt="" />
</td>
</tr>
</table>
</div>
<!--[if (gte mso 9)|(IE)]>
</td><td width="500">
<![endif]-->
<div class="column right">
<table width="100%">
<tr>
<td class="inner contents">
Praesent laoreet malesuada cursus. Maecenas scelerisque congue eros eu posuere. Praesent in felis ut velit pretium lobortis rhoncus ut erat. <a href="#">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">
<!--[if (gte mso 9)|(IE)]>
<table width="100%" dir="rtl">
<tr>
<td width="100">
<![endif]-->
<div class="column left" dir="ltr">
<table width="100%">
<tr>
<td class="inner contents">
<img src="http://www.inbucket.org/email-assets/responsive/sidebar-02.jpg" width="80" alt="" />
</td>
</tr>
</table>
</div>
<!--[if (gte mso 9)|(IE)]>
</td><td width="500">
<![endif]-->
<div class="column right" dir="ltr">
<table width="100%">
<tr>
<td class="inner contents">
Maecenas sed ante pellentesque, posuere leo id, eleifend dolor. Class aptent taciti sociosqu ad litora torquent per conubia nostra. <a href="#">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

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

322
swaks-tests/outlook.raw Normal file
View File

@@ -0,0 +1,322 @@
From: %FROM_ADDRESS%
To: %TO_ADDRESS%
Subject: Test from Outlook
Thread-Topic: Test from Outlook
Thread-Index: AdDeqI993CvUm800TamFq90sKu975w==
Date: %DATE%
Message-ID: <8D08CB465951804FA4DBB0C8B35CB0FA013B4E7ADD@exch.com>
Accept-Language: en-US
Content-Language: en-US
X-MS-Has-Attach: yes
X-MS-TNEF-Correlator:
x-originating-ip: [10.13.30.10]
Content-Type: multipart/mixed;
boundary="_005_8D08CB465951804FA4DBB0C8B35CB0FA013B4E7ADDonerdexch09on_"
MIME-Version: 1.0
--_005_8D08CB465951804FA4DBB0C8B35CB0FA013B4E7ADDonerdexch09on_
Content-Type: multipart/alternative;
boundary="_000_8D08CB465951804FA4DBB0C8B35CB0FA013B4E7ADDonerdexch09on_"
--_000_8D08CB465951804FA4DBB0C8B35CB0FA013B4E7ADDonerdexch09on_
Content-Type: text/plain; charset="us-ascii"
Content-Transfer-Encoding: quoted-printable
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque liber=
o arcu, accumsan at mattis nec, condimentum vitae erat. Praesent massa turp=
is, iaculis elementum lectus vitae, iaculis laoreet massa. Integer porta, n=
isi eget congue vulputate, tellus lacus imperdiet tellus, in tristique metu=
s nibh sed est. Vestibulum ullamcorper arcu sed lacus viverra tristique. Ve=
stibulum mattis id ante eget aliquam. Nam pulvinar, libero dignissim posuer=
e tincidunt, ipsum est finibus enim, id accumsan augue lectus eu massa. In =
dapibus consequat velit quis interdum. Cras vel augue pellentesque tortor i=
nterdum molestie. Fusce ut dui semper, ultricies nulla a, tempor lacus. In =
interdum velit in justo dapibus iaculis. Quisque et neque turpis. Aenean id=
nunc sodales, ultrices turpis non, molestie nibh.
In euismod aliquam tortor ac ornare. Donec nisi ante, lacinia eget placerat=
at, tincidunt id ante. Vestibulum mauris nisi, consectetur vitae dolor qui=
s, maximus auctor tellus. In dignissim mi blandit, laoreet mi vitae, gravid=
a risus. Sed rhoncus nisi velit, at condimentum velit efficitur id. Duis ia=
culis dictum tempor. Ut consectetur nisi in ex viverra interdum. Cras eget =
vestibulum libero. Vestibulum in efficitur ante, id tristique elit. Aliquam=
justo dolor, sagittis et dui vitae, gravida pellentesque lectus. Sed venen=
atis imperdiet cursus. Nulla quis nulla eu nisi tempor varius et ut elit. I=
nterdum et malesuada fames ac ante ipsum primis in faucibus. Mauris placera=
t interdum eros, vitae molestie urna consequat et.
Cras massa dolor, congue eu magna et, sagittis eleifend quam. Donec volutpa=
t congue leo, in sodales purus mollis nec. Integer vehicula, odio eget cong=
ue interdum, erat nunc interdum nisi, quis congue nunc felis eu ipsum. Sed =
bibendum massa dui, et sodales lacus dignissim ut. Nulla et orci vitae lacu=
s dignissim elementum eu sollicitudin turpis. Phasellus leo lorem, pellente=
sque sit amet ultricies varius, bibendum sed ligula. Nam eros orci, facilis=
is vel lacus vitae, suscipit tincidunt nibh. In id magna a velit molestie a=
uctor. Etiam a nunc ligula. Sed hendrerit, felis quis pharetra bibendum, to=
rtor sem tempor lacus, et hendrerit quam nisl ac metus. Ut convallis congue=
lectus, eu scelerisque nisl pharetra sed. Vestibulum ante ipsum primis in =
faucibus orci luctus et ultrices posuere cubilia Curae; Aliquam ut turpis n=
isi. Phasellus ac dolor laoreet, commodo turpis quis, ultricies turpis. Qui=
sque mollis lorem vestibulum diam sollicitudin, in tempor mi accumsan. Proi=
n metus nunc, fringilla eu sagittis nec, rhoncus semper sem.
Fusce posuere et felis ut ornare. Proin sodales sollicitudin tellus, non tr=
istique ex egestas at. Pellentesque arcu sem, vulputate a diam eget, blandi=
t lobortis libero. Donec accumsan, diam vel congue hendrerit, mi mi vestibu=
lum ex, ut elementum est magna eu augue. Aliquam consequat arcu eu velit tr=
istique placerat. Quisque facilisis tempor ipsum, sit amet iaculis nunc mal=
esuada a. Morbi laoreet fringilla odio sed volutpat. Integer scelerisque in=
terdum massa et fringilla. Nam pulvinar iaculis nibh, id condimentum nunc. =
Integer eleifend, dui in iaculis sollicitudin, lacus dui feugiat felis, ut =
efficitur urna sem at neque. Donec at sapien malesuada orci imperdiet eleme=
ntum. Nulla luctus tristique enim, eu euismod diam ultricies vel. Ut dui pu=
rus, commodo eu tristique nec, accumsan vel lorem. Phasellus sagittis ullam=
corper vulputate. Vestibulum sed nulla tristique, tristique lectus quis, pu=
lvinar quam.
Duis sollicitudin convallis lacinia. Maecenas erat eros, laoreet ut sapien =
vitae, convallis semper neque. Ut est turpis, pharetra sed tortor id, luctu=
s pretium odio. Mauris laoreet dapibus iaculis. Pellentesque augue tortor, =
lacinia eget mauris eu, hendrerit ultricies turpis. Vestibulum suscipit nis=
i ligula, at tempus augue vulputate ac. Vestibulum leo mauris, tristique se=
d cursus a, sodales a erat. Sed accumsan justo at dui ornare, at placerat a=
ugue vestibulum.
--_000_8D08CB465951804FA4DBB0C8B35CB0FA013B4E7ADDonerdexch09on_
Content-Type: text/html; charset="us-ascii"
Content-Transfer-Encoding: quoted-printable
<html xmlns:v=3D"urn:schemas-microsoft-com:vml" xmlns:o=3D"urn:schemas-micr=
osoft-com:office:office" xmlns:w=3D"urn:schemas-microsoft-com:office:word" =
xmlns:m=3D"http://schemas.microsoft.com/office/2004/12/omml" xmlns=3D"http:=
//www.w3.org/TR/REC-html40">
<head>
<meta http-equiv=3D"Content-Type" content=3D"text/html; charset=3Dus-ascii"=
>
<meta name=3D"Generator" content=3D"Microsoft Word 15 (filtered medium)">
<style><!--
/* Font Definitions */
@font-face
{font-family:"MS Mincho";
panose-1:2 2 6 9 4 2 5 8 3 4;}
@font-face
{font-family:"Cambria Math";
panose-1:2 4 5 3 5 4 6 3 2 4;}
@font-face
{font-family:Calibri;
panose-1:2 15 5 2 2 2 4 3 2 4;}
@font-face
{font-family:"\@MS Mincho";
panose-1:2 2 6 9 4 2 5 8 3 4;}
/* Style Definitions */
p.MsoNormal, li.MsoNormal, div.MsoNormal
{margin:0in;
margin-bottom:.0001pt;
font-size:11.0pt;
font-family:"Calibri",sans-serif;}
a:link, span.MsoHyperlink
{mso-style-priority:99;
color:#0563C1;
text-decoration:underline;}
a:visited, span.MsoHyperlinkFollowed
{mso-style-priority:99;
color:#954F72;
text-decoration:underline;}
span.EmailStyle17
{mso-style-type:personal-compose;
font-family:"Calibri",sans-serif;
color:windowtext;}
.MsoChpDefault
{mso-style-type:export-only;}
@page WordSection1
{size:8.5in 11.0in;
margin:1.0in 1.0in 1.0in 1.0in;}
div.WordSection1
{page:WordSection1;}
--></style><!--[if gte mso 9]><xml>
<o:shapedefaults v:ext=3D"edit" spidmax=3D"1026" />
</xml><![endif]--><!--[if gte mso 9]><xml>
<o:shapelayout v:ext=3D"edit">
<o:idmap v:ext=3D"edit" data=3D"1" />
</o:shapelayout></xml><![endif]-->
</head>
<body lang=3D"EN-US" link=3D"#0563C1" vlink=3D"#954F72">
<div class=3D"WordSection1">
<p class=3D"MsoNormal">Lorem ipsum dolor sit amet, consectetur adipiscing e=
lit. Pellentesque libero arcu, accumsan at mattis nec, condimentum vitae er=
at. Praesent massa turpis, iaculis elementum lectus vitae, iaculis laoreet =
massa. Integer porta, nisi eget congue
vulputate, tellus lacus imperdiet tellus, in tristique metus nibh sed est.=
Vestibulum ullamcorper arcu sed lacus viverra tristique. Vestibulum mattis=
id ante eget aliquam. Nam pulvinar, libero dignissim posuere tincidunt, ip=
sum est finibus enim, id accumsan
augue lectus eu massa. In dapibus consequat velit quis interdum. Cras vel =
augue pellentesque tortor interdum molestie. Fusce ut dui semper, ultricies=
nulla a, tempor lacus. In interdum velit in justo dapibus iaculis. Quisque=
et neque turpis. Aenean id nunc
sodales, ultrices turpis non, molestie nibh.<o:p></o:p></p>
<p class=3D"MsoNormal"><o:p>&nbsp;</o:p></p>
<p class=3D"MsoNormal">In euismod aliquam tortor ac ornare. Donec nisi ante=
, lacinia eget placerat at, tincidunt id ante. Vestibulum mauris nisi, cons=
ectetur vitae dolor quis, maximus auctor tellus. In dignissim mi blandit, l=
aoreet mi vitae, gravida risus. Sed
rhoncus nisi velit, at condimentum velit efficitur id. Duis iaculis dictum=
tempor. Ut consectetur nisi in ex viverra interdum. Cras eget vestibulum l=
ibero. Vestibulum in efficitur ante, id tristique elit. Aliquam justo dolor=
, sagittis et dui vitae, gravida
pellentesque lectus. Sed venenatis imperdiet cursus. Nulla quis nulla eu n=
isi tempor varius et ut elit. Interdum et malesuada fames ac ante ipsum pri=
mis in faucibus. Mauris placerat interdum eros, vitae molestie urna consequ=
at et.<o:p></o:p></p>
<p class=3D"MsoNormal"><o:p>&nbsp;</o:p></p>
<p class=3D"MsoNormal">Cras massa dolor, congue eu magna et, sagittis eleif=
end quam. Donec volutpat congue leo, in sodales purus mollis nec. Integer v=
ehicula, odio eget congue interdum, erat nunc interdum nisi, quis congue nu=
nc felis eu ipsum. Sed bibendum massa
dui, et sodales lacus dignissim ut. Nulla et orci vitae lacus dignissim el=
ementum eu sollicitudin turpis. Phasellus leo lorem, pellentesque sit amet =
ultricies varius, bibendum sed ligula. Nam eros orci, facilisis vel lacus v=
itae, suscipit tincidunt nibh. In
id magna a velit molestie auctor. Etiam a nunc ligula. Sed hendrerit, feli=
s quis pharetra bibendum, tortor sem tempor lacus, et hendrerit quam nisl a=
c metus. Ut convallis congue lectus, eu scelerisque nisl pharetra sed. Vest=
ibulum ante ipsum primis in faucibus
orci luctus et ultrices posuere cubilia Curae; Aliquam ut turpis nisi. Pha=
sellus ac dolor laoreet, commodo turpis quis, ultricies turpis. Quisque mol=
lis lorem vestibulum diam sollicitudin, in tempor mi accumsan. Proin metus =
nunc, fringilla eu sagittis nec,
rhoncus semper sem.<o:p></o:p></p>
<p class=3D"MsoNormal"><o:p>&nbsp;</o:p></p>
<p class=3D"MsoNormal">Fusce posuere et felis ut ornare. Proin sodales soll=
icitudin tellus, non tristique ex egestas at. Pellentesque arcu sem, vulput=
ate a diam eget, blandit lobortis libero. Donec accumsan, diam vel congue h=
endrerit, mi mi vestibulum ex, ut
elementum est magna eu augue. Aliquam consequat arcu eu velit tristique pl=
acerat. Quisque facilisis tempor ipsum, sit amet iaculis nunc malesuada a. =
Morbi laoreet fringilla odio sed volutpat. Integer scelerisque interdum mas=
sa et fringilla. Nam pulvinar iaculis
nibh, id condimentum nunc. Integer eleifend, dui in iaculis sollicitudin, =
lacus dui feugiat felis, ut efficitur urna sem at neque. Donec at sapien ma=
lesuada orci imperdiet elementum. Nulla luctus tristique enim, eu euismod d=
iam ultricies vel. Ut dui purus,
commodo eu tristique nec, accumsan vel lorem. Phasellus sagittis ullamcorp=
er vulputate. Vestibulum sed nulla tristique, tristique lectus quis, pulvin=
ar quam.<o:p></o:p></p>
<p class=3D"MsoNormal"><o:p>&nbsp;</o:p></p>
<p class=3D"MsoNormal">Duis sollicitudin convallis lacinia. Maecenas erat e=
ros, laoreet ut sapien vitae, convallis semper neque. Ut est turpis, pharet=
ra sed tortor id, luctus pretium odio. Mauris laoreet dapibus iaculis. Pell=
entesque augue tortor, lacinia eget
mauris eu, hendrerit ultricies turpis. Vestibulum suscipit nisi ligula, at=
tempus augue vulputate ac. Vestibulum leo mauris, tristique sed cursus a, =
sodales a erat. Sed accumsan justo at dui ornare, at placerat augue vestibu=
lum.<o:p></o:p></p>
</div>
</body>
</html>
--_000_8D08CB465951804FA4DBB0C8B35CB0FA013B4E7ADDonerdexch09on_--
--_005_8D08CB465951804FA4DBB0C8B35CB0FA013B4E7ADDonerdexch09on_
Content-Type: image/png; name="favicon.png"
Content-Description: favicon.png
Content-Disposition: attachment; filename="favicon.png"; size=3025;
creation-date="Mon, 24 Aug 2015 19:05:03 GMT";
modification-date="Mon, 24 Aug 2015 19:05:03 GMT"
Content-Transfer-Encoding: base64
iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAC4jAAAuIwF4pT92AAAK
T2lDQ1BQaG90b3Nob3AgSUNDIHByb2ZpbGUAAHjanVNnVFPpFj333vRCS4iAlEtvUhUIIFJCi4AU
kSYqIQkQSoghodkVUcERRUUEG8igiAOOjoCMFVEsDIoK2AfkIaKOg6OIisr74Xuja9a89+bN/rXX
Pues852zzwfACAyWSDNRNYAMqUIeEeCDx8TG4eQuQIEKJHAAEAizZCFz/SMBAPh+PDwrIsAHvgAB
eNMLCADATZvAMByH/w/qQplcAYCEAcB0kThLCIAUAEB6jkKmAEBGAYCdmCZTAKAEAGDLY2LjAFAt
AGAnf+bTAICd+Jl7AQBblCEVAaCRACATZYhEAGg7AKzPVopFAFgwABRmS8Q5ANgtADBJV2ZIALC3
AMDOEAuyAAgMADBRiIUpAAR7AGDIIyN4AISZABRG8lc88SuuEOcqAAB4mbI8uSQ5RYFbCC1xB1dX
Lh4ozkkXKxQ2YQJhmkAuwnmZGTKBNA/g88wAAKCRFRHgg/P9eM4Ors7ONo62Dl8t6r8G/yJiYuP+
5c+rcEAAAOF0ftH+LC+zGoA7BoBt/qIl7gRoXgugdfeLZrIPQLUAoOnaV/Nw+H48PEWhkLnZ2eXk
5NhKxEJbYcpXff5nwl/AV/1s+X48/Pf14L7iJIEyXYFHBPjgwsz0TKUcz5IJhGLc5o9H/LcL//wd
0yLESWK5WCoU41EScY5EmozzMqUiiUKSKcUl0v9k4t8s+wM+3zUAsGo+AXuRLahdYwP2SycQWHTA
4vcAAPK7b8HUKAgDgGiD4c93/+8//UegJQCAZkmScQAAXkQkLlTKsz/HCAAARKCBKrBBG/TBGCzA
BhzBBdzBC/xgNoRCJMTCQhBCCmSAHHJgKayCQiiGzbAdKmAv1EAdNMBRaIaTcA4uwlW4Dj1wD/ph
CJ7BKLyBCQRByAgTYSHaiAFiilgjjggXmYX4IcFIBBKLJCDJiBRRIkuRNUgxUopUIFVIHfI9cgI5
h1xGupE7yAAygvyGvEcxlIGyUT3UDLVDuag3GoRGogvQZHQxmo8WoJvQcrQaPYw2oefQq2gP2o8+
Q8cwwOgYBzPEbDAuxsNCsTgsCZNjy7EirAyrxhqwVqwDu4n1Y8+xdwQSgUXACTYEd0IgYR5BSFhM
WE7YSKggHCQ0EdoJNwkDhFHCJyKTqEu0JroR+cQYYjIxh1hILCPWEo8TLxB7iEPENyQSiUMyJ7mQ
AkmxpFTSEtJG0m5SI+ksqZs0SBojk8naZGuyBzmULCAryIXkneTD5DPkG+Qh8lsKnWJAcaT4U+Io
UspqShnlEOU05QZlmDJBVaOaUt2ooVQRNY9aQq2htlKvUYeoEzR1mjnNgxZJS6WtopXTGmgXaPdp
r+h0uhHdlR5Ol9BX0svpR+iX6AP0dwwNhhWDx4hnKBmbGAcYZxl3GK+YTKYZ04sZx1QwNzHrmOeZ
D5lvVVgqtip8FZHKCpVKlSaVGyovVKmqpqreqgtV81XLVI+pXlN9rkZVM1PjqQnUlqtVqp1Q61Mb
U2epO6iHqmeob1Q/pH5Z/YkGWcNMw09DpFGgsV/jvMYgC2MZs3gsIWsNq4Z1gTXEJrHN2Xx2KruY
/R27iz2qqaE5QzNKM1ezUvOUZj8H45hx+Jx0TgnnKKeX836K3hTvKeIpG6Y0TLkxZVxrqpaXllir
SKtRq0frvTau7aedpr1Fu1n7gQ5Bx0onXCdHZ4/OBZ3nU9lT3acKpxZNPTr1ri6qa6UbobtEd79u
p+6Ynr5egJ5Mb6feeb3n+hx9L/1U/W36p/VHDFgGswwkBtsMzhg8xTVxbzwdL8fb8VFDXcNAQ6Vh
lWGX4YSRudE8o9VGjUYPjGnGXOMk423GbcajJgYmISZLTepN7ppSTbmmKaY7TDtMx83MzaLN1pk1
mz0x1zLnm+eb15vft2BaeFostqi2uGVJsuRaplnutrxuhVo5WaVYVVpds0atna0l1rutu6cRp7lO
k06rntZnw7Dxtsm2qbcZsOXYBtuutm22fWFnYhdnt8Wuw+6TvZN9un2N/T0HDYfZDqsdWh1+c7Ry
FDpWOt6azpzuP33F9JbpL2dYzxDP2DPjthPLKcRpnVOb00dnF2e5c4PziIuJS4LLLpc+Lpsbxt3I
veRKdPVxXeF60vWdm7Obwu2o26/uNu5p7ofcn8w0nymeWTNz0MPIQ+BR5dE/C5+VMGvfrH5PQ0+B
Z7XnIy9jL5FXrdewt6V3qvdh7xc+9j5yn+M+4zw33jLeWV/MN8C3yLfLT8Nvnl+F30N/I/9k/3r/
0QCngCUBZwOJgUGBWwL7+Hp8Ib+OPzrbZfay2e1BjKC5QRVBj4KtguXBrSFoyOyQrSH355jOkc5p
DoVQfujW0Adh5mGLw34MJ4WHhVeGP45wiFga0TGXNXfR3ENz30T6RJZE3ptnMU85ry1KNSo+qi5q
PNo3ujS6P8YuZlnM1VidWElsSxw5LiquNm5svt/87fOH4p3iC+N7F5gvyF1weaHOwvSFpxapLhIs
OpZATIhOOJTwQRAqqBaMJfITdyWOCnnCHcJnIi/RNtGI2ENcKh5O8kgqTXqS7JG8NXkkxTOlLOW5
hCepkLxMDUzdmzqeFpp2IG0yPTq9MYOSkZBxQqohTZO2Z+pn5mZ2y6xlhbL+xW6Lty8elQfJa7OQ
rAVZLQq2QqboVFoo1yoHsmdlV2a/zYnKOZarnivN7cyzytuQN5zvn//tEsIS4ZK2pYZLVy0dWOa9
rGo5sjxxedsK4xUFK4ZWBqw8uIq2Km3VT6vtV5eufr0mek1rgV7ByoLBtQFr6wtVCuWFfevc1+1d
T1gvWd+1YfqGnRs+FYmKrhTbF5cVf9go3HjlG4dvyr+Z3JS0qavEuWTPZtJm6ebeLZ5bDpaql+aX
Dm4N2dq0Dd9WtO319kXbL5fNKNu7g7ZDuaO/PLi8ZafJzs07P1SkVPRU+lQ27tLdtWHX+G7R7ht7
vPY07NXbW7z3/T7JvttVAVVN1WbVZftJ+7P3P66Jqun4lvttXa1ObXHtxwPSA/0HIw6217nU1R3S
PVRSj9Yr60cOxx++/p3vdy0NNg1VjZzG4iNwRHnk6fcJ3/ceDTradox7rOEH0x92HWcdL2pCmvKa
RptTmvtbYlu6T8w+0dbq3nr8R9sfD5w0PFl5SvNUyWna6YLTk2fyz4ydlZ19fi753GDborZ752PO
32oPb++6EHTh0kX/i+c7vDvOXPK4dPKy2+UTV7hXmq86X23qdOo8/pPTT8e7nLuarrlca7nuer21
e2b36RueN87d9L158Rb/1tWeOT3dvfN6b/fF9/XfFt1+cif9zsu72Xcn7q28T7xf9EDtQdlD3YfV
P1v+3Njv3H9qwHeg89HcR/cGhYPP/pH1jw9DBY+Zj8uGDYbrnjg+OTniP3L96fynQ89kzyaeF/6i
/suuFxYvfvjV69fO0ZjRoZfyl5O/bXyl/erA6xmv28bCxh6+yXgzMV70VvvtwXfcdx3vo98PT+R8
IH8o/2j5sfVT0Kf7kxmTk/8EA5jz/GMzLdsAAAAgY0hSTQAAeiUAAICDAAD5/wAAgOkAAHUwAADq
YAAAOpgAABdvkl/FRgAAAPxJREFUeNqkU0ERgzAQ3DA1EAuxgAUsYAELsYCFWKASqASQUCRQCdfP
HuxkYProzRxJLsmyd3sJZoZ/7AEAIYQ63gLo6G4vAE8AmwfMjJ/TIoAJgNFn+iKxfAcQeXDnoVix
SgKerwAKN1v6LCwGGSf+JCpAEuTIA355F/o9wQ3AoACZwSRzz3UQgOTpA8gKMAF4c9Pr4NYJgNfq
AGgkuImEayWpywimcaybm/5IAjxwvnKdeXk9tQRGoehSLUxLe8JjsZaxq1RwCUcB3yl1vGskl6z9
0f69M6oBklAu1TtwlkXYpLu3UCTvKy9ag2BmV68xSku7baz+R2vwHQC+QKj9KkHDLAAAAABJRU5E
rkJggg==
--_005_8D08CB465951804FA4DBB0C8B35CB0FA013B4E7ADDonerdexch09on_
Content-Type: text/plain; name="README.txt"
Content-Description: README.txt
Content-Disposition: attachment; filename="README.txt"; size=1682;
creation-date="Mon, 24 Aug 2015 19:19:48 GMT";
modification-date="Mon, 24 Aug 2015 19:19:48 GMT"
Content-Transfer-Encoding: base64
SW5idWNrZXQgWyFbQnVpbGQgU3RhdHVzXShodHRwczovL3RyYXZpcy1jaS5vcmcvamhpbGx5ZXJk
L2luYnVja2V0LnBuZz9icmFuY2g9bWFzdGVyKV0oaHR0cHM6Ly90cmF2aXMtY2kub3JnL2poaWxs
eWVyZC9pbmJ1Y2tldCkKPT09PT09PT0KCkluYnVja2V0IGlzIGFuIGVtYWlsIHRlc3Rpbmcgc2Vy
dmljZTsgaXQgd2lsbCBhY2NlcHQgbWVzc2FnZXMgZm9yIGFueSBlbWFpbAphZGRyZXNzIGFuZCBt
YWtlIHRoZW0gYXZhaWxhYmxlIHZpYSB3ZWIsIFJFU1QgYW5kIFBPUDMuICBPbmNlIGNvbXBpbGVk
LApJbmJ1Y2tldCBkb2VzIG5vdCBoYXZlIGFuIGV4dGVybmFsIGRlcGVuZGVuY2llcyAoSFRUUCwg
U01UUCwgUE9QMyBhbmQgc3RvcmFnZQphcmUgYWxsIGJ1aWx0IGluKS4KClJlYWQgbW9yZSBhdCB0
aGUgW0luYnVja2V0IHdlYnNpdGVdW0luYnVja2V0XQoKRGV2ZWxvcG1lbnQgU3RhdHVzCi0tLS0t
LS0tLS0tLS0tLS0tLQoKSW5idWNrZXQgaXMgY3VycmVudGx5IHByb2R1Y3Rpb24gcXVhbGl0eTog
aXQgaXMgYmVpbmcgdXNlZCBmb3IgcmVhbCB3b3JrLgoKUGxlYXNlIGNoZWNrIHRoZSBbaXNzdWVz
IGxpc3RdW0lzc3Vlc10KZm9yIG1vcmUgZGV0YWlscy4KCkJ1aWxkaW5nIGZyb20gU291cmNlCi0t
LS0tLS0tLS0tLS0tLS0tLS0tLS0tLQoKWW91IHdpbGwgbmVlZCBhIGZ1bmN0aW9uaW5nIFtHbyBp
bnN0YWxsYXRpb25dW0dvbGFuZ10gZm9yIHRoaXMgdG8gd29yay4KCkdyYWIgdGhlIEluYnVja2V0
IHNvdXJjZSBjb2RlIGFuZCBjb21waWxlIHRoZSBkYWVtb246CgogICAgZ28gZ2V0IC12IGdpdGh1
Yi5jb20vamhpbGx5ZXJkL2luYnVja2V0CgpFZGl0IGV0Yy9pbmJ1Y2tldC5jb25mIGFuZCB0YWls
b3IgdG8geW91ciBlbnZpcm9ubWVudC4gIEl0IHNob3VsZCB3b3JrIG9uIG1vc3QKVW5peCBhbmQg
T1MgWCBtYWNoaW5lcyBhcyBpcy4gIExhdW5jaCB0aGUgZGFlbW9uOgoKICAgICRHT1BBVEgvYmlu
L2luYnVja2V0ICRHT1BBVEgvc3JjL2dpdGh1Yi5jb20vamhpbGx5ZXJkL2luYnVja2V0L2V0Yy9p
bmJ1Y2tldC5jb25mCgpCeSBkZWZhdWx0IHRoZSBTTVRQIHNlcnZlciB3aWxsIGJlIGxpc3Rlbmlu
ZyBvbiBsb2NhbGhvc3QgcG9ydCAyNTAwIGFuZAp0aGUgd2ViIGludGVyZmFjZSB3aWxsIGJlIGF2
YWlsYWJsZSBhdCBbbG9jYWxob3N0OjkwMDBdKGh0dHA6Ly9sb2NhbGhvc3Q6OTAwMC8pLgoKVGhl
IEluYnVja2V0IHdlYnNpdGUgaGFzIGEgbW9yZSBjb21wbGV0ZSBndWlkZSB0bwpbaW5zdGFsbGlu
ZyBmcm9tIHNvdXJjZV1bRnJvbSBTb3VyY2VdCgpBYm91dAotLS0tLQoKSW5idWNrZXQgaXMgd3Jp
dHRlbiBpbiBbR29vZ2xlIEdvXVtHb2xhbmddLgoKSW5idWNrZXQgaXMgb3BlbiBzb3VyY2Ugc29m
dHdhcmUgcmVsZWFzZWQgdW5kZXIgdGhlIE1JVCBMaWNlbnNlLiAgVGhlIGxhdGVzdAp2ZXJzaW9u
IGNhbiBiZSBmb3VuZCBhdCBodHRwczovL2dpdGh1Yi5jb20vamhpbGx5ZXJkL2luYnVja2V0Cgpb
SW5idWNrZXRdOiBodHRwOi8vd3d3LmluYnVja2V0Lm9yZy8KW0lzc3Vlc106IGh0dHBzOi8vZ2l0
aHViLmNvbS9qaGlsbHllcmQvaW5idWNrZXQvaXNzdWVzP3N0YXRlPW9wZW4KW0Zyb20gU291cmNl
XTogaHR0cDovL3d3dy5pbmJ1Y2tldC5vcmcvaW5zdGFsbGF0aW9uL2Zyb20tc291cmNlLmh0bWwK
W0dvbGFuZ106IGh0dHA6Ly9nb2xhbmcub3JnLwo=
--_005_8D08CB465951804FA4DBB0C8B35CB0FA013B4E7ADDonerdexch09on_--

View File

@@ -1,16 +1,58 @@
#!/bin/sh
#!/bin/bash
# run-tests.sh
# description: Generate test emails for Inbucket
set -eo pipefail
[ $TRACE ] && set -x
# We need to be in swaks-tests directory
cmdpath="$(dirname "$0")"
if [ "$cmdpath" != "." ]; then
cd "$cmdpath"
fi
case "$1" in
"")
to="swaks"
;;
--*)
to="swaks"
;;
*)
to="$1"
shift
;;
esac
export SWAKS_OPT_server="127.0.0.1:2500"
export SWAKS_OPT_to="swaks@inbucket.local"
export SWAKS_OPT_to="$to@inbucket.local"
# Basic test
swaks $* --h-Subject: "Swaks Plain Text" --body text.txt
# Multi-recipient test
swaks $* --to="$to@inbucket.local,alternate@inbucket.local" --h-Subject: "Swaks Multi-Recipient" \
--body text.txt
# HTML test
swaks $* --h-Subject: "Swaks HTML" --data html.raw
swaks $* --h-Subject: "Swaks HTML" --data mime-html.raw
# Top level HTML test
swaks $* --h-Subject: "Swaks Top Level HTML" --data nonmime-html.raw
# Attachment test
swaks $* --h-Subject: "Swaks Attachment" --attach-type image/png --attach favicon.png --body text.txt
swaks $* --h-Subject: "Swaks Attachment" --attach-type image/png --attach favicon.png \
--body text.txt
# Encoded subject line test
swaks $* --data utf8-subject.txt
swaks $* --data utf8-subject.raw
# Gmail test
swaks $* --data gmail.raw
# Outlook test
swaks $* --data outlook.raw
# Non-mime responsive HTML test
swaks $* --data nonmime-html-responsive.raw
swaks $* --data nonmime-html-inlined.raw

View File

@@ -0,0 +1,29 @@
{
"name": "inbucket-bootstrap",
"homepage": "http://www.inbucket.org/",
"authors": [
"James Hillyerd <james@hillyerd.com>"
],
"description": "Bootstrap theme for Inbucket",
"main": [],
"moduleType": [
"globals"
],
"license": "MIT",
"ignore": [
"**/.*",
"node_modules",
"bower_components",
"test",
"tests"
],
"dependencies": {
"bootstrap": "3.3",
"jquery": "2",
"jquery-color": "^2.1.2",
"jquery-sparkline": "^2.1.3",
"clipboard": "^1.5.9",
"jquery-load-template": "^1.5.6",
"moment": "^2.11.2"
}
}

View File

@@ -0,0 +1,44 @@
{
"name": "bootstrap",
"description": "The most popular front-end framework for developing responsive, mobile first projects on the web.",
"keywords": [
"css",
"js",
"less",
"mobile-first",
"responsive",
"front-end",
"framework",
"web"
],
"homepage": "http://getbootstrap.com",
"license": "MIT",
"moduleType": "globals",
"main": [
"less/bootstrap.less",
"dist/js/bootstrap.js"
],
"ignore": [
"/.*",
"_config.yml",
"CNAME",
"composer.json",
"CONTRIBUTING.md",
"docs",
"js/tests",
"test-infra"
],
"dependencies": {
"jquery": "1.9.1 - 3"
},
"version": "3.3.7",
"_release": "3.3.7",
"_resolution": {
"type": "version",
"tag": "v3.3.7",
"commit": "0b9c4a4007c44201dce9a6cc1a38407005c26c86"
},
"_source": "https://github.com/twbs/bootstrap.git",
"_target": "3.3",
"_originalSource": "bootstrap"
}

View File

@@ -0,0 +1,5 @@
Bootstrap uses [GitHub's Releases feature](https://github.com/blog/1547-release-your-software) for its changelogs.
See [the Releases section of our GitHub project](https://github.com/twbs/bootstrap/releases) for changelogs for each release version of Bootstrap.
Release announcement posts on [the official Bootstrap blog](http://blog.getbootstrap.com) contain summaries of the most noteworthy changes made in each release.

View File

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

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