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

Compare commits

..

321 Commits

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

* Add QEMU step to build for multi arch

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

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

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

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

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

* Update changelog for main branch rename

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

* Update github actions for branch rename

* Update README build badges

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

* Update README for new branch names

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

* Note branch rename in change log

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

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

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

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

Fixed
- Various MIME header decoding improvements

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

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

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

Fixed:

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

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

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

Fixes #214

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

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

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

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

Changed:

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

* ui: update webpack dependencies

* ui: update elm-webpack-loader

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

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

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

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

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

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

Uses https://github.com/halfzebra/create-elm-app
2018-11-12 20:34:35 -08:00
James Hillyerd
8b5a05eb40 ui: remove old jquery/HTML template based UI 2018-11-12 20:34:27 -08:00
James Hillyerd
60db73b813 test: Correctly shutdown integration test server 2018-11-03 18:53:24 -07:00
James Hillyerd
ef633b906c travis: remove branch master check for deploy 2018-10-31 20:51:33 -07:00
James Hillyerd
62406f05e8 travis: remove branch master check for deploy 2018-10-31 20:39:35 -07:00
James Hillyerd
2e49b591eb Merge tag 'v2.1.0-beta1' into develop
v2.1.0-beta1
2018-10-31 20:08:15 -07:00
James Hillyerd
7d7e408bfa Merge branch 'release/2.1.0-beta1' 2018-10-31 20:07:34 -07:00
James Hillyerd
91fea4e1fd Update CHANGELOG for beta 2018-10-31 20:06:47 -07:00
James Hillyerd
469132fe2f rest: Add godoc example test for client.
- Update README and CHANGELOG
2018-10-31 19:45:15 -07:00
James Hillyerd
690b19a22c rest: Rewrite client tests using httptest server. 2018-10-31 18:44:32 -07:00
James Hillyerd
30e3892cb0 webui, rest: Render UTF-8 addresses correctly, fixes #117 2018-10-22 18:29:03 -07:00
James Hillyerd
fcb4bc20e0 test: Add basic integration test suite, closes #119 2018-10-22 16:25:27 -07:00
James Hillyerd
8a3d2ff6a2 storage: Add test for id='latest', implment in mem store. 2018-10-22 15:43:17 -07:00
James Hillyerd
2f67a6922a ui: Update default greeting.html, closes #106. 2018-10-22 12:28:12 -07:00
James Hillyerd
82e6a9fe5d rest: Use a subrouter for /api/ paths 2018-10-22 10:48:08 -07:00
James Hillyerd
1a7e47b60a rest: Make tests easier to read, less logic. 2018-10-22 09:37:15 -07:00
James Hillyerd
4d17886ed6 Merge branch 'feature/retention-perf' into develop 2018-10-21 21:25:09 -07:00
James Hillyerd
0640f9fa08 file: Use os.Readdirnames to eliminate Lstat calls for #122
- This a speed/syscall optimization, not memory.
2018-10-21 09:25:32 -07:00
James Hillyerd
f68f07d896 file: pool index readers to reduce allocs for #122 2018-10-20 20:39:14 -07:00
James Hillyerd
98745b3bb9 web: Optionally mount /debug/pprof for #120
- web: eliminate use of http.DefaultServeMux
2018-10-20 16:16:09 -07:00
James Hillyerd
5e8f00fe0b Merge branch 'feature/modules-121' into develop 2018-10-20 12:37:43 -07:00
James Hillyerd
f9adced65e docker: Update build to use Go 1.11+modules for #121
- Fix outdated params in docker-run.sh
2018-10-20 12:36:09 -07:00
James Hillyerd
dc007da82e build: Use go modules for #121
- travis: Bump release trigger env to 1.11
2018-10-20 11:58:45 -07:00
James Hillyerd
bf12925fd1 travis: golint & golang updates 2018-10-20 11:18:48 -07:00
James Hillyerd
0d7c94c531 smtp: add missing log message param 2018-10-20 11:13:39 -07:00
kingforaday
00dad88bde Fixing an erroneous connection close introduced in #98. (#101) 2018-05-20 09:51:40 -07:00
James Hillyerd
fdcb29a52b smtp: rename readByteLine to readDataBlock for #98.
Update change log.
2018-05-06 12:09:55 -07:00
kingforaday
894db04d70 Opportunistic TLS Support (#98)
* STARTTLS Support, disabled by default.
* Added documentation
2018-05-06 11:56:38 -07:00
James Hillyerd
58c3e17be7 Merge tag 'v2.0.0' into develop
v2.0.0 release
2018-05-05 10:17:43 -07:00
James Hillyerd
30d8d6c64f Merge branch 'release/2.0.0' 2018-05-05 10:17:19 -07:00
James Hillyerd
37361e08e8 Change log release prep 2018-05-05 10:13:20 -07:00
James Hillyerd
2ceb510f70 Update change log 2018-05-05 10:08:40 -07:00
James Hillyerd
62fa52f42c log: disable color output on windows, closes #93 2018-05-05 09:57:01 -07:00
James Hillyerd
568474da32 travis: back to 1.10.x 2018-05-05 09:36:46 -07:00
evilmrburns
562332258d Fix INBUCKET_STORAGE_PARAMS example (#95) 2018-04-21 10:02:45 -07:00
evilmrburns
941b682197 Fix INBUCKET_STORAGE_PARAMS example (#95) 2018-04-21 10:01:10 -07:00
James Hillyerd
7fc5e06517 travis: install rpm 2018-04-07 20:35:37 -07:00
James Hillyerd
704ba04c51 travis: specify go 1.10.1 2018-04-07 20:35:19 -07:00
James Hillyerd
8a30b9717e Merge tag 'v2.0.0-rc1' into develop
v2.0.0-rc1 see CHANGELOG.md
2018-04-07 19:36:34 -07:00
777 changed files with 9792 additions and 283417 deletions

View File

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

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

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

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

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

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

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

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,24 +0,0 @@
language: go
sudo: false
addons:
apt:
packages:
- rpm
env:
- DEPLOY_WITH_MAJOR="1.10"
before_script:
- go get github.com/golang/lint/golint
- make deps
go:
- "1.10.1"
deploy:
provider: script
script: etc/travis-deploy.sh
on:
tags: true
branch: master

View File

@@ -4,7 +4,156 @@ Change Log
All notable changes to this project will be documented in this file.
This project adheres to [Semantic Versioning](http://semver.org/).
## [2.0.0-rc1] - 2018-04-07
## [Unreleased]
## [v3.0.2] - 2022-07-04
Note: We had to abandon the 3.0.1 release, see the blog post [What happened to
3.0?](https://www.inbucket.org/news/2022/05/whathappenedtothree.html) for
details.
### Changed
- arm Docker builds now rely on amd64 frontend build stage
- Frontend build migrated from npm+webpack to yarn+parcel, node 16
## [v3.0.1-rc2] - 2022-01-23
### Added
- Builds for arm7 and arm64 platforms
### Changed
- Abandoned git-flow process, the `master` branch renamed to `main`
## [v3.0.1-rc1] - 2022-01-17
### Fixed
- GitHub built packages (rpm, deb, tarball) no longer missing UI files (#250)
### Changed
- Update Go dependencies
- Update NPM dependencies
## [v3.0.0] - 2021-09-19
Unchanged from rc4.
## [v3.0.0-rc4] - 2021-08-22
### Fixed
- Various MIME header decoding improvements
### Changed
- Bump Go version to 1.17 (#233)
## v3.0.0-rc3 - 2021-08-01
Unchanaged from 3.0.0-rc2. This release is to update our build automation and
tags for Docker Hub and ghcr.io.
## [v3.0.0-rc2] - 2021-07-31
### Added
- Support for SMTP AUTH (#197, thanks makarchuk)
- Dark mode support (#218, thanks nerones)
### Fixed
- Prevent potential click jacking (#190, thanks stuartskelton)
- Error on 8 character long SMTP commands (#221)
- Allow empty username and password during AUTH (#225)
## [v3.0.0-rc1] - 2020-09-24
### Added
- Refresh button to reload mailbox contents
- Improved keyboard (tab) focus highlights
### Changed
- The UI now includes the Open Sans webfont instead of relying on browser/OS
fonts
## [v3.0.0-beta3] - 2020-09-04
### Added
- Docker `HEALTHCHECK`
- Mouse-out delay to improve pop-up menu navigation
- Support for configurable URL base path with `INBUCKET_WEB_BASEPATH`
### Changed
- Updated frontend and backend dependencies, Docker image base
### Fixed
- Improved layout on mobile and wide displays
- Prevent unexpected input for modal dialogs
- Allow empty SMTP `MAIL FROM:<>`
## [v3.0.0-beta2] - 2019-08-17
### Added
- Ability to name mailboxes after domain of email recipient, set via
`INBUCKET_MAILBOXNAMING`, thanks MatthewJohn.
### Changed
- Updated JavaScript dependencies.
- Updated Go dependencies.
- Updated Docker build: Go to 1.12, and Alpine Linux to 3.10
### Fixed
- URLs to view/download attachments from REST API, #138
- Support for late EHLO, #141
## [v3.0.0-beta1] - 2019-03-14
### Added
- `posix-millis` field to REST message and header responses for easier date
parsing.
### Changed
- Rewrote the user interface from scratch, it's now an Elm powered single page
application.
- Moved the Inbucket repository to its own GitHub organization.
- Update to enmime v0.5.0
## v2.1.0 - 2018-12-15
No change from beta1.
## [v2.1.0-beta1] - 2018-10-31
### Added
- Use Go 1.11 modules for reproducible builds.
- SMTP TLS support (thanks kingforaday.)
- `INBUCKET_WEB_PPROF` configuration option for performance profiling.
- Godoc example for the REST API client.
### Changed
- Docker build now uses Go 1.11 and Alpine 3.8
### Fixed
- Render UTF-8 addresses correctly in both REST API and Web UI.
- Memory storage now correctly returns the newest message when asked for ID
`latest`.
## [v2.0.0] - 2018-05-05
### Changed
- Corrected docs for INBUCKET_STORAGE_PARAMS (thanks evilmrburns.)
- Disabled color log output on Windows, doesn't work there.
## [v2.0.0-rc1] - 2018-04-07
### Added
- Inbucket is now configured using environment variables instead of a config
@@ -73,7 +222,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
- `rest/client` types `MessageHeader` and `Message` with convenience methods;
provides a more natural API
- Powerful command line REST
[client](https://github.com/jhillyerd/inbucket/wiki/cmd-client)
[client](https://github.com/inbucket/inbucket/wiki/cmd-client)
- Allow use of `latest` as a message ID in REST calls
### Changed
@@ -88,9 +237,9 @@ This project adheres to [Semantic Versioning](http://semver.org/).
### Added
- Storage of `To:` header in messages (likely breaks existing datastores)
- Attachment list to [GET message
JSON](https://github.com/jhillyerd/inbucket/wiki/REST-GET-message)
JSON](https://github.com/inbucket/inbucket/wiki/REST-GET-message)
- [Go client for REST
API](https://godoc.org/github.com/jhillyerd/inbucket/rest/client)
API](https://godoc.org/github.com/inbucket/inbucket/rest/client)
- Monitor feature: lists messages as they arrive, regardless of their
destination mailbox
- Make `@inbucket` mailbox prompt configurable
@@ -153,31 +302,45 @@ This project adheres to [Semantic Versioning](http://semver.org/).
- Add Link button to messages, allows for directing another person to a
specific message.
[Unreleased]: https://github.com/jhillyerd/inbucket/compare/master...develop
[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.2...main
[v3.0.2]: https://github.com/inbucket/inbucket/compare/v3.0.1-rc2...v3.0.2
[v3.0.1-rc2]: https://github.com/inbucket/inbucket/compare/v3.0.1-rc1...v3.0.1-rc2
[v3.0.1-rc1]: https://github.com/inbucket/inbucket/compare/v3.0.0...v3.0.1-rc1
[v3.0.0]: https://github.com/inbucket/inbucket/compare/v3.0.0-rc4...v3.0.0
[v3.0.0-rc4]: https://github.com/inbucket/inbucket/compare/v3.0.0-rc2...v3.0.0-rc4
[v3.0.0-rc2]: https://github.com/inbucket/inbucket/compare/v3.0.0-rc1...v3.0.0-rc2
[v3.0.0-rc1]: https://github.com/inbucket/inbucket/compare/v3.0.0-beta3...v3.0.0-rc1
[v3.0.0-beta3]: https://github.com/inbucket/inbucket/compare/v3.0.0-beta2...v3.0.0-beta3
[v3.0.0-beta2]: https://github.com/inbucket/inbucket/compare/v3.0.0-beta1...v3.0.0-beta2
[v3.0.0-beta1]: https://github.com/inbucket/inbucket/compare/v2.1.0...v3.0.0-beta1
[v2.1.0-beta1]: https://github.com/inbucket/inbucket/compare/v2.0.0...v2.1.0-beta1
[v2.0.0]: https://github.com/inbucket/inbucket/compare/v2.0.0-rc1...v2.0.0
[v2.0.0-rc1]: https://github.com/inbucket/inbucket/compare/v1.3.1...v2.0.0-rc1
[v1.3.1]: https://github.com/inbucket/inbucket/compare/v1.3.0...v1.3.1
[v1.3.0]: https://github.com/inbucket/inbucket/compare/v1.2.0...v1.3.0
[v1.2.0]: https://github.com/inbucket/inbucket/compare/1.2.0-rc2...1.2.0
[v1.2.0-rc2]: https://github.com/inbucket/inbucket/compare/1.2.0-rc1...1.2.0-rc2
[v1.2.0-rc1]: https://github.com/inbucket/inbucket/compare/1.1.0...1.2.0-rc1
[v1.1.0]: https://github.com/inbucket/inbucket/compare/1.1.0-rc2...1.1.0
[v1.1.0-rc2]: https://github.com/inbucket/inbucket/compare/1.1.0-rc1...1.1.0-rc2
[v1.1.0-rc1]: https://github.com/inbucket/inbucket/compare/1.0...1.1.0-rc1
[v1.0]: https://github.com/inbucket/inbucket/compare/1.0-rc1...1.0
## Release Checklist
1. Create release branch: `git flow release start 1.x.0`
1. Create a release branch
2. Update CHANGELOG.md:
- Ensure *Unreleased* section is up to date
- Rename *Unreleased* section to release name and date.
- Rename *Unreleased* section to release name and date
- Add new GitHub `/compare` link
- Update previous tag version for *Unreleased*
3. Run tests
4. Test cross-compile: `goreleaser --snapshot`
5. Commit changes and merge release: `git flow release finish`
6. Push tags and wait for https://travis-ci.org/jhillyerd/inbucket build to
complete
4. Update goreleaser, and then test cross-compile: `goreleaser --snapshot`
5. Commit changes and merge release into main, tag `vX.Y.Z`
6. Push tags and wait for
[GitHub actions](https://github.com/inbucket/inbucket/actions) to complete
7. Update `binary_versions` option in `inbucket-site/_config.yml`
See http://keepachangelog.com/ for additional instructions on how to update this file.

View File

@@ -1,11 +1,8 @@
How to Contribute
=================
# How to Contribute
Inbucket encourages third-party patches. It's valuable to know how other
developers are using the product.
**tl;dr:** File pull requests against the `develop` branch, not `master`!
## Getting Started
@@ -17,28 +14,18 @@ to provide validation and/or guidance on your suggested approach.
## Making Changes
Inbucket uses [git-flow] with default options. If you have git-flow installed,
you can run `git flow feature start <topic branch name>`.
Without git-flow, create a topic branch from where you want to base your work:
- This is usually the `develop` branch, example command:
`git checkout origin/develop -b <topic branch name>`
- Only target the `master` branch if the issue is already resolved in
`develop`.
Inbucket follows the regular GitHub pattern. Create a topic branch from where
you want to base your work:
Once you are on your topic branch:
1. Make commits of logical units.
2. Add unit tests to exercise your changes.
3. Run the updated code through `go fmt` and `go vet`.
4. Ensure the code builds and tests with the following commands:
- `go clean ./...`
- `go build ./...`
- `go test ./...`
3. Run `make` to test, vet and confirm your code is formatted correctly.
If you do not have Make installed, please perform these steps manually,
otherwise your PR will not pass our checks.
## Thanks
Thank you for contributing to Inbucket!
[git-flow]: https://github.com/nvie/gitflow

View File

@@ -1,9 +1,19 @@
# Docker build file for Inbucket: https://www.inbucket.org/
# Build
FROM golang:1.10-alpine as builder
RUN apk add --no-cache --virtual .build-deps git make
WORKDIR /go/src/github.com/jhillyerd/inbucket
### 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.17-alpine3.14 as backend
RUN apk add --no-cache --virtual .build-deps g++ git make
WORKDIR /build
COPY . .
ENV CGO_ENABLED 0
RUN make clean deps
@@ -11,14 +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.7
ENV SRC /go/src/github.com/jhillyerd/inbucket
### Run in minimal image
FROM alpine:3.14
RUN apk --no-cache add tzdata
WORKDIR /opt/inbucket
RUN mkdir bin defaults ui
COPY --from=builder $SRC/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
@@ -27,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,16 +1,21 @@
Inbucket
=============================================================================
[![Build Status](https://travis-ci.org/jhillyerd/inbucket.png?branch=master)][Build Status]
![Build Status](https://github.com/inbucket/inbucket/actions/workflows/build-and-test.yml/badge.svg)
![Docker Image](https://github.com/inbucket/inbucket/actions/workflows/docker-build.yml/badge.svg)
# Inbucket
Inbucket is an email testing service; it will accept messages for any email
address and make them available via web, REST and POP3. Once compiled,
Inbucket does not have any external dependencies (HTTP, SMTP, POP3 and storage
are all built in).
address and make them available via web, REST and POP3 interfaces. Once
compiled, Inbucket does not have any external dependencies - HTTP, SMTP, POP3
and storage are all built in.
A Go client for the REST API is available in
`github.com/inbucket/inbucket/pkg/rest/client` - [Go API docs]
Read more at the [Inbucket Website]
![Screenshot](http://www.inbucket.org/images/inbucket-ss1.png "Viewing a message")
## Development Status
Inbucket is currently production quality: it is being used for real work.
@@ -19,48 +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
[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)
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()
@@ -181,7 +193,7 @@ func openLog(level string, logfile string, json bool) (close func(), err error)
}
close = func() {}
var w io.Writer
color := true
color := runtime.GOOS != "windows"
switch logfile {
case "stderr":
w = os.Stderr

View File

@@ -21,17 +21,19 @@ variables it supports:
INBUCKET_SMTP_STOREDOMAINS Domains to store mail for
INBUCKET_SMTP_DISCARDDOMAINS Domains to discard mail for
INBUCKET_SMTP_TIMEOUT 300s Idle network timeout
INBUCKET_SMTP_TLSENABLED false Enable STARTTLS option
INBUCKET_SMTP_TLSPRIVKEY cert.key X509 Private Key file for TLS Support
INBUCKET_SMTP_TLSCERT cert.crt X509 Public Certificate file for TLS Support
INBUCKET_POP3_ADDR 0.0.0.0:1100 POP3 server IP4 host:port
INBUCKET_POP3_DOMAIN inbucket HELLO domain
INBUCKET_POP3_TIMEOUT 600s Idle network timeout
INBUCKET_WEB_ADDR 0.0.0.0:9000 Web server IP4 host:port
INBUCKET_WEB_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
INBUCKET_STORAGE_TYPE memory Storage impl: file or memory
INBUCKET_STORAGE_PARAMS Storage impl parameters, see docs.
INBUCKET_STORAGE_RETENTIONPERIOD 24h Duration to retain messages
@@ -75,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
@@ -202,6 +210,36 @@ to the public internet.
- Default: `300s`
- Values: Duration ending in `s` for seconds, `m` for minutes
### TLS Support Availability
`INBUCKET_SMTP_TLSENABLED`
Enable the STARTTLS option for opportunistic TLS support
- Default: `false`
- Values: `true` or `false`
### TLS Private Key File
`INBUCKET_SMTP_TLSPRIVKEY`
Specify the x509 Private key file to be used for TLS negotiation.
This option is only valid when INBUCKET_SMTP_TLSENABLED is enabled.
- Default: `cert.key`
- Values: filename or path to private key
- Example: `server.privkey`
### TLS Public Certificate File
`INBUCKET_SMTP_TLSCERT`
Specify the x509 Certificate file to be used for TLS negotiation.
This option is only valid when INBUCKET_SMTP_TLSENABLED is enabled.
- Default: `cert.crt`
- Values: filename or path to the certificate key
- Example: `server.crt`
## POP3
@@ -253,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`
@@ -264,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
@@ -277,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`
@@ -344,6 +367,20 @@ them.
- Default: `30`
- Values: Integer greater than or equal to 0
### Performance Profiling & Debug Tools
`INBUCKET_WEB_PPROF`
If true, Go's pprof package will be installed to the `/debug/pprof` URI. This
exposes detailed memory and CPU performance data for debugging Inbucket. If you
enable this option, please make sure it is not exposed to the public internet,
as its use can significantly impact performance.
For example usage, see https://golang.org/pkg/net/http/pprof/
- Default: `false`
- Values: `true` or `false`
## Storage
@@ -373,7 +410,7 @@ Parameters specific to the storage type selected. Formatted as a comma
separated list of key:value pairs.
- Default: None
- Examples: `maxkb=10240` or `path=/tmp/inbucket`
- Examples: `maxkb:10240` or `path:/tmp/inbucket`
#### `file` type parameters

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,7 +1,9 @@
<h1>Welcome to Inbucket</h1>
<p>Inbucket is an email testing service; it will accept email for any email
address and make it available to view without a password.</p>
<p>To view email for a particular address, enter the username portion
<p>To view messages for a particular address, enter the username portion
of the address into the box on the upper right and click <em>View</em>.</p>
<p>This instance of Inbucket is running inside of a <a

View File

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

View File

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

View File

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

View File

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

View File

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

23
go.mod Normal file
View File

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

91
go.sum Normal file
View File

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

View File

@@ -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
@@ -76,6 +79,9 @@ type SMTP struct {
StoreDomains []string `desc:"Domains to store mail for"`
DiscardDomains []string `desc:"Domains to discard mail for"`
Timeout time.Duration `required:"true" default:"300s" desc:"Idle network timeout"`
TLSEnabled bool `default:"false" desc:"Enable STARTTLS option"`
TLSPrivKey string `default:"cert.key" desc:"X509 Private Key file for TLS Support"`
TLSCert string `default:"cert.crt" desc:"X509 Public Certificate file for TLS Support"`
Debug bool `ignored:"true"`
}
@@ -90,13 +96,12 @@ 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"`
}
// Storage contains the mail store configuration.

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: msg.From.String(),
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: msg.From.String(),
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,14 +9,12 @@ 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 (
baseURL = "http://localhost/api/v1"
// JSON map keys
mailboxKey = "mailbox"
idKey = "id"
@@ -37,7 +35,7 @@ func TestRestMailboxList(t *testing.T) {
logbuf := setupWebServer(mm)
// Test invalid mailbox name
w, err := testRestGet(baseURL + "/mailbox/foo%20bar")
w, err := testRestGet("http://localhost/api/v1/mailbox/foo%20bar")
expectCode := 500
if err != nil {
t.Fatal(err)
@@ -47,7 +45,7 @@ func TestRestMailboxList(t *testing.T) {
}
// Test empty mailbox
w, err = testRestGet(baseURL + "/mailbox/empty")
w, err = testRestGet("http://localhost/api/v1/mailbox/empty")
expectCode = 200
if err != nil {
t.Fatal(err)
@@ -57,7 +55,7 @@ func TestRestMailboxList(t *testing.T) {
}
// Test Mailbox error
w, err = testRestGet(baseURL + "/mailbox/messageserr")
w, err = testRestGet("http://localhost/api/v1/mailbox/messageserr")
expectCode = 500
if err != nil {
t.Fatal(err)
@@ -89,7 +87,7 @@ func TestRestMailboxList(t *testing.T) {
mm.AddMessage("good", &message.Message{Metadata: meta2})
// Check return code
w, err = testRestGet(baseURL + "/mailbox/good")
w, err = testRestGet("http://localhost/api/v1/mailbox/good")
expectCode = 200
if err != nil {
t.Fatal(err)
@@ -114,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")
@@ -122,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)
@@ -139,7 +139,7 @@ func TestRestMessage(t *testing.T) {
logbuf := setupWebServer(mm)
// Test invalid mailbox name
w, err := testRestGet(baseURL + "/mailbox/foo%20bar/0001")
w, err := testRestGet("http://localhost/api/v1/mailbox/foo%20bar/0001")
expectCode := 500
if err != nil {
t.Fatal(err)
@@ -149,7 +149,7 @@ func TestRestMessage(t *testing.T) {
}
// Test requesting a message that does not exist
w, err = testRestGet(baseURL + "/mailbox/empty/0001")
w, err = testRestGet("http://localhost/api/v1/mailbox/empty/0001")
expectCode = 404
if err != nil {
t.Fatal(err)
@@ -159,7 +159,7 @@ func TestRestMessage(t *testing.T) {
}
// Test GetMessage error
w, err = testRestGet(baseURL + "/mailbox/messageerr/0001")
w, err = testRestGet("http://localhost/api/v1/mailbox/messageerr/0001")
expectCode = 500
if err != nil {
t.Fatal(err)
@@ -196,12 +196,16 @@ func TestRestMessage(t *testing.T) {
"From": []string{"noreply@inbucket.org"},
},
},
Attachments: []*enmime.Part{{
FileName: "favicon.png",
ContentType: "image/png",
}},
},
)
mm.AddMessage("good", msg1)
// Check return code
w, err = testRestGet(baseURL + "/mailbox/good/0001")
w, err = testRestGet("http://localhost/api/v1/mailbox/good/0001")
expectCode = 200
if err != nil {
t.Fatal(err)
@@ -223,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")
@@ -230,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
@@ -264,7 +273,7 @@ func TestRestMarkSeen(t *testing.T) {
mm.AddMessage("good", &message.Message{Metadata: meta1})
mm.AddMessage("good", &message.Message{Metadata: meta2})
// Mark one read.
w, err := testRestPatch(baseURL+"/mailbox/good/0002", `{"seen":true}`)
w, err := testRestPatch("http://localhost/api/v1/mailbox/good/0002", `{"seen":true}`)
expectCode := 200
if err != nil {
t.Fatal(err)
@@ -273,7 +282,7 @@ func TestRestMarkSeen(t *testing.T) {
t.Fatalf("Expected code %v, got %v", expectCode, w.Code)
}
// Get mailbox.
w, err = testRestGet(baseURL + "/mailbox/good")
w, err = testRestGet("http://localhost/api/v1/mailbox/good")
expectCode = 200
if err != nil {
t.Fatal(err)

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

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

View File

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

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,25 +1,25 @@
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) {
// API v1
r.Path("/api/v1/mailbox/{name}").Handler(
r.Path("/v1/mailbox/{name}").Handler(
web.Handler(MailboxListV1)).Name("MailboxListV1").Methods("GET")
r.Path("/api/v1/mailbox/{name}").Handler(
r.Path("/v1/mailbox/{name}").Handler(
web.Handler(MailboxPurgeV1)).Name("MailboxPurgeV1").Methods("DELETE")
r.Path("/api/v1/mailbox/{name}/{id}").Handler(
r.Path("/v1/mailbox/{name}/{id}").Handler(
web.Handler(MailboxShowV1)).Name("MailboxShowV1").Methods("GET")
r.Path("/api/v1/mailbox/{name}/{id}").Handler(
r.Path("/v1/mailbox/{name}/{id}").Handler(
web.Handler(MailboxMarkSeenV1)).Name("MailboxMarkSeenV1").Methods("PATCH")
r.Path("/api/v1/mailbox/{name}/{id}").Handler(
r.Path("/v1/mailbox/{name}/{id}").Handler(
web.Handler(MailboxDeleteV1)).Name("MailboxDeleteV1").Methods("DELETE")
r.Path("/api/v1/mailbox/{name}/{id}/source").Handler(
r.Path("/v1/mailbox/{name}/{id}/source").Handler(
web.Handler(MailboxSourceV1)).Name("MailboxSourceV1").Methods("GET")
r.Path("/api/v1/monitor/messages").Handler(
r.Path("/v1/monitor/messages").Handler(
web.Handler(MonitorAllMessagesV1)).Name("MonitorAllMessagesV1").Methods("GET")
r.Path("/api/v1/monitor/messages/{name}").Handler(
r.Path("/v1/monitor/messages/{name}").Handler(
web.Handler(MonitorMailboxMessagesV1)).Name("MonitorMailboxMessagesV1").Methods("GET")
}

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) {
@@ -43,15 +43,14 @@ func setupWebServer(mm message.Manager) *bytes.Buffer {
log.SetOutput(buf)
// Have to reset default mux to prevent duplicate routes
http.DefaultServeMux = http.NewServeMux()
cfg := &config.Root{
Web: config.Web{
UIDir: "../ui",
},
}
shutdownChan := make(chan bool)
SetupRoutes(web.Router.PathPrefix("/api/").Subrouter())
web.Initialize(cfg, shutdownChan, mm, &msghub.Hub{})
SetupRoutes(web.Router)
return buf
}
@@ -80,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

@@ -3,15 +3,17 @@ package smtp
import (
"bufio"
"bytes"
"crypto/tls"
"fmt"
"io"
"net"
"net/textproto"
"regexp"
"strconv"
"strings"
"time"
"github.com/jhillyerd/inbucket/pkg/policy"
"github.com/inbucket/inbucket/pkg/policy"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
@@ -23,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 "."
@@ -39,7 +58,7 @@ const (
// accepting '>' as quoted pair and in double quoted strings (?i) makes the regex case insensitive,
// (?:) is non-grouping sub-match
var fromRegex = regexp.MustCompile(
"(?i)^FROM:\\s*<((?:\\\\>|[^>])+|\"[^\"]+\"@[^>]+)>( [\\w= ]+)?$")
"(?i)^FROM:\\s*<((?:(?:\\\\>|[^>])+|\"[^\"]+\"@[^>])+)?>( [\\w= ]+)?$")
func (s State) String() string {
switch s {
@@ -58,21 +77,23 @@ func (s State) String() string {
}
var commands = map[string]bool{
"HELO": true,
"EHLO": true,
"MAIL": true,
"RCPT": true,
"DATA": true,
"RSET": true,
"SEND": true,
"SOML": true,
"SAML": true,
"VRFY": true,
"EXPN": true,
"HELP": true,
"NOOP": true,
"QUIT": true,
"TURN": true,
"HELO": true,
"EHLO": true,
"MAIL": true,
"RCPT": true,
"DATA": true,
"RSET": true,
"SEND": true,
"SOML": true,
"SAML": true,
"VRFY": true,
"EXPN": true,
"HELP": true,
"NOOP": true,
"QUIT": true,
"TURN": true,
"STARTTLS": true,
"AUTH": true,
}
// Session holds the state of an SMTP session
@@ -89,12 +110,15 @@ type Session struct {
recipients []*policy.Recipient // Recipients from RCPT commands.
logger zerolog.Logger // Session specific logger.
debug bool // Print network traffic to stdout.
tlsState *tls.ConnectionState
text *textproto.Conn
}
// NewSession creates a new Session for the given connection
func NewSession(server *Server, id int, conn net.Conn, logger zerolog.Logger) *Session {
reader := bufio.NewReader(conn)
host, _, _ := net.SplitHostPort(conn.RemoteAddr().String())
return &Session{
Server: server,
id: id,
@@ -105,6 +129,7 @@ func NewSession(server *Server, id int, conn net.Conn, logger zerolog.Logger) *S
recipients: make([]*policy.Recipient, 0),
logger: logger,
debug: server.config.Debug,
text: textproto.NewConn(conn),
}
}
@@ -146,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 == "" {
@@ -212,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() {
@@ -232,6 +267,7 @@ func (s *Server) startSession(id int, conn net.Conn) {
// GREET state -> waiting for HELO
func (s *Session) greetHandler(cmd string, arg string) {
const readyBanner = "Great, let's get this show on the road"
switch cmd {
case "HELO":
domain, err := parseHelloArgument(arg)
@@ -240,7 +276,7 @@ func (s *Session) greetHandler(cmd string, arg string) {
return
}
s.remoteDomain = domain
s.send("250 Great, let's get this show on the road")
s.send("250 " + readyBanner)
s.enterState(READY)
case "EHLO":
domain, err := parseHelloArgument(arg)
@@ -249,8 +285,13 @@ func (s *Session) greetHandler(cmd string, arg string) {
return
}
s.remoteDomain = domain
s.send("250-Great, let's get this show on the road")
// Features before SIZE per RFC
s.send("250-" + readyBanner)
s.send("250-8BITMIME")
s.send("250-AUTH PLAIN LOGIN")
if s.Server.config.TLSEnabled && s.Server.tlsConfig != nil && s.tlsState == nil {
s.send("250-STARTTLS")
}
s.send(fmt.Sprintf("250 SIZE %v", s.config.MaxMessageBytes))
s.enterState(READY)
default:
@@ -269,9 +310,72 @@ 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 == "MAIL" {
if cmd == "STARTTLS" {
if !s.Server.config.TLSEnabled {
// Invalid command since TLS unconfigured.
s.logger.Debug().Msgf("454 TLS unavailable on the server")
s.send("454 TLS unavailable on the server")
return
}
if s.tlsState != nil {
// TLS state previously valid.
s.logger.Debug().Msg("454 A TLS session already agreed upon.")
s.send("454 A TLS session already agreed upon.")
return
}
s.logger.Debug().Msg("Initiating TLS context.")
// Start TLS connection handshake.
s.send("220 STARTTLS")
tlsConn := tls.Server(s.conn, s.Server.tlsConfig)
s.conn = tlsConn
s.text = textproto.NewConn(s.conn)
s.tlsState = new(tls.ConnectionState)
*s.tlsState = tlsConn.ConnectionState()
s.enterState(GREET)
} else if cmd == "AUTH" {
args := strings.SplitN(arg, " ", 3)
authMethod := args[0]
switch authMethod {
case "PLAIN":
{
if len(args) != 2 {
s.send("500 Bad auth arguments")
s.logger.Warn().Msgf("Bad auth attempt: %q", arg)
return
}
s.logger.Info().Msgf("Accepting credentials: %q", args[1])
s.send("235 2.7.0 Authentication successful")
return
}
case "LOGIN":
{
s.send(fmt.Sprintf("334 %v", usernameChallenge))
s.enterState(LOGIN)
return
}
default:
{
s.send(fmt.Sprintf("500 Unsupported AUTH method: %v", authMethod))
return
}
}
} else if cmd == "MAIL" {
// Capture group 1: from address. 2: optional params.
m := fromRegex.FindStringSubmatch(arg)
if m == nil {
@@ -280,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] != "" {
@@ -312,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)
}
@@ -360,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)
}
@@ -367,57 +486,44 @@ func (s *Session) mailHandler(cmd string, arg string) {
// DATA
func (s *Session) dataHandler() {
s.send("354 Start mail input; end with <CRLF>.<CRLF>")
msgBuf := &bytes.Buffer{}
for {
lineBuf, err := s.readByteLine()
if err != nil {
if netErr, ok := err.(net.Error); ok {
if netErr.Timeout() {
s.send("221 Idle timeout, bye bye")
}
msgBuf, err := s.readDataBlock()
if err != nil {
if netErr, ok := err.(net.Error); ok {
if netErr.Timeout() {
s.send("221 Idle timeout, bye bye")
}
s.logger.Warn().Msgf("Error: %v while reading", err)
s.enterState(QUIT)
return
}
if bytes.Equal(lineBuf, []byte(".\r\n")) || bytes.Equal(lineBuf, []byte(".\n")) {
// Mail data complete.
tstamp := time.Now().Format(timeStampFormat)
for _, recip := range s.recipients {
if recip.ShouldStore() {
// Generate Received header.
prefix := fmt.Sprintf("Received: from %s ([%s]) by %s\r\n for <%s>; %s\r\n",
s.remoteDomain, s.remoteHost, s.config.Domain, recip.Address.Address,
tstamp)
// Deliver message.
_, err := s.manager.Deliver(
recip, s.from, s.recipients, prefix, msgBuf.Bytes())
if err != nil {
s.logger.Error().Msgf("delivery for %v: %v", recip.LocalPart, err)
s.send(fmt.Sprintf("451 Failed to store message for %v", recip.LocalPart))
s.reset()
return
}
}
expReceivedTotal.Add(1)
}
s.send("250 Mail accepted for delivery")
s.logger.Info().Msgf("Message size %v bytes", msgBuf.Len())
s.reset()
return
}
// RFC: remove leading periods from DATA.
if len(lineBuf) > 0 && lineBuf[0] == '.' {
lineBuf = lineBuf[1:]
}
msgBuf.Write(lineBuf)
if msgBuf.Len() > s.config.MaxMessageBytes {
s.send("552 Maximum message size exceeded")
s.logger.Warn().Msgf("Max message size exceeded while in DATA")
s.reset()
return
}
s.logger.Warn().Msgf("Error: %v while reading", err)
s.enterState(QUIT)
return
}
mailData := bytes.NewBuffer(msgBuf)
// Mail data complete.
tstamp := time.Now().Format(timeStampFormat)
for _, recip := range s.recipients {
if recip.ShouldStore() {
// Generate Received header.
prefix := fmt.Sprintf("Received: from %s ([%s]) by %s\r\n for <%s>; %s\r\n",
s.remoteDomain, s.remoteHost, s.config.Domain, recip.Address.Address,
tstamp)
// Deliver message.
_, err := s.manager.Deliver(
recip, s.from, s.recipients, prefix, mailData.Bytes())
if err != nil {
s.logger.Error().Msgf("delivery for %v: %v", recip.LocalPart, err)
s.send(fmt.Sprintf("451 Failed to store message for %v", recip.LocalPart))
s.reset()
return
}
}
expReceivedTotal.Add(1)
}
s.send("250 Mail accepted for delivery")
s.logger.Info().Msgf("Message size %v bytes", mailData.Len())
s.reset()
return
}
func (s *Session) enterState(state State) {
@@ -440,7 +546,7 @@ func (s *Session) send(msg string) {
s.sendError = err
return
}
if _, err := fmt.Fprint(s.conn, msg+"\r\n"); err != nil {
if err := s.text.PrintfLine("%s", msg); err != nil {
s.sendError = err
s.logger.Warn().Msgf("Failed to send: %q", msg)
return
@@ -450,24 +556,27 @@ func (s *Session) send(msg string) {
}
}
// readByteLine reads a line of input, returns byte slice.
func (s *Session) readByteLine() ([]byte, error) {
// readDataBlock reads message DATA until `.` using the textproto pkg.
func (s *Session) readDataBlock() ([]byte, error) {
if err := s.conn.SetReadDeadline(s.nextDeadline()); err != nil {
return nil, err
}
b, err := s.reader.ReadBytes('\n')
if err == nil && s.debug {
fmt.Printf("%04d %s\n", s.id, bytes.TrimRight(b, "\r\n"))
b, err := s.text.ReadDotBytes()
if err != nil {
return nil, err
}
if s.debug {
fmt.Printf("%04d Received %d bytes\n", s.id, len(b))
}
return b, err
}
// Reads a line of input
// readLine reads a line of input respecting deadlines.
func (s *Session) readLine() (line string, err error) {
if err = s.conn.SetReadDeadline(s.nextDeadline()); err != nil {
return "", err
}
line, err = s.reader.ReadString('\n')
line, err = s.text.ReadLine()
if err != nil {
return "", err
}
@@ -479,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:
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
@@ -513,7 +622,7 @@ func (s *Session) parseArgs(arg string) (args map[string]string, ok bool) {
re := regexp.MustCompile(` (\w+)=(\w+)`)
pm := re.FindAllStringSubmatch(arg, -1)
if pm == nil {
s.logger.Warn().Msgf("Failed to parse arg string: %q")
s.logger.Warn().Msgf("Failed to parse arg string: %q", arg)
return nil, false
}
for _, m := range pm {

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
@@ -130,6 +209,15 @@ func TestReadyState(t *testing.T) {
t.Error(err)
}
// Test Start TLS parsing.
script = []scriptStep{
{"HELO localhost", 250},
{"STARTTLS", 454}, // TLS unconfigured.
}
if err := playSession(t, server, script); err != nil {
t.Error(err)
}
if t.Failed() {
// Wait for handler to finish logging
time.Sleep(2 * time.Second)
@@ -206,6 +294,19 @@ func TestMailState(t *testing.T) {
t.Error(err)
}
// Test late EHLO, similar to RSET
script = []scriptStep{
{"EHLO localhost", 250},
{"EHLO localhost", 250},
{"MAIL FROM:<john@gmail.com>", 250},
{"RCPT TO:<u1@gmail.com>", 250},
{"EHLO localhost", 250},
{"MAIL FROM:<john@gmail.com>", 250},
}
if err := playSession(t, server, script); err != nil {
t.Error(err)
}
// Test RSET
script = []scriptStep{
{"HELO localhost", 250},

View File

@@ -3,15 +3,16 @@ package smtp
import (
"container/list"
"context"
"crypto/tls"
"expvar"
"net"
"sync"
"time"
"github.com/jhillyerd/inbucket/pkg/config"
"github.com/jhillyerd/inbucket/pkg/message"
"github.com/jhillyerd/inbucket/pkg/metric"
"github.com/jhillyerd/inbucket/pkg/policy"
"github.com/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"
)
@@ -63,6 +64,7 @@ type Server struct {
manager message.Manager // Used to deliver messages.
listener net.Listener // Incoming network connections.
wg *sync.WaitGroup // Waitgroup tracks individual sessions.
tlsConfig *tls.Config
}
// NewServer creates a new Server instance with the specificed config.
@@ -72,12 +74,28 @@ func NewServer(
manager message.Manager,
apolicy *policy.Addressing,
) *Server {
slog := log.With().Str("module", "smtp").Str("phase", "tls").Logger()
tlsConfig := &tls.Config{}
if smtpConfig.TLSEnabled {
var err error
tlsConfig.Certificates = make([]tls.Certificate, 1)
tlsConfig.Certificates[0], err = tls.LoadX509KeyPair(smtpConfig.TLSCert, smtpConfig.TLSPrivKey)
if err != nil {
slog.Error().Msgf("Failed loading X509 KeyPair: %v", err)
slog.Error().Msg("Disabling STARTTLS support")
smtpConfig.TLSEnabled = false
} else {
slog.Debug().Msg("STARTTLS feature available")
}
}
return &Server{
config: smtpConfig,
globalShutdown: globalShutdown,
manager: manager,
addrPolicy: apolicy,
wg: new(sync.WaitGroup),
tlsConfig: tlsConfig,
}
}

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,60 +3,20 @@ package web
import (
"fmt"
"html"
"html/template"
"regexp"
"strings"
"time"
"github.com/rs/zerolog/log"
)
// TemplateFuncs declares functions made available to all templates (including partials)
var TemplateFuncs = template.FuncMap{
"friendlyTime": FriendlyTime,
"reverse": Reverse,
"stringsJoin": strings.Join,
"textToHtml": TextToHTML,
}
// 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,29 +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
@@ -38,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
@@ -50,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,
@@ -60,35 +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)
// 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))
}
// Dynamic paths.
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))))
http.Handle("/", Router)
// 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))
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: nil,
Handler: requestLoggingWrapper(Router),
ReadTimeout: 60 * time.Second,
WriteTimeout: 60 * time.Second,
}
@@ -122,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
@@ -138,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

@@ -4,14 +4,14 @@ import (
"bufio"
"fmt"
"io"
"io/ioutil"
"os"
"path/filepath"
"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"
)
@@ -40,10 +40,11 @@ func countGenerator(c chan int) {
// Store implements DataStore aand is the root of the mail storage
// hiearchy. It provides access to Mailbox objects
type Store struct {
hashLock storage.HashLock
path string
mailPath string
messageCap int
hashLock storage.HashLock
path string
mailPath string
messageCap int
bufReaderPool sync.Pool
}
// New creates a new DataStore object using the specified path
@@ -60,7 +61,16 @@ func New(cfg config.Storage) (storage.Store, error) {
Msg("Error creating dir")
}
}
return &Store{path: path, mailPath: mailPath, messageCap: cfg.MailboxMsgCap}, nil
return &Store{
path: path,
mailPath: mailPath,
messageCap: cfg.MailboxMsgCap,
bufReaderPool: sync.Pool{
New: func() interface{} {
return bufio.NewReader(nil)
},
},
}, nil
}
// AddMessage adds a message to the specified mailbox.
@@ -179,41 +189,33 @@ func (fs *Store) PurgeMessages(mailbox string) error {
// VisitMailboxes accepts a function that will be called with the messages in each mailbox while it
// continues to return true.
func (fs *Store) VisitMailboxes(f func([]storage.Message) (cont bool)) error {
infos1, err := ioutil.ReadDir(fs.mailPath)
names1, err := readDirNames(fs.mailPath)
if err != nil {
return err
}
// Loop over level 1 directories
for _, inf1 := range infos1 {
if inf1.IsDir() {
l1 := inf1.Name()
infos2, err := ioutil.ReadDir(filepath.Join(fs.mailPath, l1))
for _, name1 := range names1 {
names2, err := readDirNames(fs.mailPath, name1)
if err != nil {
return err
}
// Loop over level 2 directories
for _, name2 := range names2 {
names3, err := readDirNames(fs.mailPath, name1, name2)
if err != nil {
return err
}
// Loop over level 2 directories
for _, inf2 := range infos2 {
if inf2.IsDir() {
l2 := inf2.Name()
infos3, err := ioutil.ReadDir(filepath.Join(fs.mailPath, l1, l2))
if err != nil {
return err
}
// Loop over mailboxes
for _, inf3 := range infos3 {
if inf3.IsDir() {
mb := fs.mboxFromHash(inf3.Name())
mb.RLock()
msgs, err := mb.getMessages()
mb.RUnlock()
if err != nil {
return err
}
if !f(msgs) {
return nil
}
}
}
// Loop over mailboxes
for _, name3 := range names3 {
mb := fs.mboxFromHash(name3)
mb.RLock()
msgs, err := mb.getMessages()
mb.RUnlock()
if err != nil {
return err
}
if !f(msgs) {
return nil
}
}
}
@@ -253,6 +255,18 @@ func (fs *Store) mboxFromHash(hash string) *mbox {
}
}
// getPooledReader pulls a buffered reader from the fs.bufReaderPool.
func (fs *Store) getPooledReader(r io.Reader) *bufio.Reader {
br := fs.bufReaderPool.Get().(*bufio.Reader)
br.Reset(r)
return br
}
// putPooledReader returns a buffered reader to the fs.bufReaderPool.
func (fs *Store) putPooledReader(br *bufio.Reader) {
fs.bufReaderPool.Put(br)
}
// generatePrefix converts a Time object into the ISO style format we use
// as a prefix for message files. Note: It is used directly by unit
// tests.
@@ -261,7 +275,16 @@ func generatePrefix(date time.Time) string {
}
// generateId adds a 4-digit unique number onto the end of the string
// returned by generatePrefix()
// returned by generatePrefix().
func generateID(date time.Time) string {
return generatePrefix(date) + "-" + fmt.Sprintf("%04d", <-countChannel)
}
// readDirNames returns a slice of filenames in the specified directory or an error.
func readDirNames(elem ...string) ([]string, error) {
f, err := os.Open(filepath.Join(elem...))
if err != nil {
return nil, err
}
return f.Readdirnames(0)
}

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"
)
@@ -120,7 +120,9 @@ func (mb *mbox) readIndex() error {
}
}()
// Decode gob data
dec := gob.NewDecoder(bufio.NewReader(file))
br := mb.store.getPooledReader(file)
defer mb.store.putPooledReader(br)
dec := gob.NewDecoder(br)
name := ""
if err = dec.Decode(&name); err != nil {
return fmt.Errorf("Corrupt mailbox %q: %v", mb.indexPath, err)

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.
@@ -92,6 +92,17 @@ func (s *Store) AddMessage(message storage.Message) (id string, err error) {
// GetMessage gets a mesage.
func (s *Store) GetMessage(mailbox, id string) (m storage.Message, err error) {
if id == "latest" {
ms, err := s.GetMessages(mailbox)
if err != nil {
return nil, err
}
count := len(ms)
if count == 0 {
return nil, nil
}
return ms[count-1], nil
}
s.withMailbox(mailbox, false, func(mb *mbox) {
var ok bool
m, ok = mb.messages[id]

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

@@ -19,13 +19,28 @@ func HashMailboxName(mailbox string) string {
return fmt.Sprintf("%x", h.Sum(nil))
}
// StringAddressList converts a list of addresses to a list of strings
// StringAddress converts an Address to a UTF-8 string.
func StringAddress(a *mail.Address) string {
b := &strings.Builder{}
if a != nil {
if a.Name != "" {
b.WriteString(a.Name)
b.WriteRune(' ')
}
if a.Address != "" {
b.WriteRune('<')
b.WriteString(a.Address)
b.WriteRune('>')
}
}
return b.String()
}
// StringAddressList converts a list of addresses to a list of UTF-8 strings.
func StringAddressList(addrs []*mail.Address) []string {
s := make([]string, len(addrs))
for i, a := range addrs {
if a != nil {
s[i] = a.String()
}
s[i] = StringAddress(a)
}
return s
}
@@ -46,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) {
@@ -17,10 +18,14 @@ func TestHashMailboxName(t *testing.T) {
func TestStringAddressList(t *testing.T) {
input := []*mail.Address{
{Name: "Fred B. Fish", Address: "fred@fish.org"},
{Name: "Fred ß. Fish", Address: "fred@fish.org"},
{Name: "User", Address: "user@domain.org"},
{Address: "a@b.com"},
}
want := []string{`"Fred B. Fish" <fred@fish.org>`, `"User" <user@domain.org>`}
want := []string{
`Fred ß. Fish <fred@fish.org>`,
`User <user@domain.org>`,
`<a@b.com>`}
output := stringutil.StringAddressList(input)
if len(output) != len(want) {
t.Fatalf("Got %v strings, want: %v", len(output), len(want))
@@ -31,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

@@ -0,0 +1,205 @@
package test
import (
"bytes"
"context"
"fmt"
"io/ioutil"
smtpclient "net/smtp"
"os"
"path/filepath"
"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/rs/zerolog"
"github.com/rs/zerolog/log"
)
const (
restBaseURL = "http://127.0.0.1:9000/"
smtpHost = "127.0.0.1:2500"
)
func TestSuite(t *testing.T) {
stopServer, err := startServer()
if err != nil {
t.Fatal(err)
}
defer stopServer()
testCases := []struct {
name string
test func(*testing.T)
}{
{"basic", testBasic},
{"fullname", testFullname},
{"encodedHeader", testEncodedHeader},
}
for _, tc := range testCases {
t.Run(tc.name, tc.test)
}
}
func testBasic(t *testing.T) {
client, err := client.New(restBaseURL)
if err != nil {
t.Fatal(err)
}
from := "fromuser@inbucket.org"
to := []string{"recipient@inbucket.org"}
input := readTestData("basic.txt")
// Send mail.
err = smtpclient.SendMail(smtpHost, nil, from, to, input)
if err != nil {
t.Fatal(err)
}
// Confirm receipt.
msg, err := client.GetMessage("recipient", "latest")
if err != nil {
t.Fatal(err)
}
if msg == nil {
t.Errorf("Got nil message, wanted non-nil message.")
}
// Compare to golden.
got := formatMessage(msg)
goldiff.File(t, got, "testdata", "basic.golden")
}
func testFullname(t *testing.T) {
client, err := client.New(restBaseURL)
if err != nil {
t.Fatal(err)
}
from := "fromuser@inbucket.org"
to := []string{"recipient@inbucket.org"}
input := readTestData("fullname.txt")
// Send mail.
err = smtpclient.SendMail(smtpHost, nil, from, to, input)
if err != nil {
t.Fatal(err)
}
// Confirm receipt.
msg, err := client.GetMessage("recipient", "latest")
if err != nil {
t.Fatal(err)
}
if msg == nil {
t.Errorf("Got nil message, wanted non-nil message.")
}
// Compare to golden.
got := formatMessage(msg)
goldiff.File(t, got, "testdata", "fullname.golden")
}
func testEncodedHeader(t *testing.T) {
client, err := client.New(restBaseURL)
if err != nil {
t.Fatal(err)
}
from := "fromuser@inbucket.org"
to := []string{"recipient@inbucket.org"}
input := readTestData("encodedheader.txt")
// Send mail.
err = smtpclient.SendMail(smtpHost, nil, from, to, input)
if err != nil {
t.Fatal(err)
}
// Confirm receipt.
msg, err := client.GetMessage("recipient", "latest")
if err != nil {
t.Fatal(err)
}
if msg == nil {
t.Errorf("Got nil message, wanted non-nil message.")
}
// Compare to golden.
got := formatMessage(msg)
goldiff.File(t, got, "testdata", "encodedheader.golden")
}
func formatMessage(m *client.Message) []byte {
b := &bytes.Buffer{}
fmt.Fprintf(b, "Mailbox: %v\n", m.Mailbox)
fmt.Fprintf(b, "From: %v\n", m.From)
fmt.Fprintf(b, "To: %v\n", m.To)
fmt.Fprintf(b, "Subject: %v\n", m.Subject)
fmt.Fprintf(b, "Size: %v\n", m.Size)
fmt.Fprintf(b, "\nBODY TEXT:\n%v\n", m.Body.Text)
fmt.Fprintf(b, "\nBODY HTML:\n%v\n", m.Body.HTML)
return b.Bytes()
}
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()
if err != nil {
return nil, err
}
rootCtx, rootCancel := context.WithCancel(context.Background())
shutdownChan := make(chan bool)
store, err := storage.FromConfig(conf.Storage)
if err != nil {
rootCancel()
return nil, err
}
msgHub := msghub.New(rootCtx, conf.Web.MonitorHistory)
addrPolicy := &policy.Addressing{Config: conf}
mmanager := &message.StoreManager{AddrPolicy: addrPolicy, Store: store, Hub: msgHub}
// Start HTTP server.
webui.SetupRoutes(web.Router.PathPrefix("/serve/").Subrouter())
rest.SetupRoutes(web.Router.PathPrefix("/api/").Subrouter())
web.Initialize(conf, shutdownChan, mmanager, msgHub)
go web.Start(rootCtx)
// Start SMTP server.
smtpServer := smtp.NewServer(conf.SMTP, shutdownChan, mmanager, addrPolicy)
go smtpServer.Start(rootCtx)
// TODO Implmement an elegant way to determine server readiness.
time.Sleep(500 * time.Millisecond)
return func() {
// Shut everything down.
close(shutdownChan)
rootCancel()
smtpServer.Drain()
}, nil
}
func readTestData(path ...string) []byte {
// Prefix path with testdata.
p := append([]string{"testdata"}, path...)
f, err := os.Open(filepath.Join(p...))
if err != nil {
panic(err)
}
data, err := ioutil.ReadAll(f)
if err != nil {
panic(err)
}
return data
}

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.
@@ -27,6 +27,7 @@ func StoreSuite(t *testing.T, factory StoreFactory) {
{"metadata", testMetadata, config.Storage{}},
{"content", testContent, config.Storage{}},
{"delivery order", testDeliveryOrder, config.Storage{}},
{"latest", testLatest, config.Storage{}},
{"naming", testNaming, config.Storage{}},
{"size", testSize, config.Storage{}},
{"seen", testSeen, config.Storage{}},
@@ -192,6 +193,29 @@ func testDeliveryOrder(t *testing.T, store storage.Store) {
}
}
// testLatest delivers several messages to the same mailbox, and confirms the id `latest` returns
// the last message sent.
func testLatest(t *testing.T, store storage.Store) {
mailbox := "fred"
subjects := []string{"alpha", "bravo", "charlie", "delta", "echo"}
for _, subj := range subjects {
DeliverToStore(t, store, mailbox, subj, time.Now())
}
// Confirm latest.
latest, err := store.GetMessage(mailbox, "latest")
if err != nil {
t.Fatal(err)
}
if latest == nil {
t.Fatalf("Got nil message, wanted most recent message for %v.", mailbox)
}
got := latest.Subject()
want := "echo"
if got != want {
t.Errorf("Got subject %q, want %q", got, want)
}
}
// testNaming ensures the store does not enforce local part mailbox naming.
func testNaming(t *testing.T, store storage.Store) {
DeliverToStore(t, store, "fred@fish.net", "disk #27", time.Now())
@@ -199,7 +223,7 @@ func testNaming(t *testing.T, store storage.Store) {
GetAndCountMessages(t, store, "fred@fish.net", 1)
}
// testSize verifies message contnet size metadata values.
// testSize verifies message content size metadata values.
func testSize(t *testing.T, store storage.Store) {
mailbox := "fred"
subjects := []string{"a", "br", "much longer than the others"}

12
pkg/test/testdata/basic.golden vendored Normal file
View File

@@ -0,0 +1,12 @@
Mailbox: recipient
From: <fromuser@inbucket.org>
To: [<recipient@inbucket.org>]
Subject: basic subject
Size: 217
BODY TEXT:
Basic message.
BODY HTML:

5
pkg/test/testdata/basic.txt vendored Normal file
View File

@@ -0,0 +1,5 @@
From: fromuser@inbucket.org
To: recipient@inbucket.org
Subject: basic subject
Basic message.

12
pkg/test/testdata/encodedheader.golden vendored Normal file
View File

@@ -0,0 +1,12 @@
Mailbox: recipient
From: X-äéß Y-äéß <fromuser@inbucket.org>
To: [Test of ȇɲʢȯȡɪɴʛ <recipient@inbucket.org>]
Subject: Test of ȇɲʢȯȡɪɴʛ
Size: 351
BODY TEXT:
Basic message.
BODY HTML:

5
pkg/test/testdata/encodedheader.txt vendored Normal file
View File

@@ -0,0 +1,5 @@
From: =?utf-8?q?X-=C3=A4=C3=A9=C3=9F_Y-=C3=A4=C3=A9=C3=9F?= <fromuser@inbucket.org>
To: =?utf-8?B?VGVzdCBvZiDIh8myyqLIr8ihyarJtMqb?= <recipient@inbucket.org>
Subject: =?utf-8?b?VGVzdCBvZiDIh8myyqLIr8ihyarJtMqb?=
Basic message.

12
pkg/test/testdata/fullname.golden vendored Normal file
View File

@@ -0,0 +1,12 @@
Mailbox: recipient
From: From User <fromuser@inbucket.org>
To: [Rec I. Pient <recipient@inbucket.org>]
Subject: basic subject
Size: 246
BODY TEXT:
Basic message.
BODY HTML:

5
pkg/test/testdata/fullname.txt vendored Normal file
View File

@@ -0,0 +1,5 @@
From: From User <fromuser@inbucket.org>
To: "Rec I. Pient" <recipient@inbucket.org>
Subject: basic subject
Basic message.

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,9 +1,11 @@
<h1>Welcome to Inbucket</h1>
<p>Inbucket is an email testing service; it will accept email for any email
address and make it available to view without a password.</p>
<p>To view email for a particular address, enter the username portion
<p>To view messages for a particular address, enter the username portion
of the address into the box on the upper right and click <em>View</em>.</p>
<p>This message can be customized by editing greeting.html. Change the
configuration option <code>greeting.file</code> if you'd like to move it
outside of the Inbucket installation directory.</p>
<p>This message can be customized by editing <code>ui/greeting.html</code>.
Set the <code>INBUCKET_WEB_GREETINGFILE</code> environment variable if you'd
like to move the file outside of the Inbucket installation directory.</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
}

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