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

Compare commits

...

416 Commits

Author SHA1 Message Date
James Hillyerd
a10a6244c9 Merge pull request #279 from inbucket/v302rel
Release 3.0.2
2022-07-04 12:02:17 -07:00
James Hillyerd
9185423022 Update CHANGELOG 2022-07-04 11:03:48 -07:00
James Hillyerd
9aaca449f8 Fix non-root basepaths, closes #273 2022-05-22 21:47:52 -07:00
James Hillyerd
f39395bd7f Fix error in swaks tests server default env var 2022-05-22 21:47:52 -07:00
James Hillyerd
2c68128d5d swaks-tests: allow server address override 2022-05-08 13:13:25 -07:00
James Hillyerd
06d4120682 Migrate to Yarn & Parcel (#260)
* Switch from npm to yarn
* Add minimum viable parcel dev server config
* Remove webpack configs
* Update docker build, build w/ yarn on node 16.x
2022-04-23 13:35:54 -07:00
James Hillyerd
58bcd4f557 Docker frontend build runs as amd64 (#270) 2022-04-23 10:33:46 -07:00
kaustubh105
e91e8d5aee Add support for ARMv7 and ARM64 docker images (#267)
* Build armv7 and arm64 docker containers

* Add QEMU step to build for multi arch

* Pin qemu action version
2022-02-25 08:56:18 -08:00
James Hillyerd
5322462899 Update changelog for 3.0.1-rc2
Signed-off-by: James Hillyerd <james@hillyerd.com>
2022-01-23 17:42:32 -08:00
Daniel Simkus
5def9ed183 Add arm and arm64 builds (#256)
* Add arm and arm64 builds

Signed-off-by: danielsimkus <daniel.simkus@carandclassic.com>

* Added arm and arm64 to client, and switched to goarm 7

Signed-off-by: danielsimkus <daniel.simkus@carandclassic.com>
2022-01-22 09:50:37 -08:00
James Hillyerd
357589d90e Rename master branch to main (#255)
* Update contributing guide, remove git-flow references

Signed-off-by: James Hillyerd <james@hillyerd.com>

* Update changelog for main branch rename

Signed-off-by: James Hillyerd <james@hillyerd.com>

* Update github actions for branch rename

* Update README build badges

Signed-off-by: James Hillyerd <james@hillyerd.com>

* Update README for new branch names

Signed-off-by: James Hillyerd <james@hillyerd.com>

* Note branch rename in change log

Signed-off-by: James Hillyerd <james@hillyerd.com>
2022-01-17 17:16:44 -08:00
James Hillyerd
b664bcfc4c Update changelog for 3.0.1-rc1 2022-01-17 13:28:00 -08:00
James Hillyerd
ffd13e2ee7 Update go deps (#253)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2022-01-17 13:17:50 -08:00
James Hillyerd
747775b8f2 Update npm deps (#252)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2022-01-17 12:48:44 -08:00
dependabot[bot]
2c0d942c76 build(deps): bump follow-redirects from 1.14.0 to 1.14.7 in /ui (#247)
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.14.0 to 1.14.7.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.14.0...v1.14.7)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-01-17 12:24:06 -08:00
James Hillyerd
e7263439d5 Correct goreleaser config (#250)
* Correct glob patterns to include missing index.html
* Remove broken homebrew config
2022-01-17 12:19:37 -08:00
James Hillyerd
cb6f99c487 Merge branch 'release/3.0.0' 2021-10-02 10:52:59 -07:00
James Hillyerd
04fb58e15e Update CHANGELOG for final 3.0.0 2021-09-19 11:34:43 -07:00
James Hillyerd
f11ad55474 Merge tag 'v3.0.0-rc4' into develop
v3.0.0-rc4

Fixed
- Various MIME header decoding improvements

Changed
- Bump Go version to 1.17 (#233)
2021-08-22 13:37:26 -07:00
James Hillyerd
26939f2bf6 Merge branch 'release/3.0.0-rc4' 2021-08-22 13:36:32 -07:00
James Hillyerd
05a3b1742a Update CHANGELOG for rc4 2021-08-22 13:32:47 -07:00
James Hillyerd
867d5f5d7f Update npm deps (#235) 2021-08-22 13:25:42 -07:00
James Hillyerd
8e34a21dc6 Update Go dependencies, incl enmime (#234) 2021-08-22 12:54:26 -07:00
James Hillyerd
8869acef0b Bump Go to 1.17 (#233)
* Bump Go to 1.17

* update chglog
2021-08-22 12:31:13 -07:00
James Hillyerd
752d5c9668 docker: tag versions with latest (#232) 2021-08-07 10:56:40 -07:00
James Hillyerd
091e26c467 docker-build should also run on tags (#230) 2021-08-01 13:19:19 -07:00
James Hillyerd
6593a36b48 Merge tag 'v3.0.0-rc3' into develop
Release is for CI/CD changes only.
2021-08-01 13:02:26 -07:00
James Hillyerd
68ef2d9873 Merge branch 'release/3.0.0-rc3' 2021-08-01 13:01:35 -07:00
James Hillyerd
ab988caf6b Add rc3 to change log 2021-08-01 13:00:28 -07:00
James Hillyerd
fa62220d98 Add ghcr.io to docker metadata images 2021-08-01 12:29:26 -07:00
James Hillyerd
1ecf424975 login to GitHub container registry 2021-08-01 12:14:28 -07:00
James Hillyerd
3342938dd4 Update to docker-push-action v2 w/ metadata, buildx (#228)
Docker Hub no longer builds for us, so we need to switch to GHA
2021-08-01 11:57:46 -07:00
James Hillyerd
6be1655723 Update to docker-push-action v2 w/ metadata, buildx (#228)
Docker Hub no longer builds for us, so we need to switch to GHA
2021-08-01 11:52:17 -07:00
James Hillyerd
1465e6fb49 Merge tag 'v3.0.0-rc2' into develop
Added:

- Support for SMTP AUTH (#197, thanks makarchuk)
- Dark mode support (#218, thanks nerones)

Fixed:

- Prevent potential click jacking (#190, thanks stuartskelton)
- Error on 8 character long SMTP commands (#221)
- Allow empty username and password during AUTH (#225)
2021-07-31 16:41:35 -07:00
James Hillyerd
21991cbfc7 Merge branch 'release/3.0.0-rc2' 2021-07-31 16:40:17 -07:00
James Hillyerd
7138a97935 Update change log for 3.0.0-rc2 2021-07-31 16:32:01 -07:00
James Hillyerd
beee68fc5d Update change log 2021-07-31 16:26:47 -07:00
James Hillyerd
9e2af71743 Upgrade node deps (#227)
* bump node deps

* npm audit fix
2021-07-31 16:09:30 -07:00
James Hillyerd
a2c4292fc1 update go deps (#226) 2021-07-31 15:53:38 -07:00
James Hillyerd
2016142747 smtp: allow empty user & pass during AUTH LOGIN (#225) 2021-07-31 10:38:48 -07:00
James Hillyerd
4f9f961cac smtp: fix formatting (#224) 2021-07-31 10:32:08 -07:00
Nelson Efrain A. Cruz
bf8536abb3 Adds dark mode support (#218)
* Adds dark mode support

Updates the css to support dark mode via media query.
The dark theme its heavily inspired on the new dark mode for google.com.
2021-07-20 08:07:06 -07:00
James Hillyerd
985f2702f2 Fix command line length bug (#221)
* handler: Don't fail on 8 character command lines

Fixes #214

* handler: Test that STARTTLS is parsed correctly.
2021-07-11 12:00:28 -07:00
James Hillyerd
11f3879442 goreleaser: update nfpm config to use contents attrib (#220)
fixes #219

Signed-off-by: James Hillyerd <james@hillyerd.com>
2021-07-11 10:05:00 -07:00
James Hillyerd
8562c55c98 nix: add elm-json for updating pkgs 2021-05-06 12:27:01 -07:00
James Hillyerd
e3066bb535 Update nodejs dependencies (#209)
* node: Update top level deps

* node: Audit fix
2021-05-06 11:22:50 -07:00
James Hillyerd
35ab31efbc Update go deps (#208) 2021-05-06 10:35:53 -07:00
James Hillyerd
81edf40996 store_test: Fix t.Fatal non-test goroutine lint error 2021-05-06 09:58:30 -07:00
James Hillyerd
c64e7a6a6c Revert "Add support for AUTH, closes #62" (#206)
This reverts commit 261bbef426.  It was merged to directly master by mistake, and should have gone through the normal release process.
2021-05-03 20:56:10 -07:00
James Hillyerd
4bd64563f2 Bump nodejs to 14.x (#203) 2021-05-01 16:50:43 -07:00
James Hillyerd
66dec49a49 Bump Go version to 1.16 (#202)
* bump go version
* Docker: bump go/alpine version
2021-05-01 14:16:59 -07:00
James Hillyerd
649e3743e0 Migrate off Travis CI (#201)
Adds generic Go matrix build with coverage, removes Travis config.
2021-05-01 13:49:41 -07:00
Timur Makarchuk
c096f018d6 Add support for AUTH, closes #62
* Add PLAIN and LOGIN auth support
2021-04-10 13:58:18 -07:00
Timur Makarchuk
261bbef426 Add support for AUTH, closes #62
* Add PLAIN and LOGIN auth support
2021-04-10 13:45:09 -07:00
Stuart Skelton
3c5960aba0 Avoid potential click jacking (#190) 2020-11-19 08:16:01 -08:00
James Hillyerd
7f430f2bde Merge tag 'v3.0.0-rc1' into develop
Added:

- Refresh button to reload mailbox contents
- Improved keyboard (tab) focus highlights

Changed:

- The UI now includes the Open Sans webfont instead of relying on browser/OS
  fonts
2020-09-24 20:59:30 -07:00
James Hillyerd
c480fcb341 Merge branch 'release/3.0.0-rc1' 2020-09-24 20:58:53 -07:00
James Hillyerd
e74f5e5116 Update changelog for 3.0.0-rc1 2020-09-24 20:54:07 -07:00
James Hillyerd
6ce045ddb7 Update changelog 2020-09-24 20:43:56 -07:00
James Hillyerd
9b03c311db ui: Replace Mailbox Session use with ServeUrl (#185)
Plus a couple UI padding tweaks
2020-09-24 15:59:12 -07:00
James Hillyerd
ebd25a60e1 ui: Remove to do comments, must keep session.router 2020-09-24 11:19:04 -07:00
James Hillyerd
7c87649579 ui: Convert Layout to use Effects 2020-09-23 23:00:29 -07:00
James Hillyerd
e56365b9a0 Update deps (#184)
* backend: update Go dependencies
* frontend: update npm dependencies
2020-09-23 21:39:27 -07:00
James Hillyerd
698b0406c8 Readme updates (#183)
* Add docker build badge
* Rephrase things
* add dev guide link
* Remove brew tap section until #68 is fixed
2020-09-23 20:43:51 -07:00
James Hillyerd
361bbec293 ui: Keyboard accessibility focus highlights (#180)
* Focus indication for mailbox message list
* Add focus for monitor message list
2020-09-22 14:11:06 -07:00
James Hillyerd
407ae87a3b ui: Add refresh button to mailbox page (#179)
`socketConnected` is not implemented, but will be used when we implement #92
2020-09-21 20:11:32 -07:00
James Hillyerd
4648d8e593 ui: Use OpenSans font (#178) 2020-09-21 16:16:00 -07:00
James Hillyerd
5c5b0f819b Effects refactor continued (#177)
* Use Effects instead of replaceUrl in Mailbox
* Add Effect.navigateRoute to handle monitor message clicks
* Add a focusModal effect for mailbox purge
* Remove temporary Cmd wrapper Effect
2020-09-13 17:08:11 -07:00
James Hillyerd
8adfd82232 ui: add npm run clean script 2020-09-13 10:39:16 -07:00
James Hillyerd
2162a4caaa ui: Add an Effect system to handle global state and Elm Cmds (#176)
All pages now leverage Effects for most of their Session and Cmd requests. More work required for routing and other lingering Cmd use.
2020-09-12 19:45:14 -07:00
James Hillyerd
cf4c5a29bb ui: Force file watch on dev server
file watch stopped working
2020-09-12 16:32:40 -07:00
James Hillyerd
6598b09114 ui: Start dev server with default host, not 0.0.0.0
0.0.0.0 does not work well with WSL2 on Windows 10
2020-09-06 16:38:33 -07:00
James Hillyerd
ce5bfddaa5 Migrate release process from travis to github (#175)
* set fetch depth to 0
* Only snapshot when not tagged
* Run deploy for v* tags
* travis: remove deploy stage
2020-09-05 14:21:42 -07:00
James Hillyerd
2934d799ef Add a GitHub workflow for building a snapshot release 2020-09-04 15:06:57 -07:00
James Hillyerd
8a07a24828 Merge tag 'v3.0.0-beta3' into develop 2020-09-04 12:45:45 -07:00
James Hillyerd
2408ace6c2 Merge branch 'release/3.0.0-beta3' 2020-09-04 12:45:14 -07:00
James Hillyerd
1a5db5b5f8 update CHANGELOG 2020-09-04 12:44:50 -07:00
James Hillyerd
f712f5b0f3 Update frontend dependencies (#174)
* ui: update top level npm deps

* ui: update webpack dependencies

* ui: update elm-webpack-loader

* ui: npm audit fix
2020-08-30 18:48:41 -07:00
James Hillyerd
f0520b88c5 Update backend and docker dependencies (#173)
* backend: update dependencies
* travis: go 1.15
* docker: bump to go 1.15, alpine 3.12
2020-08-30 13:18:58 -07:00
James Hillyerd
5a0c4778cb Set base path in index.html (#172)
- Create a new index-dev.html for webpack live server
- Update Go+index.html to set <base href>
- Fixes #171
2020-08-29 19:06:21 -07:00
James Hillyerd
289b38f016 Add configurable base path for reverse proxy use (#169)
* ui: Refactor routing functions into Router record
* ui: Store base URI in AppConfig
* ui: Use basePath in Router functions
* backend: Add Web.BasePath config option and update routes
* Tweaks to get SPA to bootstrap basePath configured
* ui: basePath support for apis/serve
* ui: basePath support for message monitor
* web: Redirect requests to / when basePath configured
* doc: add basepath to config.md
* Closes #107
2020-08-09 15:53:15 -07:00
James Hillyerd
316a732e7f cmd, pkg: add line breaks to several go source files 2020-07-26 11:59:27 -07:00
Sascha Andres
f0bc5741f3 feat: update gorilla websocket (#167)
Closes #144
2020-07-25 10:34:15 -07:00
Sascha Andres
046de42774 allow empty envelope (#166)
* feat: allow empty MAIL FROM

Closes #164
2020-07-25 10:23:31 -07:00
James Hillyerd
860045715c docker-run.sh: Update image repo 2020-06-28 11:02:49 -07:00
James Hillyerd
001e9fec58 Rollback #153 as it breaks storage volumes, closes #27 (#161)
Keeping tzdata pkg
2020-06-28 11:00:51 -07:00
James Hillyerd
2e0b7cc097 Merge docker github action for #160 2020-06-28 09:31:07 -07:00
James Hillyerd
b0bbf2e9f5 github: Add action to test build docker image 2020-06-28 09:27:45 -07:00
Martijn Suijlen
3372ade61b Docker image should run non-root (#153)
Changed the Dockerfile so that there is a Inbucket user (and group). This will allow the container to be executed a the Inbucket user in stead of ROOT (security best practices)

If the user wants to use a different greeting.html file he can use the environment variable to define a different one. For now we just use the greeting.html from the defaults directory.

* Permissions for /start-inbucket.sh file
* Added timezone data so you can set the timezone in the image
* Updated Docker greeting.html file to include some basic instructions
* Updated to alpine 3.11
* Updated to golang 1.14
* Updated the required packages
2020-06-26 08:38:27 -07:00
James Hillyerd
62dd540be5 Merge branch 'feature/mailbox-timer' into develop 2020-04-12 16:29:31 -07:00
James Hillyerd
65a6ab2b4f ui: Simplify updateMarkMessageSeen 2020-04-11 16:11:28 -07:00
James Hillyerd
9e1da20782 ui: Update Mailbox to use Timer module 2020-04-11 15:32:35 -07:00
James Hillyerd
930801f6da Merge branch 'feature/mouse-out-delay' into develop 2020-04-05 17:47:57 -07:00
James Hillyerd
4fc8d229eb ui: impl Timer.schedule function 2020-04-05 17:27:22 -07:00
James Hillyerd
e8e506f870 ui: Refactor Timer into it's own module. 2020-04-05 16:22:16 -07:00
James Hillyerd
8a3d291ff3 ui: Improve layout menu function
- Rename menu to mainMenu for clarity
- Rename recent to recentMenu
- Add a mouseOut timer to recentMenu
2020-04-05 15:30:08 -07:00
James Hillyerd
107b649738 docker: Capture stderr in healthcheck 2020-03-29 20:49:38 -07:00
Martijn Suijlen
c91a3ecd41 docker: Add a healthcheck (#152)
Use the INBUCKET_WEB_ADDR value to get the port number of Inbucket using 'cut'. If the variable is not set, use the default value (and that is 0.0.0.0:9000). Healtcheck will check the exit code of the command executed (0=HEALTHY)
2020-03-29 20:40:39 -07:00
James Hillyerd
2c74268014 docker: Fix + some tweaks, fixes #155
- Reorder deps & builds
- Download elm binary
- g++ and python3 for node-gyp
- Bump go & alpine versions
- Use npm ci
2020-03-29 20:15:33 -07:00
James Hillyerd
da63e4d77a travis: s/-/_/ for elm deps 2020-03-29 19:32:41 -07:00
James Hillyerd
4a90b37815 ui: Implement modal focus trap 2020-03-29 19:26:59 -07:00
James Hillyerd
cabbdacb89 Merge branch 'feature/linter-fixes' into develop 2020-03-29 12:55:44 -07:00
James Hillyerd
baad19e838 ui: Add function signature to accept and store policies 2020-03-29 12:21:16 -07:00
James Hillyerd
c520af4983 ui: Linter dead code elimination 2020-03-29 11:54:12 -07:00
James Hillyerd
c312909112 ui: Cons related linter fixes 2020-03-29 11:41:23 -07:00
James Hillyerd
083b65c9bc ui: Ignore elm make generated index.html 2020-03-29 11:39:10 -07:00
James Hillyerd
59ae2112f7 ui: Import fixes for src directory 2020-03-29 11:35:22 -07:00
James Hillyerd
1a45179e31 ui: Linter import fixes for Page directory 2020-03-29 11:28:47 -07:00
James Hillyerd
2b857245f7 ui: Fix linter warnings in Data dir 2020-03-29 11:13:31 -07:00
James Hillyerd
9573504725 Merge branch 'feature/padding' into develop 2020-03-22 22:10:54 -07:00
James Hillyerd
c21066752f travis: Switch to ubuntu bionic w/ preinstalled nodejs 2020-03-22 21:53:19 -07:00
James Hillyerd
66c95baf05 ui: Horizontally center content on wide displays 2020-03-22 21:20:38 -07:00
James Hillyerd
22a7789b7b ui: Prevent mailbox dropdown overflow 2020-03-22 20:40:02 -07:00
James Hillyerd
d2da53cc0f ui: Convert main.css to mobile first 2020-03-22 18:49:45 -07:00
James Hillyerd
bfac9a0cc2 nix: add elm lang server, for nixos newer than 19.09 2020-03-22 17:48:02 -07:00
James Hillyerd
a64429ae61 Merge branch 'feature/elm-0191' into develop 2020-03-21 13:50:56 -07:00
James Hillyerd
2436f2e3de ui: bump elm filesize, date-format versions 2020-03-21 13:41:37 -07:00
James Hillyerd
fc76ce74cb ui: bump elm indirect versions 2020-03-21 13:36:15 -07:00
James Hillyerd
eef4bbdb01 ui: bump elm core libs 2020-03-21 13:29:25 -07:00
James Hillyerd
201987f6a8 ui: Upgrade to elm 0.19.1, bump all JS deps 2020-03-21 13:07:59 -07:00
James Hillyerd
45d9d2af39 travis: Update to Go 1.14.x 2020-03-21 10:04:24 -07:00
Fred Cox
12802e93cb Fix var name for tls cert (#146) 2019-09-17 08:51:07 -07:00
James Hillyerd
0956a13618 release: Modernize goreleaser config 2019-08-17 16:47:02 -07:00
James Hillyerd
de4bb991dd Merge tag 'v3.0.0-beta2' into develop
Added
- Ability to name mailboxes after domain of email recipient, set via
  `INBUCKET_MAILBOXNAMING`, thanks MatthewJohn.

Changed
- Updated JavaScript dependencies.
- Updated Go dependencies.
- Updated Docker build: Go to 1.12, and Alpine Linux to 3.10

Fixed
- URLs to view/download attachments from REST API, #138
- Support for late EHLO, #141
2019-08-17 16:11:41 -07:00
James Hillyerd
14f0895ae7 Merge branch 'release/3.0.0-beta2' 2019-08-17 16:11:12 -07:00
James Hillyerd
8bb01570ef Update changelog 2019-08-17 16:11:02 -07:00
James Hillyerd
3a1c757d04 smtp: Handle late EHLO, fixes #141 2019-08-17 15:46:28 -07:00
James Hillyerd
d8474d56e5 Merge branch 'feature/readme' into develop 2019-08-17 15:31:16 -07:00
James Hillyerd
eef45a4473 readme: Add Docker info 2019-08-17 15:29:05 -07:00
James Hillyerd
91d19308fe readme: Update build instructions, closes #127 2019-08-17 15:20:40 -07:00
James Hillyerd
e359c0b030 docker: go 1.11 -> 1.12, alpine linux 3.8 -> 3.10 2019-08-17 13:00:01 -07:00
James Hillyerd
a73ffeabd3 rest: Update attachments to 3.0 URL scheme, closes #138 2019-08-17 12:07:20 -07:00
James Hillyerd
0b3f4eab75 backend: Update dependencies 2019-08-10 17:54:09 -07:00
James Hillyerd
7ea4798e77 backend: Update to enmime 0.6.0 2019-08-10 17:13:57 -07:00
James Hillyerd
070de88bba ui: JS dependency updates 2019-08-10 16:28:48 -07:00
James Hillyerd
383386d5fb Update lodash 2019-08-10 16:00:04 -07:00
James Hillyerd
a3e2c5247e Update elm + elm-webpack-loader 2019-08-10 15:56:46 -07:00
James Hillyerd
702f9ef48e Update top-level JS deps 2019-08-10 14:48:54 -07:00
James Hillyerd
ac4501ba35 Update change log 2019-08-10 14:31:21 -07:00
Matt John
c78656b400 Add ability to name mailboxes after domain of email (#140)
* Add ability to name mailboxes after domain of email
* Update argument documentation for 'domain' filtering
* Update address policy to verify domain against RFC and return error on invalid domain
2019-08-10 14:13:21 -07:00
James Hillyerd
b6a6cc6708 Merge tag 'v3.0.0-beta1' into develop
Release 3.0.0-beta1
2019-03-14 21:29:19 -07:00
James Hillyerd
a17fa256a2 Merge branch 'release/3.0.0-beta1' 2019-03-14 21:26:15 -07:00
James Hillyerd
7ea8e2fc03 Release 3.0.0-beta1 2019-03-14 21:15:09 -07:00
James Hillyerd
a0b6f0692d Merge branch 'feature/gh-org' into develop for #126 2019-02-24 10:36:31 -08:00
James Hillyerd
c1b7e3605c goreleaser: update github owner to inbucket 2019-02-24 10:35:38 -08:00
James Hillyerd
2b3dd51e71 docs: rename to github.com/inbucket/inbucket 2019-02-24 10:09:33 -08:00
James Hillyerd
e4c48a0705 go code: rename to github.com/inbucket/inbucket 2019-02-24 09:43:10 -08:00
James Hillyerd
5c885a067a ui: Update npm deps 2019-02-24 09:02:52 -08:00
James Hillyerd
71b3de59af ui: Add search clear button 2019-02-24 08:42:33 -08:00
James Hillyerd
fc95f6e57f ui: Add polyfill for webcomponents 2019-02-18 19:28:44 -08:00
James Hillyerd
e5e1c39097 ui: Upgrade to NodeJS 10.x 2019-02-18 17:01:18 -08:00
James Hillyerd
f1b85be23a ui: Have dev-server bind to 0.0.0.0 2019-02-18 16:31:54 -08:00
James Hillyerd
aaf8eb5ec1 ui: Combine margin/padding 2019-02-18 15:48:08 -08:00
James Hillyerd
18b85877ab ui: Ignore submit of empty mailbox input 2019-02-18 13:04:21 -08:00
James Hillyerd
cd89d77d9f ui: Unknown routes redirect to Home 2019-02-18 12:51:49 -08:00
James Hillyerd
a54e0f2438 ui: Clear flash when changing routes 2019-02-18 12:35:27 -08:00
James Hillyerd
3738ccc11d ui: Remove unnecessary Session params from Mailbox 2019-02-18 11:09:36 -08:00
James Hillyerd
a6cdd30fb1 Merge branch 'feature/keybd-nav' into develop 2019-02-17 14:05:19 -08:00
James Hillyerd
a467829103 ui: Convert hamburger to button 2019-02-17 13:40:56 -08:00
James Hillyerd
b2255fefab ui: Allow keyboard navigation of message list 2019-02-17 12:59:05 -08:00
James Hillyerd
34799b9a04 ui: Move ClearFlash & ViewMailbox into Layout 2019-02-17 12:21:20 -08:00
James Hillyerd
cfbd30d8b0 ui: Give Layout it's own Model/Msg 2019-02-17 11:22:59 -08:00
James Hillyerd
7cd45ff3c7 ui: Allow tab+enter nav of monitor messages 2019-02-17 08:55:59 -08:00
James Hillyerd
3c2b302a5f ui: Fix Source & Raw HTML tab nav 2019-02-17 08:55:59 -08:00
James Hillyerd
35969e0b0f ui: Reorg navbar CSS 2019-02-17 08:55:59 -08:00
James Hillyerd
d933d591d8 ui: Add button to open recent menu via keyboard 2019-02-15 21:28:29 -08:00
James Hillyerd
b82cafc338 ui: mailbox input to end of nav for kbd sanity 2019-02-15 18:47:05 -08:00
James Hillyerd
f739ba90a1 Merge branch 'feature/mobile-msg' into develop 2019-02-09 13:56:42 -08:00
James Hillyerd
6724c86181 ui: Add message back/close button for mobile 2019-02-09 13:44:46 -08:00
James Hillyerd
645feeaf85 ui: convert mailbox.css to mobile-first 2019-02-09 13:16:43 -08:00
James Hillyerd
99df27ee34 ui: extract mailbox.css 2019-02-09 13:16:43 -08:00
James Hillyerd
5ae69314dd Merge branch 'feature/mobile-nav' into develop 2019-01-26 14:29:01 -08:00
James Hillyerd
3df655d611 ui: Fix desktop navbar rendering 2019-01-26 14:27:38 -08:00
James Hillyerd
ae76ecef00 ui: Passable mobile menu, but desktop broken 2019-01-26 13:26:16 -08:00
James Hillyerd
37f05b08c5 ui: Extract navbar.css 2019-01-26 11:11:38 -08:00
James Hillyerd
79fdc58567 docker: set UIDIR, closes #134 2019-01-21 10:49:19 -08:00
James Hillyerd
d16699f59f travis: Build Elm UI during deploy 2019-01-06 18:48:18 -08:00
James Hillyerd
9ca179e249 travis: Make Elm part of the build matrix 2019-01-06 18:09:52 -08:00
James Hillyerd
07e75495e8 build: Update goreleaser for Elm UI 2019-01-06 13:57:20 -08:00
James Hillyerd
683ce1241e ui: Comma separate To list 2019-01-06 11:30:45 -08:00
James Hillyerd
9815a66575 ui: Implement clear button on monitor 2019-01-01 14:45:25 -08:00
James Hillyerd
8e04ce1fec ui: Fix monitor scrolling 2019-01-01 14:27:31 -08:00
James Hillyerd
6287f5fe9c ui: Truncate monitor after 500 messages, closes #87 2019-01-01 13:48:41 -08:00
James Hillyerd
f47e2cfcc2 ui: Reimplement message monitor as web component, closes #128 2019-01-01 13:23:31 -08:00
James Hillyerd
dbdc60a0fb message, webui: Fix QP address rendering, closes #117 2019-01-01 10:04:37 -08:00
James Hillyerd
c0a878db47 config: Remove mailbox prompt, no longer used 2019-01-01 09:36:39 -08:00
James Hillyerd
0ea18cbe2b Merge branch 'feature/layout' into develop 2018-12-31 16:48:30 -08:00
James Hillyerd
986377b531 ui: Clarifying renames 2018-12-31 16:47:49 -08:00
James Hillyerd
fac44b7753 ui: rename Page -> Layout 2018-12-31 16:28:22 -08:00
James Hillyerd
c977ded5ba ui: Reimplement session storage in Main 2018-12-31 16:16:20 -08:00
James Hillyerd
c2109a8df0 Merge branch 'feature/config-cookie' into develop 2018-12-31 15:29:15 -08:00
James Hillyerd
321c5615a5 ui: Respect monitor visible config option 2018-12-31 15:10:33 -08:00
James Hillyerd
c57260349b web + ui: Pass init cookie from server to client 2018-12-31 15:10:05 -08:00
James Hillyerd
91f3e08ce5 ui: Make CSS .well reusable for error/warn 2018-12-28 13:18:14 -08:00
James Hillyerd
c762c4d7a1 Merge branch 'feature/mime-errors' into develop 2018-12-28 11:47:26 -08:00
James Hillyerd
b954bea7c6 ui: Render MIME parsing errors 2018-12-28 11:21:51 -08:00
James Hillyerd
362ece171a webui: Add MIME errors to mailbox message 2018-12-28 10:28:36 -08:00
James Hillyerd
1922dc145d ui: Ouput web fonts to dist/static/ 2018-12-27 10:15:09 -08:00
James Hillyerd
4b9e432730 ui: Fix long message & list scrolling 2018-12-27 09:20:10 -08:00
James Hillyerd
78b36b0b14 Merge branch 'feature/session' into develop 2018-12-26 15:44:23 -08:00
James Hillyerd
2f7194835d ui: Remove Session from Main model 2018-12-26 15:22:45 -08:00
James Hillyerd
7c213cd897 ui: Embed Session into page models 2018-12-26 15:22:13 -08:00
James Hillyerd
6189b56b79 ui: Add fontawesome-free, use in status & mailbox 2018-12-23 15:28:23 -08:00
James Hillyerd
1a8b5184cd Merge branch 'feature/travis' into develop 2018-12-23 12:45:55 -08:00
James Hillyerd
55e11929c7 travis: Eliminate travis-deploy.sh 2018-12-23 12:45:40 -08:00
James Hillyerd
4dd3ad33f9 travis: Add Elm stage 2018-12-23 12:27:41 -08:00
James Hillyerd
92c89b98ee travis: Utilize build stages for deploy 2018-12-23 10:25:06 -08:00
James Hillyerd
51d732fa20 ui: Add raw HTML button to message 2018-12-15 21:30:13 -08:00
James Hillyerd
ffaf296faa ui: Make the error flash look nice 2018-12-15 21:17:37 -08:00
James Hillyerd
af3ed04100 ui: Move div.page into Page frame 2018-12-15 20:53:13 -08:00
James Hillyerd
caec5e7c17 ui: Add request context for error flash
- webui: Update mailbox, attachment paths
2018-12-15 20:16:20 -08:00
James Hillyerd
862aff434e Merge branch 'release/2.1.0' into master 2018-12-15 12:02:43 -08:00
James Hillyerd
6ef2beb821 CHANGELOG: release 2.1.0 2018-12-15 12:02:23 -08:00
James Hillyerd
6fd13a5215 Merge branch 'feature/cleanup' into develop #133 2018-12-15 10:01:07 -08:00
James Hillyerd
77ea66e0e6 web: Remove unused helpers 2018-12-15 09:51:37 -08:00
James Hillyerd
89886843bd web: Remove template related code 2018-12-15 09:20:57 -08:00
James Hillyerd
4894244d5c webui: Remove sessions & securecookie 2018-12-15 08:35:00 -08:00
James Hillyerd
d627da2038 webui: Remove unused routes/handlers 2018-12-15 08:35:00 -08:00
James Hillyerd
348eebe418 Add basic NixOS shell.nix file 2018-12-15 08:34:07 -08:00
James Hillyerd
bc427e237f ui: Move REST API calls into new Api module 2018-12-14 21:13:13 -08:00
James Hillyerd
f12a72871f ui: Mailbox purge prompts for confirmation 2018-12-12 22:12:35 -08:00
James Hillyerd
efe554bd77 ui: build tweaks
- ignore ui/dist dir
- dev-start.sh prompts for npm instead of elm-app
2018-12-12 20:21:40 -08:00
James Hillyerd
ecd7c9f6e6 Merge branch 'feature/docker' into develop 2018-12-11 19:48:33 -08:00
James Hillyerd
f0c9a1e7f4 ui: Add chunk hash to static js file name 2018-12-11 19:43:22 -08:00
James Hillyerd
1eba3164b5 ui: Remove built assets from repo 2018-12-11 19:25:23 -08:00
James Hillyerd
aae41ab79a docker: Build Elm UI in container 2018-12-11 19:24:38 -08:00
James Hillyerd
fc5cc4d864 ui: Display server uptime, scan completion time 2018-12-10 21:47:34 -08:00
James Hillyerd
9b3049562d ui: Display server configuration on status page 2018-12-09 16:23:15 -08:00
James Hillyerd
7a16f64ff0 ui: Make message & list scroll within viewport 2018-11-25 21:42:43 -08:00
James Hillyerd
6a95dfe5c6 ui: Invert mailbox media queries for desktop/mobile 2018-11-25 20:34:31 -08:00
James Hillyerd
22884378f3 ui: Stop using element IDs for styling 2018-11-25 19:52:28 -08:00
James Hillyerd
0cf97f5c58 ui: Style error flash, add close link 2018-11-25 09:22:59 -08:00
James Hillyerd
4eb2d5ae97 ui: Update browser URL after deleting a message 2018-11-23 17:52:10 -08:00
James Hillyerd
ce59c87250 ui: Eliminate javascript:void, closes #130 2018-11-23 17:26:09 -08:00
James Hillyerd
6215ce77dd ui: Show dates in browser local timezone 2018-11-23 16:47:22 -08:00
James Hillyerd
ba8e2de475 ui: Cleanup routing 2018-11-23 16:11:33 -08:00
James Hillyerd
0f9585a52b ui: Add session update logic into Session 2018-11-23 13:57:42 -08:00
James Hillyerd
e71377f966 ui: Refactor update and setRoute with updateWith 2018-11-23 13:02:10 -08:00
James Hillyerd
0c9cf81c94 main: bump enmime to v0.4.0 2018-11-21 21:45:27 -08:00
James Hillyerd
ff7fb8a781 docker: Fix build for webpack UI dist dir 2018-11-18 19:53:55 -08:00
James Hillyerd
6619764ea2 Merge elm 0.19 upgrade, closes #125 2018-11-18 19:47:31 -08:00
James Hillyerd
0d9952d35f ui: Commit UI build artifacts 2018-11-18 19:45:21 -08:00
James Hillyerd
5be2b57a12 ui: Easy renames and refactors 2018-11-18 19:41:32 -08:00
James Hillyerd
0ed0cd2d64 ui: Production optimization, basic babel config 2018-11-18 16:09:03 -08:00
James Hillyerd
74e7fd1179 ui: Fix external links 2018-11-18 11:36:49 -08:00
James Hillyerd
eaf41949d4 ui: Refactor page view/framing to handle titles 2018-11-18 10:51:26 -08:00
James Hillyerd
59062e1326 ui: Enable compile-error dev server overlay, SPA routing 2018-11-18 09:18:03 -08:00
James Hillyerd
019bd11309 ui: Parse POSIX millisecond date stamps 2018-11-17 21:11:41 -08:00
James Hillyerd
cf265dbe2c rest: Add posix-millis field for easier date parsing 2018-11-17 20:57:52 -08:00
James Hillyerd
c77cae2429 ui: Update README for npm/webpack 2018-11-17 19:18:39 -08:00
James Hillyerd
abd9ebeb35 ui: Use web components to render server-side HTML 2018-11-17 19:09:19 -08:00
James Hillyerd
f2cd3f92da ui: Upgrade to elm/http 2.0.0 2018-11-17 18:48:52 -08:00
James Hillyerd
e70900dd1a web: Implement SPA compatible routing 2018-11-17 14:38:04 -08:00
James Hillyerd
284dd70bc6 ui: minimal webpack 4 build w/ proxy & hot reload 2018-11-17 14:37:27 -08:00
James Hillyerd
fe20854173 ui: Get UI to compile with Elm 0.19 2018-11-17 14:36:47 -08:00
James Hillyerd
5ccdece541 ui: Remove built assets during elm upgrade 2018-11-17 14:19:10 -08:00
James Hillyerd
b67d5ba376 ui: Reformat with elm-format 0.8.1 2018-11-13 22:06:19 -08:00
James Hillyerd
ecd0c124d4 ui: Re-implement websockets with ports+JS 2018-11-13 21:27:58 -08:00
James Hillyerd
ac3a94412d Merge Elm user interface into develop, closes #105 2018-11-12 22:15:37 -08:00
James Hillyerd
8017e0ce57 docker: Update for elm UI artifacts 2018-11-12 22:05:48 -08:00
James Hillyerd
1f2d1a4622 ui: Commit build artifacts 2018-11-12 21:53:53 -08:00
James Hillyerd
bea3849c97 ui: Update README with dev instructions 2018-11-12 21:26:58 -08:00
James Hillyerd
2bbcef072a ui: Add greeting.html to Home 2018-11-12 20:38:00 -08:00
James Hillyerd
d1954cdd6f ui: Reformat with new elm-format 2018-11-12 20:38:00 -08:00
James Hillyerd
c92cd309bc ui: Add friendly date to Mailbox message view
- Refactor some date stuff
2018-11-12 20:38:00 -08:00
James Hillyerd
d05eb10851 ui: Friendly date format for mailbox list, monitor 2018-11-12 20:38:00 -08:00
James Hillyerd
9e2f138279 ui: Add purge button to Mailbox 2018-11-12 20:38:00 -08:00
James Hillyerd
af9c735cd7 ui: messageList becomes viewMessageList 2018-11-12 20:38:00 -08:00
James Hillyerd
5328406533 ui: Move selected MessageID into MessageList record 2018-11-12 20:38:00 -08:00
James Hillyerd
54ca36c442 ui: Refactor Mailbox states to reduce cases. 2018-11-12 20:38:00 -08:00
James Hillyerd
5ab273b7b8 travis: Remove Go 1.10.x 2018-11-12 20:38:00 -08:00
James Hillyerd
352e8c396d ui: Refactor Mailbox update function 2018-11-12 20:38:00 -08:00
James Hillyerd
f0b4dda8e6 ui: Add search to Mailbox 2018-11-12 20:38:00 -08:00
James Hillyerd
c8dabf8593 ui: Mailbox headers get a MessageList type for search 2018-11-12 20:38:00 -08:00
James Hillyerd
852b9fce26 ui: Refactor mailbox header list into State 2018-11-12 20:38:00 -08:00
James Hillyerd
a8795f46dc ui: Adding Transistioning state to Mailbox to stop flicker 2018-11-12 20:38:00 -08:00
James Hillyerd
bcf0cafb34 ui: Refactor Mailbox model states 2018-11-12 20:38:00 -08:00
James Hillyerd
04a3f58e6d ui: Refactor message/markSeenAt into visible record. 2018-11-12 20:38:00 -08:00
James Hillyerd
7dade7f0e4 ui: Initial impl of seen message marking 2018-11-12 20:38:00 -08:00
James Hillyerd
523c04a522 Logging improvements, handler extraction.
- rest: improve error logging.
- web: extract handlers/middleware into their own file.
- web: log all requests, not just ones hitting our handlers.
- test: improve integration test logging format.
2018-11-12 20:38:00 -08:00
James Hillyerd
7a5459ce08 test: Fix integation test server startup 2018-11-12 20:38:00 -08:00
James Hillyerd
dd14fb9989 ui: Much elm work, such wow
- ui: Fix favicon
- webui: Changes to support serving Elm UI
- Static files now served from `/` mount point.
- Old UI handlers moved to `/serve` mount point, some will still be
  needed by the Elm UI; safe HTML and attachments for example.
- Update dev-start.sh for new UI, with tip on how to build it.
- ui: Detect browser host:port for websocket URL,
- webui: Remove unused mailbox handlers, rename routes
- Many routes not needed by Elm UI.
- `/serve/mailbox/*` becomes `/serve/m/*`.
- webui: Impl custom JSON message API for web UI,
- ui: Refactor Mailbox view functions,
- ui: Add body tabs for safe HTML and plain text,
- webui: Format plain text for new UI,
- ui: List attachments with view & download links,
2018-11-12 20:34:51 -08:00
James Hillyerd
c5b5321be3 ui: Initial Elm UI import
Merged from https://github.com/jhillyerd/inbucket-elm

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

View File

@@ -6,3 +6,8 @@ inbucket
inbucket.exe
swaks-tests
target
tags
tags.*
ui/dist
ui/elm-stuff
ui/node_modules

36
.github/workflows/build-and-test.yml vendored Normal file
View File

@@ -0,0 +1,36 @@
name: Build and Test
on:
pull_request:
jobs:
go-build:
runs-on: ubuntu-latest
strategy:
matrix:
go: [ '1.17', '1.16' ]
name: Go ${{ matrix.go }} build
steps:
- uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Setup Go
uses: actions/setup-go@v2
with:
go-version: ${{ matrix.go }}
- name: Build and test
run: |
go build ./...
go test -race -coverprofile=profile.cov ./...
- name: Send coverage
uses: shogo82148/actions-goveralls@v1
with:
path-to-profile: profile.cov
flag-name: Go-${{ matrix.go }}
parallel: true
coverage:
needs: go-build
name: Test Coverage
runs-on: ubuntu-latest
steps:
- uses: shogo82148/actions-goveralls@v1
with:
parallel-finished: true

54
.github/workflows/docker-build.yml vendored Normal file
View File

@@ -0,0 +1,54 @@
name: Docker Image
on:
push:
branches: [ "main" ]
tags: [ "v*" ]
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Docker meta
id: meta
uses: docker/metadata-action@v3
with:
images: |
inbucket/inbucket
ghcr.io/${{ github.repository }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=sha
type=edge,branch=main
flavor: |
latest=auto
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
with:
platforms: all
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Login to DockerHub
if: github.event_name != 'pull_request'
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v1
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v2
with:
context: .
platforms: linux/amd64,linux/arm64, linux/arm/v7
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

42
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,42 @@
name: Build and Release
on:
push:
branches: [ "main" ]
tags: [ "v*" ]
pull_request:
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Setup Go
uses: actions/setup-go@v2
with:
go-version: 1.17
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '16.x'
cache: 'yarn'
cache-dependency-path: ui/yarn.lock
- name: Build frontend
run: |
yarn install --frozen-lockfile --non-interactive
yarn run build
working-directory: ./ui
- name: Test build release
uses: goreleaser/goreleaser-action@v2
if: "!startsWith(github.ref, 'refs/tags/v')"
with:
version: latest
args: release --snapshot
- name: Build and publish release
uses: goreleaser/goreleaser-action@v2
if: "startsWith(github.ref, 'refs/tags/v')"
with:
version: latest
args: release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

26
.gitignore vendored
View File

@@ -21,13 +21,35 @@ _testmain.go
*.exe
# vim swp files
# vim files
*.swp
*.swo
tags
tags.*
# our binaries
# Desktop Services Store on macOS
.DS_Store
# Inbucket binaries
/client
/client.exe
/inbucket
/inbucket.exe
/dist/**
/cmd/client/client
/cmd/client/client.exe
/cmd/inbucket/inbucket
/cmd/inbucket/inbucket.exe
# Elm UI
# elm-package generated files
/ui/index.html
/ui/elm-stuff
/ui/tests/elm-stuff
# elm-repl generated files
repl-temp-*
# Distribution
/ui/dist/
# Dependency directories
/ui/node_modules
/ui/.parcel-cache

View File

@@ -1,16 +1,20 @@
project_name: inbucket
release:
github:
owner: jhillyerd
owner: inbucket
name: inbucket
name_template: '{{.Tag}}'
brew:
commit_author:
name: goreleaserbot
email: goreleaser@carlosbecker.com
install: bin.install ""
before:
hooks:
- go mod download
builds:
- binary: inbucket
- id: inbucket
binary: inbucket
env:
- CGO_ENABLED=0
goos:
- darwin
- freebsd
@@ -18,11 +22,16 @@ builds:
- windows
goarch:
- amd64
- arm
- arm64
goarm:
- "6"
main: .
- "7"
main: ./cmd/inbucket
ldflags: -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}
- binary: client
- id: inbucket-client
binary: inbucket-client
env:
- CGO_ENABLED=0
goos:
- darwin
- freebsd
@@ -30,31 +39,53 @@ builds:
- windows
goarch:
- amd64
- arm
- arm64
goarm:
- "6"
- "7"
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
archives:
- id: tarball
format: tar.gz
wrap_in_directory: true
name_template: '{{ .Binary }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{
.Arm }}{{ end }}'
format_overrides:
- goos: windows
format: zip
files:
- LICENSE*
- README*
- CHANGELOG*
- etc/**
- ui/dist/**
- ui/greeting.html
nfpms:
- formats:
- deb
- rpm
vendor: inbucket.org
homepage: https://www.inbucket.org/
maintainer: github@hillyerd.com
description: All-in-one disposable webmail service.
license: MIT
contents:
- src: "ui/dist/**"
dst: "/usr/local/share/inbucket/ui"
- src: "etc/linux/inbucket.service"
dst: "/lib/systemd/system/inbucket.service"
type: config|noreplace
- src: "ui/greeting.html"
dst: "/etc/inbucket/greeting.html"
type: config|noreplace
snapshot:
name_template: SNAPSHOT-{{ .Commit }}
checksum:
name_template: '{{ .ProjectName }}_{{ .Version }}_checksums.txt'
dist: dist
sign:
artifacts: none

View File

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

View File

@@ -4,10 +4,201 @@ Change Log
All notable changes to this project will be documented in this file.
This project adheres to [Semantic Versioning](http://semver.org/).
## [Unreleased]
## [v3.0.2] - 2022-07-04
Note: We had to abandon the 3.0.1 release, see the blog post [What happened to
3.0?](https://www.inbucket.org/news/2022/05/whathappenedtothree.html) for
details.
### Changed
- arm Docker builds now rely on amd64 frontend build stage
- Frontend build migrated from npm+webpack to yarn+parcel, node 16
## [v3.0.1-rc2] - 2022-01-23
### Added
- Builds for arm7 and arm64 platforms
### Changed
- Abandoned git-flow process, the `master` branch renamed to `main`
## [v3.0.1-rc1] - 2022-01-17
### Fixed
- GitHub built packages (rpm, deb, tarball) no longer missing UI files (#250)
### Changed
- Update Go dependencies
- Update NPM dependencies
## [v3.0.0] - 2021-09-19
Unchanged from rc4.
## [v3.0.0-rc4] - 2021-08-22
### Fixed
- Various MIME header decoding improvements
### Changed
- Bump Go version to 1.17 (#233)
## v3.0.0-rc3 - 2021-08-01
Unchanaged from 3.0.0-rc2. This release is to update our build automation and
tags for Docker Hub and ghcr.io.
## [v3.0.0-rc2] - 2021-07-31
### Added
- Support for SMTP AUTH (#197, thanks makarchuk)
- Dark mode support (#218, thanks nerones)
### Fixed
- Prevent potential click jacking (#190, thanks stuartskelton)
- Error on 8 character long SMTP commands (#221)
- Allow empty username and password during AUTH (#225)
## [v3.0.0-rc1] - 2020-09-24
### Added
- Refresh button to reload mailbox contents
- Improved keyboard (tab) focus highlights
### Changed
- The UI now includes the Open Sans webfont instead of relying on browser/OS
fonts
## [v3.0.0-beta3] - 2020-09-04
### Added
- Docker `HEALTHCHECK`
- Mouse-out delay to improve pop-up menu navigation
- Support for configurable URL base path with `INBUCKET_WEB_BASEPATH`
### Changed
- Updated frontend and backend dependencies, Docker image base
### Fixed
- Improved layout on mobile and wide displays
- Prevent unexpected input for modal dialogs
- Allow empty SMTP `MAIL FROM:<>`
## [v3.0.0-beta2] - 2019-08-17
### Added
- Ability to name mailboxes after domain of email recipient, set via
`INBUCKET_MAILBOXNAMING`, thanks MatthewJohn.
### Changed
- Updated JavaScript dependencies.
- Updated Go dependencies.
- Updated Docker build: Go to 1.12, and Alpine Linux to 3.10
### Fixed
- URLs to view/download attachments from REST API, #138
- Support for late EHLO, #141
## [v3.0.0-beta1] - 2019-03-14
### Added
- `posix-millis` field to REST message and header responses for easier date
parsing.
### Changed
- Rewrote the user interface from scratch, it's now an Elm powered single page
application.
- Moved the Inbucket repository to its own GitHub organization.
- Update to enmime v0.5.0
## v2.1.0 - 2018-12-15
No change from beta1.
## [v2.1.0-beta1] - 2018-10-31
### Added
- Use Go 1.11 modules for reproducible builds.
- SMTP TLS support (thanks kingforaday.)
- `INBUCKET_WEB_PPROF` configuration option for performance profiling.
- Godoc example for the REST API client.
### Changed
- Docker build now uses Go 1.11 and Alpine 3.8
### Fixed
- Render UTF-8 addresses correctly in both REST API and Web UI.
- Memory storage now correctly returns the newest message when asked for ID
`latest`.
## [v2.0.0] - 2018-05-05
### Changed
- Corrected docs for INBUCKET_STORAGE_PARAMS (thanks evilmrburns.)
- Disabled color log output on Windows, doesn't work there.
## [v2.0.0-rc1] - 2018-04-07
### Added
- Inbucket is now configured using environment variables instead of a config
file.
- In-memory storage option, best for small installations and desktops. Will be
used by default.
- Storage type is now displayed on Status page.
- Store size is now calculated during retention scan and displayed on the Status
page.
- Debian `.deb` package generation to release process.
- RedHat `.rpm` package generation to release process.
- Message seen flag in REST and Web UI so you can see which messages have
already been read.
- Recipient domain accept policy; Inbucket can now reject mail to specific
domains.
- Configurable support for identifying a mailbox by full email address instead
of just the local part (username).
- Friendly URL support: `<inbucket-url>/<mailbox>` will redirect your browser to
that mailbox.
### Changed
- Massive refactor of back-end code. Inbucket should now be both easier and
more enjoyable to work on.
- Changes to file storage format, will require pre-2.0 mail store directories to
be deleted.
- Renamed `themes` directory to `ui` and eliminated the intermediate `bootstrap`
directory.
- Docker build:
- Uses the same default ports as other builds; smtp:2500 http:9000 pop3:1100
- Uses volume `/config` for `greeting.html`
- Uses volume `/storage` for mail storage
- Log output is now structured, and will be output as JSON with the `-logjson`
flag; which is enabled by default for the Docker container.
- SMTP and POP3 network tracing is no longer logged regardless of level, but can
be sent to stdout via `-netdebug` flag.
- Replaced store/nostore config variables with a storage policy that mirrors the
domain accept policy.
### Removed
- No longer support SIGHUP or log file rotation.
## [v1.3.1] - 2018-03-10
### Fixed
- Adding additional locking during message delivery to prevent race condition
that could lose messages.
@@ -31,7 +222,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
- `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)
[client](https://github.com/inbucket/inbucket/wiki/cmd-client)
- Allow use of `latest` as a message ID in REST calls
### Changed
@@ -46,9 +237,9 @@ This project adheres to [Semantic Versioning](http://semver.org/).
### 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)
JSON](https://github.com/inbucket/inbucket/wiki/REST-GET-message)
- [Go client for REST
API](https://godoc.org/github.com/jhillyerd/inbucket/rest/client)
API](https://godoc.org/github.com/inbucket/inbucket/rest/client)
- Monitor feature: lists messages as they arrive, regardless of their
destination mailbox
- Make `@inbucket` mailbox prompt configurable
@@ -111,30 +302,45 @@ This project adheres to [Semantic Versioning](http://semver.org/).
- Add Link button to messages, allows for directing another person to a
specific message.
[Unreleased]: https://github.com/jhillyerd/inbucket/compare/master...develop
[v1.3.1]: https://github.com/jhillyerd/inbucket/compare/v1.3.0...v1.3.1
[v1.3.0]: https://github.com/jhillyerd/inbucket/compare/v1.2.0...v1.3.0
[v1.2.0]: https://github.com/jhillyerd/inbucket/compare/1.2.0-rc2...1.2.0
[v1.2.0-rc2]: https://github.com/jhillyerd/inbucket/compare/1.2.0-rc1...1.2.0-rc2
[v1.2.0-rc1]: https://github.com/jhillyerd/inbucket/compare/1.1.0...1.2.0-rc1
[v1.1.0]: https://github.com/jhillyerd/inbucket/compare/1.1.0-rc2...1.1.0
[v1.1.0-rc2]: https://github.com/jhillyerd/inbucket/compare/1.1.0-rc1...1.1.0-rc2
[v1.1.0-rc1]: https://github.com/jhillyerd/inbucket/compare/1.0...1.1.0-rc1
[v1.0]: https://github.com/jhillyerd/inbucket/compare/1.0-rc1...1.0
[Unreleased]: https://github.com/inbucket/inbucket/compare/v3.0.2...main
[v3.0.2]: https://github.com/inbucket/inbucket/compare/v3.0.1-rc2...v3.0.2
[v3.0.1-rc2]: https://github.com/inbucket/inbucket/compare/v3.0.1-rc1...v3.0.1-rc2
[v3.0.1-rc1]: https://github.com/inbucket/inbucket/compare/v3.0.0...v3.0.1-rc1
[v3.0.0]: https://github.com/inbucket/inbucket/compare/v3.0.0-rc4...v3.0.0
[v3.0.0-rc4]: https://github.com/inbucket/inbucket/compare/v3.0.0-rc2...v3.0.0-rc4
[v3.0.0-rc2]: https://github.com/inbucket/inbucket/compare/v3.0.0-rc1...v3.0.0-rc2
[v3.0.0-rc1]: https://github.com/inbucket/inbucket/compare/v3.0.0-beta3...v3.0.0-rc1
[v3.0.0-beta3]: https://github.com/inbucket/inbucket/compare/v3.0.0-beta2...v3.0.0-beta3
[v3.0.0-beta2]: https://github.com/inbucket/inbucket/compare/v3.0.0-beta1...v3.0.0-beta2
[v3.0.0-beta1]: https://github.com/inbucket/inbucket/compare/v2.1.0...v3.0.0-beta1
[v2.1.0-beta1]: https://github.com/inbucket/inbucket/compare/v2.0.0...v2.1.0-beta1
[v2.0.0]: https://github.com/inbucket/inbucket/compare/v2.0.0-rc1...v2.0.0
[v2.0.0-rc1]: https://github.com/inbucket/inbucket/compare/v1.3.1...v2.0.0-rc1
[v1.3.1]: https://github.com/inbucket/inbucket/compare/v1.3.0...v1.3.1
[v1.3.0]: https://github.com/inbucket/inbucket/compare/v1.2.0...v1.3.0
[v1.2.0]: https://github.com/inbucket/inbucket/compare/1.2.0-rc2...1.2.0
[v1.2.0-rc2]: https://github.com/inbucket/inbucket/compare/1.2.0-rc1...1.2.0-rc2
[v1.2.0-rc1]: https://github.com/inbucket/inbucket/compare/1.1.0...1.2.0-rc1
[v1.1.0]: https://github.com/inbucket/inbucket/compare/1.1.0-rc2...1.1.0
[v1.1.0-rc2]: https://github.com/inbucket/inbucket/compare/1.1.0-rc1...1.1.0-rc2
[v1.1.0-rc1]: https://github.com/inbucket/inbucket/compare/1.0...1.1.0-rc1
[v1.0]: https://github.com/inbucket/inbucket/compare/1.0-rc1...1.0
## Release Checklist
1. Create release branch: `git flow release start 1.x.0`
1. Create a release branch
2. Update CHANGELOG.md:
- Ensure *Unreleased* section is up to date
- Rename *Unreleased* section to release name and date.
- Rename *Unreleased* section to release name and date
- Add new GitHub `/compare` link
- Update previous tag version for *Unreleased*
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
4. Update goreleaser, and then test cross-compile: `goreleaser --snapshot`
5. Commit changes and merge release into main, tag `vX.Y.Z`
6. Push tags and wait for
[GitHub actions](https://github.com/inbucket/inbucket/actions) to complete
7. Update `binary_versions` option in `inbucket-site/_config.yml`
See http://keepachangelog.com/ for additional instructions on how to update this file.

View File

@@ -1,11 +1,8 @@
How to Contribute
=================
# 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
@@ -17,28 +14,18 @@ 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`.
Inbucket follows the regular GitHub pattern. Create a topic branch from where
you want to base your work:
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 ./...`
3. Run `make` to test, vet and confirm your code is formatted correctly.
If you do not have Make installed, please perform these steps manually,
otherwise your PR will not pass our checks.
## Thanks
Thank you for contributing to Inbucket!
[git-flow]: https://github.com/nvie/gitflow

View File

@@ -1,25 +1,57 @@
# Docker build file for Inbucket, see https://www.docker.io/
# Inbucket website: http://www.inbucket.org/
# Docker build file for Inbucket: https://www.inbucket.org/
FROM golang:1.9-alpine
MAINTAINER James Hillyerd, @jameshillyerd
### Build frontend
# Due to no official elm compiler for arm; build frontend with amd64.
FROM --platform=linux/amd64 node:16 as frontend
WORKDIR /build
COPY . .
WORKDIR /build/ui
RUN rm -rf .parcel-cache dist elm-stuff node_modules
RUN yarn install --frozen-lockfile --non-interactive
RUN yarn run build
# 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"]
### Build backend
FROM golang:1.17-alpine3.14 as backend
RUN apk add --no-cache --virtual .build-deps g++ git make
WORKDIR /build
COPY . .
ENV CGO_ENABLED 0
RUN make clean deps
RUN go build -o inbucket \
-ldflags "-X 'main.version=$(git describe --tags --always)' -X 'main.date=$(date -Iseconds)'" \
-v ./cmd/inbucket
### Run in minimal image
FROM alpine:3.14
RUN apk --no-cache add tzdata
WORKDIR /opt/inbucket
RUN mkdir bin defaults ui
COPY --from=backend /build/inbucket bin
COPY --from=frontend /build/ui/dist ui
COPY etc/docker/defaults/greeting.html defaults
COPY etc/docker/defaults/start-inbucket.sh /
# Configuration
ENV INBUCKET_SMTP_DISCARDDOMAINS bitbucket.local
ENV INBUCKET_SMTP_TIMEOUT 30s
ENV INBUCKET_POP3_TIMEOUT 30s
ENV INBUCKET_WEB_GREETINGFILE /config/greeting.html
ENV INBUCKET_WEB_COOKIEAUTHKEY secret-inbucket-session-cookie-key
ENV INBUCKET_WEB_UIDIR=ui
ENV INBUCKET_STORAGE_TYPE file
ENV INBUCKET_STORAGE_PARAMS path:/storage
ENV INBUCKET_STORAGE_RETENTIONPERIOD 72h
ENV INBUCKET_STORAGE_MAILBOXMSGCAP 300
# Healthcheck
HEALTHCHECK --interval=5s --timeout=5s --retries=3 CMD /bin/sh -c 'wget localhost:$(echo ${INBUCKET_WEB_ADDR:-0.0.0.0:9000}|cut -d: -f2) -q -O - >/dev/null'
# Ports: SMTP, HTTP, POP3
EXPOSE 10025 10080 10110
EXPOSE 2500 9000 1100
# Persistent Volumes, following convention at:
# https://github.com/docker/docker/issues/9277
# NOTE /con/context is also used, not exposed by default
VOLUME /con/configuration
VOLUME /con/data
# Persistent Volumes
VOLUME /config
VOLUME /storage
# Build Inbucket
COPY . $INBUCKET_SRC/
RUN "$INBUCKET_SRC/etc/docker/install.sh"
ENTRYPOINT ["/start-inbucket.sh"]
CMD ["-logjson"]

View File

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

View File

@@ -1,16 +1,21 @@
Inbucket
=============================================================================
[![Build Status](https://travis-ci.org/jhillyerd/inbucket.png?branch=master)][Build Status]
![Build Status](https://github.com/inbucket/inbucket/actions/workflows/build-and-test.yml/badge.svg)
![Docker Image](https://github.com/inbucket/inbucket/actions/workflows/docker-build.yml/badge.svg)
# Inbucket
Inbucket is an email testing service; it will accept messages for any email
address and make them available via web, REST and POP3. Once compiled,
Inbucket does not have any external dependencies (HTTP, SMTP, POP3 and storage
are all built in).
address and make them available via web, REST and POP3 interfaces. Once
compiled, Inbucket does not have any external dependencies - HTTP, SMTP, POP3
and storage are all built in.
A Go client for the REST API is available in
`github.com/inbucket/inbucket/pkg/rest/client` - [Go API docs]
Read more at the [Inbucket Website]
![Screenshot](http://www.inbucket.org/images/inbucket-ss1.png "Viewing a message")
## Development Status
Inbucket is currently production quality: it is being used for real work.
@@ -19,45 +24,66 @@ Please see the [Change Log] and [Issues List] for more details. If you'd like
to contribute code to the project check out [CONTRIBUTING.md].
## Homebrew Tap
## Docker
Inbucket has an OS X [Homebrew] tap available as [jhillyerd/inbucket][Homebrew Tap],
see the `README.md` there for installation instructions.
Inbucket has automated [Docker Image] builds via Docker Hub. The `latest` tag
tracks our tagged releases, and `edge` tracks our potentially unstable
`main` branch.
## Building from Source
You will need a functioning [Go installation][Google Go] for this to work.
You will need functioning [Go] and [Node.js] installations for this to work.
Grab the Inbucket source code and compile the daemon:
```sh
git clone https://github.com/inbucket/inbucket.git
cd inbucket/ui
yarn install
yarn build
cd ..
go build ./cmd/inbucket
```
go get -v github.com/jhillyerd/inbucket
For more information on building and development flows, check out the
[Development Quickstart] page of our wiki.
Edit etc/inbucket.conf and tailor to your environment. It should work on most
Unix and OS X machines as is. Launch the daemon:
### Configure and Launch
$GOPATH/bin/inbucket $GOPATH/src/github.com/jhillyerd/inbucket/etc/inbucket.conf
Inbucket reads its configuration from environment variables, but comes with
reasonable defaults built-in. It should work on most Unix and OS X machines as
is. Launch the daemon:
```sh
./inbucket
```
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/).
The Inbucket website has a more complete guide to
[installing from source][From Source]
See doc/[config.md] for more information on configuring Inbucket, but you will
likely find the [Configurator] tool the easiest way to generate a configuration.
## About
Inbucket is written in [Google Go]
Inbucket is written in [Go] and [Elm].
Inbucket is open source software released under the MIT License. The latest
version can be found at https://github.com/jhillyerd/inbucket
version can be found at https://github.com/inbucket/inbucket
[Build Status]: https://travis-ci.org/jhillyerd/inbucket
[Change Log]: https://github.com/jhillyerd/inbucket/blob/master/CHANGELOG.md
[CONTRIBUTING.md]: https://github.com/jhillyerd/inbucket/blob/develop/CONTRIBUTING.md
[From Source]: http://www.inbucket.org/installation/from-source.html
[Google Go]: http://golang.org/
[Homebrew]: http://brew.sh/
[Homebrew Tap]: https://github.com/jhillyerd/homebrew-inbucket
[Inbucket Website]: http://www.inbucket.org/
[Issues List]: https://github.com/jhillyerd/inbucket/issues?state=open
[Build Status]: https://travis-ci.org/inbucket/inbucket
[Change Log]: https://github.com/inbucket/inbucket/blob/main/CHANGELOG.md
[config.md]: https://github.com/inbucket/inbucket/blob/main/doc/config.md
[Configurator]: https://www.inbucket.org/configurator/
[CONTRIBUTING.md]: https://github.com/inbucket/inbucket/blob/main/CONTRIBUTING.md
[Development Quickstart]: https://github.com/inbucket/inbucket/wiki/Development-Quickstart
[Docker Image]: https://www.inbucket.org/binaries/docker.html
[Elm]: https://elm-lang.org/
[From Source]: https://www.inbucket.org/installation/from-source.html
[Go]: https://golang.org/
[Go API docs]: https://pkg.go.dev/github.com/inbucket/inbucket/pkg/rest/client
[Homebrew]: http://brew.sh/
[Homebrew Tap]: https://github.com/inbucket/homebrew-inbucket
[Inbucket Website]: https://www.inbucket.org/
[Issues List]: https://github.com/inbucket/inbucket/issues?state=open
[Node.js]: https://nodejs.org/en/

View File

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

View File

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

View File

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

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

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

View File

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

View File

@@ -1,56 +0,0 @@
// Package datastore contains implementation independent datastore logic
package datastore
import (
"errors"
"io"
"net/mail"
"sync"
"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)
// LockFor is a temporary hack to fix #77 until Datastore revamp
LockFor(emailAddress string) (*sync.RWMutex, error)
}
// Mailbox is an interface to get and manipulate messages in a DataStore
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
}

View File

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

View File

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

461
doc/config.md Normal file
View File

@@ -0,0 +1,461 @@
# Inbucket Configuration
Inbucket is configured via environment variables. Most options have a
reasonable default, but it is likely you will need to change some to suite your
desired use cases.
Running `inbucket -help` will yield a condensed summary of the environment
variables it supports:
KEY DEFAULT DESCRIPTION
INBUCKET_LOGLEVEL info debug, info, warn, or error
INBUCKET_MAILBOXNAMING local Use local or full addressing
INBUCKET_SMTP_ADDR 0.0.0.0:2500 SMTP server IP4 host:port
INBUCKET_SMTP_DOMAIN inbucket HELO domain
INBUCKET_SMTP_MAXRECIPIENTS 200 Maximum RCPT TO per message
INBUCKET_SMTP_MAXMESSAGEBYTES 10240000 Maximum message size
INBUCKET_SMTP_DEFAULTACCEPT true Accept all mail by default?
INBUCKET_SMTP_ACCEPTDOMAINS Domains to accept mail for
INBUCKET_SMTP_REJECTDOMAINS Domains to reject mail for
INBUCKET_SMTP_DEFAULTSTORE true Store all mail by default?
INBUCKET_SMTP_STOREDOMAINS Domains to store mail for
INBUCKET_SMTP_DISCARDDOMAINS Domains to discard mail for
INBUCKET_SMTP_TIMEOUT 300s Idle network timeout
INBUCKET_SMTP_TLSENABLED false Enable STARTTLS option
INBUCKET_SMTP_TLSPRIVKEY cert.key X509 Private Key file for TLS Support
INBUCKET_SMTP_TLSCERT cert.crt X509 Public Certificate file for TLS Support
INBUCKET_POP3_ADDR 0.0.0.0:1100 POP3 server IP4 host:port
INBUCKET_POP3_DOMAIN inbucket HELLO domain
INBUCKET_POP3_TIMEOUT 600s Idle network timeout
INBUCKET_WEB_ADDR 0.0.0.0:9000 Web server IP4 host:port
INBUCKET_WEB_BASEPATH Base path prefix for UI and API URLs
INBUCKET_WEB_UIDIR ui/dist User interface dir
INBUCKET_WEB_GREETINGFILE ui/greeting.html Home page greeting HTML
INBUCKET_WEB_MONITORVISIBLE true Show monitor tab in UI?
INBUCKET_WEB_MONITORHISTORY 30 Monitor remembered messages
INBUCKET_WEB_PPROF false Expose profiling tools on /debug/pprof
INBUCKET_STORAGE_TYPE memory Storage impl: file or memory
INBUCKET_STORAGE_PARAMS Storage impl parameters, see docs.
INBUCKET_STORAGE_RETENTIONPERIOD 24h Duration to retain messages
INBUCKET_STORAGE_RETENTIONSLEEP 50ms Duration to sleep between mailboxes
INBUCKET_STORAGE_MAILBOXMSGCAP 500 Maximum messages per mailbox
The following documentation will describe each of these in more detail.
## Global
### Log Level
`INBUCKET_LOGLEVEL`
This setting controls the verbosity of log output. A small desktop installation
should probably select `info`, but a busy shared installation would be better
off with `warn` or `error`.
- Default: `info`
- Values: one of `debug`, `info`, `warn`, or `error`
### Mailbox Naming
`INBUCKET_MAILBOXNAMING`
The mailbox naming setting determines the name of a mailbox for an incoming
message, and thus where it must be retrieved from later.
#### `local` ensures the domain is removed, such that:
- `james@inbucket.org` is stored in `james`
- `james+spam@inbucket.org` is stored in `james`
#### `full` retains the domain as part of the name, such that:
- `james@inbucket.org` is stored in `james@inbucket.org`
- `james+spam@inbucket.org` is stored in `james@inbucket.org`
Prior to the addition of the mailbox naming setting, Inbucket always operated in
local mode. Regardless of this setting, the `+` wildcard/extension is not
incorporated into the mailbox name.
#### `domain` ensures the local-part is removed, such that:
- `james@inbucket.org` is stored in `inbucket.org`
- `matt@inbucket.org` is stored in `inbucket.org`
- `matt@noinbucket.com` is stored in `notinbucket.com`
- Default: `local`
- Values: one of `local` or `full` or `domain`
## SMTP
### Address and Port
`INBUCKET_SMTP_ADDR`
The IPv4 address and TCP port number the SMTP server should listen on, separated
by a colon. Some operating systems may prevent Inbucket from listening on port
25 without escalated privileges. Using an IP address of 0.0.0.0 will cause
Inbucket to listen on all available network interfaces.
- Default: `0.0.0.0:2500`
### Greeting Domain
`INBUCKET_SMTP_DOMAIN`
The domain used in the SMTP greeting:
220 domain Inbucket SMTP ready
Most SMTP clients appear to ignore this value.
- Default: `inbucket`
### Maximum Recipients
`INBUCKET_SMTP_MAXRECIPIENTS`
Maximum number of recipients allowed (SMTP `RCPT TO` phase). If you are testing
a mailing list server, you may need to increase this value. For comparison, the
Postfix SMTP server uses a default of 1000, it would be unwise to exceed this.
- Default: `200`
### Maximum Message Size
`INBUCKET_SMTP_MAXMESSAGEBYTES`
Maximum allowable size of a message (including headers) in bytes. Messages
exceeding this size will be rejected during the SMTP `DATA` phase.
- Default: `10240000` (10MB)
### Default Recipient Accept Policy
`INBUCKET_SMTP_DEFAULTACCEPT`
If true, Inbucket will accept mail to any domain unless present in the reject
domains list. If false, recipients will be rejected unless their domain is
present in the accept domains list.
- Default: `true`
- Values: `true` or `false`
### Accepted Recipient Domain List
`INBUCKET_SMTP_ACCEPTDOMAINS`
List of domains to accept mail for when `INBUCKET_SMTP_DEFAULTACCEPT` is false;
has no effect when true.
- Default: None
- Values: Comma separated list of domains
- Example: `localhost,mysite.org`
### Rejected Recipient Domain List
`INBUCKET_SMTP_REJECTDOMAINS`
List of domains to reject mail for when `INBUCKET_SMTP_DEFAULTACCEPT` is true;
has no effect when false.
- Default: None
- Values: Comma separated list of domains
- Example: `reject.com,gmail.com`
### Default Recipient Store Policy
`INBUCKET_SMTP_DEFAULTSTORE`
If true, Inbucket will store mail sent to any domain unless present in the
discard domains list. If false, messages will be discarded unless their domain
is present in the store domains list.
- Default: `true`
- Values: `true` or `false`
### Stored Recipient Domain List
`INBUCKET_SMTP_STOREDOMAINS`
List of domains to store mail for when `INBUCKET_SMTP_DEFAULTSTORE` is false;
has no effect when true.
- Default: None
- Values: Comma separated list of domains
- Example: `localhost,mysite.org`
### Discarded Recipient Domain List
`INBUCKET_SMTP_DISCARDDOMAINS`
Mail sent to these domains will not be stored by Inbucket. This is helpful if
you are load or soak testing a service, and do not plan to inspect the resulting
emails. Messages sent to a domain other than this will be stored normally.
Only has an effect when `INBUCKET_SMTP_DEFAULTSTORE` is true.
- Default: None
- Values: Comma separated list of domains
- Example: `recycle.com,loadtest.org`
### Network Idle Timeout
`INBUCKET_SMTP_TIMEOUT`
Delay before closing an idle SMTP connection. The SMTP RFC recommends 300
seconds. Consider reducing this *significantly* if you plan to expose Inbucket
to the public internet.
- Default: `300s`
- Values: Duration ending in `s` for seconds, `m` for minutes
### TLS Support Availability
`INBUCKET_SMTP_TLSENABLED`
Enable the STARTTLS option for opportunistic TLS support
- Default: `false`
- Values: `true` or `false`
### TLS Private Key File
`INBUCKET_SMTP_TLSPRIVKEY`
Specify the x509 Private key file to be used for TLS negotiation.
This option is only valid when INBUCKET_SMTP_TLSENABLED is enabled.
- Default: `cert.key`
- Values: filename or path to private key
- Example: `server.privkey`
### TLS Public Certificate File
`INBUCKET_SMTP_TLSCERT`
Specify the x509 Certificate file to be used for TLS negotiation.
This option is only valid when INBUCKET_SMTP_TLSENABLED is enabled.
- Default: `cert.crt`
- Values: filename or path to the certificate key
- Example: `server.crt`
## POP3
### Address and Port
`INBUCKET_POP3_ADDR`
The IPv4 address and TCP port number the POP3 server should listen on, separated
by a colon. Some operating systems may prevent Inbucket from listening on port
110 without escalated privileges. Using an IP address of 0.0.0.0 will cause
Inbucket to listen on all available network interfaces.
- Default: `0.0.0.0:1100`
### Greeting Domain
`INBUCKET_POP3_DOMAIN`
The domain used in the POP3 greeting:
+OK Inbucket POP3 server ready <26641.1522000423@domain>
Most POP3 clients appear to ignore this value.
- Default: `inbucket`
### Network Idle Timeout
`INBUCKET_POP3_TIMEOUT`
Delay before closing an idle POP3 connection. The POP3 RFC recommends 600
seconds. Consider reducing this *significantly* if you plan to expose Inbucket
to the public internet.
- Default: `600s`
- Values: Duration ending in `s` for seconds, `m` for minutes
## Web
### Address and Port
`INBUCKET_WEB_ADDR`
The IPv4 address and TCP port number the HTTP server should listen on, separated
by a colon. Some operating systems may prevent Inbucket from listening on port
80 without escalated privileges. Using an IP address of 0.0.0.0 will cause
Inbucket to listen on all available network interfaces.
- Default: `0.0.0.0:9000`
### Base Path
`INBUCKET_WEB_BASEPATH`
Base path prefix for UI and API URLs. This option is used when you wish to
root all Inbucket URLs to a specific path when placing it behind a
reverse-proxy.
For example, setting the base path to `prefix` will move:
- the Inbucket status page from `/status` to `/prefix/status`,
- Bob's mailbox from `/m/bob` to `/prefix/m/bob`, and
- the REST API from `/api/v1/*` to `/prefix/api/v1/*`.
*Note:* This setting will not work correctly when running Inbucket via the npm
development server.
- Default: None
### UI Directory
`INBUCKET_WEB_UIDIR`
This directory contains the templates and static assets for the web user
interface. You will need to change this if the current working directory
doesn't contain the `ui` directory at startup.
Inbucket will load templates from the `templates` sub-directory, and serve
static assets from the `static` sub-directory.
- Default: `ui/dist`
- Values: Operating system specific path syntax
### Greeting HTML File
`INBUCKET_WEB_GREETINGFILE`
The content of the greeting file will be injected into the front page of
Inbucket. It can be used to instruct users on how to send mail into your
Inbucket installation, as well as link to REST documentation, etc.
- Default: `ui/greeting.html`
### Monitor Visible
`INBUCKET_WEB_MONITORVISIBLE`
If true, the Monitor tab will be available, allowing users to observe all
messages received by Inbucket as they arrive. Disabling the monitor facilitates
security through obscurity.
This setting has no impact on the availability of the underlying WebSocket,
which may be used by other parts of the Inbucket interface or continuous
integration tests.
- Default: `true`
- Values: `true` or `false`
### Monitor History
`INBUCKET_WEB_MONITORHISTORY`
The number of messages to remember on the *server* for new Monitor clients.
Does not impact the amount of *new* messages displayed by the Monitor.
Increasing this has no appreciable impact on memory use, but may slow down the
Monitor user interface.
This setting has the same effect on the amount of messages available via
WebSocket.
Setting to 0 will disable the monitor, but will probably break new mail
notifications in the web interface when I finally get around to implementing
them.
- Default: `30`
- Values: Integer greater than or equal to 0
### Performance Profiling & Debug Tools
`INBUCKET_WEB_PPROF`
If true, Go's pprof package will be installed to the `/debug/pprof` URI. This
exposes detailed memory and CPU performance data for debugging Inbucket. If you
enable this option, please make sure it is not exposed to the public internet,
as its use can significantly impact performance.
For example usage, see https://golang.org/pkg/net/http/pprof/
- Default: `false`
- Values: `true` or `false`
## Storage
### Type
`INBUCKET_STORAGE_TYPE`
Selects the storage implementation to use. Currently Inbucket supports two:
- `file`: stores messages as individual files in a nested directory structure
based on the hash of the mailbox name. Each mailbox also includes an index
file to speed up enumeration of the mailbox contents.
- `memory`: stores messages in RAM, they will be lost if Inbucket is restarted,
or crashes, etc.
File storage is recommended for larger/shared installations. Memory is better
suited to desktop or continuous integration test use cases.
- Default: `memory`
- Values: `file` or `memory`
### Parameters
`INBUCKET_STORAGE_PARAMS`
Parameters specific to the storage type selected. Formatted as a comma
separated list of key:value pairs.
- Default: None
- Examples: `maxkb:10240` or `path:/tmp/inbucket`
#### `file` type parameters
- `path`: Operating system specific path to the directory where mail should be
stored.
#### `memory` type parameters
- `maxkb`: Maximum size of the mail store in kilobytes. The oldest messages in
the store will be deleted to enforce the limit. In-memory storage has some
overhead, for now it is recommended to set this to half the total amount of
memory you are willing to allocate to Inbucket.
### Retention Period
`INBUCKET_STORAGE_RETENTIONPERIOD`
If set, Inbucket will scan the contents of its mail store once per minute,
removing messages older than this. This will be enforced regardless of the type
of storage configured.
- Default: `24h`
- Values: Duration ending in `m` for minutes, `h` for hours. Should be
significantly longer than one minute, or `0` to disable.
### Retention Sleep
`INBUCKET_STORAGE_RETENTIONSLEEP`
Duration to sleep between scanning each mailbox for expired messages.
Increasing this number will reduce disk thrashing, but extend the length of time
required to complete a scan of the entire mail store.
This delay is still enforced for memory stores, but could be reduced from the
default. Setting to `0` may degrade performance of HTTP/SMTP/POP3 services.
- Default: `50ms`
- Values: Duration ending in `ms` for milliseconds, `s` for seconds
### Per Mailbox Message Cap
`INBUCKET_STORAGE_MAILBOXMSGCAP`
Maximum messages allowed in a single mailbox, exceeding this will cause older
messages to be deleted from the mailbox.
- Default: `500`
- Values: Positive integer, or `0` to disable

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

@@ -0,0 +1,35 @@
#!/bin/sh
# dev-start.sh
# description: Developer friendly Inbucket configuration
export INBUCKET_LOGLEVEL="debug"
export INBUCKET_SMTP_REJECTDOMAINS="bad-actors.local"
#export INBUCKET_SMTP_DEFAULTACCEPT="false"
export INBUCKET_SMTP_ACCEPTDOMAINS="good-actors.local"
export INBUCKET_SMTP_DISCARDDOMAINS="bitbucket.local"
#export INBUCKET_SMTP_DEFAULTSTORE="false"
export INBUCKET_SMTP_STOREDOMAINS="important.local"
export INBUCKET_WEB_TEMPLATECACHE="false"
export INBUCKET_WEB_COOKIEAUTHKEY="not-secret"
export INBUCKET_WEB_UIDIR="ui/dist"
#export INBUCKET_WEB_MONITORVISIBLE="false"
#export INBUCKET_WEB_BASEPATH="prefix"
export INBUCKET_STORAGE_TYPE="file"
export INBUCKET_STORAGE_PARAMS="path:/tmp/inbucket"
export INBUCKET_STORAGE_RETENTIONPERIOD="3h"
export INBUCKET_STORAGE_MAILBOXMSGCAP="300"
if ! test -x ./inbucket; then
echo "$PWD/inbucket not found/executable!" >&2
echo "Run this script from the inbucket root directory after running make." >&2
exit 1
fi
index="$INBUCKET_WEB_UIDIR/index.html"
if ! test -f "$index"; then
echo "$index does not exist!" >&2
echo "Run 'npm run build' from the 'ui' directory." >&2
exit 1
fi
exec ./inbucket $*

View File

@@ -1,129 +0,0 @@
# devel.conf
# Sample development configuration
#############################################################################
[DEFAULT]
# Not used directly, but is typically referenced below in %()s format.
install.dir=.
default.domain=inbucket.local
#############################################################################
[logging]
# Options from least to most verbose: ERROR, WARN, INFO, TRACE
level=TRACE
#############################################################################
[smtp]
# IPv4 address to listen for SMTP connections on.
ip4.address=0.0.0.0
# IPv4 port to listen for SMTP connections on.
ip4.port=2500
# used in SMTP greeting
domain=%(default.domain)s
# optional: mail sent to accounts at this domain will not be stored,
# for mixed use (content and load testing)
domain.nostore=bitbucket.local
# Maximum number of RCPT TO: addresses we allow from clients, the SMTP
# RFC recommends this be at least 100.
max.recipients=100
# How long we allow a network connection to be idle before hanging up on the
# client, SMTP RFC recommends at least 5 minutes (300 seconds).
max.idle.seconds=30
# Maximum allowable size of message body in bytes (including attachments)
max.message.bytes=20480000
# Should we place messages into the datastore, or just throw them away
# (for load testing): true or false
store.messages=true
#############################################################################
[pop3]
# IPv4 address to listen for POP3 connections on.
ip4.address=0.0.0.0
# IPv4 port to listen for POP3 connections on.
ip4.port=1100
# used in POP3 greeting
domain=%(default.domain)s
# How long we allow a network connection to be idle before hanging up on the
# client, POP3 RFC requires at least 10 minutes (600 seconds).
max.idle.seconds=600
#############################################################################
[web]
# IPv4 address to serve HTTP web interface on
ip4.address=0.0.0.0
# IPv4 port to serve HTTP web interface on
ip4.port=9000
# Name of web theme to use
theme=bootstrap
# Prompt displayed between the mailbox entry field and View button. Leave
# empty or comment out to hide the prompt.
mailbox.prompt=@inbucket
# Path to the selected themes template files
template.dir=%(install.dir)s/themes/%(theme)s/templates
# Should we cache parsed templates (set to false during theme dev)
template.cache=false
# Path to the selected themes public (static) files
public.dir=%(install.dir)s/themes/%(theme)s/public
# Path to the greeting HTML displayed on front page, can be moved out of
# installation dir for customization
greeting.file=%(install.dir)s/themes/greeting.html
# Key used to sign session cookie data so that it cannot be tampered with.
# If this is left unset, Inbucket will generate a random key at startup
# and previous sessions will be invalidated.
cookie.auth.key=secret-inbucket-session-cookie-key
# Enable or disable the live message monitor tab for the web UI. This will let
# anybody see all messages delivered to Inbucket. This setting has no impact
# on the availability of the underlying WebSocket.
monitor.visible=true
# How many historical message headers should be cached for display by new
# monitor connections. It does not limit the number of messages displayed by
# the browser once the monitor is open; all freshly received messages will be
# appended to the on screen list. This setting also affects the underlying
# API/WebSocket.
monitor.history=30
#############################################################################
[datastore]
# Path to the datastore, mail will be written into subdirectories
path=/tmp/inbucket
# How many minutes after receipt should a message be stored until it's
# automatically purged. To retain messages until manually deleted, set this
# to 0
retention.minutes=0
# How many milliseconds to sleep after purging messages from a mailbox.
# This should help reduce disk I/O when there are a large number of messages
# to purge.
retention.sleep.millis=100
# Maximum number of messages we will store in a single mailbox. If this
# number is exceeded, the oldest message in the box will be deleted each
# time a new message is received for it.
mailbox.message.cap=100

View File

@@ -1,7 +1,9 @@
<h1>Welcome to Inbucket</h1>
<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
<p>To view messages 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

View File

@@ -1,131 +0,0 @@
# inbucket.conf
# Configuration for Inbucket inside of Docker
#
# These should be reasonable defaults for a production install of Inbucket
#############################################################################
[DEFAULT]
# Not used directly, but is typically referenced below in %()s format.
install.dir=/opt/inbucket
default.domain=inbucket.local
#############################################################################
[logging]
# Options from least to most verbose: ERROR, WARN, INFO, TRACE
level=INFO
#############################################################################
[smtp]
# IPv4 address to listen for SMTP connections on.
ip4.address=0.0.0.0
# IPv4 port to listen for SMTP connections on.
ip4.port=10025
# used in SMTP greeting
domain=%(default.domain)s
# optional: mail sent to accounts at this domain will not be stored,
# for mixed use (content and load testing)
domain.nostore=bitbucket.local
# Maximum number of RCPT TO: addresses we allow from clients, the SMTP
# RFC recommends this be at least 100.
max.recipients=100
# How long we allow a network connection to be idle before hanging up on the
# client, SMTP RFC recommends at least 5 minutes (300 seconds).
max.idle.seconds=300
# Maximum allowable size of message body in bytes (including attachments)
max.message.bytes=2048000
# Should we place messages into the datastore, or just throw them away
# (for load testing): true or false
store.messages=true
#############################################################################
[pop3]
# IPv4 address to listen for POP3 connections on.
ip4.address=0.0.0.0
# IPv4 port to listen for POP3 connections on.
ip4.port=10110
# used in POP3 greeting
domain=%(default.domain)s
# How long we allow a network connection to be idle before hanging up on the
# client, POP3 RFC requires at least 10 minutes (600 seconds).
max.idle.seconds=600
#############################################################################
[web]
# IPv4 address to serve HTTP web interface on
ip4.address=0.0.0.0
# IPv4 port to serve HTTP web interface on
ip4.port=10080
# Name of web theme to use
theme=bootstrap
# Prompt displayed between the mailbox entry field and View button. Leave
# empty or comment out to hide the prompt.
mailbox.prompt=@inbucket
# Path to the selected themes template files
template.dir=%(install.dir)s/themes/%(theme)s/templates
# Should we cache parsed templates (set to false during theme dev)
template.cache=true
# Path to the selected themes public (static) files
public.dir=%(install.dir)s/themes/%(theme)s/public
# Path to the greeting HTML displayed on front page, can be moved out of
# installation dir for customization
greeting.file=/con/configuration/greeting.html
# Key used to sign session cookie data so that it cannot be tampered with.
# If this is left unset, Inbucket will generate a random key at startup
# and previous sessions will be invalidated.
#cookie.auth.key=secret-inbucket-session-cookie-key
# Enable or disable the live message monitor tab for the web UI. This will let
# anybody see all messages delivered to Inbucket. This setting has no impact
# on the availability of the underlying WebSocket.
monitor.visible=true
# How many historical message headers should be cached for display by new
# monitor connections. It does not limit the number of messages displayed by
# the browser once the monitor is open; all freshly received messages will be
# appended to the on screen list. This setting also affects the underlying
# API/WebSocket.
monitor.history=30
#############################################################################
[datastore]
# Path to the datastore, mail will be written into subdirectories
path=/con/data
# How many minutes after receipt should a message be stored until it's
# automatically purged. To retain messages until manually deleted, set this
# to 0
retention.minutes=4320
# How many milliseconds to sleep after purging messages from a mailbox.
# This should help reduce disk I/O when there are a large number of messages
# to purge.
retention.sleep.millis=100
# Maximum number of messages we will store in a single mailbox. If this
# number is exceeded, the oldest message in the box will be deleted each
# time a new message is received for it.
mailbox.message.cap=300

View File

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

View File

@@ -3,7 +3,7 @@
# description: Launch Inbucket's docker image
# Docker Image Tag
IMAGE="jhillyerd/inbucket"
IMAGE="inbucket/inbucket"
# Ports exposed on host:
PORT_HTTP=9000
@@ -12,9 +12,9 @@ PORT_POP3=1100
# Volumes exposed on host:
VOL_CONFIG="/tmp/inbucket/config"
VOL_DATA="/tmp/inbucket/data"
VOL_DATA="/tmp/inbucket/storage"
set -eo pipefail
set -e
main() {
local run_opts=""
@@ -39,11 +39,11 @@ main() {
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" \
-p $PORT_HTTP:9000 \
-p $PORT_SMTP:2500 \
-p $PORT_POP3:1100 \
-v "$VOL_CONFIG:/config" \
-v "$VOL_DATA:/storage" \
"$IMAGE"
}

View File

@@ -1,51 +0,0 @@
#!/bin/sh
# install.sh
# description: Build, test, and install Inbucket. Should be executed inside a Docker container.
set -eo pipefail
installdir="$INBUCKET_HOME"
srcdir="$INBUCKET_SRC"
bindir="$installdir/bin"
defaultsdir="$installdir/defaults"
contextdir="/con/context"
echo "### Installing OS Build Dependencies"
apk add --no-cache --virtual .build-deps git
# Setup
export GOBIN="$bindir"
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"

View File

@@ -1,131 +0,0 @@
# inbucket.conf
# homebrew inbucket configuration
# {{}} values will be replaced during installation
#############################################################################
[DEFAULT]
# Not used directly, but is typically referenced below in %()s format.
default.domain=inbucket.local
themes.dir={{HOMEBREW_PREFIX}}/share/inbucket/themes
datastore.dir={{HOMEBREW_PREFIX}}/var/inbucket/datastore
#############################################################################
[logging]
# Options from least to most verbose: ERROR, WARN, INFO, TRACE
level=INFO
#############################################################################
[smtp]
# IPv4 address to listen for SMTP connections on.
ip4.address=0.0.0.0
# IPv4 port to listen for SMTP connections on.
ip4.port=2500
# used in SMTP greeting
domain=%(default.domain)s
# optional: mail sent to accounts at this domain will not be stored,
# for mixed use (content and load testing)
domain.nostore=bitbucket.local
# Maximum number of RCPT TO: addresses we allow from clients, the SMTP
# RFC recommends this be at least 100.
max.recipients=100
# How long we allow a network connection to be idle before hanging up on the
# client, SMTP RFC recommends at least 5 minutes (300 seconds).
max.idle.seconds=300
# Maximum allowable size of message body in bytes (including attachments)
max.message.bytes=2048000
# Should we place messages into the datastore, or just throw them away
# (for load testing): true or false
store.messages=true
#############################################################################
[pop3]
# IPv4 address to listen for POP3 connections on.
ip4.address=0.0.0.0
# IPv4 port to listen for POP3 connections on.
ip4.port=1100
# used in POP3 greeting
domain=%(default.domain)s
# How long we allow a network connection to be idle before hanging up on the
# client, POP3 RFC requires at least 10 minutes (600 seconds).
max.idle.seconds=600
#############################################################################
[web]
# IPv4 address to serve HTTP web interface on
ip4.address=0.0.0.0
# IPv4 port to serve HTTP web interface on
ip4.port=9000
# Name of web theme to use
theme=bootstrap
# Prompt displayed between the mailbox entry field and View button. Leave
# empty or comment out to hide the prompt.
mailbox.prompt=@inbucket
# Path to the selected themes template files
template.dir=%(themes.dir)s/%(theme)s/templates
# Should we cache parsed templates (set to false during theme dev)
template.cache=true
# Path to the selected themes public (static) files
public.dir=%(themes.dir)s/%(theme)s/public
# Path to the greeting HTML displayed on front page, can be moved out of
# installation dir for customization
greeting.file=%(themes.dir)s/greeting.html
# Key used to sign session cookie data so that it cannot be tampered with.
# If this is left unset, Inbucket will generate a random key at startup
# and previous sessions will be invalidated.
cookie.auth.key=secret-inbucket-session-cookie-key
# Enable or disable the live message monitor tab for the web UI. This will let
# anybody see all messages delivered to Inbucket. This setting has no impact
# on the availability of the underlying WebSocket.
monitor.visible=true
# How many historical message headers should be cached for display by new
# monitor connections. It does not limit the number of messages displayed by
# the browser once the monitor is open; all freshly received messages will be
# appended to the on screen list. This setting also affects the underlying
# API/WebSocket.
monitor.history=30
#############################################################################
[datastore]
# Path to the datastore, mail will be written into subdirectories
path=%(datastore.dir)s
# How many minutes after receipt should a message be stored until it's
# automatically purged. To retain messages until manually deleted, set this
# to 0
retention.minutes=10080
# How many milliseconds to sleep after purging messages from a mailbox.
# This should help reduce disk I/O when there are a large number of messages
# to purge.
retention.sleep.millis=100
# Maximum number of messages we will store in a single mailbox. If this
# number is exceeded, the oldest message in the box will be deleted each
# time a new message is received for it.
mailbox.message.cap=100

View File

@@ -1,129 +0,0 @@
# inbucket.conf
# Sample inbucket configuration
#############################################################################
[DEFAULT]
# Not used directly, but is typically referenced below in %()s format.
install.dir=.
default.domain=inbucket.local
#############################################################################
[logging]
# Options from least to most verbose: ERROR, WARN, INFO, TRACE
level=INFO
#############################################################################
[smtp]
# IPv4 address to listen for SMTP connections on.
ip4.address=0.0.0.0
# IPv4 port to listen for SMTP connections on.
ip4.port=2500
# used in SMTP greeting
domain=%(default.domain)s
# optional: mail sent to accounts at this domain will not be stored,
# for mixed use (content and load testing)
#domain.nostore=bitbucket.local
# Maximum number of RCPT TO: addresses we allow from clients, the SMTP
# RFC recommends this be at least 100.
max.recipients=100
# How long we allow a network connection to be idle before hanging up on the
# client, SMTP RFC recommends at least 5 minutes (300 seconds).
max.idle.seconds=300
# Maximum allowable size of message body in bytes (including attachments)
max.message.bytes=2048000
# Should we place messages into the datastore, or just throw them away
# (for load testing): true or false
store.messages=true
#############################################################################
[pop3]
# IPv4 address to listen for POP3 connections on.
ip4.address=0.0.0.0
# IPv4 port to listen for POP3 connections on.
ip4.port=1100
# used in POP3 greeting
domain=%(default.domain)s
# How long we allow a network connection to be idle before hanging up on the
# client, POP3 RFC requires at least 10 minutes (600 seconds).
max.idle.seconds=600
#############################################################################
[web]
# IPv4 address to serve HTTP web interface on
ip4.address=0.0.0.0
# IPv4 port to serve HTTP web interface on
ip4.port=9000
# Name of web theme to use
theme=bootstrap
# Prompt displayed between the mailbox entry field and View button. Leave
# empty or comment out to hide the prompt.
mailbox.prompt=@inbucket
# Path to the selected themes template files
template.dir=%(install.dir)s/themes/%(theme)s/templates
# Should we cache parsed templates (set to false during theme dev)
template.cache=true
# Path to the selected themes public (static) files
public.dir=%(install.dir)s/themes/%(theme)s/public
# Path to the greeting HTML displayed on front page, can be moved out of
# installation dir for customization
greeting.file=%(install.dir)s/themes/greeting.html
# Key used to sign session cookie data so that it cannot be tampered with.
# If this is left unset, Inbucket will generate a random key at startup
# and previous sessions will be invalidated.
#cookie.auth.key=secret-inbucket-session-cookie-key
# Enable or disable the live message monitor tab for the web UI. This will let
# anybody see all messages delivered to Inbucket. This setting has no impact
# on the availability of the underlying WebSocket.
monitor.visible=true
# How many historical message headers should be cached for display by new
# monitor connections. It does not limit the number of messages displayed by
# the browser once the monitor is open; all freshly received messages will be
# appended to the on screen list. This setting also affects the underlying
# API/WebSocket.
monitor.history=30
#############################################################################
[datastore]
# Path to the datastore, mail will be written into subdirectories
path=/tmp/inbucket
# How many minutes after receipt should a message be stored until it's
# automatically purged. To retain messages until manually deleted, set this
# to 0
retention.minutes=240
# How many milliseconds to sleep after purging messages from a mailbox.
# This should help reduce disk I/O when there are a large number of messages
# to purge.
retention.sleep.millis=100
# Maximum number of messages we will store in a single mailbox. If this
# number is exceeded, the oldest message in the box will be deleted each
# time a new message is received for it.
mailbox.message.cap=500

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@@ -0,0 +1,31 @@
Date: %DATE%
To: %TO_ADDRESS%
From: %FROM_ADDRESS%
Subject: MIME Errors
Message-Id: <07B7061D-2676-487E-942E-C341CE4D13DC@makita.skynet>
Content-Type: multipart/alternative; boundary="Enmime-Test-100"
--Enmime-Test-100
Content-Transfer-Encoding: 8bit
Content-Type: text/plain; charset=us-ascii
Using Unicode/UTF-8, you can write in emails and source code things such as
Mathematics and sciences:
∮ E⋅da = Q, n → ∞, ∑ f(i) = ∏ g(i), ⎧⎡⎛┌─────┐⎞⎤⎫
⎪⎢⎜│a²+b³ ⎟⎥⎪
∀x∈: ⌈x⌉ = x⌋, α ∧ ¬β = ¬(¬α β), ⎪⎢⎜│───── ⎟⎥⎪
⎪⎢⎜⎷ c₈ ⎟⎥⎪
⊆ ℕ₀ ⊂ , ⎨⎢⎜ ⎟⎥⎬
⎪⎢⎜ ∞ ⎟⎥⎪
⊥ < a ≠ b ≡ c ≤ d ≪ ⇒ (⟦A⟧ ⇔ ⟪B⟫), ⎪⎢⎜ ⎲ ⎟⎥⎪
⎪⎢⎜ ⎳aⁱ-bⁱ⎟⎥⎪
2H₂ + O₂ ⇌ 2H₂O, R = 4.7 kΩ, ⌀ 200 mm ⎩⎣⎝i=1 ⎠⎦⎭
Linguistics and dictionaries:
ði ıntəˈnæʃənəl fəˈnɛtık əsoʊsiˈeıʃn
Y [ˈʏpsilɔn], Yen [jɛn], Yoga [ˈjoːgɑ]
--Enmime-Test-100

View File

@@ -24,7 +24,7 @@ case "$1" in
;;
esac
export SWAKS_OPT_server="127.0.0.1:2500"
export SWAKS_OPT_server="${SWAKS_OPT_server:-127.0.0.1:2500}"
export SWAKS_OPT_to="$to@inbucket.local"
# Basic test
@@ -56,3 +56,6 @@ swaks $* --data outlook.raw
# Non-mime responsive HTML test
swaks $* --data nonmime-html-responsive.raw
swaks $* --data nonmime-html-inlined.raw
# Incorrect charset, malformed final boundary
swaks $* --data mime-errors.raw

View File

@@ -1,6 +1,8 @@
Date: %DATE%
To: %TO_ADDRESS%
From: %FROM_ADDRESS%
To: %TO_ADDRESS%,
=?utf-8?B?VGVzdCBvZiDIh8myyqLIr8ihyarJtMqb?= <recipient@inbucket.org>
From: =?utf-8?q?X-=C3=A4=C3=A9=C3=9F_Y-=C3=A4=C3=A9=C3=9F?=
<fromuser@inbucket.org>
Subject: =?utf-8?B?VGVzdCBvZiDIh8myyqLIr8ihyarJtMqb?=
Thread-Topic: =?utf-8?B?VGVzdCBvZiDIh8myyqLIr8ihyarJtMqb?=
Thread-Index: Ac6+4nH7mOymA+1JRQyk2LQPe1bEcw==

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,129 +0,0 @@
# inbucket.conf
# Sample inbucket configuration
#############################################################################
[DEFAULT]
# Not used directly, but is typically referenced below in %()s format.
install.dir=/opt/inbucket
default.domain=inbucket.local
#############################################################################
[logging]
# Options from least to most verbose: ERROR, WARN, INFO, TRACE
level=INFO
#############################################################################
[smtp]
# IPv4 address to listen for SMTP connections on.
ip4.address=0.0.0.0
# IPv4 port to listen for SMTP connections on.
ip4.port=25
# used in SMTP greeting
domain=%(default.domain)s
# optional: mail sent to accounts at this domain will not be stored,
# for mixed use (content and load testing)
#domain.nostore=bitbucket.local
# Maximum number of RCPT TO: addresses we allow from clients, the SMTP
# RFC recommends this be at least 100.
max.recipients=100
# How long we allow a network connection to be idle before hanging up on the
# client, SMTP RFC recommends at least 5 minutes (300 seconds).
max.idle.seconds=300
# Maximum allowable size of message body in bytes (including attachments)
max.message.bytes=2048000
# Should we place messages into the datastore, or just throw them away
# (for load testing): true or false
store.messages=true
#############################################################################
[pop3]
# IPv4 address to listen for POP3 connections on.
ip4.address=0.0.0.0
# IPv4 port to listen for POP3 connections on.
ip4.port=110
# used in POP3 greeting
domain=%(default.domain)s
# How long we allow a network connection to be idle before hanging up on the
# client, POP3 RFC requires at least 10 minutes (600 seconds).
max.idle.seconds=600
#############################################################################
[web]
# IPv4 address to serve HTTP web interface on
ip4.address=0.0.0.0
# IPv4 port to serve HTTP web interface on
ip4.port=80
# Name of web theme to use
theme=bootstrap
# Prompt displayed between the mailbox entry field and View button. Leave
# empty or comment out to hide the prompt.
mailbox.prompt=@inbucket
# Path to the selected themes template files
template.dir=%(install.dir)s/themes/%(theme)s/templates
# Should we cache parsed templates (set to false during theme dev)
template.cache=true
# Path to the selected themes public (static) files
public.dir=%(install.dir)s/themes/%(theme)s/public
# Path to the greeting HTML displayed on front page, can be moved out of
# installation dir for customization
greeting.file=%(install.dir)s/themes/greeting.html
# Key used to sign session cookie data so that it cannot be tampered with.
# If this is left unset, Inbucket will generate a random key at startup
# and previous sessions will be invalidated.
#cookie.auth.key=secret-inbucket-session-cookie-key
# Enable or disable the live message monitor tab for the web UI. This will let
# anybody see all messages delivered to Inbucket. This setting has no impact
# on the availability of the underlying WebSocket.
monitor.visible=true
# How many historical message headers should be cached for display by new
# monitor connections. It does not limit the number of messages displayed by
# the browser once the monitor is open; all freshly received messages will be
# appended to the on screen list. This setting also affects the underlying
# API/WebSocket.
monitor.history=30
#############################################################################
[datastore]
# Path to the datastore, mail will be written into subdirectories
path=/var/opt/inbucket
# How many minutes after receipt should a message be stored until it's
# automatically purged. To retain messages until manually deleted, set this
# to 0
retention.minutes=240
# How many milliseconds to sleep after purging messages from a mailbox.
# This should help reduce disk I/O when there are a large number of messages
# to purge.
retention.sleep.millis=100
# Maximum number of messages we will store in a single mailbox. If this
# number is exceeded, the oldest message in the box will be deleted each
# time a new message is received for it.
mailbox.message.cap=500

View File

@@ -1,129 +0,0 @@
# win-sample.conf
# Sample inbucket configuration for Windows
#############################################################################
[DEFAULT]
# Not used directly, but is typically referenced below in %()s format.
install.dir=.
default.domain=inbucket.local
#############################################################################
[logging]
# Options from least to most verbose: ERROR, WARN, INFO, TRACE
level=INFO
#############################################################################
[smtp]
# IPv4 address to listen for SMTP connections on.
ip4.address=0.0.0.0
# IPv4 port to listen for SMTP connections on.
ip4.port=2500
# used in SMTP greeting
domain=%(default.domain)s
# optional: mail sent to accounts at this domain will not be stored,
# for mixed use (content and load testing)
#domain.nostore=bitbucket.local
# Maximum number of RCPT TO: addresses we allow from clients, the SMTP
# RFC recommends this be at least 100.
max.recipients=100
# How long we allow a network connection to be idle before hanging up on the
# client, SMTP RFC recommends at least 5 minutes (300 seconds).
max.idle.seconds=300
# Maximum allowable size of message body in bytes (including attachments)
max.message.bytes=2048000
# Should we place messages into the datastore, or just throw them away
# (for load testing): true or false
store.messages=true
#############################################################################
[pop3]
# IPv4 address to listen for POP3 connections on.
ip4.address=0.0.0.0
# IPv4 port to listen for POP3 connections on.
ip4.port=1100
# used in POP3 greeting
domain=%(default.domain)s
# How long we allow a network connection to be idle before hanging up on the
# client, POP3 RFC requires at least 10 minutes (600 seconds).
max.idle.seconds=600
#############################################################################
[web]
# IPv4 address to serve HTTP web interface on
ip4.address=0.0.0.0
# IPv4 port to serve HTTP web interface on
ip4.port=9000
# Name of web theme to use
theme=bootstrap
# Prompt displayed between the mailbox entry field and View button. Leave
# empty or comment out to hide the prompt.
mailbox.prompt=@inbucket
# Path to the selected themes template files
template.dir=%(install.dir)s\themes\%(theme)s\templates
# Should we cache parsed templates (set to false during theme dev)
template.cache=true
# Path to the selected themes public (static) files
public.dir=%(install.dir)s\themes\%(theme)s\public
# Path to the greeting HTML displayed on front page, can be moved out of
# installation dir for customization
greeting.file=%(install.dir)s\themes\greeting.html
# Key used to sign session cookie data so that it cannot be tampered with.
# If this is left unset, Inbucket will generate a random key at startup
# and previous sessions will be invalidated.
#cookie.auth.key=secret-inbucket-session-cookie-key
# Enable or disable the live message monitor tab for the web UI. This will let
# anybody see all messages delivered to Inbucket. This setting has no impact
# on the availability of the underlying WebSocket.
monitor.visible=true
# How many historical message headers should be cached for display by new
# monitor connections. It does not limit the number of messages displayed by
# the browser once the monitor is open; all freshly received messages will be
# appended to the on screen list. This setting also affects the underlying
# API/WebSocket.
monitor.history=30
#############################################################################
[datastore]
# Path to the datastore, mail will be written into subdirectories
path=.\inbucket-data
# How many minutes after receipt should a message be stored until it's
# automatically purged. To retain messages until manually deleted, set this
# to 0
retention.minutes=240
# How many milliseconds to sleep after purging messages from a mailbox.
# This should help reduce disk I/O when there are a large number of messages
# to purge.
retention.sleep.millis=100
# Maximum number of messages we will store in a single mailbox. If this
# number is exceeded, the oldest message in the box will be deleted each
# time a new message is received for it.
mailbox.message.cap=500

View File

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

View File

@@ -1,367 +0,0 @@
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 {
hashLock datastore.HashLock
path string
mailPath string
messageCap int
}
// NewFileDataStore creates a new DataStore object using the specified path
func NewFileDataStore(cfg config.DataStoreConfig) datastore.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
}
func (ds *FileDataStore) LockFor(emailAddress string) (*sync.RWMutex, error) {
name, err := stringutil.ParseMailboxName(emailAddress)
if err != nil {
return nil, err
}
hash := stringutil.HashMailboxName(name)
return ds.hashLock.Get(hash), nil
}
// FileMailbox implements Mailbox, manages the mail for a specific user and
// correlates to a particular directory on disk.
type FileMailbox struct {
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,583 +0,0 @@
package filestore
import (
"bytes"
"fmt"
"io"
"io/ioutil"
"log"
"os"
"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(config.DataStoreConfig{})
defer teardownDataStore(ds)
root := ds.path
// james hashes to 474ba67bdb289c6263b36dfd8a7bed6c85b04943
mbName := "james"
// Check filestore root exists
assert.True(t, isDir(root), "Expected %q to be a directory", root)
// Check mail dir exists
expect := filepath.Join(root, "mail")
assert.True(t, isDir(expect), "Expected %q to be a directory", expect)
// Check first hash section does not exist
expect = filepath.Join(root, "mail", "474")
assert.False(t, isDir(expect), "Expected %q to not exist", expect)
// Deliver test message
id1, _ := deliverMessage(ds, mbName, "test", time.Now())
// Check path to message exists
assert.True(t, isDir(expect), "Expected %q to be a directory", expect)
expect = filepath.Join(expect, "474ba6")
assert.True(t, isDir(expect), "Expected %q to be a directory", expect)
expect = filepath.Join(expect, "474ba67bdb289c6263b36dfd8a7bed6c85b04943")
assert.True(t, isDir(expect), "Expected %q to be a directory", expect)
// Check files
mbPath := expect
expect = filepath.Join(mbPath, "index.gob")
assert.True(t, isFile(expect), "Expected %q to be a file", expect)
expect = filepath.Join(mbPath, id1+".raw")
assert.True(t, isFile(expect), "Expected %q to be a file", expect)
// Deliver second test message
id2, _ := deliverMessage(ds, mbName, "test 2", time.Now())
// Check files
expect = filepath.Join(mbPath, "index.gob")
assert.True(t, isFile(expect), "Expected %q to be a file", expect)
expect = filepath.Join(mbPath, id2+".raw")
assert.True(t, isFile(expect), "Expected %q to be a file", expect)
// Delete message
mb, err := ds.MailboxFor(mbName)
assert.Nil(t, err)
msg, err := mb.GetMessage(id1)
assert.Nil(t, err)
err = msg.Delete()
assert.Nil(t, err)
// Message should be removed
expect = filepath.Join(mbPath, id1+".raw")
assert.False(t, isPresent(expect), "Did not expect %q to exist", expect)
expect = filepath.Join(mbPath, "index.gob")
assert.True(t, isFile(expect), "Expected %q to be a file", expect)
// Delete message
msg, err = mb.GetMessage(id2)
assert.Nil(t, err)
err = msg.Delete()
assert.Nil(t, err)
// Message should be removed
expect = filepath.Join(mbPath, id2+".raw")
assert.False(t, isPresent(expect), "Did not expect %q to exist", expect)
// No messages, index & maildir should be removed
expect = filepath.Join(mbPath, "index.gob")
assert.False(t, isPresent(expect), "Did not expect %q to exist", expect)
expect = mbPath
assert.False(t, isPresent(expect), "Did not expect %q to exist", expect)
if t.Failed() {
// Wait for handler to finish logging
time.Sleep(2 * time.Second)
// Dump buffered log data if there was a failure
_, _ = io.Copy(os.Stderr, logbuf)
}
}
// Test FileDataStore.AllMailboxes()
func TestFSAllMailboxes(t *testing.T) {
ds, logbuf := setupDataStore(config.DataStoreConfig{})
defer teardownDataStore(ds)
for _, name := range []string{"abby", "bill", "christa", "donald", "evelyn"} {
// Create day old message
date := time.Now().Add(-24 * time.Hour)
deliverMessage(ds, name, "Old Message", date)
// Create current message
date = time.Now()
deliverMessage(ds, name, "New Message", date)
}
mboxes, err := ds.AllMailboxes()
assert.Nil(t, err)
assert.Equal(t, len(mboxes), 5)
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, meanwhile querying its
// contents with a new mailbox object each time
func TestFSDeliverMany(t *testing.T) {
ds, logbuf := setupDataStore(config.DataStoreConfig{})
defer teardownDataStore(ds)
mbName := "fred"
subjects := []string{"alpha", "bravo", "charlie", "delta", "echo"}
for i, subj := range subjects {
// 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)
}
assert.Equal(t, i, len(msgs), "Expected %v message(s), but got %v", i, len(msgs))
// Add a message
deliverMessage(ds, mbName, subj, time.Now())
}
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)
}
assert.Equal(t, len(subjects), len(msgs), "Expected %v message(s), but got %v",
len(subjects), len(msgs))
// Confirm delivery order
for i, expect := range subjects {
subj := msgs[i].Subject()
assert.Equal(t, expect, subj, "Expected subject %q, got %q", expect, subj)
}
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 deleting messages
func TestFSDelete(t *testing.T) {
ds, logbuf := setupDataStore(config.DataStoreConfig{})
defer teardownDataStore(ds)
mbName := "fred"
subjects := []string{"alpha", "bravo", "charlie", "delta", "echo"}
for _, subj := range subjects {
// Add a message
deliverMessage(ds, mbName, subj, time.Now())
}
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)
}
assert.Equal(t, len(subjects), len(msgs), "Expected %v message(s), but got %v",
len(subjects), len(msgs))
// Delete a couple messages
_ = msgs[1].Delete()
_ = msgs[3].Delete()
// Confirm deletion
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)
}
subjects = []string{"alpha", "charlie", "echo"}
assert.Equal(t, len(subjects), len(msgs), "Expected %v message(s), but got %v",
len(subjects), len(msgs))
for i, expect := range subjects {
subj := msgs[i].Subject()
assert.Equal(t, expect, subj, "Expected subject %q, got %q", expect, subj)
}
// Try appending one more
deliverMessage(ds, mbName, "foxtrot", time.Now())
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)
}
subjects = []string{"alpha", "charlie", "echo", "foxtrot"}
assert.Equal(t, len(subjects), len(msgs), "Expected %v message(s), but got %v",
len(subjects), len(msgs))
for i, expect := range subjects {
subj := msgs[i].Subject()
assert.Equal(t, expect, subj, "Expected subject %q, got %q", expect, subj)
}
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 purging a mailbox
func TestFSPurge(t *testing.T) {
ds, logbuf := setupDataStore(config.DataStoreConfig{})
defer teardownDataStore(ds)
mbName := "fred"
subjects := []string{"alpha", "bravo", "charlie", "delta", "echo"}
for _, subj := range subjects {
// Add a message
deliverMessage(ds, mbName, subj, time.Now())
}
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)
}
assert.Equal(t, len(subjects), len(msgs), "Expected %v message(s), but got %v",
len(subjects), len(msgs))
// Purge mailbox
err = mb.Purge()
assert.Nil(t, err)
// Confirm deletion
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)
}
assert.Equal(t, len(msgs), 0, "Expected mailbox to have zero messages, got %v", 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 message size calculation
func TestFSSize(t *testing.T) {
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([]int64, len(subjects))
for i, subj := range subjects {
// Add a message
id, size := deliverMessage(ds, mbName, subj, time.Now())
sentIds[i] = id
sentSizes[i] = size
}
mb, err := ds.MailboxFor(mbName)
if err != nil {
t.Fatalf("Failed to MailboxFor(%q): %v", mbName, err)
}
for i, id := range sentIds {
msg, err := mb.GetMessage(id)
assert.Nil(t, err)
expect := sentSizes[i]
size := msg.Size()
assert.Equal(t, expect, size, "Expected size of %v, got %v", expect, size)
}
if t.Failed() {
// Wait for handler to finish logging
time.Sleep(2 * time.Second)
// Dump buffered log data if there was a failure
_, _ = io.Copy(os.Stderr, logbuf)
}
}
// Test missing files
func TestFSMissing(t *testing.T) {
ds, logbuf := setupDataStore(config.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(cfg config.DataStoreConfig) (*FileDataStore, *bytes.Buffer) {
path, err := ioutil.TempDir("", "inbucket")
if err != nil {
panic(err)
}
// Capture log output
buf := new(bytes.Buffer)
log.SetOutput(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 int64) {
// Build fake SMTP message for delivery
testMsg := make([]byte, 0, 300)
testMsg = append(testMsg, []byte("To: somebody@host\r\n")...)
testMsg = append(testMsg, []byte("From: somebodyelse@host\r\n")...)
testMsg = append(testMsg, []byte(fmt.Sprintf("Subject: %s\r\n", subject))...)
testMsg = append(testMsg, []byte("\r\n")...)
testMsg = append(testMsg, []byte("Test Body\r\n")...)
mb, err := ds.MailboxFor(mbName)
if err != nil {
panic(err)
}
// Create message object
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)
}
if err = msg.Close(); err != nil {
panic(err)
}
return id, int64(len(testMsg))
}
func teardownDataStore(ds *FileDataStore) {
if err := os.RemoveAll(ds.path); err != nil {
panic(err)
}
}
func isPresent(path string) bool {
_, err := os.Lstat(path)
return err == nil
}
func isFile(path string) bool {
if fi, err := os.Lstat(path); err == nil {
return !fi.IsDir()
}
return false
}
func isDir(path string) bool {
if fi, err := os.Lstat(path); err == nil {
return fi.IsDir()
}
return false
}

23
go.mod Normal file
View File

@@ -0,0 +1,23 @@
module github.com/inbucket/inbucket
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect
github.com/google/subcommands v1.2.0
github.com/gorilla/css v1.0.0
github.com/gorilla/mux v1.8.0
github.com/gorilla/websocket v1.4.2
github.com/jaytaylor/html2text v0.0.0-20211105163654-bc68cce691ba // indirect
github.com/jhillyerd/enmime v0.9.2
github.com/jhillyerd/goldiff v0.1.0
github.com/kelseyhightower/envconfig v1.4.0
github.com/mattn/go-runewidth v0.0.13 // indirect
github.com/microcosm-cc/bluemonday v1.0.17
github.com/rs/zerolog v1.26.1
github.com/stretchr/testify v1.7.0
golang.org/x/net v0.0.0-20220114011407-0dd24b26b47d
golang.org/x/text v0.3.7 // indirect
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
)
go 1.13

91
go.sum Normal file
View File

@@ -0,0 +1,91 @@
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a h1:MISbI8sU/PSK/ztvmWKFcI7UGb5/HQT7B+i3a2myKgI=
github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a/go.mod h1:2GxOXOlEPAMFPfp014mK1SWq8G8BN8o7/dfYqJrVGn8=
github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-test/deep v1.0.7 h1:/VSMRlnY/JSyqxQUzQLKVMAskpY/NZKFA5j2P+0pP2M=
github.com/go-test/deep v1.0.7/go.mod h1:QV8Hv/iy04NyLBxAdO9njL0iVPN1S4d/A3NVv1V36o8=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gogs/chardet v0.0.0-20191104214054-4b6791f73a28/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f h1:3BSP1Tbs2djlpprl7wCLuiqMaUh5SJkkzI2gDs+FgLs=
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/jaytaylor/html2text v0.0.0-20200412013138-3577fbdbcff7/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
github.com/jaytaylor/html2text v0.0.0-20211105163654-bc68cce691ba h1:QFQpJdgbON7I0jr2hYW7Bs+XV0qjc3d5tZoDnRFnqTg=
github.com/jaytaylor/html2text v0.0.0-20211105163654-bc68cce691ba/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
github.com/jhillyerd/enmime v0.9.2 h1:Njvy7yubcX21WaM+kWdVxGFJ99Rk6xHqgon3Ep++qDw=
github.com/jhillyerd/enmime v0.9.2/go.mod h1:S5ge4lnv/dDDBbAWwtoOFlj14NHiXdw/EqMB2lJz3b8=
github.com/jhillyerd/goldiff v0.1.0 h1:7JzKPKVwAg1GzrbnsToYzq3Y5+S7dXM4hgEYiOzaf4A=
github.com/jhillyerd/goldiff v0.1.0/go.mod h1:WeDal6DTqhbMhNkf5REzWCIvKl3JWs0Q9omZ/huIWAs=
github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8=
github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg=
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/microcosm-cc/bluemonday v1.0.17 h1:Z1a//hgsQ4yjC+8zEkV8IWySkXnsxmdSY642CTFQb5Y=
github.com/microcosm-cc/bluemonday v1.0.17/go.mod h1:Z0r70sCuXHig8YpBzCc5eGHAap2K7e/u082ZUpDRRqM=
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rs/xid v1.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.26.1 h1:/ihwxqH+4z8UxyI70wM1z9yCvkWcfz/a3mj48k/Zngc=
github.com/rs/zerolog v1.26.1/go.mod h1:/wSSJWX7lVrsOwlbyTRSOJvqRlc+WjWlfes+CiJ+tmc=
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo=
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20211215165025-cf75a172585e/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210501142056-aec3718b3fa0/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220114011407-0dd24b26b47d h1:1n1fc535VhN8SYtD4cDUyNlfpAF2ROMM9+11equK3hs=
golang.org/x/net v0.0.0-20220114011407-0dd24b26b47d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.7/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -1,64 +0,0 @@
package httpd
import (
"fmt"
"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,
}
// 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`!()\\[\\]{};:'\".,<>?«»“”‘’]))")
// 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) {
return template.HTML(t.Format("03:04:05 PM"))
}
return template.HTML(t.Format("Mon Jan 2, 2006"))
}
// 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 {
strs[i] = fmt.Sprint(th)
}
// Grab the route
u, err := Router.Get(name).URL(strs...)
if err != nil {
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
// HTML display
func TextToHTML(text string) template.HTML {
text = html.EscapeString(text)
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 {
unescaped := strings.Replace(url, "&amp;", "&", -1)
return fmt.Sprintf("<a href=\"%s\" target=\"_blank\">%s</a>", unescaped, url)
}

View File

@@ -1,30 +0,0 @@
package httpd
import (
"html/template"
"testing"
"github.com/stretchr/testify/assert"
)
func TestTextToHtml(t *testing.T) {
// Identity
assert.Equal(t, TextToHTML("html"), template.HTML("html"))
// Check it escapes
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"))
}
func TestURLDetection(t *testing.T) {
assert.Equal(t,
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"),
template.HTML("<a href=\"http://a.com/?q=a&n=v\" target=\"_blank\">http://a.com/?q=a&amp;n=v</a>"))
}

View File

@@ -1,159 +0,0 @@
// 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,83 +0,0 @@
package httpd
import (
"html/template"
"net/http"
"path"
"path/filepath"
"strings"
"sync"
"github.com/jhillyerd/inbucket/log"
)
var cachedMutex sync.Mutex
var cachedTemplates = map[string]*template.Template{}
var cachedPartials = map[string]*template.Template{}
// RenderTemplate fetches the named template and renders it to the provided
// ResponseWriter.
func RenderTemplate(name string, w http.ResponseWriter, data interface{}) error {
t, err := ParseTemplate(name, false)
if err != nil {
log.Errorf("Error in template '%v': %v", name, err)
return err
}
w.Header().Set("Expires", "-1")
return t.Execute(w, data)
}
// RenderPartial fetches the named template and renders it to the provided
// ResponseWriter.
func RenderPartial(name string, w http.ResponseWriter, data interface{}) error {
t, err := ParseTemplate(name, true)
if err != nil {
log.Errorf("Error in template '%v': %v", name, err)
return err
}
w.Header().Set("Expires", "-1")
return t.Execute(w, data)
}
// ParseTemplate loads the requested template along with _base.html, caching
// the result (if configured to do so)
func ParseTemplate(name string, partial bool) (*template.Template, error) {
cachedMutex.Lock()
defer cachedMutex.Unlock()
if t, ok := cachedTemplates[name]; ok {
return t, nil
}
tempPath := strings.Replace(name, "/", string(filepath.Separator), -1)
tempFile := filepath.Join(webConfig.TemplateDir, tempPath)
log.Tracef("Parsing template %v", tempFile)
var err error
var t *template.Template
if partial {
// Need to get basename of file to make it root template w/ funcs
base := path.Base(name)
t = template.New(base).Funcs(TemplateFuncs)
t, err = t.ParseFiles(tempFile)
} else {
t = template.New("_base.html").Funcs(TemplateFuncs)
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 webConfig.TemplateCache {
if partial {
log.Tracef("Caching partial %v", name)
cachedTemplates[name] = t
} else {
log.Tracef("Caching template %v", name)
cachedTemplates[name] = t
}
}
return t, nil
}

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

View File

@@ -1,4 +1,4 @@
package log
package metric
import (
"container/list"
@@ -7,7 +7,7 @@ import (
"time"
)
// TickerFunc is the type of metrics function accepted by AddTickerFunc
// TickerFunc is the function signature accepted by AddTickerFunc, will be called once per minute.
type TickerFunc func()
var tickerFuncChan = make(chan TickerFunc)
@@ -22,10 +22,10 @@ func AddTickerFunc(f TickerFunc) {
tickerFuncChan <- f
}
// PushMetric adds the metric to the end of the list and returns a comma separated string of the
// Push adds the metric to the end of the list and returns a comma separated string of the
// previous 61 entries. We return 61 instead of 60 (an hour) because the chart on the client
// tracks deltas between these values - there is nothing to compare the first value against.
func PushMetric(history *list.List, ev expvar.Var) string {
func Push(history *list.List, ev expvar.Var) string {
history.PushBack(ev.String())
if history.Len() > 61 {
history.Remove(history.Front())
@@ -33,18 +33,7 @@ func PushMetric(history *list.List, ev expvar.Var) string {
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, ",")
}
// metricsTicker calls the current list of TickerFuncs once per minute.
func metricsTicker() {
funcs := make([]TickerFunc, 0)
ticker := time.NewTicker(time.Minute)
@@ -60,3 +49,15 @@ func metricsTicker() {
}
}
}
// joinStringList joins a List containing strings by commas.
func joinStringList(listOfStrings *list.List) string {
if listOfStrings.Len() == 0 {
return ""
}
s := make([]string, 0, listOfStrings.Len())
for e := listOfStrings.Front(); e != nil; e = e.Next() {
s = append(s, e.Value.(string))
}
return strings.Join(s, ",")
}

View File

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

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

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

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

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,361 @@
package client_test
import (
"github.com/gorilla/mux"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/inbucket/inbucket/pkg/rest/client"
)
func TestClientV1ListMailbox(t *testing.T) {
// Setup.
c, router, teardown := setup()
defer teardown()
listHandler := &jsonHandler{json: `[
{
"mailbox": "testbox",
"id": "1",
"from": "fromuser",
"subject": "test subject",
"date": "2013-10-15T16:12:02.231532239-07:00",
"size": 264,
"seen": true
}
]`}
router.Path("/api/v1/mailbox/testbox").Methods("GET").Handler(listHandler)
// Method under test.
headers, err := c.ListMailbox("testbox")
if err != nil {
t.Fatal(err)
}
if len(headers) != 1 {
t.Fatalf("Got %v headers, want 1", len(headers))
}
h := headers[0]
got := h.Mailbox
want := "testbox"
if got != want {
t.Errorf("Mailbox got %q, want %q", got, want)
}
got = h.ID
want = "1"
if got != want {
t.Errorf("ID got %q, want %q", got, want)
}
got = h.From
want = "fromuser"
if got != want {
t.Errorf("From got %q, want %q", got, want)
}
got = h.Subject
want = "test subject"
if got != want {
t.Errorf("Subject got %q, want %q", got, want)
}
gotTime := h.Date
wantTime := time.Date(2013, 10, 15, 16, 12, 02, 231532239, time.FixedZone("UTC-7", -7*60*60))
if !wantTime.Equal(gotTime) {
t.Errorf("Date got %v, want %v", gotTime, wantTime)
}
gotInt := h.Size
wantInt := int64(264)
if gotInt != wantInt {
t.Errorf("Size got %v, want %v", gotInt, wantInt)
}
wantBool := true
gotBool := h.Seen
if gotBool != wantBool {
t.Errorf("Seen got %v, want %v", gotBool, wantBool)
}
}
func TestClientV1GetMessage(t *testing.T) {
// Setup.
c, router, teardown := setup()
defer teardown()
messageHandler := &jsonHandler{json: `{
"mailbox": "testbox",
"id": "20170107T224128-0000",
"from": "fromuser",
"subject": "test subject",
"date": "2013-10-15T16:12:02.231532239-07:00",
"size": 264,
"seen": true,
"body": {
"text": "Plain text",
"html": "<html>"
}
}`}
router.Path("/api/v1/mailbox/testbox/20170107T224128-0000").Methods("GET").Handler(messageHandler)
// Method under test.
m, err := c.GetMessage("testbox", "20170107T224128-0000")
if err != nil {
t.Fatal(err)
}
if m == nil {
t.Fatalf("message was nil, wanted a value")
}
got := m.Mailbox
want := "testbox"
if got != want {
t.Errorf("Mailbox got %q, want %q", got, want)
}
got = m.ID
want = "20170107T224128-0000"
if got != want {
t.Errorf("ID got %q, want %q", got, want)
}
got = m.From
want = "fromuser"
if got != want {
t.Errorf("From got %q, want %q", got, want)
}
got = m.Subject
want = "test subject"
if got != want {
t.Errorf("Subject got %q, want %q", got, want)
}
gotTime := m.Date
wantTime := time.Date(2013, 10, 15, 16, 12, 02, 231532239, time.FixedZone("UTC-7", -7*60*60))
if !wantTime.Equal(gotTime) {
t.Errorf("Date got %v, want %v", gotTime, wantTime)
}
gotInt := m.Size
wantInt := int64(264)
if gotInt != wantInt {
t.Errorf("Size got %v, want %v", gotInt, wantInt)
}
gotBool := m.Seen
wantBool := true
if gotBool != wantBool {
t.Errorf("Seen got %v, want %v", gotBool, wantBool)
}
got = m.Body.Text
want = "Plain text"
if got != want {
t.Errorf("Body Text got %q, want %q", got, want)
}
got = m.Body.HTML
want = "<html>"
if got != want {
t.Errorf("Body HTML got %q, want %q", got, want)
}
}
func TestClientV1MarkSeen(t *testing.T) {
// Setup.
c, router, teardown := setup()
defer teardown()
handler := &jsonHandler{}
router.Path("/api/v1/mailbox/testbox/20170107T224128-0000").Methods("PATCH").
Handler(handler)
// Method under test.
err := c.MarkSeen("testbox", "20170107T224128-0000")
if err != nil {
t.Fatal(err)
}
if !handler.called {
t.Error("Wanted HTTP handler to be called, but it was not")
}
}
func TestClientV1GetMessageSource(t *testing.T) {
// Setup.
c, router, teardown := setup()
defer teardown()
router.Path("/api/v1/mailbox/testbox/20170107T224128-0000/source").Methods("GET").
Handler(&jsonHandler{json: `message source`})
// Method under test.
source, err := c.GetMessageSource("testbox", "20170107T224128-0000")
if err != nil {
t.Fatal(err)
}
want := "message source"
got := source.String()
if got != want {
t.Errorf("Source got %q, want %q", got, want)
}
}
func TestClientV1DeleteMessage(t *testing.T) {
// Setup.
c, router, teardown := setup()
defer teardown()
handler := &jsonHandler{}
router.Path("/api/v1/mailbox/testbox/20170107T224128-0000").Methods("DELETE").
Handler(handler)
// Method under test.
err := c.DeleteMessage("testbox", "20170107T224128-0000")
if err != nil {
t.Fatal(err)
}
if !handler.called {
t.Error("Wanted HTTP handler to be called, but it was not")
}
}
func TestClientV1PurgeMailbox(t *testing.T) {
// Setup.
c, router, teardown := setup()
defer teardown()
handler := &jsonHandler{}
router.Path("/api/v1/mailbox/testbox").Methods("DELETE").Handler(handler)
// Method under test.
err := c.PurgeMailbox("testbox")
if err != nil {
t.Fatal(err)
}
if !handler.called {
t.Error("Wanted HTTP handler to be called, but it was not")
}
}
func TestClientV1MessageHeader(t *testing.T) {
// Setup.
c, router, teardown := setup()
defer teardown()
listHandler := &jsonHandler{json: `[
{
"mailbox":"mailbox1",
"id":"id1",
"from":"from1",
"subject":"subject1",
"date":"2017-01-01T00:00:00.000-07:00",
"size":100,
"seen":true
}
]`}
router.Path("/api/v1/mailbox/testbox").Methods("GET").Handler(listHandler)
// Method under test.
headers, err := c.ListMailbox("testbox")
if err != nil {
t.Fatal(err)
}
if len(headers) != 1 {
t.Fatalf("len(headers) == %v, want 1", len(headers))
}
header := headers[0]
// Test MessageHeader.Delete().
handler := &jsonHandler{}
router.Path("/api/v1/mailbox/mailbox1/id1").Methods("DELETE").Handler(handler)
err = header.Delete()
if err != nil {
t.Fatal(err)
}
// Test MessageHeader.GetSource().
router.Path("/api/v1/mailbox/mailbox1/id1/source").Methods("GET").
Handler(&jsonHandler{json: `source1`})
buf, err := header.GetSource()
if err != nil {
t.Fatal(err)
}
want := "source1"
got := buf.String()
if got != want {
t.Errorf("Got source %q, want %q", got, want)
}
// Test MessageHeader.GetMessage().
messageHandler := &jsonHandler{json: `{
"mailbox":"mailbox1",
"id":"id1",
"from":"from1",
"subject":"subject1",
"date":"2017-01-01T00:00:00.000-07:00",
"size":100
}`}
router.Path("/api/v1/mailbox/mailbox1/id1").Methods("GET").Handler(messageHandler)
message, err := header.GetMessage()
if err != nil {
t.Fatal(err)
}
if message == nil {
t.Fatalf("message was nil, wanted a value")
}
// Test Message.Delete().
err = message.Delete()
if err != nil {
t.Fatal(err)
}
// Test Message.GetSource().
buf, err = message.GetSource()
if err != nil {
t.Fatal(err)
}
want = "source1"
got = buf.String()
if got != want {
t.Errorf("Got source %q, want %q", got, want)
}
}
// setup returns a client, router and server for API testing.
func setup() (c *client.Client, router *mux.Router, teardown func()) {
router = mux.NewRouter()
server := httptest.NewServer(router)
c, err := client.New(server.URL)
if err != nil {
panic(err)
}
return c, router, func() {
server.Close()
}
}
// jsonHandler returns the string in json when servicing a request.
type jsonHandler struct {
json string
called bool
}
func (j *jsonHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
j.called = true
w.Write([]byte(j.json))
}

View File

@@ -0,0 +1,102 @@
package client_test
import (
"fmt"
"log"
"net/http"
"net/http/httptest"
"github.com/gorilla/mux"
"github.com/inbucket/inbucket/pkg/rest/client"
)
// Example demonstrates basic usage for the Inbucket REST client.
func Example() {
// Setup a fake Inbucket server for this example.
baseURL, teardown := exampleSetup()
defer teardown()
// Begin by creating a new client using the base URL of your Inbucket server, i.e.
// `localhost:9000`.
restClient, err := client.New(baseURL)
if err != nil {
log.Fatal(err)
}
// Get a slice of message headers for the mailbox named `user1`.
headers, err := restClient.ListMailbox("user1")
if err != nil {
log.Fatal(err)
}
for _, header := range headers {
fmt.Printf("ID: %v, Subject: %v\n", header.ID, header.Subject)
}
// Get the content of the first message.
message, err := headers[0].GetMessage()
if err != nil {
log.Fatal(err)
}
fmt.Printf("\nFrom: %v\n", message.From)
fmt.Printf("Text body:\n%v", message.Body.Text)
// Delete the second message.
err = headers[1].Delete()
if err != nil {
log.Fatal(err)
}
// Output:
// ID: 20180107T224128-0000, Subject: First subject
// ID: 20180108T121212-0123, Subject: Second subject
//
// From: admin@inbucket.org
// Text body:
// This is the plain text body
}
// exampleSetup creates a fake Inbucket server to power Example() below.
func exampleSetup() (baseURL string, teardown func()) {
router := mux.NewRouter()
server := httptest.NewServer(router)
// Handle ListMailbox request.
router.HandleFunc("/api/v1/mailbox/user1", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`[
{
"mailbox": "user1",
"id": "20180107T224128-0000",
"subject": "First subject"
},
{
"mailbox": "user1",
"id": "20180108T121212-0123",
"subject": "Second subject"
}
]`))
})
// Handle GetMessage request.
router.HandleFunc("/api/v1/mailbox/user1/20180107T224128-0000",
func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{
"mailbox": "user1",
"id": "20180107T224128-0000",
"from": "admin@inbucket.org",
"subject": "First subject",
"body": {
"text": "This is the plain text body"
}
}`))
})
// Handle Delete request.
router.HandleFunc("/api/v1/mailbox/user1/20180108T121212-0123",
func(w http.ResponseWriter, r *http.Request) {
// Nop.
})
return server.URL, func() {
server.Close()
}
}

81
pkg/rest/client/rest.go Normal file
View File

@@ -0,0 +1,81 @@
package client
import (
"bytes"
"encoding/json"
"fmt"
"io"
"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, body []byte) (*http.Response, error) {
rel, err := url.Parse(uri)
if err != nil {
return nil, err
}
url := c.baseURL.ResolveReference(rel)
var r io.Reader
if body != nil {
r = bytes.NewReader(body)
}
req, err := http.NewRequest(method, url.String(), r)
if err != nil {
return nil, fmt.Errorf("%s for %q: %v", method, url, err)
}
return c.client.Do(req)
}
// doJSON performs an HTTP request with this client and marshalls the JSON response into v.
func (c *restClient) doJSON(method string, uri string, v interface{}) error {
resp, err := c.do(method, uri, nil)
if err != nil {
return err
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode == http.StatusOK {
if v == nil {
return nil
}
// Decode response body
return json.NewDecoder(resp.Body).Decode(v)
}
return fmt.Errorf("%s for %q, unexpected %v: %s", method, uri, resp.StatusCode, resp.Status)
}
// doJSONBody performs an HTTP request with this client and marshalls the JSON response into v.
func (c *restClient) doJSONBody(method string, uri string, body []byte, v interface{}) error {
resp, err := c.do(method, uri, body)
if err != nil {
return err
}
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("%s for %q, unexpected %v: %s", method, uri, resp.StatusCode, resp.Status)
}

View File

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

View File

@@ -1,19 +1,20 @@
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"`
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"`
PosixMillis int64 `json:"posix-millis"`
Size int64 `json:"size"`
Seen bool `json:"seen"`
}
// JSONMessageV1 contains the same data as the header plus a JSONMessageBody
@@ -24,12 +25,15 @@ type JSONMessageV1 struct {
To []string `json:"to"`
Subject string `json:"subject"`
Date time.Time `json:"date"`
PosixMillis int64 `json:"posix-millis"`
Size int64 `json:"size"`
Seen bool `json:"seen"`
Body *JSONMessageBodyV1 `json:"body"`
Header mail.Header `json:"header"`
Header map[string][]string `json:"header"`
Attachments []*JSONMessageAttachmentV1 `json:"attachments"`
}
// JSONMessageAttachmentV1 contains information about a MIME attachment
type JSONMessageAttachmentV1 struct {
FileName string `json:"filename"`
ContentType string `json:"content-type"`

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

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

View File

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

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

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

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

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

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

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

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

@@ -0,0 +1,644 @@
package smtp
import (
"bufio"
"bytes"
"crypto/tls"
"fmt"
"io"
"net"
"net/textproto"
"regexp"
"strconv"
"strings"
"time"
"github.com/inbucket/inbucket/pkg/policy"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
// State tracks the current mode of our SMTP state machine.
type State int
const (
// timeStampFormat to use in Received header.
timeStampFormat = "Mon, 02 Jan 2006 15:04:05 -0700 (MST)"
// Messages sent to user during LOGIN auth procedure. Can vary, but values are taken directly
// from spec https://tools.ietf.org/html/draft-murchison-sasl-login-00
// usernameChallenge sent when inviting user to provide username. Is base64 encoded string
// `User Name`
usernameChallenge = "VXNlciBOYW1lAA=="
// passwordChallenge sent when inviting user to provide password. Is base64 encoded string
// `Password`
passwordChallenge = "UGFzc3dvcmQA"
)
const (
// GREET State: Waiting for HELO
GREET State = iota
// READY State: Got HELO, waiting for MAIL
READY
// LOGIN State: Got AUTH LOGIN command, expecting Username
LOGIN
// PASSWORD State: Got Username, expecting password
PASSWORD
// MAIL State: Got MAIL, accepting RCPTs
MAIL
// DATA State: Got DATA, waiting for "."
DATA
// QUIT State: Client requested end of session
QUIT
)
// fromRegex captures the from address and optional BODY=8BITMIME clause. Matches FROM, while
// accepting '>' as quoted pair and in double quoted strings (?i) makes the regex case insensitive,
// (?:) is non-grouping sub-match
var fromRegex = regexp.MustCompile(
"(?i)^FROM:\\s*<((?:(?:\\\\>|[^>])+|\"[^\"]+\"@[^>])+)?>( [\\w= ]+)?$")
func (s State) String() string {
switch s {
case GREET:
return "GREET"
case READY:
return "READY"
case MAIL:
return "MAIL"
case DATA:
return "DATA"
case QUIT:
return "QUIT"
}
return "Unknown"
}
var commands = map[string]bool{
"HELO": true,
"EHLO": true,
"MAIL": true,
"RCPT": true,
"DATA": true,
"RSET": true,
"SEND": true,
"SOML": true,
"SAML": true,
"VRFY": true,
"EXPN": true,
"HELP": true,
"NOOP": true,
"QUIT": true,
"TURN": true,
"STARTTLS": true,
"AUTH": true,
}
// Session holds the state of an SMTP session
type Session struct {
*Server // Server this session belongs to.
id int // Session ID.
conn net.Conn // TCP connection.
remoteDomain string // Remote domain from HELO command.
remoteHost string // Remote host.
sendError error // Last network send error.
state State // Session state machine.
reader *bufio.Reader // Buffered reading for TCP conn.
from string // Sender from MAIL command.
recipients []*policy.Recipient // Recipients from RCPT commands.
logger zerolog.Logger // Session specific logger.
debug bool // Print network traffic to stdout.
tlsState *tls.ConnectionState
text *textproto.Conn
}
// NewSession creates a new Session for the given connection
func NewSession(server *Server, id int, conn net.Conn, logger zerolog.Logger) *Session {
reader := bufio.NewReader(conn)
host, _, _ := net.SplitHostPort(conn.RemoteAddr().String())
return &Session{
Server: server,
id: id,
conn: conn,
state: GREET,
reader: reader,
remoteHost: host,
recipients: make([]*policy.Recipient, 0),
logger: logger,
debug: server.config.Debug,
text: textproto.NewConn(conn),
}
}
func (s *Session) String() string {
return fmt.Sprintf("Session{id: %v, state: %v}", s.id, s.state)
}
/* Session flow:
* 1. Send initial greeting
* 2. Receive cmd
* 3. If good cmd, respond, optionally change state
* 4. If bad cmd, respond error
* 5. Goto 2
*/
func (s *Server) startSession(id int, conn net.Conn) {
logger := log.Hook(logHook{}).With().
Str("module", "smtp").
Str("remote", conn.RemoteAddr().String()).
Int("session", id).Logger()
logger.Info().Msg("Starting SMTP session")
expConnectsCurrent.Add(1)
defer func() {
if err := conn.Close(); err != nil {
logger.Warn().Err(err).Msg("Closing connection")
}
s.wg.Done()
expConnectsCurrent.Add(-1)
}()
ssn := NewSession(s, id, conn, logger)
ssn.greet()
// This is our command reading loop
for ssn.state != QUIT && ssn.sendError == nil {
if ssn.state == DATA {
// Special case, does not use SMTP command format
ssn.dataHandler()
continue
}
line, err := ssn.readLine()
if err == nil {
//Handle LOGIN/PASSWORD states here, because they don't expect a command
switch ssn.state {
case LOGIN:
ssn.loginHandler(line)
continue
case PASSWORD:
ssn.passwordHandler(line)
continue
}
if cmd, arg, ok := ssn.parseCmd(line); ok {
// Check against valid SMTP commands
if cmd == "" {
ssn.send("500 Speak up")
continue
}
if !commands[cmd] {
ssn.send(fmt.Sprintf("500 Syntax error, %v command unrecognized", cmd))
ssn.logger.Warn().Msgf("Unrecognized command: %v", cmd)
continue
}
// Commands we handle in any state
switch cmd {
case "SEND", "SOML", "SAML", "EXPN", "HELP", "TURN":
// These commands are not implemented in any state
ssn.send(fmt.Sprintf("502 %v command not implemented", cmd))
ssn.logger.Warn().Msgf("Command %v not implemented by Inbucket", cmd)
continue
case "VRFY":
ssn.send("252 Cannot VRFY user, but will accept message")
continue
case "NOOP":
ssn.send("250 I have sucessfully done nothing")
continue
case "RSET":
// Reset session
ssn.logger.Debug().Msgf("Resetting session state on RSET request")
ssn.reset()
ssn.send("250 Session reset")
continue
case "QUIT":
ssn.send("221 Goodnight and good luck")
ssn.enterState(QUIT)
continue
}
// Send command to handler for current state
switch ssn.state {
case GREET:
ssn.greetHandler(cmd, arg)
continue
case READY:
ssn.readyHandler(cmd, arg)
continue
case MAIL:
ssn.mailHandler(cmd, arg)
continue
}
ssn.logger.Error().Msgf("Session entered unexpected state %v", ssn.state)
break
} else {
ssn.send("500 Syntax error, command garbled")
}
} else {
// readLine() returned an error
if err == io.EOF {
switch ssn.state {
case GREET, READY:
// EOF is common here
ssn.logger.Info().Msgf("Client closed connection (state %v)", ssn.state)
default:
ssn.logger.Warn().Msgf("Got EOF while in state %v", ssn.state)
}
break
}
// Not an EOF
ssn.logger.Warn().Msgf("Connection error: %v", err)
if netErr, ok := err.(net.Error); ok {
if netErr.Timeout() {
ssn.send("221 Idle timeout, bye bye")
break
}
}
ssn.send("221 Connection error, sorry")
break
}
}
if ssn.sendError != nil {
ssn.logger.Warn().Msgf("Network send error: %v", ssn.sendError)
}
ssn.logger.Info().Msgf("Closing connection")
}
// GREET state -> waiting for HELO
func (s *Session) greetHandler(cmd string, arg string) {
const readyBanner = "Great, let's get this show on the road"
switch cmd {
case "HELO":
domain, err := parseHelloArgument(arg)
if err != nil {
s.send("501 Domain/address argument required for HELO")
return
}
s.remoteDomain = domain
s.send("250 " + readyBanner)
s.enterState(READY)
case "EHLO":
domain, err := parseHelloArgument(arg)
if err != nil {
s.send("501 Domain/address argument required for EHLO")
return
}
s.remoteDomain = domain
// Features before SIZE per RFC
s.send("250-" + readyBanner)
s.send("250-8BITMIME")
s.send("250-AUTH PLAIN LOGIN")
if s.Server.config.TLSEnabled && s.Server.tlsConfig != nil && s.tlsState == nil {
s.send("250-STARTTLS")
}
s.send(fmt.Sprintf("250 SIZE %v", s.config.MaxMessageBytes))
s.enterState(READY)
default:
s.ooSeq(cmd)
}
}
func parseHelloArgument(arg string) (string, error) {
domain := arg
if idx := strings.IndexRune(arg, ' '); idx >= 0 {
domain = arg[:idx]
}
if domain == "" {
return "", fmt.Errorf("Invalid domain")
}
return domain, nil
}
func (s *Session) loginHandler(line string) {
// Content and length of username is ignored.
s.send(fmt.Sprintf("334 %v", passwordChallenge))
s.enterState(PASSWORD)
}
func (s *Session) passwordHandler(line string) {
// Content and length of password is ignored.
s.send("235 Authentication successful")
s.enterState(READY)
}
// READY state -> waiting for MAIL
// AUTH can change
func (s *Session) readyHandler(cmd string, arg string) {
if cmd == "STARTTLS" {
if !s.Server.config.TLSEnabled {
// Invalid command since TLS unconfigured.
s.logger.Debug().Msgf("454 TLS unavailable on the server")
s.send("454 TLS unavailable on the server")
return
}
if s.tlsState != nil {
// TLS state previously valid.
s.logger.Debug().Msg("454 A TLS session already agreed upon.")
s.send("454 A TLS session already agreed upon.")
return
}
s.logger.Debug().Msg("Initiating TLS context.")
// Start TLS connection handshake.
s.send("220 STARTTLS")
tlsConn := tls.Server(s.conn, s.Server.tlsConfig)
s.conn = tlsConn
s.text = textproto.NewConn(s.conn)
s.tlsState = new(tls.ConnectionState)
*s.tlsState = tlsConn.ConnectionState()
s.enterState(GREET)
} else if cmd == "AUTH" {
args := strings.SplitN(arg, " ", 3)
authMethod := args[0]
switch authMethod {
case "PLAIN":
{
if len(args) != 2 {
s.send("500 Bad auth arguments")
s.logger.Warn().Msgf("Bad auth attempt: %q", arg)
return
}
s.logger.Info().Msgf("Accepting credentials: %q", args[1])
s.send("235 2.7.0 Authentication successful")
return
}
case "LOGIN":
{
s.send(fmt.Sprintf("334 %v", usernameChallenge))
s.enterState(LOGIN)
return
}
default:
{
s.send(fmt.Sprintf("500 Unsupported AUTH method: %v", authMethod))
return
}
}
} else if cmd == "MAIL" {
// Capture group 1: from address. 2: optional params.
m := fromRegex.FindStringSubmatch(arg)
if m == nil {
s.send("501 Was expecting MAIL arg syntax of FROM:<address>")
s.logger.Warn().Msgf("Bad MAIL argument: %q", arg)
return
}
from := m[1]
if _, _, err := policy.ParseEmailAddress(from); from != "" && err != nil {
s.send("501 Bad sender address syntax")
s.logger.Warn().Msgf("Bad address as MAIL arg: %q, %s", from, err)
return
}
if from == "" {
from = "unspecified"
}
// This is where the client may put BODY=8BITMIME, but we already
// read the DATA as bytes, so it does not effect our processing.
if m[2] != "" {
args, ok := s.parseArgs(m[2])
if !ok {
s.send("501 Unable to parse MAIL ESMTP parameters")
s.logger.Warn().Msgf("Bad MAIL argument: %q", arg)
return
}
if args["SIZE"] != "" {
size, err := strconv.ParseInt(args["SIZE"], 10, 32)
if err != nil {
s.send("501 Unable to parse SIZE as an integer")
s.logger.Warn().Msgf("Unable to parse SIZE %q as an integer", args["SIZE"])
return
}
if int(size) > s.config.MaxMessageBytes {
s.send("552 Max message size exceeded")
s.logger.Warn().Msgf("Client wanted to send oversized message: %v", args["SIZE"])
return
}
}
}
s.from = from
s.logger.Info().Msgf("Mail from: %v", from)
s.send(fmt.Sprintf("250 Roger, accepting mail from <%v>", from))
s.enterState(MAIL)
} else if cmd == "EHLO" {
// Reset session
s.logger.Debug().Msgf("Resetting session state on EHLO request")
s.reset()
s.send("250 Session reset")
} else {
s.ooSeq(cmd)
}
}
// MAIL state -> waiting for RCPTs followed by DATA
func (s *Session) mailHandler(cmd string, arg string) {
switch cmd {
case "RCPT":
if (len(arg) < 4) || (strings.ToUpper(arg[0:3]) != "TO:") {
s.send("501 Was expecting RCPT arg syntax of TO:<address>")
s.logger.Warn().Msgf("Bad RCPT argument: %q", arg)
return
}
addr := strings.Trim(arg[3:], "<> ")
recip, err := s.addrPolicy.NewRecipient(addr)
if err != nil {
s.send("501 Bad recipient address syntax")
s.logger.Warn().Str("to", addr).Err(err).Msg("Bad address as RCPT arg")
return
}
if !recip.ShouldAccept() {
s.logger.Warn().Str("to", addr).Msg("Rejecting recipient domain")
s.send("550 Relay not permitted")
return
}
if len(s.recipients) >= s.config.MaxRecipients {
s.logger.Warn().Msgf("Limit of %v recipients exceeded", s.config.MaxRecipients)
s.send(fmt.Sprintf("552 Limit of %v recipients exceeded", s.config.MaxRecipients))
return
}
s.recipients = append(s.recipients, recip)
s.logger.Debug().Str("to", addr).Msg("Recipient added")
s.send(fmt.Sprintf("250 I'll make sure <%v> gets this", addr))
return
case "DATA":
if arg != "" {
s.send("501 DATA command should not have any arguments")
s.logger.Warn().Msgf("Got unexpected args on DATA: %q", arg)
return
}
if len(s.recipients) == 0 {
// DATA out of sequence
s.ooSeq(cmd)
return
}
s.enterState(DATA)
return
case "EHLO":
// Reset session
s.logger.Debug().Msgf("Resetting session state on EHLO request")
s.reset()
s.send("250 Session reset")
return
}
s.ooSeq(cmd)
}
// DATA
func (s *Session) dataHandler() {
s.send("354 Start mail input; end with <CRLF>.<CRLF>")
msgBuf, err := s.readDataBlock()
if err != nil {
if netErr, ok := err.(net.Error); ok {
if netErr.Timeout() {
s.send("221 Idle timeout, bye bye")
}
}
s.logger.Warn().Msgf("Error: %v while reading", err)
s.enterState(QUIT)
return
}
mailData := bytes.NewBuffer(msgBuf)
// Mail data complete.
tstamp := time.Now().Format(timeStampFormat)
for _, recip := range s.recipients {
if recip.ShouldStore() {
// Generate Received header.
prefix := fmt.Sprintf("Received: from %s ([%s]) by %s\r\n for <%s>; %s\r\n",
s.remoteDomain, s.remoteHost, s.config.Domain, recip.Address.Address,
tstamp)
// Deliver message.
_, err := s.manager.Deliver(
recip, s.from, s.recipients, prefix, mailData.Bytes())
if err != nil {
s.logger.Error().Msgf("delivery for %v: %v", recip.LocalPart, err)
s.send(fmt.Sprintf("451 Failed to store message for %v", recip.LocalPart))
s.reset()
return
}
}
expReceivedTotal.Add(1)
}
s.send("250 Mail accepted for delivery")
s.logger.Info().Msgf("Message size %v bytes", mailData.Len())
s.reset()
return
}
func (s *Session) enterState(state State) {
s.state = state
s.logger.Debug().Msgf("Entering state %v", state)
}
func (s *Session) greet() {
s.send(fmt.Sprintf("220 %v Inbucket SMTP ready", s.config.Domain))
}
// nextDeadline calculates the next read or write deadline based on configured timeout.
func (s *Session) nextDeadline() time.Time {
return time.Now().Add(s.config.Timeout)
}
// Send requested message, store errors in Session.sendError
func (s *Session) send(msg string) {
if err := s.conn.SetWriteDeadline(s.nextDeadline()); err != nil {
s.sendError = err
return
}
if err := s.text.PrintfLine("%s", msg); err != nil {
s.sendError = err
s.logger.Warn().Msgf("Failed to send: %q", msg)
return
}
if s.debug {
fmt.Printf("%04d > %v\n", s.id, msg)
}
}
// readDataBlock reads message DATA until `.` using the textproto pkg.
func (s *Session) readDataBlock() ([]byte, error) {
if err := s.conn.SetReadDeadline(s.nextDeadline()); err != nil {
return nil, err
}
b, err := s.text.ReadDotBytes()
if err != nil {
return nil, err
}
if s.debug {
fmt.Printf("%04d Received %d bytes\n", s.id, len(b))
}
return b, err
}
// readLine reads a line of input respecting deadlines.
func (s *Session) readLine() (line string, err error) {
if err = s.conn.SetReadDeadline(s.nextDeadline()); err != nil {
return "", err
}
line, err = s.text.ReadLine()
if err != nil {
return "", err
}
if s.debug {
fmt.Printf("%04d %v\n", s.id, strings.TrimRight(line, "\r\n"))
}
return line, nil
}
func (s *Session) parseCmd(line string) (cmd string, arg string, ok bool) {
line = strings.TrimRight(line, "\r\n")
// Find length of command or entire line.
hasArg := true
l := strings.IndexByte(line, ' ')
if l == -1 {
hasArg = false
l = len(line)
}
switch {
case l == 0:
return "", "", true
case l < 4:
s.logger.Warn().Msgf("Command too short: %q", line)
return "", "", false
}
if hasArg {
return strings.ToUpper(line[0:l]), strings.Trim(line[l+1:], " "), true
}
return strings.ToUpper(line), "", true
}
// parseArgs takes the arguments proceeding a command and files them
// into a map[string]string after uppercasing each key. Sample arg
// string:
// " BODY=8BITMIME SIZE=1024"
// The leading space is mandatory.
func (s *Session) parseArgs(arg string) (args map[string]string, ok bool) {
args = make(map[string]string)
re := regexp.MustCompile(` (\w+)=(\w+)`)
pm := re.FindAllStringSubmatch(arg, -1)
if pm == nil {
s.logger.Warn().Msgf("Failed to parse arg string: %q", arg)
return nil, false
}
for _, m := range pm {
args[strings.ToUpper(m[1])] = m[2]
}
s.logger.Debug().Msgf("ESMTP params: %v", args)
return args, true
}
func (s *Session) reset() {
s.enterState(READY)
s.from = ""
s.recipients = nil
}
func (s *Session) ooSeq(cmd string) {
s.send(fmt.Sprintf("503 Command %v is out of sequence", cmd))
s.logger.Warn().Msgf("Wasn't expecting %v here", cmd)
}

View File

@@ -1,8 +1,7 @@
package smtpd
package smtp
import (
"bytes"
"context"
"fmt"
"io"
@@ -13,9 +12,11 @@ import (
"testing"
"time"
"github.com/jhillyerd/inbucket/config"
"github.com/jhillyerd/inbucket/datastore"
"github.com/jhillyerd/inbucket/msghub"
"github.com/inbucket/inbucket/pkg/config"
"github.com/inbucket/inbucket/pkg/message"
"github.com/inbucket/inbucket/pkg/policy"
"github.com/inbucket/inbucket/pkg/storage"
"github.com/inbucket/inbucket/pkg/test"
)
type scriptStep struct {
@@ -25,10 +26,8 @@ type scriptStep struct {
// Test commands in GREET state
func TestGreetState(t *testing.T) {
// Setup mock objects
mds := &datastore.MockDataStore{}
server, logbuf, teardown := setupSMTPServer(mds)
ds := test.NewStore()
server, logbuf, teardown := setupSMTPServer(ds)
defer teardown()
// Test out some mangled HELOs
@@ -57,6 +56,9 @@ func TestGreetState(t *testing.T) {
if err := playSession(t, server, []scriptStep{{"helo 127.0.0.1", 250}}); err != nil {
t.Error(err)
}
if err := playSession(t, server, []scriptStep{{"HELO ABC", 250}}); err != nil {
t.Error(err)
}
// Valid EHLOs
if err := playSession(t, server, []scriptStep{{"EHLO mydomain", 250}}); err != nil {
@@ -71,6 +73,82 @@ func TestGreetState(t *testing.T) {
if err := playSession(t, server, []scriptStep{{"ehlo 127.0.0.1", 250}}); err != nil {
t.Error(err)
}
if err := playSession(t, server, []scriptStep{{"EHLO a", 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)
}
}
// Test commands in READY state
func TestEmptyEnvelope(t *testing.T) {
ds := test.NewStore()
server, logbuf, teardown := setupSMTPServer(ds)
defer teardown()
// Test out some empty envelope without blanks
script := []scriptStep{
{"HELO localhost", 250},
{"MAIL FROM:<>", 250},
}
if err := playSession(t, server, script); err != nil {
// Dump buffered log data if there was a failure
_, _ = io.Copy(os.Stderr, logbuf)
t.Error(err)
}
// Test out some empty envelope with blanks
script = []scriptStep{
{"HELO localhost", 250},
{"MAIL FROM: <>", 250},
}
if err := playSession(t, server, script); err != nil {
// Dump buffered log data if there was a failure
_, _ = io.Copy(os.Stderr, logbuf)
t.Error(err)
}
}
// Test AUTH
func TestAuth(t *testing.T) {
ds := test.NewStore()
server, logbuf, teardown := setupSMTPServer(ds)
defer teardown()
// PLAIN AUTH
script := []scriptStep{
{"EHLO localhost", 250},
{"AUTH PLAIN aW5idWNrZXQ6cGFzc3dvcmQK", 235},
{"RSET", 250},
{"AUTH GSSAPI aW5idWNrZXQ6cGFzc3dvcmQK", 500},
{"RSET", 250},
{"AUTH PLAIN", 500},
{"RSET", 250},
{"AUTH PLAIN aW5idWNrZXQ6cG Fzc3dvcmQK", 500},
}
if err := playSession(t, server, script); err != nil {
t.Error(err)
}
// LOGIN AUTH
script = []scriptStep{
{"EHLO localhost", 250},
{"AUTH LOGIN", 334}, // Test with user/pass present.
{"username", 334},
{"password", 235},
{"RSET", 250},
{"AUTH LOGIN", 334}, // Test with empty user/pass.
{"", 334},
{"", 235},
}
if err := playSession(t, server, script); err != nil {
t.Error(err)
}
if t.Failed() {
// Wait for handler to finish logging
@@ -82,10 +160,8 @@ func TestGreetState(t *testing.T) {
// Test commands in READY state
func TestReadyState(t *testing.T) {
// Setup mock objects
mds := &datastore.MockDataStore{}
server, logbuf, teardown := setupSMTPServer(mds)
ds := test.NewStore()
server, logbuf, teardown := setupSMTPServer(ds)
defer teardown()
// Test out some mangled READY commands
@@ -133,6 +209,15 @@ func TestReadyState(t *testing.T) {
t.Error(err)
}
// Test Start TLS parsing.
script = []scriptStep{
{"HELO localhost", 250},
{"STARTTLS", 454}, // TLS unconfigured.
}
if err := playSession(t, server, script); err != nil {
t.Error(err)
}
if t.Failed() {
// Wait for handler to finish logging
time.Sleep(2 * time.Second)
@@ -143,21 +228,7 @@ func TestReadyState(t *testing.T) {
// Test commands in MAIL state
func TestMailState(t *testing.T) {
// Setup mock objects
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)
mds := test.NewStore()
server, logbuf, teardown := setupSMTPServer(mds)
defer teardown()
@@ -186,13 +257,11 @@ func TestMailState(t *testing.T) {
{"RCPT TO:<u1@gmail.com>", 250},
{"RCPT TO: <u2@gmail.com>", 250},
{"RCPT TO:u3@gmail.com", 250},
{"RCPT TO:u3@deny.com", 550},
{"RCPT TO: u4@gmail.com", 250},
{"RSET", 250},
{"MAIL FROM:<john@gmail.com>", 250},
{"RCPT TO:<user\\@internal@external.com", 250},
{"RCPT TO:<\"first last\"@host.com", 250},
{"RCPT TO:<user\\>name@host.com>", 250},
{"RCPT TO:<\"user>name\"@host.com>", 250},
{`RCPT TO:<"first/last"@host.com`, 250},
}
if err := playSession(t, server, script); err != nil {
t.Error(err)
@@ -225,6 +294,19 @@ func TestMailState(t *testing.T) {
t.Error(err)
}
// Test late EHLO, similar to RSET
script = []scriptStep{
{"EHLO localhost", 250},
{"EHLO localhost", 250},
{"MAIL FROM:<john@gmail.com>", 250},
{"RCPT TO:<u1@gmail.com>", 250},
{"EHLO localhost", 250},
{"MAIL FROM:<john@gmail.com>", 250},
}
if err := playSession(t, server, script); err != nil {
t.Error(err)
}
// Test RSET
script = []scriptStep{
{"HELO localhost", 250},
@@ -258,21 +340,7 @@ func TestMailState(t *testing.T) {
// Test commands in DATA state
func TestDataState(t *testing.T) {
// Setup mock objects
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)
mds := test.NewStore()
server, logbuf, teardown := setupSMTPServer(mds)
defer teardown()
@@ -280,7 +348,6 @@ func TestDataState(t *testing.T) {
pipe := setupSMTPSession(server)
c := textproto.NewConn(pipe)
// Get us into DATA state
if code, _, err := c.ReadCodeLine(220); err != nil {
t.Errorf("Expected a 220 greeting, got %v", code)
}
@@ -307,6 +374,33 @@ Hi!
t.Errorf("Expected a 250 greeting, got %v", code)
}
// Test with no useful headers.
pipe = setupSMTPSession(server)
c = textproto.NewConn(pipe)
if code, _, err := c.ReadCodeLine(220); err != nil {
t.Errorf("Expected a 220 greeting, got %v", code)
}
script = []scriptStep{
{"HELO localhost", 250},
{"MAIL FROM:<john@gmail.com>", 250},
{"RCPT TO:<u1@gmail.com>", 250},
{"DATA", 354},
}
if err := playScriptAgainst(t, c, script); err != nil {
t.Error(err)
}
// Send a message
body = `X-Useless-Header: true
Hi! Can you still deliver this?
`
dw = c.DotWriter()
_, _ = io.WriteString(dw, body)
_ = dw.Close()
if code, _, err := c.ReadCodeLine(250); err != nil {
t.Errorf("Expected a 250 greeting, got %v", code)
}
if t.Failed() {
// Wait for handler to finish logging
time.Sleep(2 * time.Second)
@@ -367,43 +461,41 @@ func (m *mockConn) SetDeadline(t time.Time) error { return nil }
func (m *mockConn) SetReadDeadline(t time.Time) error { return nil }
func (m *mockConn) SetWriteDeadline(t time.Time) error { return nil }
func setupSMTPServer(ds datastore.DataStore) (s *Server, buf *bytes.Buffer, teardown func()) {
// Test Server Config
cfg := config.SMTPConfig{
IP4address: net.IPv4(127, 0, 0, 1),
IP4port: 2500,
Domain: "inbucket.local",
DomainNoStore: "bitbucket.local",
MaxRecipients: 5,
MaxIdleSeconds: 5,
MaxMessageBytes: 5000,
StoreMessages: true,
func setupSMTPServer(ds storage.Store) (s *Server, buf *bytes.Buffer, teardown func()) {
cfg := &config.Root{
MailboxNaming: config.FullNaming,
SMTP: config.SMTP{
Addr: "127.0.0.1:2500",
Domain: "inbucket.local",
MaxRecipients: 5,
MaxMessageBytes: 5000,
DefaultAccept: true,
RejectDomains: []string{"deny.com"},
Timeout: 5,
},
}
// Capture log output
// Capture log output.
buf = new(bytes.Buffer)
log.SetOutput(buf)
// Create a server, don't start it
// Create a server, don't start it.
shutdownChan := make(chan bool)
ctx, cancel := context.WithCancel(context.Background())
teardown = func() {
close(shutdownChan)
cancel()
}
s = NewServer(cfg, shutdownChan, ds, msghub.New(ctx, 100))
addrPolicy := &policy.Addressing{Config: cfg}
manager := &message.StoreManager{Store: ds}
s = NewServer(cfg.SMTP, shutdownChan, manager, addrPolicy)
return s, buf, teardown
}
var sessionNum int
func setupSMTPSession(server *Server) net.Conn {
// Pair of pipes to communicate
// Pair of pipes to communicate.
serverConn, clientConn := net.Pipe()
// Start the session
server.waitgroup.Add(1)
// Start the session.
server.wg.Add(1)
sessionNum++
go server.startSession(sessionNum, &mockConn{serverConn})
return clientConn
}

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

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

View File

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

View File

@@ -0,0 +1,6 @@
package web
type jsonAppConfig struct {
BasePath string `json:"base-path"`
MonitorVisible bool `json:"monitor-visible"`
}

View File

@@ -1,24 +1,24 @@
package httpd
package web
import (
"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"
"github.com/inbucket/inbucket/pkg/config"
"github.com/inbucket/inbucket/pkg/message"
"github.com/inbucket/inbucket/pkg/msghub"
)
// Context is passed into every request handler function
// TODO remove redundant web config
type Context struct {
Vars map[string]string
Session *sessions.Session
DataStore datastore.DataStore
MsgHub *msghub.Hub
WebConfig config.WebConfig
IsJSON bool
Vars map[string]string
MsgHub *msghub.Hub
Manager message.Manager
RootConfig *config.Root
WebConfig config.Web
IsJSON bool
}
// Close the Context (currently does nothing)
@@ -46,23 +46,13 @@ func headerMatch(req *http.Request, name string, value string) bool {
// 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,
MsgHub: msgHub,
WebConfig: webConfig,
IsJSON: headerMatch(req, "Accept", "application/json"),
Vars: vars,
MsgHub: msgHub,
Manager: manager,
RootConfig: rootConfig,
WebConfig: rootConfig.Web,
IsJSON: headerMatch(req, "Accept", "application/json"),
}
return ctx, err
return ctx, nil
}

106
pkg/server/web/handlers.go Normal file
View File

@@ -0,0 +1,106 @@
package web
import (
"html/template"
"net/http"
"os"
"github.com/inbucket/inbucket/pkg/config"
"github.com/rs/zerolog/log"
)
// Handler is a function type that handles an HTTP request in Inbucket.
type Handler func(http.ResponseWriter, *http.Request, *Context) error
// 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.Error().Str("module", "web").Err(err).Msg("HTTP failed to create context")
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer ctx.Close()
// Run the handler, grab the error, and report it.
err = h(w, req, ctx)
if err != nil {
log.Error().Str("module", "web").Str("path", req.RequestURI).Err(err).
Msg("Error handling request")
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
// cookieHandler injects an HTTP cookie into the response.
func cookieHandler(cookie *http.Cookie, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
log.Debug().Str("module", "web").Str("remote", req.RemoteAddr).Str("proto", req.Proto).
Str("method", req.Method).Str("path", req.RequestURI).Msg("Injecting cookie")
http.SetCookie(w, cookie)
next.ServeHTTP(w, req)
})
}
// fileHandler creates a handler that sends the named file regardless of the requested URL.
func fileHandler(name string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
f, err := os.Open(name)
if err != nil {
log.Error().Str("module", "web").Str("path", req.RequestURI).Str("file", name).Err(err).
Msg("Error opening file")
http.Error(w, "Error opening file", http.StatusInternalServerError)
return
}
defer f.Close()
d, err := f.Stat()
if err != nil {
log.Error().Str("module", "web").Str("path", req.RequestURI).Str("file", name).Err(err).
Msg("Error stating file")
http.Error(w, "Error opening file", http.StatusInternalServerError)
return
}
http.ServeContent(w, req, d.Name(), d.ModTime(), f)
})
}
// noMatchHandler creates a handler to log requests that Gorilla mux is unable to route,
// returning specified statusCode to the client.
func noMatchHandler(statusCode int, message string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
log.Warn().Str("module", "web").Str("remote", req.RemoteAddr).Str("proto", req.Proto).
Str("method", req.Method).Str("path", req.RequestURI).Msg(message)
w.WriteHeader(statusCode)
})
}
// requestLoggingWrapper returns middleware that logs client requests.
func requestLoggingWrapper(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
log.Debug().Str("module", "web").Str("remote", req.RemoteAddr).Str("proto", req.Proto).
Str("method", req.Method).Str("path", req.RequestURI).Msg("Request")
next.ServeHTTP(w, req)
})
}
// spaTemplateHandler creates a handler to serve the index.html template for our SPA.
func spaTemplateHandler(tmpl *template.Template, basePath string,
webConfig config.Web) http.Handler {
tmplData := struct {
BasePath string
}{
BasePath: basePath,
}
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
// ensure we do now allow click jacking
w.Header().Set("X-Frame-Options", "SameOrigin")
err := tmpl.Execute(w, tmplData)
if err != nil {
log.Error().Str("module", "web").Str("remote", req.RemoteAddr).Str("proto", req.Proto).
Str("method", req.Method).Str("path", req.RequestURI).Err(err).
Msg("Error rendering SPA index template")
}
})
}

26
pkg/server/web/helpers.go Normal file
View File

@@ -0,0 +1,26 @@
package web
import (
"fmt"
"html"
"regexp"
"strings"
)
// 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`!()\\[\\]{};:'\".,<>?«»“”‘’]))")
// TextToHTML takes plain text, escapes it and tries to pretty it up for
// HTML display
func TextToHTML(text string) string {
text = html.EscapeString(text)
text = urlRE.ReplaceAllStringFunc(text, WrapURL)
replacer := strings.NewReplacer("\r\n", "<br/>\n", "\r", "<br/>\n", "\n", "<br/>\n")
return replacer.Replace(text)
}
// 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)
}

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