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

Compare commits

..

292 Commits

Author SHA1 Message Date
James Hillyerd
344c3ffb21 Update CHANGELOG for 3.0.3 (#286)
Signed-off-by: James Hillyerd <james@hillyerd.com>
2022-08-07 19:39:48 -07:00
James Hillyerd
87018ed42d Allow AUTH=<> FROM parameter (#284)
* Use backtick on regex
* Accept AUTH=<> FROM parameter
* Update changelog
2022-07-30 10:57:29 -07:00
James Hillyerd
1650a5b375 Merge pull request #281 from inbucket/dependabot/npm_and_yarn/ui/terser-5.14.2
build(deps): bump terser from 5.12.1 to 5.14.2 in /ui
2022-07-30 09:41:41 -07:00
dependabot[bot]
3f7adbfb22 build(deps): bump terser from 5.12.1 to 5.14.2 in /ui
Bumps [terser](https://github.com/terser/terser) from 5.12.1 to 5.14.2.
- [Release notes](https://github.com/terser/terser/releases)
- [Changelog](https://github.com/terser/terser/blob/master/CHANGELOG.md)
- [Commits](https://github.com/terser/terser/commits)

---
updated-dependencies:
- dependency-name: terser
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-07-21 02:45:45 +00:00
James Hillyerd
03cc31fb70 Build with Go 1.18 2022-07-04 16:23:06 -07:00
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
2e49b591eb Merge tag 'v2.1.0-beta1' into develop
v2.1.0-beta1
2018-10-31 20:08:15 -07:00
770 changed files with 8902 additions and 283117 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.18', '1.17' ]
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.18
- 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 }}

22
.gitignore vendored
View File

@@ -21,11 +21,16 @@ _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
@@ -35,3 +40,16 @@ _testmain.go
/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

@@ -2,18 +2,19 @@ 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
@@ -21,11 +22,16 @@ builds:
- windows
goarch:
- amd64
- arm
- arm64
goarm:
- "6"
- "7"
main: ./cmd/inbucket
ldflags: -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}
- binary: inbucket-client
- id: inbucket-client
binary: inbucket-client
env:
- CGO_ENABLED=0
goos:
- darwin
- freebsd
@@ -33,40 +39,48 @@ 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*
- etc/**/*
- ui/**/*
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
nfpm:
vendor: inbucket.org
homepage: https://www.inbucket.org/
maintainer: github@hillyerd.com
description: All-in-one disposable webmail service.
license: MIT
formats:
- deb
- rpm
files:
"ui/**/*": "/usr/local/share/inbucket/ui"
config_files:
"etc/linux/inbucket.service": "/lib/systemd/system/inbucket.service"
"ui/greeting.html": "/etc/inbucket/greeting.html"
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 }}
@@ -75,6 +89,3 @@ checksum:
name_template: '{{ .ProjectName }}_{{ .Version }}_checksums.txt'
dist: dist
sign:
artifacts: none

View File

@@ -1,26 +0,0 @@
language: go
sudo: false
addons:
apt:
packages:
- rpm
env:
global:
- GO111MODULE=on
- DEPLOY_WITH_MAJOR="1.11"
before_script:
- go get golang.org/x/lint/golint
- make deps
go:
- "1.10.x"
- "1.11.x"
deploy:
provider: script
script: etc/travis-deploy.sh
on:
tags: true

View File

@@ -4,8 +4,139 @@ Change Log
All notable changes to this project will be documented in this file.
This project adheres to [Semantic Versioning](http://semver.org/).
## [Unreleased]
## [v2.1.0-beta1]
## [v3.0.3] - 2022-08-07
### Fixed
- Support for `AUTH=<>` FROM parameter (#284)
## [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.
@@ -98,7 +229,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
@@ -113,9 +244,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
@@ -178,33 +309,46 @@ 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
[v2.1.0-beta1]: https://github.com/jhillyerd/inbucket/compare/v2.0.0...v2.1.0-beta1
[v2.0.0]: https://github.com/jhillyerd/inbucket/compare/v2.0.0-rc1...v2.0.0
[v2.0.0-rc1]: https://github.com/jhillyerd/inbucket/compare/v1.3.1...v2.0.0-rc1
[v1.3.1]: https://github.com/jhillyerd/inbucket/compare/v1.3.0...v1.3.1
[v1.3.0]: https://github.com/jhillyerd/inbucket/compare/v1.2.0...v1.3.0
[v1.2.0]: https://github.com/jhillyerd/inbucket/compare/1.2.0-rc2...1.2.0
[v1.2.0-rc2]: https://github.com/jhillyerd/inbucket/compare/1.2.0-rc1...1.2.0-rc2
[v1.2.0-rc1]: https://github.com/jhillyerd/inbucket/compare/1.1.0...1.2.0-rc1
[v1.1.0]: https://github.com/jhillyerd/inbucket/compare/1.1.0-rc2...1.1.0
[v1.1.0-rc2]: https://github.com/jhillyerd/inbucket/compare/1.1.0-rc1...1.1.0-rc2
[v1.1.0-rc1]: https://github.com/jhillyerd/inbucket/compare/1.0...1.1.0-rc1
[v1.0]: https://github.com/jhillyerd/inbucket/compare/1.0-rc1...1.0
[Unreleased]: https://github.com/inbucket/inbucket/compare/v3.0.3...main
[v3.0.3]: https://github.com/inbucket/inbucket/compare/v3.0.2...v3.0.3
[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,8 +1,18 @@
# Docker build file for Inbucket: https://www.inbucket.org/
# Build
FROM golang:1.11-alpine3.8 as builder
RUN apk add --no-cache --virtual .build-deps git make
### 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
### Build backend
FROM golang:1.18-alpine3.16 as backend
RUN apk add --no-cache --virtual .build-deps g++ git make
WORKDIR /build
COPY . .
ENV CGO_ENABLED 0
@@ -11,13 +21,14 @@ 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.8
### Run in minimal image
FROM alpine:3.16
RUN apk --no-cache add tzdata
WORKDIR /opt/inbucket
RUN mkdir bin defaults ui
COPY --from=builder /build/inbucket bin
COPY --from=backend /build/inbucket bin
COPY --from=frontend /build/ui/dist ui
COPY etc/docker/defaults/greeting.html defaults
COPY ui ui
COPY etc/docker/defaults/start-inbucket.sh /
# Configuration
@@ -26,11 +37,15 @@ 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 2500 9000 1100

View File

@@ -1,19 +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/jhillyerd/inbucket/pkg/rest/client` - [Go API docs]
`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.
@@ -22,49 +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
(currently broken, being tracked in [issue
#68](https://github.com/jhillyerd/inbucket/issues/68))
Inbucket has an OS X [Homebrew] tap available as [jhillyerd/inbucket][Homebrew Tap],
see the `README.md` there for installation instructions.
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/cmd/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
[Go API docs]: https://godoc.org/github.com/jhillyerd/inbucket/pkg/rest/client
[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/pkg/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/pkg/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/pkg/rest/client"
"github.com/inbucket/inbucket/pkg/rest/client"
)
type mboxCmd struct {

View File

@@ -14,18 +14,19 @@ import (
"syscall"
"time"
"github.com/jhillyerd/inbucket/pkg/config"
"github.com/jhillyerd/inbucket/pkg/message"
"github.com/jhillyerd/inbucket/pkg/msghub"
"github.com/jhillyerd/inbucket/pkg/policy"
"github.com/jhillyerd/inbucket/pkg/rest"
"github.com/jhillyerd/inbucket/pkg/server/pop3"
"github.com/jhillyerd/inbucket/pkg/server/smtp"
"github.com/jhillyerd/inbucket/pkg/server/web"
"github.com/jhillyerd/inbucket/pkg/storage"
"github.com/jhillyerd/inbucket/pkg/storage/file"
"github.com/jhillyerd/inbucket/pkg/storage/mem"
"github.com/jhillyerd/inbucket/pkg/webui"
"github.com/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"
)
@@ -40,10 +41,8 @@ var (
func init() {
// Server uptime for status page.
startTime := time.Now()
expvar.Publish("uptime", expvar.Func(func() interface{} {
return time.Since(startTime) / time.Second
}))
startTime := expvar.NewInt("startMillis")
startTime.Set(time.Now().UnixNano() / 1000000)
// Goroutine count for status page.
expvar.Publish("goroutines", expvar.Func(func() interface{} {
@@ -73,6 +72,7 @@ func main() {
config.Usage()
return
}
// Process configuration.
config.Version = version
config.BuildDate = date
@@ -85,6 +85,7 @@ func main() {
conf.POP3.Debug = true
conf.SMTP.Debug = true
}
// Logger setup.
closeLog, err := openLog(conf.LogLevel, *logfile, *logjson)
if err != nil {
@@ -92,12 +93,15 @@ func main() {
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)
@@ -109,6 +113,7 @@ func main() {
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)
@@ -120,20 +125,26 @@ func main() {
msgHub := msghub.New(rootCtx, conf.Web.MonitorHistory)
addrPolicy := &policy.Addressing{Config: conf}
mmanager := &message.StoreManager{AddrPolicy: addrPolicy, Store: store, Hub: msgHub}
// Start Retention scanner.
retentionScanner := storage.NewRetentionScanner(conf.Storage, store, shutdownChan)
retentionScanner.Start()
// Start HTTP server.
// 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)
rest.SetupRoutes(web.Router.PathPrefix("/api/").Subrouter())
webui.SetupRoutes(web.Router)
go web.Start(rootCtx)
// Start POP3 server.
pop3Server := pop3.New(conf.POP3, shutdownChan, store)
go pop3Server.Start(rootCtx)
// Start SMTP server.
smtpServer := smtp.NewServer(conf.SMTP, shutdownChan, mmanager, addrPolicy)
go smtpServer.Start(rootCtx)
// Loop forever waiting for signals or shutdown channel.
signalLoop:
for {
@@ -156,6 +167,7 @@ signalLoop:
break signalLoop
}
}
// Wait for active connections to finish.
go timedExit(*pidfile)
smtpServer.Drain()

View File

@@ -28,11 +28,9 @@ variables it supports:
INBUCKET_POP3_DOMAIN inbucket HELLO domain
INBUCKET_POP3_TIMEOUT 600s Idle network timeout
INBUCKET_WEB_ADDR 0.0.0.0:9000 Web server IP4 host:port
INBUCKET_WEB_UIDIR ui User interface dir
INBUCKET_WEB_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_TEMPLATECACHE true Cache templates after first use?
INBUCKET_WEB_MAILBOXPROMPT @inbucket Prompt next to mailbox input
INBUCKET_WEB_COOKIEAUTHKEY Session cipher key (text)
INBUCKET_WEB_MONITORVISIBLE true Show monitor tab in UI?
INBUCKET_WEB_MONITORHISTORY 30 Monitor remembered messages
INBUCKET_WEB_PPROF false Expose profiling tools on /debug/pprof
@@ -79,8 +77,14 @@ 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`
- Values: one of `local` or `full` or `domain`
## SMTP
@@ -228,7 +232,7 @@ This option is only valid when INBUCKET_SMTP_TLSENABLED is enabled.
### TLS Public Certificate File
`INBUCKET_SMTP_TLSPRIVKEY`
`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.
@@ -287,6 +291,24 @@ 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`
@@ -298,7 +320,7 @@ 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`
- Default: `ui/dist`
- Values: Operating system specific path syntax
### Greeting HTML File
@@ -311,39 +333,6 @@ Inbucket installation, as well as link to REST documentation, etc.
- Default: `ui/greeting.html`
### Template Caching
`INBUCKET_WEB_TEMPLATECACHE`
Tells Inbucket to cache parsed template files. This should be left as default
unless you are a developer working on the Inbucket web interface.
- Default: `true`
- Values: `true` or `false`
### Mailbox Prompt
`INBUCKET_WEB_MAILBOXPROMPT`
Text prompt displayed to the right of the mailbox name input field in the web
interface. Can be used to nudge your users into typing just the mailbox name
instead of an entire email address.
Set to an empty string to hide the prompt.
- Default: `@inbucket`
### Cookie Authentication Key
`INBUCKET_WEB_COOKIEAUTHKEY`
Inbucket stores session information in an encrypted browser cookie. Unless
specified, Inbucket generates a random key at startup. The only notable data
stored in a user session is the list of recently accessed mailboxes.
- Default: None
- Value: Text string, no particular format required
### Monitor Visible
`INBUCKET_WEB_MONITORVISIBLE`

View File

@@ -3,16 +3,32 @@
# 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="15m"
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
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

View File

@@ -1,3 +1,5 @@
<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>

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

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

30
go.mod
View File

@@ -1,19 +1,23 @@
module github.com/jhillyerd/inbucket
module github.com/inbucket/inbucket
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/google/subcommands v0.0.0-20181012225330-46f0354f6315
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.6.2
github.com/gorilla/securecookie v1.1.1
github.com/gorilla/sessions v1.1.3
github.com/gorilla/websocket v1.4.0
github.com/jhillyerd/enmime v0.2.1
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.3.0
github.com/microcosm-cc/bluemonday v1.0.1
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rs/zerolog v1.9.1
github.com/stretchr/testify v1.2.2
golang.org/x/net v0.0.0-20181017193950-04a2e542c03f
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

111
go.sum
View File

@@ -1,42 +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/google/subcommands v0.0.0-20181012225330-46f0354f6315 h1:WW91Hq2v0qDzoPME+TPD4En72+d2Ue3ZMKPYfwR9yBU=
github.com/google/subcommands v0.0.0-20181012225330-46f0354f6315/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8=
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
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.6.2 h1:Pgr17XVTNXAk3q/r4CpKzC5xBM/qW1uVLV+IhRZpIIk=
github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/sessions v1.1.3 h1:uXoZdcdA5XdXF3QzuSlheVRUvjl+1rKY7zBXL68L9RU=
github.com/gorilla/sessions v1.1.3/go.mod h1:8KCfur6+4Mqcc6S0FEfKuN15Vl5MgXW92AE8ovaJD0w=
github.com/gorilla/websocket v1.4.0 h1:WDFjx/TMzVgy9VdMMQi2K2Emtwi2QcUQsztZ/zLaH/Q=
github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
github.com/jaytaylor/html2text v0.0.0-20180606194806-57d518f124b0 h1:xqgexXAGQgY3HAjNPSaCqn5Aahbo5TKsmhp8VRfr1iQ=
github.com/jaytaylor/html2text v0.0.0-20180606194806-57d518f124b0/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
github.com/jhillyerd/enmime v0.2.1 h1:YodBfMH3jmrZn68Gg4ZoZH1ECDsdh8BLW9+DjoFce6o=
github.com/jhillyerd/enmime v0.2.1/go.mod h1:0gWUCFBL87cvx6/MSSGNBHJ6r+fMArqltDFwHxC10P4=
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.3.0 h1:IvRS4f2VcIQy6j4ORGIf9145T/AsUB+oY8LyvN8BXNM=
github.com/kelseyhightower/envconfig v1.3.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg=
github.com/mattn/go-runewidth v0.0.3 h1:a+kO+98RDGEfo6asOGMmpodZq4FNtnGP54yps8BzLR4=
github.com/mattn/go-runewidth v0.0.3/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
github.com/microcosm-cc/bluemonday v1.0.1 h1:SIYunPjnlXcW+gVfvm0IlSeR5U3WZUOLfVmqg85Go44=
github.com/microcosm-cc/bluemonday v1.0.1/go.mod h1:hsXNsILzKxV+sX77C5b8FSuKF00vh2OMYv+xgHpAMF4=
github.com/olekukonko/tablewriter v0.0.0-20180912035003-be2c049b30cc h1:rQ1O4ZLYR2xXHXgBCCfIIGnuZ0lidMQw2S5n1oOv+Wg=
github.com/olekukonko/tablewriter v0.0.0-20180912035003-be2c049b30cc/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo=
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/rs/zerolog v1.9.1 h1:AjV/SFRF0+gEa6rSjkh0Eji/DnkrJKVpPho6SW5g4mU=
github.com/rs/zerolog v1.9.1/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
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/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
golang.org/x/net v0.0.0-20181017193950-04a2e542c03f h1:4pRM7zYwpBjCnfA1jRmhItLxYJkaEnsmuAcRtA347DA=
golang.org/x/net v0.0.0-20181017193950-04a2e542c03f/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
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

@@ -8,7 +8,7 @@ import (
"text/tabwriter"
"time"
"github.com/jhillyerd/inbucket/pkg/stringutil"
"github.com/inbucket/inbucket/pkg/stringutil"
"github.com/kelseyhightower/envconfig"
)
@@ -38,6 +38,7 @@ const (
UnknownNaming mbNaming = iota
LocalNaming
FullNaming
DomainNaming
)
// Decode a naming strategy from string.
@@ -47,6 +48,8 @@ func (n *mbNaming) Decode(v string) error {
*n = LocalNaming
case "full":
*n = FullNaming
case "domain":
*n = DomainNaming
default:
return fmt.Errorf("Unknown MailboxNaming strategy: %q", v)
}
@@ -56,7 +59,7 @@ func (n *mbNaming) Decode(v string) error {
// Root contains global configuration, and structs with for specific sub-systems.
type Root struct {
LogLevel string `required:"true" default:"info" desc:"debug, info, warn, or error"`
MailboxNaming mbNaming `required:"true" default:"local" desc:"Use local or full addressing"`
MailboxNaming mbNaming `required:"true" default:"local" desc:"Use local, full or domain addressing"`
SMTP SMTP
POP3 POP3
Web Web
@@ -93,11 +96,9 @@ type POP3 struct {
// Web contains the HTTP server configuration.
type Web struct {
Addr string `required:"true" default:"0.0.0.0:9000" desc:"Web server IP4 host:port"`
UIDir string `required:"true" default:"ui" desc:"User interface dir"`
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"`
TemplateCache bool `required:"true" default:"true" desc:"Cache templates after first use?"`
MailboxPrompt string `required:"true" default:"@inbucket" desc:"Prompt next to mailbox input"`
CookieAuthKey string `desc:"Session cipher key (text)"`
MonitorVisible bool `required:"true" default:"true" desc:"Show monitor tab in UI?"`
MonitorHistory int `required:"true" default:"30" desc:"Monitor remembered messages"`
PProf bool `required:"true" default:"false" desc:"Expose profiling tools on /debug/pprof"`

View File

@@ -7,11 +7,11 @@ import (
"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/jhillyerd/inbucket/pkg/msghub"
"github.com/jhillyerd/inbucket/pkg/policy"
"github.com/jhillyerd/inbucket/pkg/storage"
"github.com/jhillyerd/inbucket/pkg/stringutil"
"github.com/rs/zerolog/log"
)
@@ -85,7 +85,7 @@ func (s *StoreManager) Deliver(
broadcast := msghub.Message{
Mailbox: to.Mailbox,
ID: id,
From: delivery.From().String(),
From: stringutil.StringAddress(delivery.From()),
To: stringutil.StringAddressList(delivery.To()),
Subject: delivery.Subject(),
Date: delivery.Date(),

View File

@@ -8,8 +8,8 @@ import (
"net/textproto"
"time"
"github.com/inbucket/inbucket/pkg/storage"
"github.com/jhillyerd/enmime"
"github.com/jhillyerd/inbucket/pkg/storage"
)
// Metadata holds information about a message, but not the content.

View File

@@ -6,8 +6,8 @@ import (
"net/mail"
"strings"
"github.com/jhillyerd/inbucket/pkg/config"
"github.com/jhillyerd/inbucket/pkg/stringutil"
"github.com/inbucket/inbucket/pkg/config"
"github.com/inbucket/inbucket/pkg/stringutil"
)
// Addressing handles email address policy.
@@ -28,6 +28,20 @@ func (a *Addressing) ExtractMailbox(address string) (string, error) {
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 a.Config.MailboxNaming != config.FullNaming {
return "", fmt.Errorf("Unknown MailboxNaming value: %v", a.Config.MailboxNaming)
}
@@ -128,8 +142,8 @@ func ValidateDomainPart(domain string) bool {
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 == '.':
@@ -159,16 +173,16 @@ func ValidateDomainPart(domain string) bool {
// 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.
buf := new(bytes.Buffer)

View File

@@ -4,8 +4,8 @@ import (
"strings"
"testing"
"github.com/jhillyerd/inbucket/pkg/config"
"github.com/jhillyerd/inbucket/pkg/policy"
"github.com/inbucket/inbucket/pkg/config"
"github.com/inbucket/inbucket/pkg/policy"
)
func TestShouldAcceptDomain(t *testing.T) {
@@ -125,111 +125,139 @@ func TestShouldStoreDomain(t *testing.T) {
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
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",
input: "mailbox",
local: "mailbox",
full: "mailbox",
domain: "mailbox",
},
{
input: "user123",
local: "user123",
full: "user123",
input: "user123",
local: "user123",
full: "user123",
domain: "user123",
},
{
input: "MailBOX",
local: "mailbox",
full: "mailbox",
input: "MailBOX",
local: "mailbox",
full: "mailbox",
domain: "mailbox",
},
{
input: "First.Last",
local: "first.last",
full: "first.last",
input: "First.Last",
local: "first.last",
full: "first.last",
domain: "first.last",
},
{
input: "user+label",
local: "user",
full: "user",
input: "user+label",
local: "user",
full: "user",
domain: "user",
},
{
input: "chars!#$%",
local: "chars!#$%",
full: "chars!#$%",
input: "chars!#$%",
local: "chars!#$%",
full: "chars!#$%",
domain: "",
},
{
input: "chars&'*-",
local: "chars&'*-",
full: "chars&'*-",
input: "chars&'*-",
local: "chars&'*-",
full: "chars&'*-",
domain: "",
},
{
input: "chars=/?^",
local: "chars=/?^",
full: "chars=/?^",
input: "chars=/?^",
local: "chars=/?^",
full: "chars=/?^",
domain: "",
},
{
input: "chars_`.{",
local: "chars_`.{",
full: "chars_`.{",
input: "chars_`.{",
local: "chars_`.{",
full: "chars_`.{",
domain: "",
},
{
input: "chars|}~",
local: "chars|}~",
full: "chars|}~",
input: "chars|}~",
local: "chars|}~",
full: "chars|}~",
domain: "",
},
{
input: "mailbox@domain.com",
local: "mailbox",
full: "mailbox@domain.com",
input: "mailbox@domain.com",
local: "mailbox",
full: "mailbox@domain.com",
domain: "domain.com",
},
{
input: "user123@domain.com",
local: "user123",
full: "user123@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",
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",
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",
input: "user+label@domain.com",
local: "user",
full: "user@domain.com",
domain: "domain.com",
},
{
input: "chars!#$%@domain.com",
local: "chars!#$%",
full: "chars!#$%@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",
input: "chars&'*-@domain.com",
local: "chars&'*-",
full: "chars&'*-@domain.com",
domain: "domain.com",
},
{
input: "chars=/?^@domain.com",
local: "chars=/?^",
full: "chars=/?^@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",
input: "chars_`.{@domain.com",
local: "chars_`.{",
full: "chars_`.{@domain.com",
domain: "domain.com",
},
{
input: "chars|}~@domain.com",
local: "chars|}~",
full: "chars|}~@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 {
@@ -247,12 +275,20 @@ func TestExtractMailboxValid(t *testing.T) {
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
@@ -282,6 +318,28 @@ func TestExtractMailboxInvalid(t *testing.T) {
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) {

View File

@@ -10,10 +10,10 @@ import (
"encoding/json"
"strconv"
"github.com/jhillyerd/inbucket/pkg/rest/model"
"github.com/jhillyerd/inbucket/pkg/server/web"
"github.com/jhillyerd/inbucket/pkg/storage"
"github.com/jhillyerd/inbucket/pkg/stringutil"
"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
@@ -31,14 +31,15 @@ func MailboxListV1(w http.ResponseWriter, req *http.Request, ctx *web.Context) (
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,
Size: msg.Size,
Seen: msg.Seen,
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)
@@ -64,28 +65,30 @@ func MailboxShowV1(w http.ResponseWriter, req *http.Request, ctx *web.Context) (
attachments := make([]*model.JSONMessageAttachmentV1, len(attachParts))
for i, part := range attachParts {
content := part.Content
var checksum = md5.Sum(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: "http://" + req.Host + "/mailbox/dattach/" + name + "/" + id + "/" +
strconv.Itoa(i) + "/" + part.FileName,
ViewLink: "http://" + req.Host + "/mailbox/vattach/" + name + "/" + id + "/" +
strconv.Itoa(i) + "/" + part.FileName,
MD5: hex.EncodeToString(checksum[:]),
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,
Size: msg.Size,
Seen: msg.Seen,
Header: msg.Header(),
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(),

View File

@@ -9,9 +9,9 @@ import (
"testing"
"time"
"github.com/inbucket/inbucket/pkg/message"
"github.com/inbucket/inbucket/pkg/test"
"github.com/jhillyerd/enmime"
"github.com/jhillyerd/inbucket/pkg/message"
"github.com/jhillyerd/inbucket/pkg/test"
)
const (
@@ -112,6 +112,7 @@ func TestRestMailboxList(t *testing.T) {
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")
@@ -120,6 +121,7 @@ func TestRestMailboxList(t *testing.T) {
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)
@@ -194,6 +196,10 @@ func TestRestMessage(t *testing.T) {
"From": []string{"noreply@inbucket.org"},
},
},
Attachments: []*enmime.Part{{
FileName: "favicon.png",
ContentType: "image/png",
}},
},
)
mm.AddMessage("good", msg1)
@@ -221,6 +227,7 @@ func TestRestMessage(t *testing.T) {
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")
@@ -228,6 +235,10 @@ func TestRestMessage(t *testing.T) {
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

View File

@@ -8,7 +8,7 @@ import (
"net/url"
"time"
"github.com/jhillyerd/inbucket/pkg/rest/model"
"github.com/inbucket/inbucket/pkg/rest/model"
)
// Client accesses the Inbucket REST API v1

View File

@@ -7,7 +7,7 @@ import (
"testing"
"time"
"github.com/jhillyerd/inbucket/pkg/rest/client"
"github.com/inbucket/inbucket/pkg/rest/client"
)
func TestClientV1ListMailbox(t *testing.T) {

View File

@@ -7,7 +7,7 @@ import (
"net/http/httptest"
"github.com/gorilla/mux"
"github.com/jhillyerd/inbucket/pkg/rest/client"
"github.com/inbucket/inbucket/pkg/rest/client"
)
// Example demonstrates basic usage for the Inbucket REST client.

View File

@@ -33,7 +33,7 @@ func (c *restClient) do(method, uri string, body []byte) (*http.Response, error)
}
req, err := http.NewRequest(method, url.String(), r)
if err != nil {
return nil, err
return nil, fmt.Errorf("%s for %q: %v", method, url, err)
}
return c.client.Do(req)
}
@@ -56,7 +56,7 @@ func (c *restClient) doJSON(method string, uri string, v interface{}) error {
return json.NewDecoder(resp.Body).Decode(v)
}
return fmt.Errorf("Unexpected HTTP response status %v: %s", resp.StatusCode, resp.Status)
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.
@@ -77,5 +77,5 @@ func (c *restClient) doJSONBody(method string, uri string, body []byte, v interf
return json.NewDecoder(resp.Body).Decode(v)
}
return fmt.Errorf("Unexpected HTTP response status %v: %s", resp.StatusCode, resp.Status)
return fmt.Errorf("%s for %q, unexpected %v: %s", method, uri, resp.StatusCode, resp.Status)
}

View File

@@ -6,14 +6,15 @@ import (
// 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"`
Seen bool `json:"seen"`
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,6 +25,7 @@ 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"`

View File

@@ -1,7 +1,7 @@
package rest
import "github.com/gorilla/mux"
import "github.com/jhillyerd/inbucket/pkg/server/web"
import "github.com/inbucket/inbucket/pkg/server/web"
// SetupRoutes populates the routes for the REST interface
func SetupRoutes(r *mux.Router) {

View File

@@ -5,9 +5,9 @@ import (
"time"
"github.com/gorilla/websocket"
"github.com/jhillyerd/inbucket/pkg/msghub"
"github.com/jhillyerd/inbucket/pkg/rest/model"
"github.com/jhillyerd/inbucket/pkg/server/web"
"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"
)
@@ -110,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

View File

@@ -9,10 +9,10 @@ import (
"strings"
"testing"
"github.com/jhillyerd/inbucket/pkg/config"
"github.com/jhillyerd/inbucket/pkg/message"
"github.com/jhillyerd/inbucket/pkg/msghub"
"github.com/jhillyerd/inbucket/pkg/server/web"
"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) {
@@ -49,8 +49,8 @@ func setupWebServer(mm message.Manager) *bytes.Buffer {
},
}
shutdownChan := make(chan bool)
web.Initialize(cfg, shutdownChan, mm, &msghub.Hub{})
SetupRoutes(web.Router.PathPrefix("/api/").Subrouter())
web.Initialize(cfg, shutdownChan, mm, &msghub.Hub{})
return buf
}
@@ -79,12 +79,14 @@ func decodedNumberEquals(t *testing.T, json interface{}, path string, want float
t.Errorf("JSON result%s", msg)
return
}
if got, ok := val.(float64); ok {
got, ok := val.(float64)
if ok {
if got == want {
return
}
}
t.Errorf("JSON result/%s == %v (%T), want: %v", path, val, val, want)
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) {

View File

@@ -10,7 +10,7 @@ import (
"strings"
"time"
"github.com/jhillyerd/inbucket/pkg/storage"
"github.com/inbucket/inbucket/pkg/storage"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
@@ -169,6 +169,7 @@ func (s *Server) startSession(id int, conn net.Conn) {
}
break
}
// not an EOF
ssn.logger.Warn().Msgf("Connection error: %v", err)
if netErr, ok := err.(net.Error); ok {

View File

@@ -6,8 +6,8 @@ import (
"sync"
"time"
"github.com/jhillyerd/inbucket/pkg/config"
"github.com/jhillyerd/inbucket/pkg/storage"
"github.com/inbucket/inbucket/pkg/config"
"github.com/inbucket/inbucket/pkg/storage"
"github.com/rs/zerolog/log"
)

View File

@@ -13,7 +13,7 @@ import (
"strings"
"time"
"github.com/jhillyerd/inbucket/pkg/policy"
"github.com/inbucket/inbucket/pkg/policy"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
@@ -25,10 +25,27 @@ 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 "."
@@ -37,11 +54,11 @@ const (
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
// fromRegex captures the from address and optional parameters. Matches FROM, while accepting '>'
// as quoted pair and in double quoted strings (?i) makes the regex case insensitive, (?:) is
// non-grouping sub-match. Accepts empty angle bracket value in options for 'AUTH=<>'.
var fromRegex = regexp.MustCompile(
"(?i)^FROM:\\s*<((?:\\\\>|[^>])+|\"[^\"]+\"@[^>]+)>( [\\w= ]+)?$")
`(?i)^FROM:\s*<((?:(?:\\>|[^>])+|"[^"]+"@[^>])+)?>( [\w= ]+(?:=<>)?)?$`)
func (s State) String() string {
switch s {
@@ -76,6 +93,7 @@ var commands = map[string]bool{
"QUIT": true,
"TURN": true,
"STARTTLS": true,
"AUTH": true,
}
// Session holds the state of an SMTP session
@@ -153,6 +171,16 @@ func (s *Server) startSession(id int, conn net.Conn) {
}
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 == "" {
@@ -219,7 +247,7 @@ func (s *Server) startSession(id int, conn net.Conn) {
}
break
}
// not an EOF
// Not an EOF
ssn.logger.Warn().Msgf("Connection error: %v", err)
if netErr, ok := err.(net.Error); ok {
if netErr.Timeout() {
@@ -257,9 +285,10 @@ func (s *Session) greetHandler(cmd string, arg string) {
return
}
s.remoteDomain = domain
// features before SIZE per RFC
// 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")
}
@@ -281,30 +310,71 @@ func parseHelloArgument(arg string) (string, error) {
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 unconfigured
// 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
// 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")
// start tls connection handshake
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)
@@ -314,11 +384,15 @@ func (s *Session) readyHandler(cmd string, arg string) {
return
}
from := m[1]
if _, _, err := policy.ParseEmailAddress(from); err != nil {
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] != "" {
@@ -346,6 +420,11 @@ func (s *Session) readyHandler(cmd string, arg string) {
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)
}
@@ -394,6 +473,12 @@ func (s *Session) mailHandler(cmd string, arg string) {
}
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)
}
@@ -422,6 +507,7 @@ func (s *Session) dataHandler() {
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())
@@ -502,28 +588,28 @@ func (s *Session) readLine() (line string, err error) {
func (s *Session) parseCmd(line string) (cmd string, arg string, ok bool) {
line = strings.TrimRight(line, "\r\n")
l := len(line)
// 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
case l == 4 || l == 8:
return strings.ToUpper(line), "", true
case l == 5:
// Too long to be only command, too short to have args
s.logger.Warn().Msgf("Mangled command: %q", line)
return "", "", false
}
// If we made it here, command is long enough to have args
if line[4] != ' ' {
// There wasn't a space after the command?
s.logger.Warn().Msgf("Mangled command: %q", line)
return "", "", false
if hasArg {
return strings.ToUpper(line[0:l]), strings.Trim(line[l+1:], " "), true
}
// I'm not sure if we should trim the args or not, but we will for now
return strings.ToUpper(line[0:4]), strings.Trim(line[5:], " "), true
return strings.ToUpper(line), "", true
}
// parseArgs takes the arguments proceeding a command and files them

View File

@@ -12,11 +12,11 @@ import (
"testing"
"time"
"github.com/jhillyerd/inbucket/pkg/config"
"github.com/jhillyerd/inbucket/pkg/message"
"github.com/jhillyerd/inbucket/pkg/policy"
"github.com/jhillyerd/inbucket/pkg/storage"
"github.com/jhillyerd/inbucket/pkg/test"
"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 {
@@ -56,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 {
@@ -70,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
@@ -114,6 +193,10 @@ func TestReadyState(t *testing.T) {
{"RSET", 250},
{"MAIL FROM:<john@gmail.com> SIZE=1024", 250},
{"RSET", 250},
{"MAIL FROM:<john@gmail.com> SIZE=1024 BODY=8BITMIME", 250},
{"RSET", 250},
{"MAIL FROM:<bounces@onmicrosoft.com> SIZE=4096 AUTH=<>", 250},
{"RSET", 250},
{"MAIL FROM:<host!host!user/data@foo.com>", 250},
{"RSET", 250},
{"MAIL FROM:<\"first last\"@space.com>", 250},
@@ -130,6 +213,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)
@@ -206,6 +298,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},

View File

@@ -9,10 +9,10 @@ import (
"sync"
"time"
"github.com/jhillyerd/inbucket/pkg/config"
"github.com/jhillyerd/inbucket/pkg/message"
"github.com/jhillyerd/inbucket/pkg/metric"
"github.com/jhillyerd/inbucket/pkg/policy"
"github.com/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"
)

View File

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

View File

@@ -5,17 +5,15 @@ import (
"strings"
"github.com/gorilla/mux"
"github.com/gorilla/sessions"
"github.com/jhillyerd/inbucket/pkg/config"
"github.com/jhillyerd/inbucket/pkg/message"
"github.com/jhillyerd/inbucket/pkg/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
MsgHub *msghub.Hub
Manager message.Manager
RootConfig *config.Root
@@ -48,24 +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,
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")
}
})
}

View File

@@ -3,62 +3,20 @@ package web
import (
"fmt"
"html"
"html/template"
"regexp"
"strings"
"time"
"github.com/jhillyerd/inbucket/pkg/stringutil"
"github.com/rs/zerolog/log"
)
// TemplateFuncs declares functions made available to all templates (including partials)
var TemplateFuncs = template.FuncMap{
"address": stringutil.StringAddress,
"friendlyTime": FriendlyTime,
"reverse": Reverse,
"stringsJoin": strings.Join,
"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.Error().Str("module", "web").Str("name", name).Err(err).
Msg("Failed to reverse route")
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 {
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 template.HTML(replacer.Replace(text))
return replacer.Replace(text)
}
// WrapURL wraps a <a href> tag around the provided URL

View File

@@ -1,30 +1,55 @@
package web
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>"))
testCases := []struct {
input, want string
}{
{
input: "html",
want: "html",
},
// Check it escapes.
{
input: "<html>",
want: "&lt;html&gt;",
},
// Check for linebreaks.
{
input: "line\nbreak",
want: "line<br/>\nbreak",
},
{
input: "line\r\nbreak",
want: "line<br/>\nbreak",
},
{
input: "line\rbreak",
want: "line<br/>\nbreak",
},
// Check URL detection.
{
input: "http://google.com/",
want: "<a href=\"http://google.com/\" target=\"_blank\">http://google.com/</a>",
},
{
input: "http://a.com/?q=a&n=v",
want: "<a href=\"http://a.com/?q=a&n=v\" target=\"_blank\">http://a.com/?q=a&amp;n=v</a>",
},
{
input: "(http://a.com/?q=a&n=v)",
want: "(<a href=\"http://a.com/?q=a&n=v\" target=\"_blank\">http://a.com/?q=a&amp;n=v</a>)",
},
}
for _, tc := range testCases {
t.Run(tc.input, func(t *testing.T) {
got := TextToHTML(tc.input)
if got != tc.want {
t.Errorf("TextToHTML(%q)\ngot : %q\nwant: %q", tc.input, got, tc.want)
}
})
}
}

View File

@@ -3,30 +3,25 @@ package web
import (
"context"
"encoding/json"
"expvar"
"html/template"
"net"
"net/http"
"net/http/pprof"
"net/url"
"os"
"path/filepath"
"time"
"github.com/gorilla/mux"
"github.com/gorilla/securecookie"
"github.com/gorilla/sessions"
"github.com/jhillyerd/inbucket/pkg/config"
"github.com/jhillyerd/inbucket/pkg/message"
"github.com/jhillyerd/inbucket/pkg/msghub"
"github.com/inbucket/inbucket/pkg/config"
"github.com/inbucket/inbucket/pkg/message"
"github.com/inbucket/inbucket/pkg/msghub"
"github.com/inbucket/inbucket/pkg/stringutil"
"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
const (
staticDir = "static"
templateDir = "templates"
)
var (
// msgHub holds a reference to the message pub/sub system
msgHub *msghub.Hub
@@ -39,7 +34,6 @@ var (
rootConfig *config.Root
server *http.Server
listener net.Listener
sessionStore sessions.Store
globalShutdown chan bool
// ExpWebSocketConnectsCurrent tracks the number of open WebSockets
@@ -51,7 +45,7 @@ func init() {
m.Set("WebSocketConnectsCurrent", ExpWebSocketConnectsCurrent)
}
// Initialize sets up things for unit tests or the Start() method
// Initialize sets up things for unit tests or the Start() method.
func Initialize(
conf *config.Root,
shutdownChan chan bool,
@@ -61,44 +55,76 @@ func Initialize(
rootConfig = conf
globalShutdown = shutdownChan
// NewContext() will use this DataStore for the web handlers
// NewContext() will use this DataStore for the web handlers.
msgHub = mh
manager = mm
// Content Paths
staticPath := filepath.Join(conf.Web.UIDir, staticDir)
log.Info().Str("module", "web").Str("phase", "startup").Str("path", conf.Web.UIDir).
Msg("Web UI content mapped")
Router.PathPrefix("/public/").Handler(http.StripPrefix("/public/",
http.FileServer(http.Dir(staticPath))))
Router.Handle("/debug/vars", expvar.Handler())
if conf.Web.PProf {
Router.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
Router.HandleFunc("/debug/pprof/profile", pprof.Profile)
Router.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
Router.HandleFunc("/debug/pprof/trace", pprof.Trace)
Router.PathPrefix("/debug/pprof/").HandlerFunc(pprof.Index)
log.Warn().Str("module", "web").Str("phase", "startup").
Msg("Go pprof tools installed to /debug/pprof")
// Redirect requests to / if there is a base path configured.
prefix := stringutil.MakePathPrefixer(conf.Web.BasePath)
redirectBase := prefix("/")
if redirectBase != "/" {
log.Info().Str("module", "web").Str("phase", "startup").Str("path", redirectBase).
Msg("Base path configured")
Router.Path("/").Handler(http.RedirectHandler(redirectBase, http.StatusFound))
}
// Session cookie setup
if conf.Web.CookieAuthKey == "" {
log.Info().Str("module", "web").Str("phase", "startup").
Msg("Generating random cookie.auth.key")
sessionStore = sessions.NewCookieStore(securecookie.GenerateRandomKey(64))
} else {
log.Info().Str("module", "web").Str("phase", "startup").
Msg("Using configured cookie.auth.key")
sessionStore = sessions.NewCookieStore([]byte(conf.Web.CookieAuthKey))
// Dynamic paths.
log.Info().Str("module", "web").Str("phase", "startup").Str("path", conf.Web.UIDir).
Msg("Web UI content mapped")
Router.Handle(prefix("/debug/vars"), expvar.Handler())
if conf.Web.PProf {
Router.HandleFunc(prefix("/debug/pprof/cmdline"), pprof.Cmdline)
Router.HandleFunc(prefix("/debug/pprof/profile"), pprof.Profile)
Router.HandleFunc(prefix("/debug/pprof/symbol"), pprof.Symbol)
Router.HandleFunc(prefix("/debug/pprof/trace"), pprof.Trace)
Router.PathPrefix(prefix("/debug/pprof/")).HandlerFunc(pprof.Index)
log.Warn().Str("module", "web").Str("phase", "startup").
Msg("Go pprof tools installed to " + prefix("/debug/pprof"))
}
// Static paths.
Router.PathPrefix(prefix("/static")).Handler(
http.StripPrefix(prefix("/"), http.FileServer(http.Dir(conf.Web.UIDir))))
Router.Path(prefix("/favicon.png")).Handler(
fileHandler(filepath.Join(conf.Web.UIDir, "favicon.png")))
// Parse index.html template, allowing for configuration to be passed to the SPA.
indexPath := filepath.Join(conf.Web.UIDir, "index.html")
indexTmpl, err := template.ParseFiles(indexPath)
if err != nil {
msg := "Failed to parse HTML template"
cwd, _ := os.Getwd()
log.Error().
Str("module", "web").
Str("phase", "startup").
Str("path", indexPath).
Str("cwd", cwd).
Err(err).
Msg(msg)
// Create a dummy template to allow tests to pass.
indexTmpl, _ = template.New("index.html").Parse(msg)
}
// SPA managed paths.
spaHandler := cookieHandler(appConfigCookie(conf.Web),
spaTemplateHandler(indexTmpl, prefix("/"), conf.Web))
Router.Path(prefix("/")).Handler(spaHandler)
Router.Path(prefix("/monitor")).Handler(spaHandler)
Router.Path(prefix("/status")).Handler(spaHandler)
Router.PathPrefix(prefix("/m/")).Handler(spaHandler)
// Error handlers.
Router.NotFoundHandler = noMatchHandler(
http.StatusNotFound, "No route matches URI path")
Router.MethodNotAllowedHandler = noMatchHandler(
http.StatusMethodNotAllowed, "Method not allowed for URI path")
}
// Start begins listening for HTTP requests
func Start(ctx context.Context) {
server = &http.Server{
Addr: rootConfig.Web.Addr,
Handler: Router,
Handler: requestLoggingWrapper(Router),
ReadTimeout: 60 * time.Second,
WriteTimeout: 60 * time.Second,
}
@@ -132,6 +158,23 @@ func Start(ctx context.Context) {
}
}
func appConfigCookie(webConfig config.Web) *http.Cookie {
o := &jsonAppConfig{
BasePath: webConfig.BasePath,
MonitorVisible: webConfig.MonitorVisible,
}
b, err := json.Marshal(o)
if err != nil {
log.Error().Str("module", "web").Str("phase", "startup").Err(err).
Msg("Failed to convert app-config to JSON")
}
return &http.Cookie{
Name: "app-config",
Value: url.PathEscape(string(b)),
Path: "/",
}
}
// serve begins serving HTTP requests
func serve(ctx context.Context) {
// server.Serve blocks until we close the listener
@@ -148,29 +191,6 @@ func serve(ctx context.Context) {
}
}
// 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
log.Debug().Str("module", "web").Str("remote", req.RemoteAddr).Str("proto", req.Proto).
Str("method", req.Method).Str("path", req.RequestURI).Msg("Request")
err = h(w, req, ctx)
if err != nil {
log.Error().Str("module", "web").Str("path", req.RequestURI).Err(err).
Msg("Error handling request")
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
func emergencyShutdown() {
// Shutdown Inbucket
select {

View File

@@ -1,84 +0,0 @@
package web
import (
"html/template"
"net/http"
"path"
"path/filepath"
"sync"
"github.com/rs/zerolog/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.Error().Str("module", "web").Str("path", name).Err(err).
Msg("Error in template")
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.Error().Str("module", "web").Str("path", name).Err(err).
Msg("Error in template")
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
}
tempFile := filepath.Join(rootConfig.Web.UIDir, templateDir, filepath.FromSlash(name))
log.Debug().Str("module", "web").Str("path", name).Msg("Parsing template")
var err error
var t *template.Template
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(rootConfig.Web.UIDir, templateDir, "_base.html"), tempFile)
}
if err != nil {
return nil, err
}
// Allows us to disable caching for theme development
if rootConfig.Web.TemplateCache {
if partial {
log.Debug().Str("module", "web").Str("path", name).Msg("Caching partial")
cachedTemplates[name] = t
} else {
log.Debug().Str("module", "web").Str("path", name).Msg("Caching template")
cachedTemplates[name] = t
}
}
return t, nil
}

View File

@@ -9,9 +9,9 @@ import (
"sync"
"time"
"github.com/jhillyerd/inbucket/pkg/config"
"github.com/jhillyerd/inbucket/pkg/storage"
"github.com/jhillyerd/inbucket/pkg/stringutil"
"github.com/inbucket/inbucket/pkg/config"
"github.com/inbucket/inbucket/pkg/storage"
"github.com/inbucket/inbucket/pkg/stringutil"
"github.com/rs/zerolog/log"
)

View File

@@ -13,10 +13,10 @@ import (
"testing"
"time"
"github.com/jhillyerd/inbucket/pkg/config"
"github.com/jhillyerd/inbucket/pkg/message"
"github.com/jhillyerd/inbucket/pkg/storage"
"github.com/jhillyerd/inbucket/pkg/test"
"github.com/inbucket/inbucket/pkg/config"
"github.com/inbucket/inbucket/pkg/message"
"github.com/inbucket/inbucket/pkg/storage"
"github.com/inbucket/inbucket/pkg/test"
"github.com/stretchr/testify/assert"
)

View File

@@ -9,7 +9,7 @@ import (
"path/filepath"
"sync"
"github.com/jhillyerd/inbucket/pkg/storage"
"github.com/inbucket/inbucket/pkg/storage"
"github.com/rs/zerolog/log"
)

View File

@@ -3,7 +3,7 @@ package storage_test
import (
"testing"
"github.com/jhillyerd/inbucket/pkg/storage"
"github.com/inbucket/inbucket/pkg/storage"
)
func TestHashLock(t *testing.T) {

View File

@@ -8,7 +8,7 @@ import (
"net/mail"
"time"
"github.com/jhillyerd/inbucket/pkg/storage"
"github.com/inbucket/inbucket/pkg/storage"
)
// Message is a memory store message.

View File

@@ -7,8 +7,8 @@ import (
"strconv"
"sync"
"github.com/jhillyerd/inbucket/pkg/config"
"github.com/jhillyerd/inbucket/pkg/storage"
"github.com/inbucket/inbucket/pkg/config"
"github.com/inbucket/inbucket/pkg/storage"
)
// Store implements an in-memory message store.

View File

@@ -5,9 +5,9 @@ import (
"testing"
"time"
"github.com/jhillyerd/inbucket/pkg/config"
"github.com/jhillyerd/inbucket/pkg/storage"
"github.com/jhillyerd/inbucket/pkg/test"
"github.com/inbucket/inbucket/pkg/config"
"github.com/inbucket/inbucket/pkg/storage"
"github.com/inbucket/inbucket/pkg/test"
)
// TestSuite runs storage package test suite on file store.
@@ -65,7 +65,7 @@ func TestMaxSize(t *testing.T) {
go func(mailbox string) {
err := s.PurgeMessages(mailbox)
if err != nil {
t.Fatal(err)
panic(err) // Cannot call t.Fatal from non-test goroutine.
}
wg.Done()
}(mailbox)

View File

@@ -3,17 +3,15 @@ package storage
import (
"container/list"
"expvar"
"sync"
"time"
"github.com/jhillyerd/inbucket/pkg/config"
"github.com/jhillyerd/inbucket/pkg/metric"
"github.com/inbucket/inbucket/pkg/config"
"github.com/inbucket/inbucket/pkg/metric"
"github.com/rs/zerolog/log"
)
var (
retentionScanCompleted = time.Now()
retentionScanCompletedMu sync.RWMutex
scanCompletedMillis = new(expvar.Int)
// History counters
expRetentionDeletesTotal = new(expvar.Int)
@@ -34,7 +32,7 @@ var (
func init() {
rm := expvar.NewMap("retention")
rm.Set("SecondsSinceScanCompleted", expvar.Func(secondsSinceRetentionScanCompleted))
rm.Set("ScanCompletedMillis", scanCompletedMillis)
rm.Set("DeletesHist", expRetentionDeletesHist)
rm.Set("DeletesTotal", expRetentionDeletesTotal)
rm.Set("Period", expRetentionPeriod)
@@ -159,7 +157,7 @@ func (rs *RetentionScanner) DoScan() error {
return err
}
// Update metrics
setRetentionScanCompleted(time.Now())
scanCompletedMillis.Set(time.Now().UnixNano() / 1000000)
expRetainedCurrent.Set(int64(retained))
expRetainedSize.Set(storeSize)
return nil
@@ -171,19 +169,3 @@ func (rs *RetentionScanner) Join() {
<-rs.retentionShutdown
}
}
func setRetentionScanCompleted(t time.Time) {
retentionScanCompletedMu.Lock()
defer retentionScanCompletedMu.Unlock()
retentionScanCompleted = t
}
func getRetentionScanCompleted() time.Time {
retentionScanCompletedMu.RLock()
defer retentionScanCompletedMu.RUnlock()
return retentionScanCompleted
}
func secondsSinceRetentionScanCompleted() interface{} {
return time.Since(getRetentionScanCompleted()) / time.Second
}

View File

@@ -5,10 +5,10 @@ import (
"testing"
"time"
"github.com/jhillyerd/inbucket/pkg/config"
"github.com/jhillyerd/inbucket/pkg/message"
"github.com/jhillyerd/inbucket/pkg/storage"
"github.com/jhillyerd/inbucket/pkg/test"
"github.com/inbucket/inbucket/pkg/config"
"github.com/inbucket/inbucket/pkg/message"
"github.com/inbucket/inbucket/pkg/storage"
"github.com/inbucket/inbucket/pkg/test"
)
func TestDoRetentionScan(t *testing.T) {

View File

@@ -8,7 +8,7 @@ import (
"net/mail"
"time"
"github.com/jhillyerd/inbucket/pkg/config"
"github.com/inbucket/inbucket/pkg/config"
)
var (

View File

@@ -61,3 +61,16 @@ func SliceToLower(slice []string) {
slice[i] = strings.ToLower(s)
}
}
// MakePathPrefixer returns a function that will add the specified prefix (base) to URI strings.
// The returned prefixer expects all provided paths to start with /.
func MakePathPrefixer(prefix string) func(string) string {
prefix = strings.Trim(prefix, "/")
if prefix != "" {
prefix = "/" + prefix
}
return func(path string) string {
return prefix + path
}
}

View File

@@ -1,10 +1,11 @@
package stringutil_test
import (
"fmt"
"net/mail"
"testing"
"github.com/jhillyerd/inbucket/pkg/stringutil"
"github.com/inbucket/inbucket/pkg/stringutil"
)
func TestHashMailboxName(t *testing.T) {
@@ -35,3 +36,43 @@ func TestStringAddressList(t *testing.T) {
}
}
}
func TestMakePathPrefixer(t *testing.T) {
testCases := []struct {
prefix, path, want string
}{
{prefix: "", path: "", want: ""},
{prefix: "", path: "relative", want: "relative"},
{prefix: "", path: "/qualified", want: "/qualified"},
{prefix: "", path: "/many/path/segments", want: "/many/path/segments"},
{prefix: "pfx", path: "", want: "/pfx"},
{prefix: "pfx", path: "/", want: "/pfx/"},
{prefix: "pfx", path: "relative", want: "/pfxrelative"},
{prefix: "pfx", path: "/qualified", want: "/pfx/qualified"},
{prefix: "pfx", path: "/many/path/segments", want: "/pfx/many/path/segments"},
{prefix: "/pfx/", path: "", want: "/pfx"},
{prefix: "/pfx/", path: "/", want: "/pfx/"},
{prefix: "/pfx/", path: "relative", want: "/pfxrelative"},
{prefix: "/pfx/", path: "/qualified", want: "/pfx/qualified"},
{prefix: "/pfx/", path: "/many/path/segments", want: "/pfx/many/path/segments"},
{prefix: "a/b/c", path: "", want: "/a/b/c"},
{prefix: "a/b/c", path: "/", want: "/a/b/c/"},
{prefix: "a/b/c", path: "relative", want: "/a/b/crelative"},
{prefix: "a/b/c", path: "/qualified", want: "/a/b/c/qualified"},
{prefix: "a/b/c", path: "/many/path/segments", want: "/a/b/c/many/path/segments"},
{prefix: "/a/b/c/", path: "", want: "/a/b/c"},
{prefix: "/a/b/c/", path: "/", want: "/a/b/c/"},
{prefix: "/a/b/c/", path: "relative", want: "/a/b/crelative"},
{prefix: "/a/b/c/", path: "/qualified", want: "/a/b/c/qualified"},
{prefix: "/a/b/c/", path: "/many/path/segments", want: "/a/b/c/many/path/segments"},
}
for _, tc := range testCases {
t.Run(fmt.Sprintf("prefix %s for path %s", tc.prefix, tc.path), func(t *testing.T) {
prefixer := stringutil.MakePathPrefixer(tc.prefix)
got := prefixer(tc.path)
if got != tc.want {
t.Errorf("Got: %q, want: %q", got, tc.want)
}
})
}
}

View File

@@ -11,18 +11,20 @@ import (
"testing"
"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/rest/client"
"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/mem"
"github.com/inbucket/inbucket/pkg/webui"
"github.com/jhillyerd/goldiff"
"github.com/jhillyerd/inbucket/pkg/config"
"github.com/jhillyerd/inbucket/pkg/message"
"github.com/jhillyerd/inbucket/pkg/msghub"
"github.com/jhillyerd/inbucket/pkg/policy"
"github.com/jhillyerd/inbucket/pkg/rest"
"github.com/jhillyerd/inbucket/pkg/rest/client"
"github.com/jhillyerd/inbucket/pkg/server/smtp"
"github.com/jhillyerd/inbucket/pkg/server/web"
"github.com/jhillyerd/inbucket/pkg/storage"
"github.com/jhillyerd/inbucket/pkg/storage/mem"
"github.com/jhillyerd/inbucket/pkg/webui"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
const (
@@ -35,8 +37,7 @@ func TestSuite(t *testing.T) {
if err != nil {
t.Fatal(err)
}
_ = stopServer
// defer stopServer()
defer stopServer()
testCases := []struct {
name string
@@ -152,6 +153,7 @@ func formatMessage(m *client.Message) []byte {
func startServer() (func(), error) {
// TODO Refactor inbucket/main.go so we don't need to repeat all this here.
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, NoColor: true})
storage.Constructors["memory"] = mem.New
os.Clearenv()
conf, err := config.Process()
@@ -169,9 +171,9 @@ func startServer() (func(), error) {
addrPolicy := &policy.Addressing{Config: conf}
mmanager := &message.StoreManager{AddrPolicy: addrPolicy, Store: store, Hub: msgHub}
// Start HTTP server.
web.Initialize(conf, shutdownChan, mmanager, msgHub)
webui.SetupRoutes(web.Router.PathPrefix("/serve/").Subrouter())
rest.SetupRoutes(web.Router.PathPrefix("/api/").Subrouter())
webui.SetupRoutes(web.Router)
web.Initialize(conf, shutdownChan, mmanager, msgHub)
go web.Start(rootCtx)
// Start SMTP server.
smtpServer := smtp.NewServer(conf.SMTP, shutdownChan, mmanager, addrPolicy)

View File

@@ -3,10 +3,10 @@ package test
import (
"errors"
"github.com/jhillyerd/inbucket/pkg/config"
"github.com/jhillyerd/inbucket/pkg/message"
"github.com/jhillyerd/inbucket/pkg/policy"
"github.com/jhillyerd/inbucket/pkg/storage"
"github.com/inbucket/inbucket/pkg/config"
"github.com/inbucket/inbucket/pkg/message"
"github.com/inbucket/inbucket/pkg/policy"
"github.com/inbucket/inbucket/pkg/storage"
)
// ManagerStub is a test stub for message.Manager

View File

@@ -3,7 +3,7 @@ package test
import (
"errors"
"github.com/jhillyerd/inbucket/pkg/storage"
"github.com/inbucket/inbucket/pkg/storage"
)
// StoreStub stubs storage.Store for testing.

View File

@@ -9,9 +9,9 @@ import (
"testing"
"time"
"github.com/jhillyerd/inbucket/pkg/config"
"github.com/jhillyerd/inbucket/pkg/message"
"github.com/jhillyerd/inbucket/pkg/storage"
"github.com/inbucket/inbucket/pkg/config"
"github.com/inbucket/inbucket/pkg/message"
"github.com/inbucket/inbucket/pkg/storage"
)
// StoreFactory returns a new store for the test suite.

View File

@@ -1,144 +1,82 @@
package webui
import (
"errors"
"fmt"
"html/template"
"io"
"net/http"
"strconv"
"github.com/jhillyerd/inbucket/pkg/server/web"
"github.com/jhillyerd/inbucket/pkg/storage"
"github.com/jhillyerd/inbucket/pkg/webui/sanitize"
"github.com/inbucket/inbucket/pkg/server/web"
"github.com/inbucket/inbucket/pkg/storage"
"github.com/inbucket/inbucket/pkg/stringutil"
"github.com/inbucket/inbucket/pkg/webui/sanitize"
"github.com/rs/zerolog/log"
)
// MailboxIndex renders the index page for a particular mailbox
func MailboxIndex(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
// Form values must be validated manually
name := req.FormValue("name")
selected := req.FormValue("id")
if len(name) == 0 {
ctx.Session.AddFlash("Account name is required", "errors")
_ = ctx.Session.Save(req, w)
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
return nil
}
name, err = ctx.Manager.MailboxForAddress(name)
if err != nil {
ctx.Session.AddFlash(err.Error(), "errors")
_ = ctx.Session.Save(req, w)
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
return nil
}
// Remember this mailbox was visited
RememberMailbox(ctx, name)
// Get flash messages, save session
errorFlash := ctx.Session.Flashes("errors")
if err = ctx.Session.Save(req, w); err != nil {
return err
}
// Render template
return web.RenderTemplate("mailbox/index.html", w, map[string]interface{}{
"ctx": ctx,
"errorFlash": errorFlash,
"name": name,
"selected": selected,
})
}
// MailboxIndexFriendly handles pretty links to a particular mailbox. Renders a redirect
func MailboxIndexFriendly(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
if err != nil {
ctx.Session.AddFlash(err.Error(), "errors")
_ = ctx.Session.Save(req, w)
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
return nil
}
// Build redirect
uri := fmt.Sprintf("%s?name=%s", web.Reverse("MailboxIndex"), name)
http.Redirect(w, req, uri, http.StatusSeeOther)
return nil
}
// MailboxLink handles pretty links to a particular message. Renders a redirect
func MailboxLink(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
// Don't have to validate these aren't empty, Gorilla returns 404
id := ctx.Vars["id"]
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
if err != nil {
ctx.Session.AddFlash(err.Error(), "errors")
_ = ctx.Session.Save(req, w)
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
return nil
}
// Build redirect
uri := fmt.Sprintf("%s?name=%s&id=%s", web.Reverse("MailboxIndex"), name, id)
http.Redirect(w, req, uri, http.StatusSeeOther)
return nil
}
// MailboxList renders a list of messages in a mailbox. Renders a partial
func MailboxList(w http.ResponseWriter, req *http.Request, ctx *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)
}
// Render partial template
return web.RenderPartial("mailbox/_list.html", w, map[string]interface{}{
"ctx": ctx,
"name": name,
"messages": messages,
})
}
// MailboxShow renders a particular message from a mailbox. Renders an HTML partial
func MailboxShow(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
// Don't have to validate these aren't empty, Gorilla returns 404
// MailboxMessage outputs a particular message as JSON for the UI.
func MailboxMessage(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
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 == storage.ErrNotExist {
if err != nil && err != storage.ErrNotExist {
return fmt.Errorf("GetMessage(%q) failed: %v", id, err)
}
if msg == nil {
http.NotFound(w, req)
return nil
}
if err != nil {
// This doesn't indicate empty, likely an IO error
return fmt.Errorf("GetMessage(%q) failed: %v", id, err)
attachments := make([]*jsonAttachment, 0)
for i, part := range msg.Attachments() {
attachments = append(attachments, &jsonAttachment{
ID: strconv.Itoa(i),
FileName: part.FileName,
ContentType: part.ContentType,
})
}
body := template.HTML(web.TextToHTML(msg.Text()))
htmlAvailable := msg.HTML() != ""
var htmlBody template.HTML
if htmlAvailable {
mimeErrors := make([]*jsonMIMEError, 0)
for _, e := range msg.MIMEErrors() {
mimeErrors = append(mimeErrors, &jsonMIMEError{
Name: e.Name,
Detail: e.Detail,
Severe: e.Severe,
})
}
// Sanitize HTML body.
htmlBody := ""
if msg.HTML() != "" {
if str, err := sanitize.HTML(msg.HTML()); err == nil {
htmlBody = template.HTML(str)
htmlBody = str
} else {
// Soft failure, render empty tab.
htmlBody = "Inbucket HTML sanitizer failed."
log.Warn().Str("module", "webui").Str("mailbox", name).Str("id", id).Err(err).
Msg("HTML sanitizer failed")
}
}
// Render partial template
return web.RenderPartial("mailbox/_show.html", w, map[string]interface{}{
"ctx": ctx,
"name": name,
"message": msg,
"body": body,
"htmlAvailable": htmlAvailable,
"htmlBody": htmlBody,
"mimeErrors": msg.MIMEErrors(),
"attachments": msg.Attachments(),
})
return web.RenderJSON(w,
&jsonMessage{
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(),
Text: web.TextToHTML(msg.Text()),
HTML: htmlBody,
Attachments: attachments,
Errors: mimeErrors,
})
}
// MailboxHTML displays the HTML content of a message. Renders a partial
@@ -158,14 +96,10 @@ func MailboxHTML(w http.ResponseWriter, req *http.Request, ctx *web.Context) (er
// This doesn't indicate empty, likely an IO error
return fmt.Errorf("GetMessage(%q) failed: %v", id, err)
}
// Render partial template
// Render HTML
w.Header().Set("Content-Type", "text/html; charset=UTF-8")
return web.RenderPartial("mailbox/_html.html", w, map[string]interface{}{
"ctx": ctx,
"name": name,
"message": msg,
"body": template.HTML(msg.HTML()),
})
_, err = w.Write([]byte(msg.HTML()))
return err
}
// MailboxSource displays the raw source of a message, including headers. Renders text/plain
@@ -191,66 +125,18 @@ func MailboxSource(w http.ResponseWriter, req *http.Request, ctx *web.Context) (
return err
}
// MailboxDownloadAttach sends the attachment to the client; disposition:
// attachment, type: application/octet-stream
func MailboxDownloadAttach(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
// Don't have to validate these aren't empty, Gorilla returns 404
id := ctx.Vars["id"]
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
if err != nil {
ctx.Session.AddFlash(err.Error(), "errors")
_ = ctx.Session.Save(req, w)
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
return nil
}
numStr := ctx.Vars["num"]
num, err := strconv.ParseUint(numStr, 10, 32)
if err != nil {
ctx.Session.AddFlash("Attachment number must be unsigned numeric", "errors")
_ = ctx.Session.Save(req, w)
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
return nil
}
msg, err := ctx.Manager.GetMessage(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("GetMessage(%q) failed: %v", id, err)
}
if int(num) >= len(msg.Attachments()) {
ctx.Session.AddFlash("Attachment number too high", "errors")
_ = ctx.Session.Save(req, w)
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
return nil
}
// Output attachment
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", "attachment")
_, err = w.Write(msg.Attachments()[num].Content)
return err
}
// MailboxViewAttach sends the attachment to the client for online viewing
func MailboxViewAttach(w http.ResponseWriter, req *http.Request, ctx *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 {
ctx.Session.AddFlash(err.Error(), "errors")
_ = ctx.Session.Save(req, w)
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
return nil
return err
}
id := ctx.Vars["id"]
numStr := ctx.Vars["num"]
num, err := strconv.ParseUint(numStr, 10, 32)
if err != nil {
ctx.Session.AddFlash("Attachment number must be unsigned numeric", "errors")
_ = ctx.Session.Save(req, w)
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
return nil
return err
}
msg, err := ctx.Manager.GetMessage(name, id)
if err == storage.ErrNotExist {
@@ -262,10 +148,7 @@ func MailboxViewAttach(w http.ResponseWriter, req *http.Request, ctx *web.Contex
return fmt.Errorf("GetMessage(%q) failed: %v", id, err)
}
if int(num) >= len(msg.Attachments()) {
ctx.Session.AddFlash("Attachment number too high", "errors")
_ = ctx.Session.Save(req, w)
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
return nil
return errors.New("requested attachment number does not exist")
}
// Output attachment
part := msg.Attachments()[num]

34
pkg/webui/mailbox_json.go Normal file
View File

@@ -0,0 +1,34 @@
package webui
import "time"
// jsonMessage formats message data for the UI.
type jsonMessage 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"`
PosixMillis int64 `json:"posix-millis"`
Size int64 `json:"size"`
Seen bool `json:"seen"`
Header map[string][]string `json:"header"`
Text string `json:"text"`
HTML string `json:"html"`
Attachments []*jsonAttachment `json:"attachments"`
Errors []*jsonMIMEError `json:"errors"`
}
// jsonAttachment formats attachment data for the UI.
type jsonAttachment struct {
ID string `json:"id"`
FileName string `json:"filename"`
ContentType string `json:"content-type"`
}
type jsonMIMEError struct {
Name string `json:"name"`
Detail string `json:"detail"`
Severe bool `json:"severe"`
}

View File

@@ -1,35 +0,0 @@
package webui
import (
"github.com/jhillyerd/inbucket/pkg/server/web"
)
const (
// maximum mailboxes to remember
maxRemembered = 8
// session value key; referenced in templates, do not change
mailboxKey = "recentMailboxes"
)
// RememberMailbox manages the list of recently accessed mailboxes stored in the session
func RememberMailbox(ctx *web.Context, mailbox string) {
recent := RecentMailboxes(ctx)
newRecent := make([]string, 1, maxRemembered)
newRecent[0] = mailbox
for _, recBox := range recent {
// Insert until newRecent is full, but don't repeat the new mailbox
if len(newRecent) < maxRemembered && mailbox != recBox {
newRecent = append(newRecent, recBox)
}
}
ctx.Session.Values[mailboxKey] = newRecent
}
// RecentMailboxes returns a slice of the most recently accessed mailboxes
func RecentMailboxes(ctx *web.Context) []string {
val := ctx.Session.Values[mailboxKey]
recent, _ := val.([]string)
return recent
}

View File

@@ -2,98 +2,52 @@ package webui
import (
"fmt"
"html/template"
"io/ioutil"
"net/http"
"github.com/jhillyerd/inbucket/pkg/config"
"github.com/jhillyerd/inbucket/pkg/server/web"
"github.com/inbucket/inbucket/pkg/config"
"github.com/inbucket/inbucket/pkg/server/web"
)
// RootIndex serves the Inbucket landing page
func RootIndex(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
// RootGreeting serves the Inbucket greeting.
func RootGreeting(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
greeting, err := ioutil.ReadFile(ctx.RootConfig.Web.GreetingFile)
if err != nil {
return fmt.Errorf("Failed to load greeting: %v", err)
}
// Get flash messages, save session
errorFlash := ctx.Session.Flashes("errors")
if err = ctx.Session.Save(req, w); err != nil {
return err
}
// Render template
return web.RenderTemplate("root/index.html", w, map[string]interface{}{
"ctx": ctx,
"errorFlash": errorFlash,
"greeting": template.HTML(string(greeting)),
})
w.Header().Set("Content-Type", "text/html")
_, err = w.Write(greeting)
return err
}
// RootMonitor serves the Inbucket monitor page
func RootMonitor(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
if !ctx.RootConfig.Web.MonitorVisible {
ctx.Session.AddFlash("Monitor is disabled in configuration", "errors")
_ = ctx.Session.Save(req, w)
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
return nil
}
// Get flash messages, save session
errorFlash := ctx.Session.Flashes("errors")
if err = ctx.Session.Save(req, w); err != nil {
return err
}
// Render template
return web.RenderTemplate("root/monitor.html", w, map[string]interface{}{
"ctx": ctx,
"errorFlash": errorFlash,
})
}
// RootMonitorMailbox serves the Inbucket monitor page for a particular mailbox
func RootMonitorMailbox(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
if !ctx.RootConfig.Web.MonitorVisible {
ctx.Session.AddFlash("Monitor is disabled in configuration", "errors")
_ = ctx.Session.Save(req, w)
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
return nil
}
name, err := ctx.Manager.MailboxForAddress(ctx.Vars["name"])
if err != nil {
ctx.Session.AddFlash(err.Error(), "errors")
_ = ctx.Session.Save(req, w)
http.Redirect(w, req, web.Reverse("RootIndex"), http.StatusSeeOther)
return nil
}
// Get flash messages, save session
errorFlash := ctx.Session.Flashes("errors")
if err = ctx.Session.Save(req, w); err != nil {
return err
}
// Render template
return web.RenderTemplate("root/monitor.html", w, map[string]interface{}{
"ctx": ctx,
"errorFlash": errorFlash,
"name": name,
})
}
// RootStatus serves the Inbucket status page
// RootStatus renders portions of the server configuration as JSON.
func RootStatus(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err error) {
// Get flash messages, save session
errorFlash := ctx.Session.Flashes("errors")
if err = ctx.Session.Save(req, w); err != nil {
return err
root := ctx.RootConfig
retPeriod := ""
if root.Storage.RetentionPeriod > 0 {
retPeriod = root.Storage.RetentionPeriod.String()
}
// Render template
return web.RenderTemplate("root/status.html", w, map[string]interface{}{
"ctx": ctx,
"errorFlash": errorFlash,
"version": config.Version,
"buildDate": config.BuildDate,
"smtpListener": ctx.RootConfig.SMTP.Addr,
"pop3Listener": ctx.RootConfig.POP3.Addr,
"webListener": ctx.RootConfig.Web.Addr,
"smtpConfig": ctx.RootConfig.SMTP,
"storageConfig": ctx.RootConfig.Storage,
})
return web.RenderJSON(w,
&jsonServerConfig{
Version: config.Version,
BuildDate: config.BuildDate,
POP3Listener: root.POP3.Addr,
WebListener: root.Web.Addr,
SMTPConfig: jsonSMTPConfig{
Addr: root.SMTP.Addr,
DefaultAccept: root.SMTP.DefaultAccept,
AcceptDomains: root.SMTP.AcceptDomains,
RejectDomains: root.SMTP.RejectDomains,
DefaultStore: root.SMTP.DefaultStore,
StoreDomains: root.SMTP.StoreDomains,
DiscardDomains: root.SMTP.DiscardDomains,
},
StorageConfig: jsonStorageConfig{
MailboxMsgCap: root.Storage.MailboxMsgCap,
StoreType: root.Storage.Type,
RetentionPeriod: retPeriod,
},
})
}

View File

@@ -3,35 +3,21 @@ package webui
import (
"github.com/gorilla/mux"
"github.com/jhillyerd/inbucket/pkg/server/web"
"github.com/inbucket/inbucket/pkg/server/web"
)
// SetupRoutes populates routes for the webui into the provided Router
// SetupRoutes populates routes for the webui into the provided Router.
func SetupRoutes(r *mux.Router) {
r.Path("/").Handler(
web.Handler(RootIndex)).Name("RootIndex").Methods("GET")
r.Path("/monitor").Handler(
web.Handler(RootMonitor)).Name("RootMonitor").Methods("GET")
r.Path("/monitor/{name}").Handler(
web.Handler(RootMonitorMailbox)).Name("RootMonitorMailbox").Methods("GET")
r.Path("/greeting").Handler(
web.Handler(RootGreeting)).Name("RootGreeting").Methods("GET")
r.Path("/status").Handler(
web.Handler(RootStatus)).Name("RootStatus").Methods("GET")
r.Path("/link/{name}/{id}").Handler(
web.Handler(MailboxLink)).Name("MailboxLink").Methods("GET")
r.Path("/mailbox").Handler(
web.Handler(MailboxIndex)).Name("MailboxIndex").Methods("GET")
r.Path("/mailbox/{name}").Handler(
web.Handler(MailboxList)).Name("MailboxList").Methods("GET")
r.Path("/mailbox/{name}/{id}").Handler(
web.Handler(MailboxShow)).Name("MailboxShow").Methods("GET")
web.Handler(MailboxMessage)).Name("MailboxMessage").Methods("GET")
r.Path("/mailbox/{name}/{id}/html").Handler(
web.Handler(MailboxHTML)).Name("MailboxHtml").Methods("GET")
web.Handler(MailboxHTML)).Name("MailboxHTML").Methods("GET")
r.Path("/mailbox/{name}/{id}/source").Handler(
web.Handler(MailboxSource)).Name("MailboxSource").Methods("GET")
r.Path("/mailbox/dattach/{name}/{id}/{num}/{file}").Handler(
web.Handler(MailboxDownloadAttach)).Name("MailboxDownloadAttach").Methods("GET")
r.Path("/mailbox/vattach/{name}/{id}/{num}/{file}").Handler(
r.Path("/mailbox/{name}/{id}/attach/{num}/{file}").Handler(
web.Handler(MailboxViewAttach)).Name("MailboxViewAttach").Methods("GET")
r.Path("/{name}").Handler(
web.Handler(MailboxIndexFriendly)).Name("MailboxListFriendly").Methods("GET")
}

View File

@@ -3,7 +3,7 @@ package sanitize_test
import (
"testing"
"github.com/jhillyerd/inbucket/pkg/webui/sanitize"
"github.com/inbucket/inbucket/pkg/webui/sanitize"
)
// TestHTMLPlainStrings test plain text passthrough

26
pkg/webui/status_json.go Normal file
View File

@@ -0,0 +1,26 @@
package webui
type jsonServerConfig struct {
Version string `json:"version"`
BuildDate string `json:"build-date"`
POP3Listener string `json:"pop3-listener"`
WebListener string `json:"web-listener"`
SMTPConfig jsonSMTPConfig `json:"smtp-config"`
StorageConfig jsonStorageConfig `json:"storage-config"`
}
type jsonSMTPConfig struct {
Addr string `json:"addr"`
DefaultAccept bool `json:"default-accept"`
AcceptDomains []string `json:"accept-domains"`
RejectDomains []string `json:"reject-domains"`
DefaultStore bool `json:"default-store"`
StoreDomains []string `json:"store-domains"`
DiscardDomains []string `json:"discard-domains"`
}
type jsonStorageConfig struct {
MailboxMsgCap int `json:"mailbox-msg-cap"`
StoreType string `json:"store-type"`
RetentionPeriod string `json:"retention-period"`
}

21
shell.nix Normal file
View File

@@ -0,0 +1,21 @@
with import <nixpkgs> {};
stdenv.mkDerivation rec {
name = "env";
env = buildEnv { name = name; paths = buildInputs; };
buildInputs = [
act
dpkg
elmPackages.elm
elmPackages.elm-analyse
elmPackages.elm-format
elmPackages.elm-json
elmPackages.elm-language-server
elmPackages.elm-test
go
golint
nodejs-16_x
nodePackages.yarn
rpm
swaks
];
}

4
ui/.parcelrc Normal file
View File

@@ -0,0 +1,4 @@
{
"extends": "@parcel/config-default",
"namers": [ "parcel-namer-rewrite" ]
}

12
ui/.proxyrc.json Normal file
View File

@@ -0,0 +1,12 @@
{
"/api": {
"target": "http://localhost:9000",
"ws": true
},
"/debug": {
"target": "http://localhost:9000"
},
"/serve": {
"target": "http://localhost:9000"
}
}

44
ui/README.md Normal file
View File

@@ -0,0 +1,44 @@
# Inbucket User Interface
This directory contains the source code for the Inbucket web user interface.
It is written in [Elm] 0.19, a *delightful language for reliable webapps.*
## Development
With `$INBUCKET` as the root of the git repository.
One time setup (assuming [Node.js] is already installed):
```
cd $INBUCKET/ui
yarn install
yarn build
```
This will the create `node_modules`, `elm-stuff`, and `dist` directories.
### Terminal 1: inbucket daemon
```
cd $INBUCKET
make
etc/dev-start.sh
```
Inbucket will start, with HTTP listening on port 9000. You may verify the web
UI is functional if this is your first time building Inbucket, but your dev/test
cycle should favor the development server below.
### Terminal 2: parcel development server
```
cd $INBUCKET/ui
yarn start
```
yarn will start a development HTTP server listening on port 1234. You should
use this server for UI development, as it features hot reload and the Elm
debugger.
[Elm]: https://elm-lang.org
[Node.js]: https://nodejs.org

34
ui/elm.json Normal file
View File

@@ -0,0 +1,34 @@
{
"type": "application",
"source-directories": [
"src"
],
"elm-version": "0.19.1",
"dependencies": {
"direct": {
"NoRedInk/elm-json-decode-pipeline": "1.0.0",
"basti1302/elm-human-readable-filesize": "1.2.0",
"elm/browser": "1.0.2",
"elm/core": "1.0.5",
"elm/html": "1.0.0",
"elm/http": "2.0.0",
"elm/json": "1.1.3",
"elm/svg": "1.0.1",
"elm/time": "1.0.0",
"elm/url": "1.0.0",
"jweir/sparkline": "4.0.0",
"ryannhg/date-format": "2.3.0"
},
"indirect": {
"elm/bytes": "1.0.8",
"elm/file": "1.0.5",
"elm/regex": "1.0.0",
"elm/virtual-dom": "1.0.2",
"myrho/elm-round": "1.0.4"
}
},
"test-dependencies": {
"direct": {},
"indirect": {}
}
}

View File

@@ -1,3 +1,5 @@
<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>

32
ui/package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "inbucket-ui",
"version": "3.0.0",
"license": "MIT",
"private": true,
"scripts": {
"build": "parcel build --public-url ./",
"start": "parcel --hmr-port 1235 src/index-dev.html",
"clean": "rm -rf .parcel-cache dist elm-stuff"
},
"source": "src/index.html",
"parcel-namer-rewrite": {
"rules": {
"(.*)\\.(css|js|json|eot|png|svg|ttf|webmanifest|woff|woff2)": "static/$1{.hash}.$2"
}
},
"browserslist": "defaults",
"dependencies": {},
"devDependencies": {
"@fortawesome/fontawesome-free": "^5.15.3",
"@parcel/packager-raw-url": "2.4.1",
"@parcel/transformer-elm": "^2.2.1",
"@parcel/transformer-webmanifest": "2.4.1",
"@webcomponents/webcomponentsjs": "^2.5.0",
"opensans-npm-webfont": "^1.0.0",
"parcel": "^2.4.1",
"parcel-namer-rewrite": "^2.0.0-rc.2"
},
"optionalDependencies": {
"elm": "^0.19.1-5"
}
}

188
ui/src/Api.elm Normal file
View File

@@ -0,0 +1,188 @@
module Api exposing
( DataResult
, HttpResult
, deleteMessage
, getGreeting
, getHeaderList
, getMessage
, getServerConfig
, getServerMetrics
, markMessageSeen
, monitorUri
, purgeMailbox
, serveUrl
)
import Data.Message as Message exposing (Message)
import Data.MessageHeader as MessageHeader exposing (MessageHeader)
import Data.Metrics as Metrics exposing (Metrics)
import Data.ServerConfig as ServerConfig exposing (ServerConfig)
import Data.Session exposing (Session)
import Http
import HttpUtil
import Json.Decode as Decode
import Json.Encode as Encode
import String
import Url.Builder
type alias DataResult msg data =
Result HttpUtil.Error data -> msg
type alias HttpResult msg =
Result HttpUtil.Error () -> msg
deleteMessage : Session -> HttpResult msg -> String -> String -> Cmd msg
deleteMessage session msg mailboxName id =
HttpUtil.delete msg (apiV1Url session [ "mailbox", mailboxName, id ])
getHeaderList : Session -> DataResult msg (List MessageHeader) -> String -> Cmd msg
getHeaderList session msg mailboxName =
let
context =
{ method = "GET"
, url = apiV1Url session [ "mailbox", mailboxName ]
}
in
Http.get
{ url = context.url
, expect = HttpUtil.expectJson context msg (Decode.list MessageHeader.decoder)
}
getGreeting : Session -> DataResult msg String -> Cmd msg
getGreeting session msg =
let
context =
{ method = "GET"
, url = serveUrl session [ "greeting" ]
}
in
Http.get
{ url = context.url
, expect = HttpUtil.expectString context msg
}
getMessage : Session -> DataResult msg Message -> String -> String -> Cmd msg
getMessage session msg mailboxName id =
let
context =
{ method = "GET"
, url = serveUrl session [ "mailbox", mailboxName, id ]
}
in
Http.get
{ url = context.url
, expect = HttpUtil.expectJson context msg Message.decoder
}
getServerConfig : Session -> DataResult msg ServerConfig -> Cmd msg
getServerConfig session msg =
let
context =
{ method = "GET"
, url = serveUrl session [ "status" ]
}
in
Http.get
{ url = context.url
, expect = HttpUtil.expectJson context msg ServerConfig.decoder
}
getServerMetrics : Session -> DataResult msg Metrics -> Cmd msg
getServerMetrics session msg =
let
context =
{ method = "GET"
, url =
Url.Builder.absolute
(splitBasePath session.config.basePath
++ [ "debug"
, "vars"
]
)
[]
}
in
Http.get
{ url = context.url
, expect = HttpUtil.expectJson context msg Metrics.decoder
}
markMessageSeen : Session -> HttpResult msg -> String -> String -> Cmd msg
markMessageSeen session msg mailboxName id =
-- The URL tells the API which message ID to update, so we only need to indicate the
-- desired change in the body.
Encode.object [ ( "seen", Encode.bool True ) ]
|> Http.jsonBody
|> HttpUtil.patch msg (apiV1Url session [ "mailbox", mailboxName, id ])
monitorUri : Session -> String
monitorUri session =
apiV1Url session [ "monitor", "messages" ]
purgeMailbox : Session -> HttpResult msg -> String -> Cmd msg
purgeMailbox session msg mailboxName =
HttpUtil.delete msg (apiV1Url session [ "mailbox", mailboxName ])
{-| Builds a public REST API URL (see wiki).
-}
apiV1Url : Session -> List String -> String
apiV1Url session elements =
Url.Builder.absolute
(List.concat
[ splitBasePath session.config.basePath
, [ "api", "v1" ]
, elements
]
)
[]
{-| Builds an internal `serve` REST API URL; only used by this UI.
-}
serveUrl : Session -> List String -> String
serveUrl session elements =
Url.Builder.absolute
(List.concat
[ splitBasePath session.config.basePath
, [ "serve" ]
, elements
]
)
[]
{-| Converts base path into a list of path elements.
-}
splitBasePath : String -> List String
splitBasePath path =
if path == "" then
[]
else
let
stripSlashes str =
if String.startsWith "/" str then
stripSlashes (String.dropLeft 1 str)
else if String.endsWith "/" str then
stripSlashes (String.dropRight 1 str)
else
str
newPath =
stripSlashes path
in
String.split "/" newPath

22
ui/src/Data/AppConfig.elm Normal file
View File

@@ -0,0 +1,22 @@
module Data.AppConfig exposing (AppConfig, decoder, default)
import Json.Decode as D
import Json.Decode.Pipeline as P
type alias AppConfig =
{ basePath : String
, monitorVisible : Bool
}
decoder : D.Decoder AppConfig
decoder =
D.succeed AppConfig
|> P.optional "base-path" D.string ""
|> P.required "monitor-visible" D.bool
default : AppConfig
default =
AppConfig "" True

11
ui/src/Data/Date.elm Normal file
View File

@@ -0,0 +1,11 @@
module Data.Date exposing (date)
import Json.Decode exposing (Decoder, int, map)
import Time exposing (Posix)
{-| Decode a POSIX milliseconds timestamp.
-}
date : Decoder Posix
date =
int |> map Time.millisToPosix

69
ui/src/Data/Message.elm Normal file
View File

@@ -0,0 +1,69 @@
module Data.Message exposing (Attachment, Message, attachmentDecoder, decoder)
import Data.Date exposing (date)
import Json.Decode exposing (Decoder, bool, int, list, string, succeed)
import Json.Decode.Pipeline exposing (optional, required)
import Time exposing (Posix)
type alias Message =
{ mailbox : String
, id : String
, from : String
, to : List String
, subject : String
, date : Posix
, size : Int
, seen : Bool
, text : String
, html : String
, attachments : List Attachment
, errors : List Error
}
type alias Attachment =
{ id : String
, fileName : String
, contentType : String
}
type alias Error =
{ name : String
, detail : String
, severe : Bool
}
decoder : Decoder Message
decoder =
succeed Message
|> required "mailbox" string
|> required "id" string
|> optional "from" string ""
|> required "to" (list string)
|> optional "subject" string ""
|> required "posix-millis" date
|> required "size" int
|> required "seen" bool
|> required "text" string
|> required "html" string
|> required "attachments" (list attachmentDecoder)
|> required "errors" (list errorDecoder)
attachmentDecoder : Decoder Attachment
attachmentDecoder =
succeed Attachment
|> required "id" string
|> required "filename" string
|> required "content-type" string
errorDecoder : Decoder Error
errorDecoder =
succeed Error
|> required "name" string
|> required "detail" string
|> required "severe" bool

View File

@@ -0,0 +1,31 @@
module Data.MessageHeader exposing (MessageHeader, decoder)
import Data.Date exposing (date)
import Json.Decode exposing (Decoder, bool, int, list, string, succeed)
import Json.Decode.Pipeline exposing (optional, required)
import Time exposing (Posix)
type alias MessageHeader =
{ mailbox : String
, id : String
, from : String
, to : List String
, subject : String
, date : Posix
, size : Int
, seen : Bool
}
decoder : Decoder MessageHeader
decoder =
succeed MessageHeader
|> required "mailbox" string
|> required "id" string
|> optional "from" string ""
|> required "to" (list string)
|> optional "subject" string ""
|> required "posix-millis" date
|> required "size" int
|> required "seen" bool

70
ui/src/Data/Metrics.elm Normal file
View File

@@ -0,0 +1,70 @@
module Data.Metrics exposing (Metrics, decodeIntList, decoder)
import Data.Date exposing (date)
import Json.Decode exposing (Decoder, int, map, string, succeed)
import Json.Decode.Pipeline exposing (requiredAt)
import Time exposing (Posix)
type alias Metrics =
{ startTime : Posix
, sysMem : Int
, heapSize : Int
, heapUsed : Int
, heapObjects : Int
, goRoutines : Int
, webSockets : Int
, smtpConnOpen : Int
, smtpConnTotal : Int
, smtpConnHist : List Int
, smtpReceivedTotal : Int
, smtpReceivedHist : List Int
, smtpErrorsTotal : Int
, smtpErrorsHist : List Int
, smtpWarnsTotal : Int
, smtpWarnsHist : List Int
, retentionDeletesTotal : Int
, retentionDeletesHist : List Int
, retainedCount : Int
, retainedCountHist : List Int
, retainedSize : Int
, retainedSizeHist : List Int
, scanCompleted : Posix
}
decoder : Decoder Metrics
decoder =
succeed Metrics
|> requiredAt [ "startMillis" ] date
|> requiredAt [ "memstats", "Sys" ] int
|> requiredAt [ "memstats", "HeapSys" ] int
|> requiredAt [ "memstats", "HeapAlloc" ] int
|> requiredAt [ "memstats", "HeapObjects" ] int
|> requiredAt [ "goroutines" ] int
|> requiredAt [ "http", "WebSocketConnectsCurrent" ] int
|> requiredAt [ "smtp", "ConnectsCurrent" ] int
|> requiredAt [ "smtp", "ConnectsTotal" ] int
|> requiredAt [ "smtp", "ConnectsHist" ] decodeIntList
|> requiredAt [ "smtp", "ReceivedTotal" ] int
|> requiredAt [ "smtp", "ReceivedHist" ] decodeIntList
|> requiredAt [ "smtp", "ErrorsTotal" ] int
|> requiredAt [ "smtp", "ErrorsHist" ] decodeIntList
|> requiredAt [ "smtp", "WarnsTotal" ] int
|> requiredAt [ "smtp", "WarnsHist" ] decodeIntList
|> requiredAt [ "retention", "DeletesTotal" ] int
|> requiredAt [ "retention", "DeletesHist" ] decodeIntList
|> requiredAt [ "retention", "RetainedCurrent" ] int
|> requiredAt [ "retention", "RetainedHist" ] decodeIntList
|> requiredAt [ "retention", "RetainedSize" ] int
|> requiredAt [ "retention", "SizeHist" ] decodeIntList
|> requiredAt [ "retention", "ScanCompletedMillis" ] date
{-| Decodes Inbuckets hacky comma-separated-int JSON strings.
-}
decodeIntList : Decoder (List Int)
decodeIntList =
string
|> map (String.split ",")
|> map (List.map (String.toInt >> Maybe.withDefault 0))

View File

@@ -0,0 +1,107 @@
module Data.ServerConfig exposing (ServerConfig, decoder, encode)
import Json.Decode as D
import Json.Decode.Pipeline as P
import Json.Encode as E
-- Generated by https://github.com/jhillyerd/go-to-elm-json
type alias ServerConfig =
{ version : String
, buildDate : String
, pop3Listener : String
, webListener : String
, smtpConfig : SmtpConfig
, storageConfig : StorageConfig
}
type alias SmtpConfig =
{ addr : String
, defaultAccept : Bool
, acceptDomains : Maybe (List String)
, rejectDomains : Maybe (List String)
, defaultStore : Bool
, storeDomains : Maybe (List String)
, discardDomains : Maybe (List String)
}
type alias StorageConfig =
{ mailboxMsgCap : Int
, storeType : String
, retentionPeriod : String
}
decoder : D.Decoder ServerConfig
decoder =
D.succeed ServerConfig
|> P.required "version" D.string
|> P.required "build-date" D.string
|> P.required "pop3-listener" D.string
|> P.required "web-listener" D.string
|> P.required "smtp-config" smtpConfigDecoder
|> P.required "storage-config" storageConfigDecoder
encode : ServerConfig -> E.Value
encode r =
E.object
[ ( "version", E.string r.version )
, ( "build-date", E.string r.buildDate )
, ( "pop3-listener", E.string r.pop3Listener )
, ( "web-listener", E.string r.webListener )
, ( "smtp-config", encodeSmtpConfig r.smtpConfig )
, ( "storage-config", encodeStorageConfig r.storageConfig )
]
smtpConfigDecoder : D.Decoder SmtpConfig
smtpConfigDecoder =
D.succeed SmtpConfig
|> P.required "addr" D.string
|> P.required "default-accept" D.bool
|> P.required "accept-domains" (D.nullable (D.list D.string))
|> P.required "reject-domains" (D.nullable (D.list D.string))
|> P.required "default-store" D.bool
|> P.required "store-domains" (D.nullable (D.list D.string))
|> P.required "discard-domains" (D.nullable (D.list D.string))
encodeSmtpConfig : SmtpConfig -> E.Value
encodeSmtpConfig r =
E.object
[ ( "addr", E.string r.addr )
, ( "default-accept", E.bool r.defaultAccept )
, ( "accept-domains", maybe (E.list E.string) r.acceptDomains )
, ( "reject-domains", maybe (E.list E.string) r.rejectDomains )
, ( "default-store", E.bool r.defaultStore )
, ( "store-domains", maybe (E.list E.string) r.storeDomains )
, ( "discard-domains", maybe (E.list E.string) r.discardDomains )
]
storageConfigDecoder : D.Decoder StorageConfig
storageConfigDecoder =
D.succeed StorageConfig
|> P.required "mailbox-msg-cap" D.int
|> P.required "store-type" D.string
|> P.required "retention-period" D.string
encodeStorageConfig : StorageConfig -> E.Value
encodeStorageConfig r =
E.object
[ ( "mailbox-msg-cap", E.int r.mailboxMsgCap )
, ( "store-type", E.string r.storeType )
, ( "retention-period", E.string r.retentionPeriod )
]
maybe : (a -> E.Value) -> Maybe a -> E.Value
maybe encoder =
Maybe.map encoder >> Maybe.withDefault E.null

124
ui/src/Data/Session.elm Normal file
View File

@@ -0,0 +1,124 @@
module Data.Session exposing
( Flash
, Persistent
, Session
, addRecent
, clearFlash
, decoder
, disableRouting
, enableRouting
, encode
, init
, initError
, showFlash
)
import Browser.Navigation as Nav
import Data.AppConfig as AppConfig exposing (AppConfig)
import Json.Decode as D
import Json.Decode.Pipeline exposing (optional)
import Json.Encode as E
import Route exposing (Router)
import Time
import Url exposing (Url)
type alias Session =
{ key : Nav.Key
, host : String
, flash : Maybe Flash
, routing : Bool
, router : Router
, zone : Time.Zone
, config : AppConfig
, persistent : Persistent
}
type alias Flash =
{ title : String
, table : List ( String, String )
}
type alias Persistent =
{ recentMailboxes : List String
}
init : Nav.Key -> Url -> AppConfig -> Persistent -> Session
init key location config persistent =
{ key = key
, host = location.host
, flash = Nothing
, routing = True
, router = Route.newRouter config.basePath
, zone = Time.utc
, config = config
, persistent = persistent
}
initError : Nav.Key -> Url -> String -> Session
initError key location error =
{ key = key
, host = location.host
, flash = Just (Flash "Initialization failed" [ ( "Error", error ) ])
, routing = True
, router = Route.newRouter ""
, zone = Time.utc
, config = AppConfig.default
, persistent = Persistent []
}
addRecent : String -> Session -> Session
addRecent mailbox session =
if List.head session.persistent.recentMailboxes == Just mailbox then
session
else
let
recent =
session.persistent.recentMailboxes
|> List.filter ((/=) mailbox)
|> List.take 7
|> (::) mailbox
persistent =
session.persistent
in
{ session | persistent = { persistent | recentMailboxes = recent } }
disableRouting : Session -> Session
disableRouting session =
{ session | routing = False }
enableRouting : Session -> Session
enableRouting session =
{ session | routing = True }
clearFlash : Session -> Session
clearFlash session =
{ session | flash = Nothing }
showFlash : Flash -> Session -> Session
showFlash flash session =
{ session | flash = Just flash }
decoder : D.Decoder Persistent
decoder =
D.succeed Persistent
|> optional "recentMailboxes" (D.list D.string) []
encode : Persistent -> E.Value
encode persistent =
E.object
[ ( "recentMailboxes", E.list E.string persistent.recentMailboxes )
]

370
ui/src/Effect.elm Normal file
View File

@@ -0,0 +1,370 @@
module Effect exposing
( Effect
, addRecent
, append
, batch
, clearFlash
, deleteMessage
, disableRouting
, enableRouting
, focusModal
, focusModalResult
, getGreeting
, getHeaderList
, getMessage
, getServerConfig
, getServerMetrics
, map
, markMessageSeen
, navigateRoute
, none
, perform
, posixTime
, purgeMailbox
, schedule
, showFlash
, updateRoute
)
import Api exposing (DataResult, HttpResult)
import Browser.Navigation as Nav
import Data.Message exposing (Message)
import Data.MessageHeader exposing (MessageHeader)
import Data.Metrics exposing (Metrics)
import Data.ServerConfig exposing (ServerConfig)
import Data.Session as Session exposing (Session)
import Modal
import Route exposing (Route)
import Task
import Time
import Timer exposing (Timer)
type Effect msg
= None
| ApiEffect (ApiEffect msg)
| Batch (List (Effect msg))
| ModalFocus (Modal.Msg -> msg)
| PosixTime (Time.Posix -> msg)
| RouteNavigate Bool Route
| RouteUpdate Route
| ScheduleTimer (Timer -> msg) Timer Float
| SessionEffect SessionEffect
type ApiEffect msg
= DeleteMessage (HttpResult msg) String String
| GetGreeting (DataResult msg String)
| GetServerConfig (DataResult msg ServerConfig)
| GetServerMetrics (DataResult msg Metrics)
| GetHeaderList (DataResult msg (List MessageHeader)) String
| GetMessage (DataResult msg Message) String String
| MarkMessageSeen (HttpResult msg) String String
| PurgeMailbox (HttpResult msg) String
type SessionEffect
= FlashClear
| FlashShow Session.Flash
| ModalFocusResult Modal.Msg
| RecentAdd String
| RoutingDisable
| RoutingEnable
{-| Appends a new effect to a model/effect tuple.
-}
append : Effect msg -> ( a, Effect msg ) -> ( a, Effect msg )
append e ( model, effect ) =
( model, batch [ effect, e ] )
{-| Packs a List of Effects into a single Effect
-}
batch : List (Effect msg) -> Effect msg
batch effects =
Batch effects
{-| Transform message types produced by an effect.
-}
map : (a -> b) -> Effect a -> Effect b
map f effect =
case effect of
None ->
None
Batch effects ->
Batch <| List.map (map f) effects
ModalFocus toMsg ->
ModalFocus <| toMsg >> f
PosixTime toMsg ->
PosixTime <| toMsg >> f
ScheduleTimer toMsg timer millis ->
ScheduleTimer (toMsg >> f) timer millis
RouteNavigate pushHistory route ->
RouteNavigate pushHistory route
RouteUpdate route ->
RouteUpdate route
ApiEffect apiEffect ->
ApiEffect <| mapApi f apiEffect
SessionEffect sessionEffect ->
SessionEffect sessionEffect
mapApi : (a -> b) -> ApiEffect a -> ApiEffect b
mapApi f effect =
case effect of
DeleteMessage result mailbox id ->
DeleteMessage (result >> f) mailbox id
GetGreeting result ->
GetGreeting (result >> f)
GetServerConfig result ->
GetServerConfig (result >> f)
GetServerMetrics result ->
GetServerMetrics (result >> f)
GetHeaderList result mailbox ->
GetHeaderList (result >> f) mailbox
GetMessage result mailbox id ->
GetMessage (result >> f) mailbox id
MarkMessageSeen result mailbox id ->
MarkMessageSeen (result >> f) mailbox id
PurgeMailbox result mailbox ->
PurgeMailbox (result >> f) mailbox
{-| Applies an effect by updating the session and/or producing a Cmd.
-}
perform : ( Session, Effect msg ) -> ( Session, Cmd msg )
perform ( session, effect ) =
case effect of
None ->
( session, Cmd.none )
Batch effects ->
List.foldl batchPerform ( session, [] ) effects
|> Tuple.mapSecond Cmd.batch
ModalFocus toMsg ->
( session, Modal.resetFocusCmd toMsg )
PosixTime toMsg ->
( session, Task.perform toMsg Time.now )
ScheduleTimer toMsg timer millis ->
( session, Timer.schedule toMsg timer millis )
RouteNavigate pushHistory route ->
let
url =
session.router.toPath route
in
( Session.enableRouting session
, if pushHistory then
Nav.pushUrl session.key url
else
Nav.replaceUrl session.key url
)
RouteUpdate route ->
( Session.disableRouting session
, session.router.toPath route
|> Nav.replaceUrl session.key
)
ApiEffect apiEffect ->
performApi ( session, apiEffect )
SessionEffect sessionEffect ->
performSession ( session, sessionEffect )
performApi : ( Session, ApiEffect msg ) -> ( Session, Cmd msg )
performApi ( session, effect ) =
case effect of
DeleteMessage toMsg mailbox id ->
( session, Api.deleteMessage session toMsg mailbox id )
GetGreeting toMsg ->
( session, Api.getGreeting session toMsg )
GetServerConfig toMsg ->
( session, Api.getServerConfig session toMsg )
GetServerMetrics toMsg ->
( session, Api.getServerMetrics session toMsg )
GetHeaderList toMsg mailbox ->
( session, Api.getHeaderList session toMsg mailbox )
GetMessage toMsg mailbox id ->
( session, Api.getMessage session toMsg mailbox id )
MarkMessageSeen toMsg mailbox id ->
( session, Api.markMessageSeen session toMsg mailbox id )
PurgeMailbox toMsg mailbox ->
( session, Api.purgeMailbox session toMsg mailbox )
performSession : ( Session, SessionEffect ) -> ( Session, Cmd msg )
performSession ( session, effect ) =
case effect of
RecentAdd mailbox ->
( Session.addRecent mailbox session, Cmd.none )
FlashClear ->
( Session.clearFlash session, Cmd.none )
FlashShow flash ->
( Session.showFlash flash session, Cmd.none )
ModalFocusResult result ->
( Modal.updateSession result session, Cmd.none )
RoutingDisable ->
( Session.disableRouting session, Cmd.none )
RoutingEnable ->
( Session.enableRouting session, Cmd.none )
-- EFFECT CONSTRUCTORS
none : Effect msg
none =
None
{-| Adds specified mailbox to the recently viewed list
-}
addRecent : String -> Effect msg
addRecent mailbox =
SessionEffect (RecentAdd mailbox)
disableRouting : Effect msg
disableRouting =
SessionEffect RoutingDisable
enableRouting : Effect msg
enableRouting =
SessionEffect RoutingEnable
clearFlash : Effect msg
clearFlash =
SessionEffect FlashClear
showFlash : Session.Flash -> Effect msg
showFlash flash =
SessionEffect (FlashShow flash)
{-| Locks focus to the `modal-dialog` dom ID.
-}
focusModal : (Modal.Msg -> msg) -> Effect msg
focusModal toMsg =
ModalFocus toMsg
focusModalResult : Modal.Msg -> Effect msg
focusModalResult msg =
SessionEffect (ModalFocusResult msg)
deleteMessage : HttpResult msg -> String -> String -> Effect msg
deleteMessage toMsg mailboxName id =
ApiEffect (DeleteMessage toMsg mailboxName id)
getGreeting : DataResult msg String -> Effect msg
getGreeting toMsg =
ApiEffect (GetGreeting toMsg)
getHeaderList : DataResult msg (List MessageHeader) -> String -> Effect msg
getHeaderList toMsg mailboxName =
ApiEffect (GetHeaderList toMsg mailboxName)
getServerConfig : DataResult msg ServerConfig -> Effect msg
getServerConfig toMsg =
ApiEffect (GetServerConfig toMsg)
getServerMetrics : DataResult msg Metrics -> Effect msg
getServerMetrics toMsg =
ApiEffect (GetServerMetrics toMsg)
getMessage : DataResult msg Message -> String -> String -> Effect msg
getMessage toMsg mailboxName id =
ApiEffect (GetMessage toMsg mailboxName id)
markMessageSeen : HttpResult msg -> String -> String -> Effect msg
markMessageSeen toMsg mailboxName id =
ApiEffect (MarkMessageSeen toMsg mailboxName id)
posixTime : (Time.Posix -> msg) -> Effect msg
posixTime toMsg =
PosixTime toMsg
purgeMailbox : HttpResult msg -> String -> Effect msg
purgeMailbox toMsg mailboxName =
ApiEffect (PurgeMailbox toMsg mailboxName)
{-| Schedules a Timer to fire after the specified delay.
-}
schedule : (Timer -> msg) -> Timer -> Float -> Effect msg
schedule toMsg timer millis =
ScheduleTimer toMsg timer millis
{-| Updates the browsers displayed URL to the specified route, and triggers the route to be
handled by the frontend.
-}
navigateRoute : Bool -> Route -> Effect msg
navigateRoute pushHistory route =
RouteNavigate pushHistory route
{-| Updates the browsers displayed URL to the specified route. Does not trigger our own route
handling.
-}
updateRoute : Route -> Effect msg
updateRoute route =
RouteUpdate route
-- UTILITY
batchPerform : Effect msg -> ( Session, List (Cmd msg) ) -> ( Session, List (Cmd msg) )
batchPerform effect ( session, cmds ) =
perform ( session, effect )
|> Tuple.mapSecond (\cmd -> cmd :: cmds)

132
ui/src/HttpUtil.elm Normal file
View File

@@ -0,0 +1,132 @@
module HttpUtil exposing (Error, RequestContext, delete, errorFlash, expectJson, expectString, patch)
import Data.Session as Session
import Http
import Json.Decode as Decode
type alias Error =
{ error : Http.Error
, request : RequestContext
}
type alias RequestContext =
{ method : String
, url : String
}
delete : (Result Error () -> msg) -> String -> Cmd msg
delete msg url =
let
context =
{ method = "DELETE"
, url = url
}
in
Http.request
{ method = context.method
, headers = []
, url = url
, body = Http.emptyBody
, expect = expectWhatever context msg
, timeout = Nothing
, tracker = Nothing
}
patch : (Result Error () -> msg) -> String -> Http.Body -> Cmd msg
patch msg url body =
let
context =
{ method = "PATCH"
, url = url
}
in
Http.request
{ method = context.method
, headers = []
, url = url
, body = body
, expect = expectWhatever context msg
, timeout = Nothing
, tracker = Nothing
}
errorFlash : Error -> Session.Flash
errorFlash error =
let
requestContext flash =
{ flash
| table =
flash.table
++ [ ( "Request URL", error.request.url )
, ( "HTTP Method", error.request.method )
]
}
in
requestContext <|
case error.error of
Http.BadUrl str ->
{ title = "Bad URL"
, table = [ ( "URL", str ) ]
}
Http.Timeout ->
{ title = "HTTP timeout"
, table = []
}
Http.NetworkError ->
{ title = "HTTP network error"
, table = []
}
Http.BadStatus res ->
{ title = "HTTP response error"
, table = [ ( "Response Code", String.fromInt res ) ]
}
Http.BadBody body ->
{ title = "Bad HTTP body"
, table = [ ( "Error", body ) ]
}
expectJson : RequestContext -> (Result Error a -> msg) -> Decode.Decoder a -> Http.Expect msg
expectJson context toMsg decoder =
Http.expectStringResponse toMsg <|
resolve context <|
\string ->
Result.mapError Decode.errorToString (Decode.decodeString decoder string)
expectString : RequestContext -> (Result Error String -> msg) -> Http.Expect msg
expectString context toMsg =
Http.expectStringResponse toMsg (resolve context Ok)
expectWhatever : RequestContext -> (Result Error () -> msg) -> Http.Expect msg
expectWhatever context toMsg =
Http.expectBytesResponse toMsg (resolve context (\_ -> Ok ()))
resolve : RequestContext -> (body -> Result String a) -> Http.Response body -> Result Error a
resolve context toResult response =
case response of
Http.BadUrl_ url ->
Err (Error (Http.BadUrl url) context)
Http.Timeout_ ->
Err (Error Http.Timeout context)
Http.NetworkError_ ->
Err (Error Http.NetworkError context)
Http.BadStatus_ metadata _ ->
Err (Error (Http.BadStatus metadata.statusCode) context)
Http.GoodStatus_ _ body ->
Result.mapError (\x -> Error (Http.BadBody x) context) (toResult body)

309
ui/src/Layout.elm Normal file
View File

@@ -0,0 +1,309 @@
module Layout exposing (Model, Msg, Page(..), frame, init, reset, update)
import Data.Session as Session exposing (Session)
import Effect exposing (Effect)
import Html
exposing
( Attribute
, Html
, a
, button
, div
, footer
, form
, h2
, header
, i
, input
, li
, nav
, pre
, span
, td
, text
, th
, tr
, ul
)
import Html.Attributes
exposing
( attribute
, class
, classList
, href
, placeholder
, rel
, target
, type_
, value
)
import Html.Events as Events
import Modal
import Route
import Timer exposing (Timer)
{-| Used to highlight current page in navbar.
-}
type Page
= Other
| Mailbox
| Monitor
| Status
type alias Model msg =
{ mapMsg : Msg -> msg
, mainMenuVisible : Bool
, recentMenuVisible : Bool
, recentMenuTimer : Timer
, mailboxName : String
}
init : (Msg -> msg) -> Model msg
init mapMsg =
{ mapMsg = mapMsg
, mainMenuVisible = False
, recentMenuVisible = False
, recentMenuTimer = Timer.empty
, mailboxName = ""
}
{-| Resets layout state, used when navigating to a new page.
-}
reset : Model msg -> Model msg
reset model =
{ model
| mainMenuVisible = False
, recentMenuVisible = False
, recentMenuTimer = Timer.cancel model.recentMenuTimer
, mailboxName = ""
}
type Msg
= ClearFlash
| MainMenuToggled
| ModalFocused Modal.Msg
| ModalUnfocused
| OnMailboxNameInput String
| OpenMailbox
| RecentMenuMouseOver
| RecentMenuMouseOut
| RecentMenuTimeout Timer
| RecentMenuToggled
update : Msg -> Model msg -> ( Model msg, Effect msg )
update msg model =
case msg of
ClearFlash ->
( model, Effect.clearFlash )
MainMenuToggled ->
( { model | mainMenuVisible = not model.mainMenuVisible }, Effect.none )
ModalFocused message ->
( model, Effect.focusModalResult message )
ModalUnfocused ->
( model, Effect.focusModal (ModalFocused >> model.mapMsg) )
OnMailboxNameInput name ->
( { model | mailboxName = name }, Effect.none )
OpenMailbox ->
if model.mailboxName == "" then
( model, Effect.none )
else
( model
, Effect.navigateRoute True (Route.Mailbox model.mailboxName)
)
RecentMenuMouseOver ->
( { model
| recentMenuVisible = True
, recentMenuTimer = Timer.cancel model.recentMenuTimer
}
, Effect.none
)
RecentMenuMouseOut ->
let
-- Keep the recent menu open for a moment even if the mouse leaves it.
newTimer =
Timer.replace model.recentMenuTimer
in
( { model
| recentMenuTimer = newTimer
}
, Effect.schedule (RecentMenuTimeout >> model.mapMsg) newTimer 400
)
RecentMenuTimeout timer ->
if timer == model.recentMenuTimer then
( { model
| recentMenuVisible = False
, recentMenuTimer = Timer.cancel timer
}
, Effect.none
)
else
-- Timer was no longer valid.
( model, Effect.none )
RecentMenuToggled ->
( { model | recentMenuVisible = not model.recentMenuVisible }
, Effect.none
)
type alias State msg =
{ model : Model msg
, session : Session
, activePage : Page
, activeMailbox : String
, modal : Maybe (Html msg)
, content : List (Html msg)
}
frame : State msg -> Html msg
frame { model, session, activePage, activeMailbox, modal, content } =
div [ class "app" ]
[ header []
[ nav [ class "navbar" ]
[ button [ class "navbar-toggle", Events.onClick (MainMenuToggled |> model.mapMsg) ]
[ i [ class "fas fa-bars" ] [] ]
, span [ class "navbar-brand" ]
[ a [ href <| session.router.toPath Route.Home ] [ text "@ inbucket" ] ]
, ul [ class "main-nav", classList [ ( "active", model.mainMenuVisible ) ] ]
[ if session.config.monitorVisible then
navbarLink Monitor (session.router.toPath Route.Monitor) [ text "Monitor" ] activePage
else
text ""
, navbarLink Status (session.router.toPath Route.Status) [ text "Status" ] activePage
, navbarRecent activePage activeMailbox model session
, li [ class "navbar-mailbox" ]
[ form [ Events.onSubmit (OpenMailbox |> model.mapMsg) ]
[ input
[ type_ "text"
, placeholder "mailbox"
, value model.mailboxName
, Events.onInput (OnMailboxNameInput >> model.mapMsg)
]
[]
]
]
]
]
]
, div [ class "navbar-bg" ] [ text "" ]
, Modal.view (ModalUnfocused |> model.mapMsg) modal
, div [ class "page" ] (errorFlash model session.flash :: content)
, footer []
[ div [ class "footer" ]
[ externalLink "https://www.inbucket.org" "Inbucket"
, text " is an open source project hosted on "
, externalLink "https://github.com/inbucket/inbucket" "GitHub"
, text "."
]
]
]
errorFlash : Model msg -> Maybe Session.Flash -> Html msg
errorFlash model maybeFlash =
let
row ( heading, message ) =
tr []
[ th [] [ text (heading ++ ":") ]
, td [] [ pre [] [ text message ] ]
]
in
case maybeFlash of
Nothing ->
text ""
Just flash ->
div [ class "well well-error" ]
[ div [ class "flash-header" ]
[ h2 [] [ text flash.title ]
, a [ href "#", Events.onClick (ClearFlash |> model.mapMsg) ] [ text "Close" ]
]
, div [ class "flash-table" ] (List.map row flash.table)
]
externalLink : String -> String -> Html a
externalLink url title =
a [ href url, target "_blank", rel "noopener" ] [ text title ]
navbarLink : Page -> String -> List (Html a) -> Page -> Html a
navbarLink page url linkContent activePage =
li [ classList [ ( "navbar-active", page == activePage ) ] ]
[ a [ href url ] linkContent ]
{-| Renders list of recent mailboxes, selecting the currently active mailbox.
-}
navbarRecent : Page -> String -> Model msg -> Session -> Html msg
navbarRecent page activeMailbox model session =
let
-- Active means we are viewing a specific mailbox.
active =
page == Mailbox
-- Recent tab title is the name of the current mailbox when active.
title =
if active then
activeMailbox
else
"Recent Mailboxes"
-- Mailboxes to show in recent list, doesn't include active mailbox.
recentMailboxes =
if active then
List.tail session.persistent.recentMailboxes |> Maybe.withDefault []
else
session.persistent.recentMailboxes
recentLink mailbox =
a [ href <| session.router.toPath <| Route.Mailbox mailbox ] [ text mailbox ]
in
li
[ class "navbar-dropdown-container"
, classList [ ( "navbar-active", active ) ]
, attribute "aria-haspopup" "true"
, ariaExpanded model.recentMenuVisible
, Events.onMouseOver (RecentMenuMouseOver |> model.mapMsg)
, Events.onMouseOut (RecentMenuMouseOut |> model.mapMsg)
]
[ span [ class "navbar-dropdown" ]
[ text title
, button
[ class "navbar-dropdown-button"
, Events.onClick (RecentMenuToggled |> model.mapMsg)
]
[ i [ class "fas fa-chevron-down" ] [] ]
]
, div [ class "navbar-dropdown-content" ] (List.map recentLink recentMailboxes)
]
ariaExpanded : Bool -> Attribute msg
ariaExpanded value =
attribute "aria-expanded" <|
if value then
"true"
else
"false"

413
ui/src/Main.elm Normal file
View File

@@ -0,0 +1,413 @@
module Main exposing (main)
import Browser exposing (Document, UrlRequest)
import Browser.Navigation as Nav
import Data.AppConfig as AppConfig exposing (AppConfig)
import Data.Session as Session exposing (Session)
import Effect exposing (Effect)
import Html exposing (Html)
import Json.Decode as D exposing (Value)
import Layout
import Page.Home as Home
import Page.Mailbox as Mailbox
import Page.Monitor as Monitor
import Page.Status as Status
import Ports
import Route exposing (Route)
import Task
import Time
import Url exposing (Url)
-- MODEL
type alias Model =
{ layout : Layout.Model Msg
, page : PageModel
}
type PageModel
= Home Home.Model
| Mailbox Mailbox.Model
| Monitor Monitor.Model
| Status Status.Model
type alias InitConfig =
{ appConfig : AppConfig
, session : Session.Persistent
}
init : Value -> Url -> Nav.Key -> ( Model, Cmd Msg )
init configValue location key =
let
configDecoder =
D.map2 InitConfig
(D.field "app-config" AppConfig.decoder)
(D.field "session" Session.decoder)
session =
case D.decodeValue configDecoder configValue of
Ok config ->
Session.init key location config.appConfig config.session
Err error ->
Session.initError key location (D.errorToString error)
( subModel, _ ) =
-- Home.init effect is discarded because this subModel will be immediately replaced
-- when we change routes to the specified location.
Home.init session
initModel =
{ layout = Layout.init LayoutMsg
, page = Home subModel
}
route =
session.router.fromUrl location
in
changeRouteTo route initModel
|> Tuple.mapSecond (\cmd -> Cmd.batch [ cmd, Task.perform TimeZoneLoaded Time.here ])
type Msg
= UrlChanged Url
| LinkClicked UrlRequest
| SessionUpdated (Result D.Error Session.Persistent)
| TimeZoneLoaded Time.Zone
| LayoutMsg Layout.Msg
| HomeMsg Home.Msg
| MailboxMsg Mailbox.Msg
| MonitorMsg Monitor.Msg
| StatusMsg Status.Msg
-- SUBSCRIPTIONS
subscriptions : Model -> Sub Msg
subscriptions model =
Sub.batch
[ pageSubscriptions model.page
, Sub.map SessionUpdated sessionChange
]
sessionChange : Sub (Result D.Error Session.Persistent)
sessionChange =
Ports.onSessionChange (D.decodeValue Session.decoder)
pageSubscriptions : PageModel -> Sub Msg
pageSubscriptions page =
case page of
Mailbox subModel ->
Sub.map MailboxMsg (Mailbox.subscriptions subModel)
Status subModel ->
Sub.map StatusMsg (Status.subscriptions subModel)
_ ->
Sub.none
-- UPDATE
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
let
session =
getSession model
( newModel, cmd ) =
updateMain msg model session
newSession =
getSession newModel
in
if session.persistent == newSession.persistent then
( newModel, cmd )
else
-- Store updated persistent session.
( newModel
, Cmd.batch
[ Ports.storeSession (Session.encode newSession.persistent)
, cmd
]
)
{-| Handle global/navbar related msgs.
-}
updateMain : Msg -> Model -> Session -> ( Model, Cmd Msg )
updateMain msg model session =
case msg of
LinkClicked req ->
case req of
Browser.Internal url ->
case url.fragment of
Just "" ->
-- Anchor tag for accessibility purposes only, already handled.
( model, Cmd.none )
_ ->
( model, Nav.pushUrl session.key (Url.toString url) )
Browser.External url ->
( model, Nav.load url )
UrlChanged url ->
-- Responds to new browser URL.
if session.routing then
changeRouteTo (session.router.fromUrl url) model
else
-- Skip once, but re-enable routing.
( applyToModelSession Session.enableRouting model
, Cmd.none
)
SessionUpdated (Ok persistent) ->
( updateSession model { session | persistent = persistent }
, Cmd.none
)
SessionUpdated (Err error) ->
let
flash =
{ title = "Error decoding session"
, table = [ ( "Error", D.errorToString error ) ]
}
in
( applyToModelSession (Session.showFlash flash) model
, Cmd.none
)
TimeZoneLoaded zone ->
( updateSession model { session | zone = zone }
, Cmd.none
)
LayoutMsg subMsg ->
let
( layout, effect ) =
Layout.update subMsg model.layout
in
( { model | layout = layout }, effect ) |> performEffects
_ ->
updatePage msg model |> performEffects
{-| Delegate incoming messages to their respective sub-pages.
-}
updatePage : Msg -> Model -> ( Model, Effect Msg )
updatePage msg model =
case ( msg, model.page ) of
( HomeMsg subMsg, Home subModel ) ->
Home.update subMsg subModel
|> updateWith Home HomeMsg model
( MailboxMsg subMsg, Mailbox subModel ) ->
Mailbox.update subMsg subModel
|> updateWith Mailbox MailboxMsg model
( MonitorMsg subMsg, Monitor subModel ) ->
Monitor.update subMsg subModel
|> updateWith Monitor MonitorMsg model
( StatusMsg subMsg, Status subModel ) ->
Status.update subMsg subModel
|> updateWith Status StatusMsg model
( _, _ ) ->
-- Disregard messages destined for the wrong page.
( model, Effect.none )
changeRouteTo : Route -> Model -> ( Model, Cmd Msg )
changeRouteTo route model =
let
session =
Session.clearFlash (getSession model)
newModel =
{ model | layout = Layout.reset model.layout }
in
performEffects <|
case route of
Route.Home ->
Home.init session
|> updateWith Home HomeMsg newModel
Route.Mailbox name ->
Mailbox.init session name Nothing
|> updateWith Mailbox MailboxMsg newModel
Route.Message mailbox id ->
Mailbox.init session mailbox (Just id)
|> updateWith Mailbox MailboxMsg newModel
Route.Monitor ->
if session.config.monitorVisible then
Monitor.init session
|> updateWith Monitor MonitorMsg newModel
else
let
flash =
{ title = "Disabled route requested"
, table = [ ( "Error", "Monitor disabled by configuration." ) ]
}
in
( applyToModelSession (Session.showFlash flash) newModel
, Effect.none
)
Route.Status ->
Status.init session
|> updateWith Status StatusMsg newModel
Route.Unknown path ->
-- Unknown routes display Home with an error flash.
let
flash =
{ title = "Unknown route requested"
, table = [ ( "Path", path ) ]
}
in
Home.init (Session.showFlash flash session)
|> updateWith Home HomeMsg newModel
{-| Perform effects by updating model and/or producing Cmds to be executed.
-}
performEffects : ( Model, Effect Msg ) -> ( Model, Cmd Msg )
performEffects ( model, effect ) =
Effect.perform ( getSession model, effect )
|> Tuple.mapFirst (\newSession -> updateSession model newSession)
getSession : Model -> Session
getSession model =
case model.page of
Home subModel ->
subModel.session
Mailbox subModel ->
subModel.session
Monitor subModel ->
subModel.session
Status subModel ->
subModel.session
updateSession : Model -> Session -> Model
updateSession model session =
case model.page of
Home subModel ->
{ model | page = Home { subModel | session = session } }
Mailbox subModel ->
{ model | page = Mailbox { subModel | session = session } }
Monitor subModel ->
{ model | page = Monitor { subModel | session = session } }
Status subModel ->
{ model | page = Status { subModel | session = session } }
applyToModelSession : (Session -> Session) -> Model -> Model
applyToModelSession f model =
updateSession model (f (getSession model))
{-| Map page updates to Main Model and Msg types.
-}
updateWith :
(subModel -> PageModel)
-> (subMsg -> Msg)
-> Model
-> ( subModel, Effect subMsg )
-> ( Model, Effect Msg )
updateWith toPage toMsg model ( subModel, subEffect ) =
( { model | page = toPage subModel }
, Effect.map toMsg subEffect
)
-- VIEW
view : Model -> Document Msg
view model =
let
session =
getSession model
mailbox =
case model.page of
Mailbox subModel ->
subModel.mailboxName
_ ->
""
framePage :
Layout.Page
-> (msg -> Msg)
-> { title : String, modal : Maybe (Html msg), content : List (Html msg) }
-> Document Msg
framePage page toMsg { title, modal, content } =
Document title
[ Layout.frame
{ model = model.layout
, session = session
, activePage = page
, activeMailbox = mailbox
, modal = Maybe.map (Html.map toMsg) modal
, content = List.map (Html.map toMsg) content
}
]
in
case model.page of
Home subModel ->
framePage Layout.Other HomeMsg (Home.view subModel)
Mailbox subModel ->
framePage Layout.Mailbox MailboxMsg (Mailbox.view subModel)
Monitor subModel ->
framePage Layout.Monitor MonitorMsg (Monitor.view subModel)
Status subModel ->
framePage Layout.Status StatusMsg (Status.view subModel)
-- MAIN
main : Program Value Model Msg
main =
Browser.application
{ init = init
, view = view
, update = update
, subscriptions = subscriptions
, onUrlChange = UrlChanged
, onUrlRequest = LinkClicked
}

58
ui/src/Modal.elm Normal file
View File

@@ -0,0 +1,58 @@
module Modal exposing (Msg, resetFocusCmd, updateSession, view)
import Browser.Dom as Dom
import Data.Session as Session exposing (Session)
import Html exposing (Html, div, span, text)
import Html.Attributes exposing (class, id, tabindex)
import Html.Events exposing (onFocus)
import Task
type alias Msg =
Result Dom.Error ()
{-| Creates a command to focus the modal dialog.
-}
resetFocusCmd : (Msg -> msg) -> Cmd msg
resetFocusCmd resultMsg =
Task.attempt resultMsg (Dom.focus domId)
{-| Updates a Session with an error Flash if the resetFocusCmd failed.
-}
updateSession : Msg -> Session -> Session
updateSession result session =
case result of
Ok () ->
session
Err (Dom.NotFound missingDomId) ->
let
flash =
{ title = "DOM element not found"
, table = [ ( "Element ID", missingDomId ) ]
}
in
Session.showFlash flash session
view : msg -> Maybe (Html msg) -> Html msg
view unfocusedMsg maybeModal =
case maybeModal of
Just modal ->
div [ class "modal-mask" ]
[ span [ onFocus unfocusedMsg, tabindex 0 ] []
, div [ id domId, class "modal well", tabindex -1 ] [ modal ]
, span [ onFocus unfocusedMsg, tabindex 0 ] []
]
Nothing ->
text ""
{-| DOM ID of the modal dialog.
-}
domId : String
domId =
"modal-dialog"

59
ui/src/Page/Home.elm Normal file
View File

@@ -0,0 +1,59 @@
module Page.Home exposing (Model, Msg, init, update, view)
import Data.Session exposing (Session)
import Effect exposing (Effect)
import Html exposing (Html)
import Html.Attributes exposing (class, property)
import HttpUtil
import Json.Encode as Encode
-- MODEL --
type alias Model =
{ session : Session
, greeting : String
}
init : Session -> ( Model, Effect Msg )
init session =
( Model session "", Effect.getGreeting GreetingLoaded )
-- UPDATE --
type Msg
= GreetingLoaded (Result HttpUtil.Error String)
update : Msg -> Model -> ( Model, Effect Msg )
update msg model =
case msg of
GreetingLoaded (Ok greeting) ->
( { model | greeting = greeting }, Effect.none )
GreetingLoaded (Err err) ->
( model, Effect.showFlash (HttpUtil.errorFlash err) )
-- VIEW --
view : Model -> { title : String, modal : Maybe (Html msg), content : List (Html Msg) }
view model =
{ title = "Inbucket"
, modal = Nothing
, content =
[ Html.node "rendered-html"
[ class "greeting"
, property "content" (Encode.string model.greeting)
]
[]
]
}

775
ui/src/Page/Mailbox.elm Normal file
View File

@@ -0,0 +1,775 @@
module Page.Mailbox exposing (Model, Msg, init, subscriptions, update, view)
import Api
import Data.Message as Message exposing (Message)
import Data.MessageHeader exposing (MessageHeader)
import Data.Session exposing (Session)
import DateFormat as DF
import DateFormat.Relative as Relative
import Effect exposing (Effect)
import Html
exposing
( Attribute
, Html
, a
, article
, aside
, button
, dd
, div
, dl
, dt
, h3
, i
, input
, li
, main_
, nav
, p
, span
, table
, td
, text
, tr
, ul
)
import Html.Attributes
exposing
( alt
, class
, classList
, disabled
, download
, href
, placeholder
, property
, tabindex
, target
, type_
, value
)
import Html.Events as Events
import HttpUtil
import Json.Decode as D
import Json.Encode as E
import Modal
import Route
import Time exposing (Posix)
import Timer exposing (Timer)
-- MODEL
type Body
= TextBody
| SafeHtmlBody
type State
= LoadingList (Maybe MessageID)
| ShowingList MessageList MessageState
type MessageState
= NoMessage
| LoadingMessage
| ShowingMessage Message
| Transitioning Message
type alias MessageID =
String
type alias MessageList =
{ headers : List MessageHeader
, selected : Maybe MessageID
, searchFilter : String
}
type alias Model =
{ session : Session
, mailboxName : String
, state : State
, socketConnected : Bool
, bodyMode : Body
, searchInput : String
, promptPurge : Bool
, markSeenTimer : Timer
, now : Posix
}
type alias ServeUrl =
List String -> String
init : Session -> String -> Maybe MessageID -> ( Model, Effect Msg )
init session mailboxName selection =
( { session = session
, mailboxName = mailboxName
, state = LoadingList selection
, socketConnected = False
, bodyMode = SafeHtmlBody
, searchInput = ""
, promptPurge = False
, markSeenTimer = Timer.empty
, now = Time.millisToPosix 0
}
, Effect.batch
[ Effect.posixTime Tick
, Effect.getHeaderList ListLoaded mailboxName
]
)
-- SUBSCRIPTIONS
subscriptions : Model -> Sub Msg
subscriptions _ =
Time.every (30 * 1000) Tick
-- UPDATE
type Msg
= ListLoaded (Result HttpUtil.Error (List MessageHeader))
| ClickMessage MessageID
| ClickRefresh
| ListKeyPress String Int
| CloseMessage
| MessageLoaded (Result HttpUtil.Error Message)
| MessageBody Body
| MarkSeenTriggered Timer
| MarkSeenLoaded (Result HttpUtil.Error ())
| DeleteMessage Message
| DeletedMessage (Result HttpUtil.Error ())
| PurgeMailboxPrompt
| PurgeMailboxCanceled
| PurgeMailboxConfirmed
| PurgedMailbox (Result HttpUtil.Error ())
| OnSearchInput String
| Tick Posix
| ModalFocused Modal.Msg
update : Msg -> Model -> ( Model, Effect Msg )
update msg model =
case msg of
ClickMessage id ->
( updateSelected model id
, Effect.batch
[ -- Update browser location.
Effect.updateRoute (Route.Message model.mailboxName id)
, Effect.getMessage MessageLoaded model.mailboxName id
]
)
ClickRefresh ->
let
selection =
case model.state of
ShowingList _ (ShowingMessage message) ->
Just message.id
_ ->
Nothing
in
-- Reset to loading state, preserving the current message selection.
( { model | state = LoadingList selection }
, Effect.getHeaderList ListLoaded model.mailboxName
)
CloseMessage ->
case model.state of
ShowingList list _ ->
( { model | state = ShowingList list NoMessage }, Effect.none )
_ ->
( model, Effect.none )
DeleteMessage message ->
updateDeleteMessage model message
DeletedMessage (Ok _) ->
( model, Effect.none )
DeletedMessage (Err err) ->
( model, Effect.showFlash (HttpUtil.errorFlash err) )
ListKeyPress id keyCode ->
case keyCode of
13 ->
updateOpenMessage model id
_ ->
( model, Effect.none )
ListLoaded (Ok headers) ->
updateListLoaded model headers
ListLoaded (Err err) ->
( model, Effect.showFlash (HttpUtil.errorFlash err) )
MarkSeenLoaded (Ok _) ->
( model, Effect.none )
MarkSeenLoaded (Err err) ->
( model, Effect.showFlash (HttpUtil.errorFlash err) )
MessageLoaded (Ok message) ->
updateMessageResult model message
MessageLoaded (Err err) ->
( model, Effect.showFlash (HttpUtil.errorFlash err) )
MessageBody bodyMode ->
( { model | bodyMode = bodyMode }, Effect.none )
ModalFocused message ->
( model, Effect.focusModalResult message )
OnSearchInput searchInput ->
updateSearchInput model searchInput
PurgeMailboxPrompt ->
( { model | promptPurge = True }, Effect.focusModal ModalFocused )
PurgeMailboxCanceled ->
( { model | promptPurge = False }, Effect.none )
PurgeMailboxConfirmed ->
updateTriggerPurge model
PurgedMailbox (Ok _) ->
( model, Effect.none )
PurgedMailbox (Err err) ->
( model, Effect.showFlash (HttpUtil.errorFlash err) )
MarkSeenTriggered timer ->
if timer == model.markSeenTimer then
-- Matching timer means we have changed messages, mark this one seen.
updateMarkMessageSeen model
else
( model, Effect.none )
Tick now ->
( { model | now = now }, Effect.none )
updateListLoaded : Model -> List MessageHeader -> ( Model, Effect Msg )
updateListLoaded model headers =
case model.state of
LoadingList selection ->
let
newModel =
{ model
| state = ShowingList (MessageList headers Nothing "") NoMessage
}
in
Effect.append (Effect.addRecent newModel.mailboxName) <|
case selection of
Just id ->
-- Don't try to load selected message if not present in headers.
if List.any (\header -> Just header.id == selection) headers then
updateOpenMessage newModel id
else
( newModel, Effect.updateRoute (Route.Mailbox model.mailboxName) )
Nothing ->
( newModel, Effect.none )
_ ->
( model, Effect.none )
{-| Replace the currently displayed message.
-}
updateMessageResult : Model -> Message -> ( Model, Effect Msg )
updateMessageResult model message =
let
bodyMode =
if message.html == "" then
TextBody
else
model.bodyMode
in
case model.state of
LoadingList _ ->
( model, Effect.none )
ShowingList list _ ->
let
newTimer =
Timer.replace model.markSeenTimer
in
( { model
| state =
ShowingList
{ list | selected = Just message.id }
(ShowingMessage message)
, bodyMode = bodyMode
, markSeenTimer = newTimer
}
-- Set 1500ms delay before reporting message as seen to backend.
, Effect.schedule MarkSeenTriggered newTimer 1500
)
{-| Updates model and triggers commands to purge this mailbox.
-}
updateTriggerPurge : Model -> ( Model, Effect Msg )
updateTriggerPurge model =
( { model
| promptPurge = False
, state = ShowingList (MessageList [] Nothing "") NoMessage
}
, Effect.batch
[ Effect.updateRoute (Route.Mailbox model.mailboxName)
, Effect.purgeMailbox PurgedMailbox model.mailboxName
]
)
updateSearchInput : Model -> String -> ( Model, Effect Msg )
updateSearchInput model searchInput =
let
searchFilter =
if String.length searchInput > 1 then
String.toLower searchInput
else
""
in
case model.state of
LoadingList _ ->
( model, Effect.none )
ShowingList list messageState ->
( { model
| searchInput = searchInput
, state = ShowingList { list | searchFilter = searchFilter } messageState
}
, Effect.none
)
{-| Set the selected message in our model.
-}
updateSelected : Model -> MessageID -> Model
updateSelected model id =
case model.state of
LoadingList _ ->
model
ShowingList list messageState ->
let
newList =
{ list | selected = Just id }
in
case messageState of
NoMessage ->
{ model | state = ShowingList newList LoadingMessage }
LoadingMessage ->
{ model | state = ShowingList newList LoadingMessage }
ShowingMessage visible ->
-- Use Transitioning state to prevent blank message flicker.
{ model | state = ShowingList newList (Transitioning visible) }
Transitioning visible ->
{ model | state = ShowingList newList (Transitioning visible) }
updateDeleteMessage : Model -> Message -> ( Model, Effect Msg )
updateDeleteMessage model message =
let
filter f messageList =
{ messageList | headers = List.filter f messageList.headers }
in
case model.state of
ShowingList list _ ->
( { model | state = ShowingList (filter (\x -> x.id /= message.id) list) NoMessage }
, Effect.batch
[ Effect.deleteMessage DeletedMessage message.mailbox message.id
, Effect.updateRoute (Route.Mailbox model.mailboxName)
]
)
_ ->
( model, Effect.none )
{-| Updates both the active message, and the message list to mark the currently viewed message as seen.
-}
updateMarkMessageSeen : Model -> ( Model, Effect Msg )
updateMarkMessageSeen model =
case model.state of
ShowingList messages (ShowingMessage visibleMessage) ->
let
updateHeader header =
if header.id == visibleMessage.id then
{ header | seen = True }
else
header
newMessages =
{ messages | headers = List.map updateHeader messages.headers }
in
( { model
| state =
ShowingList newMessages (ShowingMessage { visibleMessage | seen = True })
}
, Effect.markMessageSeen MarkSeenLoaded visibleMessage.mailbox visibleMessage.id
)
_ ->
( model, Effect.none )
updateOpenMessage : Model -> String -> ( Model, Effect Msg )
updateOpenMessage model id =
( updateSelected model id
, Effect.getMessage MessageLoaded model.mailboxName id
)
-- VIEW
view : Model -> { title : String, modal : Maybe (Html Msg), content : List (Html Msg) }
view model =
let
serveUrl : ServeUrl
serveUrl =
Api.serveUrl model.session
mode =
case model.state of
ShowingList _ (ShowingMessage _) ->
"message-active"
_ ->
"list-active"
in
{ title = model.mailboxName ++ " - Inbucket"
, modal = viewModal model.promptPurge
, content =
[ div [ class ("mailbox " ++ mode) ]
[ viewMessageListControls model
, viewMessageList model
, main_
[ class "message" ]
[ case model.state of
ShowingList _ NoMessage ->
text
("Select a message on the left,"
++ " or enter a different username into the box on upper right."
)
ShowingList _ (ShowingMessage message) ->
viewMessage serveUrl model.session.zone message model.bodyMode
ShowingList _ (Transitioning message) ->
viewMessage serveUrl model.session.zone message model.bodyMode
_ ->
text ""
]
]
]
}
viewModal : Bool -> Maybe (Html Msg)
viewModal promptPurge =
if promptPurge then
Just <|
div []
[ p [] [ text "Are you sure you want to delete all messages in this mailbox?" ]
, div [ class "button-bar" ]
[ button [ Events.onClick PurgeMailboxConfirmed, class "danger" ] [ text "Yes" ]
, button [ Events.onClick PurgeMailboxCanceled ] [ text "Cancel" ]
]
]
else
Nothing
viewMessageListControls : Model -> Html Msg
viewMessageListControls model =
let
clearButton =
Just <|
button
[ Events.onClick (OnSearchInput "")
, disabled (model.searchInput == "")
, alt "Clear Search"
]
[ i [ class "fas fa-times" ] [] ]
purgeButton =
Just <|
button
[ Events.onClick PurgeMailboxPrompt
, alt "Purge Mailbox"
]
[ i [ class "fas fa-trash" ] [] ]
refreshButton =
if model.socketConnected then
Nothing
else
Just <|
button
[ Events.onClick ClickRefresh
, alt "Refresh Mailbox"
]
[ i [ class "fas fa-sync" ] [] ]
searchInput =
Just <|
input
[ type_ "text"
, placeholder "search"
, Events.onInput OnSearchInput
, value model.searchInput
]
[]
in
[ searchInput, clearButton, refreshButton, purgeButton ]
|> List.filterMap identity
|> aside [ class "message-list-controls" ]
viewMessageList : Model -> Html Msg
viewMessageList model =
aside [ class "message-list" ] <|
case model.state of
LoadingList _ ->
[]
ShowingList list _ ->
list
|> filterMessageList
|> List.reverse
|> List.map (messageChip model list.selected)
messageChip : Model -> Maybe MessageID -> MessageHeader -> Html Msg
messageChip model selected message =
div
[ class "message-list-entry"
, classList
[ ( "selected", selected == Just message.id )
, ( "unseen", not message.seen )
]
, Events.onClick (ClickMessage message.id)
, onKeyUp (ListKeyPress message.id)
, tabindex 0
]
[ div [ class "subject" ] [ text message.subject ]
, div [ class "from" ] [ text message.from ]
, div [ class "date" ] [ relativeDate model message.date ]
]
viewMessage : ServeUrl -> Time.Zone -> Message -> Body -> Html Msg
viewMessage serveUrl zone message bodyMode =
let
htmlUrl =
serveUrl [ "mailbox", message.mailbox, message.id, "html" ]
sourceUrl =
serveUrl [ "mailbox", message.mailbox, message.id, "source" ]
htmlButton =
if message.html == "" then
text ""
else
a [ href htmlUrl, target "_blank" ]
[ button [ tabindex -1 ] [ text "Raw HTML" ] ]
in
div []
[ div [ class "button-bar" ]
[ button [ class "message-close light", Events.onClick CloseMessage ]
[ i [ class "fas fa-arrow-left" ] [] ]
, button [ class "danger", Events.onClick (DeleteMessage message) ] [ text "Delete" ]
, a [ href sourceUrl, target "_blank" ]
[ button [ tabindex -1 ] [ text "Source" ] ]
, htmlButton
]
, dl [ class "message-header" ]
[ dt [] [ text "From:" ]
, dd [] [ text message.from ]
, dt [] [ text "To:" ]
, dd [] [ text (String.join ", " message.to) ]
, dt [] [ text "Date:" ]
, dd [] [ verboseDate zone message.date ]
, dt [] [ text "Subject:" ]
, dd [] [ text message.subject ]
]
, messageErrors message
, messageBody message bodyMode
, attachments serveUrl message
]
messageErrors : Message -> Html Msg
messageErrors message =
let
row error =
li []
[ span
[ classList [ ( "message-warn-severe", error.severe ) ] ]
[ text (error.name ++ ": ") ]
, text error.detail
]
in
case message.errors of
[] ->
text ""
errors ->
div [ class "well well-warn message-warn" ]
[ div [] [ h3 [] [ text "MIME problems detected" ] ]
, ul [] (List.map row errors)
]
messageBody : Message -> Body -> Html Msg
messageBody message bodyMode =
let
bodyModeTab mode label =
a
[ classList [ ( "active", bodyMode == mode ) ]
, Events.onClick (MessageBody mode)
, href "#"
]
[ text label ]
safeHtml =
bodyModeTab SafeHtmlBody "Safe HTML"
plainText =
bodyModeTab TextBody "Plain Text"
tabs =
if message.html == "" then
[ plainText ]
else
[ safeHtml, plainText ]
in
div [ class "tab-panel" ]
[ nav [ class "tab-bar" ] tabs
, article [ class "message-body" ]
[ case bodyMode of
SafeHtmlBody ->
Html.node "rendered-html" [ property "content" (E.string message.html) ] []
TextBody ->
Html.node "rendered-html" [ property "content" (E.string message.text) ] []
]
]
attachments : ServeUrl -> Message -> Html Msg
attachments serveUrl message =
if List.isEmpty message.attachments then
text ""
else
message.attachments
|> List.map (attachmentRow serveUrl message)
|> table [ class "attachments well" ]
attachmentRow : ServeUrl -> Message -> Message.Attachment -> Html Msg
attachmentRow serveUrl message attach =
let
url =
serveUrl
[ "mailbox"
, message.mailbox
, message.id
, "attach"
, attach.id
, attach.fileName
]
in
tr []
[ td []
[ a [ href url, target "_blank" ] [ text attach.fileName ]
, text (" (" ++ attach.contentType ++ ") ")
]
, td [] [ a [ href url, download attach.fileName, class "button" ] [ text "Download" ] ]
]
relativeDate : Model -> Posix -> Html Msg
relativeDate model date =
Relative.relativeTime model.now date |> text
verboseDate : Time.Zone -> Posix -> Html Msg
verboseDate zone date =
text <|
DF.format
[ DF.monthNameFull
, DF.text " "
, DF.dayOfMonthSuffix
, DF.text ", "
, DF.yearNumber
, DF.text " "
, DF.hourNumber
, DF.text ":"
, DF.minuteFixed
, DF.text ":"
, DF.secondFixed
, DF.text " "
, DF.amPmUppercase
, DF.text " (Local)"
]
zone
date
-- UTILITY
filterMessageList : MessageList -> List MessageHeader
filterMessageList list =
if list.searchFilter == "" then
list.headers
else
let
matches header =
String.contains list.searchFilter (String.toLower header.subject)
|| String.contains list.searchFilter (String.toLower header.from)
in
List.filter matches list.headers
onKeyUp : (Int -> msg) -> Attribute msg
onKeyUp tagger =
Events.on "keyup" (D.map tagger Events.keyCode)

193
ui/src/Page/Monitor.elm Normal file
View File

@@ -0,0 +1,193 @@
module Page.Monitor exposing (Model, Msg, init, update, view)
import Api
import Data.MessageHeader as MessageHeader exposing (MessageHeader)
import Data.Session exposing (Session)
import DateFormat as DF
import Effect exposing (Effect)
import Html
exposing
( Attribute
, Html
, button
, div
, em
, h1
, node
, span
, table
, tbody
, td
, text
, th
, thead
, tr
)
import Html.Attributes exposing (class, src, tabindex)
import Html.Events as Events
import Json.Decode as D
import Route
import Time exposing (Posix)
-- MODEL
type alias Model =
{ session : Session
, connected : Bool
, messages : List MessageHeader
}
init : Session -> ( Model, Effect Msg )
init session =
( Model session False [], Effect.none )
-- UPDATE
type Msg
= Connected Bool
| MessageReceived D.Value
| Clear
| OpenMessage MessageHeader
| MessageKeyPress MessageHeader Int
update : Msg -> Model -> ( Model, Effect Msg )
update msg model =
case msg of
Connected True ->
( { model | connected = True, messages = [] }, Effect.none )
Connected False ->
( { model | connected = False }, Effect.none )
MessageReceived value ->
case D.decodeValue (MessageHeader.decoder |> D.at [ "detail" ]) value of
Ok header ->
( { model | messages = header :: List.take 500 model.messages }
, Effect.none
)
Err err ->
let
flash =
{ title = "Message decoding failed"
, table = [ ( "Error", D.errorToString err ) ]
}
in
( model, Effect.showFlash flash )
Clear ->
( { model | messages = [] }, Effect.none )
OpenMessage header ->
openMessage header model
MessageKeyPress header keyCode ->
case keyCode of
13 ->
openMessage header model
_ ->
( model, Effect.none )
openMessage : MessageHeader -> Model -> ( Model, Effect Msg )
openMessage header model =
( model
, Effect.navigateRoute True (Route.Message header.mailbox header.id)
)
-- VIEW
view : Model -> { title : String, modal : Maybe (Html msg), content : List (Html Msg) }
view model =
{ title = "Inbucket Monitor"
, modal = Nothing
, content =
[ h1 [] [ text "Monitor" ]
, div [ class "monitor-header" ]
[ span [ class "monitor-description" ]
[ text "Messages will be listed here shortly after delivery. "
, em []
[ text
(if model.connected then
"Connected."
else
"Disconnected!"
)
]
]
, span [ class "button-bar monitor-buttons" ]
[ button [ Events.onClick Clear ] [ text "Clear" ]
]
]
-- monitor-messages maintains a websocket connection to the Inbucket daemon at the path
-- specified by `src`.
, node "monitor-messages"
[ src (Api.monitorUri model.session)
, Events.on "connected" (D.map Connected <| D.at [ "detail" ] <| D.bool)
, Events.on "message" (D.map MessageReceived D.value)
]
[]
, table [ class "monitor" ]
[ thead []
[ th [] [ text "Date" ]
, th [ class "desktop" ] [ text "From" ]
, th [] [ text "Mailbox" ]
, th [] [ text "Subject" ]
]
, tbody [] (List.map (viewMessage model.session.zone) model.messages)
]
]
}
viewMessage : Time.Zone -> MessageHeader -> Html Msg
viewMessage zone message =
tr
[ tabindex 0
, Events.onClick (OpenMessage message)
, onKeyUp (MessageKeyPress message)
]
[ td [] [ shortDate zone message.date ]
, td [ class "desktop" ] [ text message.from ]
, td [] [ text message.mailbox ]
, td [] [ text message.subject ]
]
shortDate : Time.Zone -> Posix -> Html Msg
shortDate zone date =
DF.format
[ DF.dayOfMonthFixed
, DF.text "-"
, DF.monthNameAbbreviated
, DF.text " "
, DF.hourNumber
, DF.text ":"
, DF.minuteFixed
, DF.text ":"
, DF.secondFixed
, DF.text " "
, DF.amPmUppercase
]
zone
date
|> text
onKeyUp : (Int -> msg) -> Attribute msg
onKeyUp tagger =
Events.on "keyup" (D.map tagger Events.keyCode)

555
ui/src/Page/Status.elm Normal file
View File

@@ -0,0 +1,555 @@
module Page.Status exposing (Model, Msg, init, subscriptions, update, view)
import Data.Metrics exposing (Metrics)
import Data.ServerConfig exposing (ServerConfig)
import Data.Session exposing (Session)
import DateFormat.Relative as Relative
import Effect exposing (Effect)
import Filesize
import Html
exposing
( Html
, div
, h1
, h2
, i
, text
)
import Html.Attributes exposing (class)
import HttpUtil
import Sparkline as Spark
import Svg.Attributes as SvgAttrib
import Time exposing (Posix)
-- MODEL --
type alias Model =
{ session : Session
, now : Posix
, config : Maybe ServerConfig
, metrics : Maybe Metrics
, xCounter : Float
, sysMem : Metric
, heapSize : Metric
, heapUsed : Metric
, heapObjects : Metric
, goRoutines : Metric
, webSockets : Metric
, smtpConnOpen : Metric
, smtpConnTotal : Metric
, smtpReceivedTotal : Metric
, smtpErrorsTotal : Metric
, smtpWarnsTotal : Metric
, retentionDeletesTotal : Metric
, retainedCount : Metric
, retainedSize : Metric
}
type alias Metric =
{ label : String
, value : Int
, formatter : Int -> String
, graph : Spark.DataSet -> Html Msg
, history : Spark.DataSet
, minutes : Int
}
init : Session -> ( Model, Effect Msg )
init session =
( { session = session
, now = Time.millisToPosix 0
, config = Nothing
, metrics = Nothing
, xCounter = 60
, sysMem = Metric "System Memory" 0 Filesize.format graphZero initDataSet 10
, heapSize = Metric "Heap Size" 0 Filesize.format graphZero initDataSet 10
, heapUsed = Metric "Heap Used" 0 Filesize.format graphZero initDataSet 10
, heapObjects = Metric "Heap # Objects" 0 fmtInt graphZero initDataSet 10
, goRoutines = Metric "Goroutines" 0 fmtInt graphZero initDataSet 10
, webSockets = Metric "Open WebSockets" 0 fmtInt graphZero initDataSet 10
, smtpConnOpen = Metric "Open Connections" 0 fmtInt graphZero initDataSet 10
, smtpConnTotal = Metric "Total Connections" 0 fmtInt graphChange initDataSet 60
, smtpReceivedTotal = Metric "Messages Received" 0 fmtInt graphChange initDataSet 60
, smtpErrorsTotal = Metric "Message Errors" 0 fmtInt graphChange initDataSet 60
, smtpWarnsTotal = Metric "Message Warnings" 0 fmtInt graphChange initDataSet 60
, retentionDeletesTotal = Metric "Retention Deletes" 0 fmtInt graphChange initDataSet 60
, retainedCount = Metric "Stored Messages" 0 fmtInt graphZero initDataSet 60
, retainedSize = Metric "Store Size" 0 Filesize.format graphZero initDataSet 60
}
, Effect.batch
[ Effect.posixTime Tick
, Effect.getServerConfig ServerConfigLoaded
]
)
initDataSet : Spark.DataSet
initDataSet =
List.range 0 59
|> List.map (\x -> ( toFloat x, 0 ))
-- SUBSCRIPTIONS --
subscriptions : Model -> Sub Msg
subscriptions _ =
Time.every (10 * 1000) Tick
-- UPDATE --
type Msg
= MetricsReceived (Result HttpUtil.Error Metrics)
| ServerConfigLoaded (Result HttpUtil.Error ServerConfig)
| Tick Posix
update : Msg -> Model -> ( Model, Effect Msg )
update msg model =
case msg of
MetricsReceived (Ok metrics) ->
( updateMetrics metrics model, Effect.none )
MetricsReceived (Err err) ->
( model, Effect.showFlash (HttpUtil.errorFlash err) )
ServerConfigLoaded (Ok config) ->
( { model | config = Just config }, Effect.none )
ServerConfigLoaded (Err err) ->
( model, Effect.showFlash (HttpUtil.errorFlash err) )
Tick time ->
( { model | now = time }
, Effect.getServerMetrics MetricsReceived
)
{-| Update all metrics in Model; increment xCounter.
-}
updateMetrics : Metrics -> Model -> Model
updateMetrics metrics model =
let
x =
model.xCounter
in
{ model
| metrics = Just metrics
, xCounter = x + 1
, sysMem = updateLocalMetric model.sysMem x metrics.sysMem
, heapSize = updateLocalMetric model.heapSize x metrics.heapSize
, heapUsed = updateLocalMetric model.heapUsed x metrics.heapUsed
, heapObjects = updateLocalMetric model.heapObjects x metrics.heapObjects
, goRoutines = updateLocalMetric model.goRoutines x metrics.goRoutines
, webSockets = updateLocalMetric model.webSockets x metrics.webSockets
, smtpConnOpen = updateLocalMetric model.smtpConnOpen x metrics.smtpConnOpen
, smtpConnTotal =
updateRemoteTotal
model.smtpConnTotal
metrics.smtpConnTotal
metrics.smtpConnHist
, smtpReceivedTotal =
updateRemoteTotal
model.smtpReceivedTotal
metrics.smtpReceivedTotal
metrics.smtpReceivedHist
, smtpErrorsTotal =
updateRemoteTotal
model.smtpErrorsTotal
metrics.smtpErrorsTotal
metrics.smtpErrorsHist
, smtpWarnsTotal =
updateRemoteTotal
model.smtpWarnsTotal
metrics.smtpWarnsTotal
metrics.smtpWarnsHist
, retentionDeletesTotal =
updateRemoteTotal
model.retentionDeletesTotal
metrics.retentionDeletesTotal
metrics.retentionDeletesHist
, retainedCount =
updateRemoteMetric
model.retainedCount
metrics.retainedCount
metrics.retainedCountHist
, retainedSize =
updateRemoteMetric
model.retainedSize
metrics.retainedSize
metrics.retainedSizeHist
}
{-| Update a single Metric, with history tracked locally.
-}
updateLocalMetric : Metric -> Float -> Int -> Metric
updateLocalMetric metric x value =
{ metric
| value = value
, history =
Maybe.withDefault [] (List.tail metric.history)
++ [ ( x, toFloat value ) ]
}
{-| Update a single Metric, with history tracked on server.
-}
updateRemoteMetric : Metric -> Int -> List Int -> Metric
updateRemoteMetric metric value history =
{ metric
| value = value
, history =
history
|> zeroPadList
|> List.indexedMap (\x y -> ( toFloat x, toFloat y ))
}
{-| Update a single Metric, with history tracked on server. Sparkline will chart changes to the
total instead of its absolute value.
-}
updateRemoteTotal : Metric -> Int -> List Int -> Metric
updateRemoteTotal metric value history =
{ metric
| value = value
, history =
history
|> zeroPadList
|> changeList
|> List.indexedMap (\x y -> ( toFloat x, toFloat y ))
}
-- VIEW --
view : Model -> { title : String, modal : Maybe (Html msg), content : List (Html Msg) }
view model =
{ title = "Inbucket Status"
, modal = Nothing
, content =
[ h1 [] [ text "Status" ]
, div [] (configPanel model.config :: metricPanels model)
]
}
configPanel : Maybe ServerConfig -> Html Msg
configPanel maybeConfig =
let
mailboxCap config =
case config.storageConfig.mailboxMsgCap of
0 ->
"Unlimited"
cap ->
String.fromInt cap ++ " messages per mailbox"
retentionPeriod config =
case config.storageConfig.retentionPeriod of
"" ->
"Forever"
period ->
period
in
case maybeConfig of
Nothing ->
text "Loading server config..."
Just config ->
framePanel "Configuration"
"fa-cog"
[ textEntry "Version" (config.version ++ ", built on " ++ config.buildDate)
, textEntry "SMTP Listener" config.smtpConfig.addr
, textEntry "POP3 Listener" config.pop3Listener
, textEntry "HTTP Listener" config.webListener
, textEntry "Accept Policy" (acceptPolicy config)
, textEntry "Store Policy" (storePolicy config)
, textEntry "Store Type" config.storageConfig.storeType
, textEntry "Message Cap" (mailboxCap config)
, textEntry "Retention Period" (retentionPeriod config)
]
acceptPolicy : ServerConfig -> String
acceptPolicy config =
if config.smtpConfig.defaultAccept then
"All domains"
++ (case config.smtpConfig.rejectDomains of
Nothing ->
""
Just [] ->
""
Just domains ->
", except: " ++ String.join ", " domains
)
else
"No domains"
++ (case config.smtpConfig.acceptDomains of
Nothing ->
""
Just [] ->
""
Just domains ->
", except: " ++ String.join ", " domains
)
storePolicy : ServerConfig -> String
storePolicy config =
if config.smtpConfig.defaultStore then
"All domains"
++ (case config.smtpConfig.discardDomains of
Nothing ->
""
Just [] ->
""
Just domains ->
", except: " ++ String.join ", " domains
)
else
"No domains"
++ (case config.smtpConfig.storeDomains of
Nothing ->
""
Just [] ->
""
Just domains ->
", except: " ++ String.join ", " domains
)
metricPanels : Model -> List (Html Msg)
metricPanels model =
case model.metrics of
Nothing ->
[ text "Loading metrics..." ]
Just metrics ->
[ framePanel "General Metrics"
"fa-tachometer-alt"
[ textEntry "Uptime" <|
"Started "
++ Relative.relativeTime model.now metrics.startTime
, viewMetric model.sysMem
, viewMetric model.heapSize
, viewMetric model.heapUsed
, viewMetric model.heapObjects
, viewMetric model.goRoutines
, viewMetric model.webSockets
]
, framePanel "SMTP Metrics"
"fa-envelope"
[ viewMetric model.smtpConnOpen
, viewMetric model.smtpConnTotal
, viewMetric model.smtpReceivedTotal
, viewMetric model.smtpErrorsTotal
, viewMetric model.smtpWarnsTotal
]
, framePanel "Storage Metrics"
"fa-archive"
[ textEntry "Retention Scan" (retentionScan model)
, viewMetric model.retentionDeletesTotal
, viewMetric model.retainedCount
, viewMetric model.retainedSize
]
]
retentionScan : Model -> String
retentionScan model =
case ( model.config, model.metrics ) of
( Just config, Just metrics ) ->
case config.storageConfig.retentionPeriod of
"" ->
"Disabled"
_ ->
case Time.posixToMillis metrics.scanCompleted of
0 ->
"Not completed"
_ ->
"Completed " ++ Relative.relativeTime model.now metrics.scanCompleted
( _, _ ) ->
"No data"
textEntry : String -> String -> Html Msg
textEntry name value =
div [ class "metric" ]
[ div [ class "label" ] [ text name ]
, div [ class "text-value" ] [ text value ]
]
viewMetric : Metric -> Html Msg
viewMetric metric =
div [ class "metric" ]
[ div [ class "label" ] [ text metric.label ]
, div [ class "value" ] [ text (metric.formatter metric.value) ]
, div [ class "graph" ]
[ metric.graph metric.history
, text (" (" ++ String.fromInt metric.minutes ++ "min)")
]
]
graphSize : Spark.Size
graphSize =
{ width = 180
, height = 16
, marginLR = 0
, marginTB = 0
}
areaStyle : Spark.Param a -> Spark.Param a
areaStyle =
Spark.Style
[ SvgAttrib.fill "rgba(50,100,255,0.3)"
, SvgAttrib.stroke "rgba(50,100,255,1.0)"
, SvgAttrib.strokeWidth "1.0"
]
barStyle : Spark.Param a -> Spark.Param a
barStyle =
Spark.Style
[ SvgAttrib.fill "rgba(50,200,50,0.7)"
]
zeroStyle : Spark.Param a -> Spark.Param a
zeroStyle =
Spark.Style
[ SvgAttrib.stroke "rgba(0,0,0,0.2)"
, SvgAttrib.strokeWidth "1.0"
]
{-| Bar graph to be used with updateRemoteTotal metrics (change instead of absolute values).
-}
graphChange : Spark.DataSet -> Html a
graphChange data =
let
-- Used with Domain to stop sparkline forgetting about zero; continue scrolling graph.
x =
case List.head data of
Nothing ->
0
Just point ->
Tuple.first point
in
Spark.sparkline graphSize
[ Spark.Bar 2.5 data |> barStyle
, Spark.ZeroLine |> zeroStyle
, Spark.Domain [ ( x, 0 ), ( x, 1 ) ]
]
{-| Zero based area graph, for charting absolute values relative to 0.
-}
graphZero : Spark.DataSet -> Html a
graphZero data =
let
-- Used with Domain to stop sparkline forgetting about zero; continue scrolling graph.
x =
case List.head data of
Nothing ->
0
Just point ->
Tuple.first point
in
Spark.sparkline graphSize
[ Spark.Area data |> areaStyle
, Spark.ZeroLine |> zeroStyle
, Spark.Domain [ ( x, 0 ), ( x, 1 ) ]
]
framePanel : String -> String -> List (Html a) -> Html a
framePanel name icon html =
let
fontIcon cn =
i [ class ("fas " ++ cn) ] []
in
div [ class "metric-panel" ]
[ h2 []
[ fontIcon icon
, text " "
, text name
]
, div [ class "metrics" ] html
]
-- UTILS --
{-| Compute difference between each Int in numbers.
-}
changeList : List Int -> List Int
changeList numbers =
let
tail =
List.tail numbers |> Maybe.withDefault []
in
List.map2 (-) tail numbers
{-| Pad the front of a list with 0s to make it at least 60 elements long.
-}
zeroPadList : List Int -> List Int
zeroPadList numbers =
let
needed =
60 - List.length numbers
in
if needed > 0 then
List.repeat needed 0 ++ numbers
else
numbers
{-| Format an Int with thousands separators.
-}
fmtInt : Int -> String
fmtInt n =
let
-- thousands recursively inserts thousands separators.
thousands str =
if String.length str <= 3 then
str
else
thousands (String.slice 0 -3 str) ++ "," ++ String.right 3 str
in
thousands (String.fromInt n)

12
ui/src/Ports.elm Normal file
View File

@@ -0,0 +1,12 @@
port module Ports exposing
( onSessionChange
, storeSession
)
import Json.Encode exposing (Value)
port onSessionChange : (Value -> msg) -> Sub msg
port storeSession : Value -> Cmd msg

117
ui/src/Route.elm Normal file
View File

@@ -0,0 +1,117 @@
module Route exposing (Route(..), Router, newRouter)
import Url exposing (Url)
import Url.Builder as Builder
import Url.Parser as Parser exposing ((</>), Parser, map, oneOf, s, string, top)
type Route
= Unknown String
| Home
| Mailbox String
| Message String String
| Monitor
| Status
type alias Router =
{ fromUrl : Url -> Route
, toPath : Route -> String
}
{-| Returns a configured Router.
-}
newRouter : String -> Router
newRouter basePath =
let
newPath =
prepareBasePath basePath
in
{ fromUrl = fromUrl newPath
, toPath = toPath newPath
}
{-| Routes our application handles.
-}
routes : List (Parser (Route -> a) a)
routes =
[ map Home top
, map Message (s "m" </> string </> string)
, map Mailbox (s "m" </> string)
, map Monitor (s "monitor")
, map Status (s "status")
]
{-| Returns the Route for a given URL.
-}
fromUrl : String -> Url -> Route
fromUrl basePath url =
let
relative =
{ url | path = String.replace basePath "" url.path }
in
case Parser.parse (oneOf routes) relative of
Nothing ->
Unknown url.path
Just route ->
route
{-| Convert route to a URI.
-}
toPath : String -> Route -> String
toPath basePath page =
let
pieces =
case page of
Unknown _ ->
[]
Home ->
[]
Mailbox name ->
[ "m", name ]
Message mailbox id ->
[ "m", mailbox, id ]
Monitor ->
[ "monitor" ]
Status ->
[ "status" ]
in
basePath ++ Builder.absolute pieces []
{-| Make sure basePath starts with a slash and does not have trailing slashes.
"inbucket/" becomes "/inbucket", "" remains ""
-}
prepareBasePath : String -> String
prepareBasePath path =
let
stripSlashes str =
if String.startsWith "/" str then
stripSlashes (String.dropLeft 1 str)
else if String.endsWith "/" str then
stripSlashes (String.dropRight 1 str)
else
str
newPath =
stripSlashes path
in
if newPath == "" then
""
else
"/" ++ newPath

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