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

Compare commits

..

129 Commits

Author SHA1 Message Date
James Hillyerd
0738791ba8 Update CHANGELOG for 3.0.4 2022-10-02 15:53:32 -07:00
James Hillyerd
66831d10c7 backport FROM <> fix from #291 for bug #283 (#296)
* bump nix go to 1.18

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

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

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

* Add QEMU step to build for multi arch

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

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

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

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

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

* Update changelog for main branch rename

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

* Update github actions for branch rename

* Update README build badges

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

* Update README for new branch names

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

* Note branch rename in change log

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

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

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

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

Fixed
- Various MIME header decoding improvements

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

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

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

Fixed:

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

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

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

Fixes #214

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

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

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

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

Changed:

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

* ui: update webpack dependencies

* ui: update elm-webpack-loader

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

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

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

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

View File

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

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

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

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

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

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

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

6
.gitignore vendored
View File

@@ -21,9 +21,11 @@ _testmain.go
*.exe
# vim swp files
# vim files
*.swp
*.swo
tags
tags.*
# Desktop Services Store on macOS
.DS_Store
@@ -41,6 +43,7 @@ _testmain.go
# Elm UI
# elm-package generated files
/ui/index.html
/ui/elm-stuff
/ui/tests/elm-stuff
# elm-repl generated files
@@ -49,3 +52,4 @@ repl-temp-*
/ui/dist/
# Dependency directories
/ui/node_modules
/ui/.parcel-cache

View File

@@ -6,12 +6,6 @@ release:
name: inbucket
name_template: '{{.Tag}}'
brews:
- commit_author:
name: goreleaserbot
email: goreleaser@carlosbecker.com
install: bin.install ""
before:
hooks:
- go mod download
@@ -28,8 +22,10 @@ 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}}
- id: inbucket-client
@@ -43,8 +39,10 @@ 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}}
@@ -61,8 +59,8 @@ archives:
- LICENSE*
- README*
- CHANGELOG*
- etc/**/*
- ui/dist/**/*
- etc/**
- ui/dist/**
- ui/greeting.html
nfpms:
@@ -74,11 +72,15 @@ nfpms:
maintainer: github@hillyerd.com
description: All-in-one disposable webmail service.
license: MIT
files:
"ui/dist/**/*": "/usr/local/share/inbucket/ui"
config_files:
"etc/linux/inbucket.service": "/lib/systemd/system/inbucket.service"
"ui/greeting.html": "/etc/inbucket/greeting.html"
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 }}

View File

@@ -1,43 +0,0 @@
sudo: false
env:
global:
- GO111MODULE=on
language: go
install:
- "go get golang.org/x/lint/golint"
- "make deps"
jobs:
include:
- go: "1.11.x"
- go: "master"
- language: elm
elm: "0.19.0"
install:
- "cd ui"
- "npm ci"
script:
- "elm-format --validate ."
- "npm run build"
- stage: deploy
go: "1.11.x"
before_install:
- "nvm install 10.13.0"
install:
- "cd ui"
- "npm ci"
- "npm run build"
- "cd .."
script: "curl -sL https://git.io/goreleaser | bash"
addons:
apt:
packages:
- rpm
stages:
- test
- name: deploy
if: tag IS present

View File

@@ -4,7 +4,110 @@ Change Log
All notable changes to this project will be documented in this file.
This project adheres to [Semantic Versioning](http://semver.org/).
## [v3.0.0-beta2]
## [Unreleased]
## [v3.0.4] - 2022-10-02
### Fixed
- More flexible support of `AUTH=<>` FROM parameter (#291)
## [v3.0.3] - 2022-08-07
### Fixed
- Support for `AUTH=<>` FROM parameter (#284)
## [v3.0.2] - 2022-07-04
Note: We had to abandon the 3.0.1 release, see the blog post [What happened to
3.0?](https://www.inbucket.org/news/2022/05/whathappenedtothree.html) for
details.
### Changed
- arm Docker builds now rely on amd64 frontend build stage
- Frontend build migrated from npm+webpack to yarn+parcel, node 16
## [v3.0.1-rc2] - 2022-01-23
### Added
- Builds for arm7 and arm64 platforms
### Changed
- Abandoned git-flow process, the `master` branch renamed to `main`
## [v3.0.1-rc1] - 2022-01-17
### Fixed
- GitHub built packages (rpm, deb, tarball) no longer missing UI files (#250)
### Changed
- Update Go dependencies
- Update NPM dependencies
## [v3.0.0] - 2021-09-19
Unchanged from rc4.
## [v3.0.0-rc4] - 2021-08-22
### Fixed
- Various MIME header decoding improvements
### Changed
- Bump Go version to 1.17 (#233)
## v3.0.0-rc3 - 2021-08-01
Unchanaged from 3.0.0-rc2. This release is to update our build automation and
tags for Docker Hub and ghcr.io.
## [v3.0.0-rc2] - 2021-07-31
### Added
- Support for SMTP AUTH (#197, thanks makarchuk)
- Dark mode support (#218, thanks nerones)
### Fixed
- Prevent potential click jacking (#190, thanks stuartskelton)
- Error on 8 character long SMTP commands (#221)
- Allow empty username and password during AUTH (#225)
## [v3.0.0-rc1] - 2020-09-24
### Added
- Refresh button to reload mailbox contents
- Improved keyboard (tab) focus highlights
### Changed
- The UI now includes the Open Sans webfont instead of relying on browser/OS
fonts
## [v3.0.0-beta3] - 2020-09-04
### Added
- Docker `HEALTHCHECK`
- Mouse-out delay to improve pop-up menu navigation
- Support for configurable URL base path with `INBUCKET_WEB_BASEPATH`
### Changed
- Updated frontend and backend dependencies, Docker image base
### Fixed
- Improved layout on mobile and wide displays
- Prevent unexpected input for modal dialogs
- Allow empty SMTP `MAIL FROM:<>`
## [v3.0.0-beta2] - 2019-08-17
### Added
- Ability to name mailboxes after domain of email recipient, set via
@@ -20,7 +123,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
- Support for late EHLO, #141
## [v3.0.0-beta1]
## [v3.0.0-beta1] - 2019-03-14
### Added
- `posix-millis` field to REST message and header responses for easier date
@@ -33,12 +136,12 @@ This project adheres to [Semantic Versioning](http://semver.org/).
- Update to enmime v0.5.0
## v2.1.0
## v2.1.0 - 2018-12-15
No change from beta1.
## [v2.1.0-beta1]
## [v2.1.0-beta1] - 2018-10-31
### Added
- Use Go 1.11 modules for reproducible builds.
@@ -211,35 +314,47 @@ No change from beta1.
- Add Link button to messages, allows for directing another person to a
specific message.
[Unreleased]: https://github.com/inbucket/inbucket/compare/master...develop
[Unreleased]: https://github.com/inbucket/inbucket/compare/v3.0.4...main
[v3.0.4]: https://github.com/inbucket/inbucket/compare/v3.0.3...v3.0.4
[v3.0.3]: https://github.com/inbucket/inbucket/compare/v3.0.2...v3.0.3
[v3.0.2]: https://github.com/inbucket/inbucket/compare/v3.0.1-rc2...v3.0.2
[v3.0.1-rc2]: https://github.com/inbucket/inbucket/compare/v3.0.1-rc1...v3.0.1-rc2
[v3.0.1-rc1]: https://github.com/inbucket/inbucket/compare/v3.0.0...v3.0.1-rc1
[v3.0.0]: https://github.com/inbucket/inbucket/compare/v3.0.0-rc4...v3.0.0
[v3.0.0-rc4]: https://github.com/inbucket/inbucket/compare/v3.0.0-rc2...v3.0.0-rc4
[v3.0.0-rc2]: https://github.com/inbucket/inbucket/compare/v3.0.0-rc1...v3.0.0-rc2
[v3.0.0-rc1]: https://github.com/inbucket/inbucket/compare/v3.0.0-beta3...v3.0.0-rc1
[v3.0.0-beta3]: https://github.com/inbucket/inbucket/compare/v3.0.0-beta2...v3.0.0-beta3
[v3.0.0-beta2]: https://github.com/inbucket/inbucket/compare/v3.0.0-beta1...v3.0.0-beta2
[v3.0.0-beta1]: https://github.com/inbucket/inbucket/compare/v2.1.0...v3.0.0-beta1
[v2.1.0-beta1]: https://github.com/inbucket/inbucket/compare/v2.0.0...v2.1.0-beta1
[v2.0.0]: https://github.com/inbucket/inbucket/compare/v2.0.0-rc1...v2.0.0
[v2.0.0-rc1]: https://github.com/inbucket/inbucket/compare/v1.3.1...v2.0.0-rc1
[v1.3.1]: https://github.com/inbucket/inbucket/compare/v1.3.0...v1.3.1
[v1.3.0]: https://github.com/inbucket/inbucket/compare/v1.2.0...v1.3.0
[v1.2.0]: https://github.com/inbucket/inbucket/compare/1.2.0-rc2...1.2.0
[v1.2.0-rc2]: https://github.com/inbucket/inbucket/compare/1.2.0-rc1...1.2.0-rc2
[v1.2.0-rc1]: https://github.com/inbucket/inbucket/compare/1.1.0...1.2.0-rc1
[v1.1.0]: https://github.com/inbucket/inbucket/compare/1.1.0-rc2...1.1.0
[v1.1.0-rc2]: https://github.com/inbucket/inbucket/compare/1.1.0-rc1...1.1.0-rc2
[v1.1.0-rc1]: https://github.com/inbucket/inbucket/compare/1.0...1.1.0-rc1
[v1.0]: https://github.com/inbucket/inbucket/compare/1.0-rc1...1.0
[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. Update goreleaser, and then 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/inbucket/inbucket build to
complete
5. Commit changes and merge release into main, tag `vX.Y.Z`
6. Push tags and wait for
[GitHub actions](https://github.com/inbucket/inbucket/actions) to complete
7. Update `binary_versions` option in `inbucket-site/_config.yml`
See http://keepachangelog.com/ for additional instructions on how to update this file.

View File

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

View File

@@ -1,8 +1,18 @@
# Docker build file for Inbucket: https://www.inbucket.org/
# Build
FROM golang:1.12-alpine3.10 as builder
RUN apk add --no-cache --virtual .build-deps git make npm
### Build frontend
# Due to no official elm compiler for arm; build frontend with amd64.
FROM --platform=linux/amd64 node:16 as frontend
WORKDIR /build
COPY . .
WORKDIR /build/ui
RUN rm -rf .parcel-cache dist elm-stuff node_modules
RUN yarn install --frozen-lockfile --non-interactive
RUN yarn run build
### Build backend
FROM golang:1.18-alpine3.16 as backend
RUN apk add --no-cache --virtual .build-deps g++ git make
WORKDIR /build
COPY . .
ENV CGO_ENABLED 0
@@ -10,17 +20,14 @@ RUN make clean deps
RUN go build -o inbucket \
-ldflags "-X 'main.version=$(git describe --tags --always)' -X 'main.date=$(date -Iseconds)'" \
-v ./cmd/inbucket
WORKDIR /build/ui
RUN rm -rf dist elm-stuff node_modules
RUN npm i
RUN npm run build
# Run in minimal image
FROM alpine:3.10
### Run in minimal image
FROM alpine:3.16
RUN apk --no-cache add tzdata
WORKDIR /opt/inbucket
RUN mkdir bin defaults ui
COPY --from=builder /build/inbucket bin
COPY --from=builder /build/ui/dist ui
COPY --from=backend /build/inbucket bin
COPY --from=frontend /build/ui/dist ui
COPY etc/docker/defaults/greeting.html defaults
COPY etc/docker/defaults/start-inbucket.sh /
@@ -36,6 +43,9 @@ 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,11 +1,12 @@
Inbucket
=============================================================================
[![Build Status](https://travis-ci.org/inbucket/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]
@@ -14,6 +15,7 @@ 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.
@@ -24,18 +26,9 @@ to contribute code to the project check out [CONTRIBUTING.md].
## Docker
Inbucket has automated [Docker Image] builds via Docker Hub. The `stable` tag
tracks our `master` branch (releases), `latest` tracks our unstable
`development` branch.
## Homebrew Tap
(currently broken, being tracked in [issue
#68](https://github.com/inbucket/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
@@ -45,17 +38,20 @@ You will need functioning [Go] and [Node.js] installations for this to work.
```sh
git clone https://github.com/inbucket/inbucket.git
cd inbucket/ui
npm i
npm run build
yarn install
yarn build
cd ..
go build ./cmd/inbucket
```
_Note:_ You may also use the included Makefile to build and test the Go binaries.
For more information on building and development flows, check out the
[Development Quickstart] page of our wiki.
### Configure and Launch
Inbucket reads its configuration from environment variables, but comes with
built in sane defaults. It should work on most Unix and OS X machines as is.
Launch the daemon:
reasonable defaults built-in. It should work on most Unix and OS X machines as
is. Launch the daemon:
```sh
./inbucket
@@ -65,27 +61,29 @@ 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/).
See doc/[config.md] for more information on configuring Inbucket, but you will
likely find the [Configurator] tool easier to use.
likely find the [Configurator] tool the easiest way to generate a configuration.
## About
Inbucket is written in [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/inbucket/inbucket
[Build Status]: https://travis-ci.org/inbucket/inbucket
[Change Log]: https://github.com/inbucket/inbucket/blob/master/CHANGELOG.md
[config.md]: https://github.com/inbucket/inbucket/blob/master/doc/config.md
[Configurator]: https://www.inbucket.org/configurator/
[CONTRIBUTING.md]: https://github.com/inbucket/inbucket/blob/develop/CONTRIBUTING.md
[Docker Image]: https://www.inbucket.org/binaries/docker.html
[From Source]: https://www.inbucket.org/installation/from-source.html
[Go]: https://golang.org/
[Go API docs]: https://godoc.org/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/
[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

@@ -25,6 +25,7 @@ import (
"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"
@@ -71,6 +72,7 @@ func main() {
config.Usage()
return
}
// Process configuration.
config.Version = version
config.BuildDate = date
@@ -83,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 {
@@ -90,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)
@@ -107,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)
@@ -118,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.
webui.SetupRoutes(web.Router.PathPrefix("/serve/").Subrouter())
rest.SetupRoutes(web.Router.PathPrefix("/api/").Subrouter())
// Configure routes and start HTTP server.
prefix := stringutil.MakePathPrefixer(conf.Web.BasePath)
webui.SetupRoutes(web.Router.PathPrefix(prefix("/serve/")).Subrouter())
rest.SetupRoutes(web.Router.PathPrefix(prefix("/api/")).Subrouter())
web.Initialize(conf, shutdownChan, mmanager, msgHub)
go web.Start(rootCtx)
// Start POP3 server.
pop3Server := pop3.New(conf.POP3, shutdownChan, store)
go pop3Server.Start(rootCtx)
// Start SMTP server.
smtpServer := smtp.NewServer(conf.SMTP, shutdownChan, mmanager, addrPolicy)
go smtpServer.Start(rootCtx)
// Loop forever waiting for signals or shutdown channel.
signalLoop:
for {
@@ -154,6 +167,7 @@ signalLoop:
break signalLoop
}
}
// Wait for active connections to finish.
go timedExit(*pidfile)
smtpServer.Drain()

View File

@@ -28,6 +28,7 @@ variables it supports:
INBUCKET_POP3_DOMAIN inbucket HELLO domain
INBUCKET_POP3_TIMEOUT 600s Idle network timeout
INBUCKET_WEB_ADDR 0.0.0.0:9000 Web server IP4 host:port
INBUCKET_WEB_BASEPATH Base path prefix for UI and API URLs
INBUCKET_WEB_UIDIR ui/dist User interface dir
INBUCKET_WEB_GREETINGFILE ui/greeting.html Home page greeting HTML
INBUCKET_WEB_MONITORVISIBLE true Show monitor tab in UI?
@@ -231,7 +232,7 @@ This option is only valid when INBUCKET_SMTP_TLSENABLED is enabled.
### TLS Public Certificate File
`INBUCKET_SMTP_TLSPRIVKEY`
`INBUCKET_SMTP_TLSCERT`
Specify the x509 Certificate file to be used for TLS negotiation.
This option is only valid when INBUCKET_SMTP_TLSENABLED is enabled.
@@ -290,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`

View File

@@ -13,6 +13,7 @@ export INBUCKET_WEB_TEMPLATECACHE="false"
export INBUCKET_WEB_COOKIEAUTHKEY="not-secret"
export INBUCKET_WEB_UIDIR="ui/dist"
#export INBUCKET_WEB_MONITORVISIBLE="false"
#export INBUCKET_WEB_BASEPATH="prefix"
export INBUCKET_STORAGE_TYPE="file"
export INBUCKET_STORAGE_PARAMS="path:/tmp/inbucket"
export INBUCKET_STORAGE_RETENTIONPERIOD="3h"

View File

@@ -3,7 +3,7 @@
# description: Launch Inbucket's docker image
# Docker Image Tag
IMAGE="jhillyerd/inbucket"
IMAGE="inbucket/inbucket"
# Ports exposed on host:
PORT_HTTP=9000

View File

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

28
go.mod
View File

@@ -2,20 +2,22 @@ module github.com/inbucket/inbucket
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/go-test/deep v1.0.2 // indirect
github.com/google/subcommands v1.0.1
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.7.3
github.com/gorilla/websocket v1.4.0
github.com/jaytaylor/html2text v0.0.0-20190408195923-01ec452cbe43 // indirect
github.com/jhillyerd/enmime v0.6.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.4 // indirect
github.com/microcosm-cc/bluemonday v1.0.2
github.com/olekukonko/tablewriter v0.0.1 // indirect
github.com/rs/zerolog v1.15.0
github.com/stretchr/testify v1.3.0
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80
golang.org/x/text v0.3.2 // indirect
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

121
go.sum
View File

@@ -1,68 +1,91 @@
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
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.1 h1:UQhStjbkDClarlmv0am7OXXO4/GaPdCGiUiMTvi28sg=
github.com/go-test/deep v1.0.1/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
github.com/go-test/deep v1.0.2 h1:onZX1rnHT3Wv6cqNgYyFOOlgVKJrksuCMCRvJStbMYw=
github.com/go-test/deep v1.0.2/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
github.com/gogs/chardet v0.0.0-20150115103509-2404f7772561 h1:aBzukfDxQlCTVS0NBUjI5YA3iVeaZ9Tb5PxNrrIP1xs=
github.com/gogs/chardet v0.0.0-20150115103509-2404f7772561/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
github.com/google/subcommands v1.0.1 h1:/eqq+otEXm5vhfBrbREPCSVQbvofip6kIz+mX5TUH7k=
github.com/google/subcommands v1.0.1/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
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.7.3 h1:gnP5JzjVOuiZD07fKKToCAOjS0yOpj/qPETTXCCS6hw=
github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/websocket v1.4.0 h1:WDFjx/TMzVgy9VdMMQi2K2Emtwi2QcUQsztZ/zLaH/Q=
github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
github.com/jaytaylor/html2text v0.0.0-20180606194806-57d518f124b0 h1:xqgexXAGQgY3HAjNPSaCqn5Aahbo5TKsmhp8VRfr1iQ=
github.com/jaytaylor/html2text v0.0.0-20180606194806-57d518f124b0/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
github.com/jaytaylor/html2text v0.0.0-20190408195923-01ec452cbe43 h1:jTkyeF7NZ5oIr0ESmcrpiDgAfoidCBF4F5kJhjtaRwE=
github.com/jaytaylor/html2text v0.0.0-20190408195923-01ec452cbe43/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
github.com/jhillyerd/enmime v0.6.0 h1:FeypffI/uD1xt+Csd7gfD7mYx1h+qjgGlcI/ko5+LsI=
github.com/jhillyerd/enmime v0.6.0/go.mod h1:lwWyVhHVBdmzXx3wtRTmpIdNEJyZ85LJuVqZHVK/Rlo=
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.3 h1:a+kO+98RDGEfo6asOGMmpodZq4FNtnGP54yps8BzLR4=
github.com/mattn/go-runewidth v0.0.3/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
github.com/mattn/go-runewidth v0.0.4 h1:2BvfKmzob6Bmd4YsL0zygOqfdFnK7GR4QL06Do4/p7Y=
github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
github.com/microcosm-cc/bluemonday v1.0.2 h1:5lPfLTTAvAbtS0VqT+94yOtFnGfUWYyx0+iToC3Os3s=
github.com/microcosm-cc/bluemonday v1.0.2/go.mod h1:iVP4YcDBq+n/5fb23BhYFvIMq/leAFZyRl6bYmGDlGc=
github.com/olekukonko/tablewriter v0.0.0-20180912035003-be2c049b30cc h1:rQ1O4ZLYR2xXHXgBCCfIIGnuZ0lidMQw2S5n1oOv+Wg=
github.com/olekukonko/tablewriter v0.0.0-20180912035003-be2c049b30cc/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo=
github.com/olekukonko/tablewriter v0.0.1 h1:b3iUnf1v+ppJiOfNX4yxxqfWKMQPZR5yoh8urCTFX88=
github.com/olekukonko/tablewriter v0.0.1/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo=
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/microcosm-cc/bluemonday v1.0.17 h1:Z1a//hgsQ4yjC+8zEkV8IWySkXnsxmdSY642CTFQb5Y=
github.com/microcosm-cc/bluemonday v1.0.17/go.mod h1:Z0r70sCuXHig8YpBzCc5eGHAap2K7e/u082ZUpDRRqM=
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
github.com/rs/zerolog v1.15.0 h1:uPRuwkWF4J6fGsJ2R0Gn2jB1EQiav9k3S6CSdygQJXY=
github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc=
github.com/saintfish/chardet v0.0.0-20120816061221-3af4cd4741ca h1:NugYot0LIVPxTvN8n+Kvkn6TrbMyxQiuvKdEwFdR9vI=
github.com/saintfish/chardet v0.0.0-20120816061221-3af4cd4741ca/go.mod h1:uugorj2VCxiV1x+LzaIdVa9b4S4qGAcH6cbhh4qVxOU=
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.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
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/net v0.0.0-20181017193950-04a2e542c03f h1:4pRM7zYwpBjCnfA1jRmhItLxYJkaEnsmuAcRtA347DA=
golang.org/x/net v0.0.0-20181017193950-04a2e542c03f/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80 h1:Ao/3l156eZf2AW5wK8a7/smtodRU+gha3+BeqJ69lRk=
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
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/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
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.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
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-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
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

@@ -96,6 +96,7 @@ 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"`
BasePath string `default:"" desc:"Base path prefix for UI and API URLs"`
UIDir string `required:"true" default:"ui/dist" desc:"User interface dir"`
GreetingFile string `required:"true" default:"ui/greeting.html" desc:"Home page greeting HTML"`
MonitorVisible bool `required:"true" default:"true" desc:"Show monitor tab in UI?"`

View File

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

@@ -25,10 +25,27 @@ const (
// timeStampFormat to use in Received header.
timeStampFormat = "Mon, 02 Jan 2006 15:04:05 -0700 (MST)"
// Messages sent to user during LOGIN auth procedure. Can vary, but values are taken directly
// from spec https://tools.ietf.org/html/draft-murchison-sasl-login-00
// usernameChallenge sent when inviting user to provide username. Is base64 encoded string
// `User Name`
usernameChallenge = "VXNlciBOYW1lAA=="
// passwordChallenge sent when inviting user to provide password. Is base64 encoded string
// `Password`
passwordChallenge = "UGFzc3dvcmQA"
)
const (
// GREET State: Waiting for HELO
GREET State = iota
// READY State: Got HELO, waiting for MAIL
READY
// LOGIN State: Got AUTH LOGIN command, expecting Username
LOGIN
// PASSWORD State: Got Username, expecting password
PASSWORD
// MAIL State: Got MAIL, accepting RCPTs
MAIL
// DATA State: Got DATA, waiting for "."
@@ -37,11 +54,11 @@ const (
QUIT
)
// fromRegex captures the from address and optional BODY=8BITMIME clause. Matches FROM, while
// accepting '>' as quoted pair and in double quoted strings (?i) makes the regex case insensitive,
// (?:) is non-grouping sub-match
// fromRegex captures the from address and optional parameters. Matches FROM, while accepting '>'
// as quoted pair and in double quoted strings (?i) makes the regex case insensitive, (?:) is
// non-grouping sub-match. Accepts empty angle bracket value in options for 'AUTH=<>'.
var fromRegex = regexp.MustCompile(
"(?i)^FROM:\\s*<((?:\\\\>|[^>])+|\"[^\"]+\"@[^>]+)>( [\\w= ]+)?$")
`(?i)^FROM:\s*<((?:(?:\\>|[^>])+|"[^"]+"@[^>])+)?>( ([\w= ]|=<>)+)?$`)
func (s State) String() string {
switch s {
@@ -76,6 +93,7 @@ var commands = map[string]bool{
"QUIT": true,
"TURN": true,
"STARTTLS": true,
"AUTH": true,
}
// Session holds the state of an SMTP session
@@ -153,6 +171,16 @@ func (s *Server) startSession(id int, conn net.Conn) {
}
line, err := ssn.readLine()
if err == nil {
//Handle LOGIN/PASSWORD states here, because they don't expect a command
switch ssn.state {
case LOGIN:
ssn.loginHandler(line)
continue
case PASSWORD:
ssn.passwordHandler(line)
continue
}
if cmd, arg, ok := ssn.parseCmd(line); ok {
// Check against valid SMTP commands
if cmd == "" {
@@ -219,7 +247,7 @@ func (s *Server) startSession(id int, conn net.Conn) {
}
break
}
// not an EOF
// Not an EOF
ssn.logger.Warn().Msgf("Connection error: %v", err)
if netErr, ok := err.(net.Error); ok {
if netErr.Timeout() {
@@ -257,9 +285,10 @@ func (s *Session) greetHandler(cmd string, arg string) {
return
}
s.remoteDomain = domain
// features before SIZE per RFC
// Features before SIZE per RFC
s.send("250-" + readyBanner)
s.send("250-8BITMIME")
s.send("250-AUTH PLAIN LOGIN")
if s.Server.config.TLSEnabled && s.Server.tlsConfig != nil && s.tlsState == nil {
s.send("250-STARTTLS")
}
@@ -281,30 +310,71 @@ func parseHelloArgument(arg string) (string, error) {
return domain, nil
}
func (s *Session) loginHandler(line string) {
// Content and length of username is ignored.
s.send(fmt.Sprintf("334 %v", passwordChallenge))
s.enterState(PASSWORD)
}
func (s *Session) passwordHandler(line string) {
// Content and length of password is ignored.
s.send("235 Authentication successful")
s.enterState(READY)
}
// READY state -> waiting for MAIL
// AUTH can change
func (s *Session) readyHandler(cmd string, arg string) {
if cmd == "STARTTLS" {
if !s.Server.config.TLSEnabled {
// invalid command since unconfigured
// Invalid command since TLS unconfigured.
s.logger.Debug().Msgf("454 TLS unavailable on the server")
s.send("454 TLS unavailable on the server")
return
}
if s.tlsState != nil {
// tls state previously valid
// TLS state previously valid.
s.logger.Debug().Msg("454 A TLS session already agreed upon.")
s.send("454 A TLS session already agreed upon.")
return
}
s.logger.Debug().Msg("Initiating TLS context.")
// Start TLS connection handshake.
s.send("220 STARTTLS")
// start tls connection handshake
tlsConn := tls.Server(s.conn, s.Server.tlsConfig)
s.conn = tlsConn
s.text = textproto.NewConn(s.conn)
s.tlsState = new(tls.ConnectionState)
*s.tlsState = tlsConn.ConnectionState()
s.enterState(GREET)
} else if cmd == "AUTH" {
args := strings.SplitN(arg, " ", 3)
authMethod := args[0]
switch authMethod {
case "PLAIN":
{
if len(args) != 2 {
s.send("500 Bad auth arguments")
s.logger.Warn().Msgf("Bad auth attempt: %q", arg)
return
}
s.logger.Info().Msgf("Accepting credentials: %q", args[1])
s.send("235 2.7.0 Authentication successful")
return
}
case "LOGIN":
{
s.send(fmt.Sprintf("334 %v", usernameChallenge))
s.enterState(LOGIN)
return
}
default:
{
s.send(fmt.Sprintf("500 Unsupported AUTH method: %v", authMethod))
return
}
}
} else if cmd == "MAIL" {
// Capture group 1: from address. 2: optional params.
m := fromRegex.FindStringSubmatch(arg)
@@ -314,11 +384,15 @@ func (s *Session) readyHandler(cmd string, arg string) {
return
}
from := m[1]
if _, _, err := policy.ParseEmailAddress(from); err != nil {
if _, _, err := policy.ParseEmailAddress(from); from != "" && err != nil {
s.send("501 Bad sender address syntax")
s.logger.Warn().Msgf("Bad address as MAIL arg: %q, %s", from, err)
return
}
if from == "" {
from = "unspecified"
}
// This is where the client may put BODY=8BITMIME, but we already
// read the DATA as bytes, so it does not effect our processing.
if m[2] != "" {
@@ -433,6 +507,7 @@ func (s *Session) dataHandler() {
prefix := fmt.Sprintf("Received: from %s ([%s]) by %s\r\n for <%s>; %s\r\n",
s.remoteDomain, s.remoteHost, s.config.Domain, recip.Address.Address,
tstamp)
// Deliver message.
_, err := s.manager.Deliver(
recip, s.from, s.recipients, prefix, mailData.Bytes())
@@ -513,28 +588,28 @@ func (s *Session) readLine() (line string, err error) {
func (s *Session) parseCmd(line string) (cmd string, arg string, ok bool) {
line = strings.TrimRight(line, "\r\n")
l := len(line)
// Find length of command or entire line.
hasArg := true
l := strings.IndexByte(line, ' ')
if l == -1 {
hasArg = false
l = len(line)
}
switch {
case l == 0:
return "", "", true
case l < 4:
s.logger.Warn().Msgf("Command too short: %q", line)
return "", "", false
case l == 4 || l == 8:
return strings.ToUpper(line), "", true
case l == 5:
// Too long to be only command, too short to have args
s.logger.Warn().Msgf("Mangled command: %q", line)
return "", "", false
}
// If we made it here, command is long enough to have args
if line[4] != ' ' {
// There wasn't a space after the command?
s.logger.Warn().Msgf("Mangled command: %q", line)
return "", "", false
if hasArg {
return strings.ToUpper(line[0:l]), strings.Trim(line[l+1:], " "), true
}
// I'm not sure if we should trim the args or not, but we will for now
return strings.ToUpper(line[0:4]), strings.Trim(line[5:], " "), true
return strings.ToUpper(line), "", true
}
// parseArgs takes the arguments proceeding a command and files them
@@ -544,7 +619,7 @@ func (s *Session) parseCmd(line string) (cmd string, arg string, ok bool) {
// The leading space is mandatory.
func (s *Session) parseArgs(arg string) (args map[string]string, ok bool) {
args = make(map[string]string)
re := regexp.MustCompile(` (\w+)=(\w+)`)
re := regexp.MustCompile(` (\w+)=(\w+|<>)`)
pm := re.FindAllStringSubmatch(arg, -1)
if pm == nil {
s.logger.Warn().Msgf("Failed to parse arg string: %q", arg)

View File

@@ -56,6 +56,9 @@ func TestGreetState(t *testing.T) {
if err := playSession(t, server, []scriptStep{{"helo 127.0.0.1", 250}}); err != nil {
t.Error(err)
}
if err := playSession(t, server, []scriptStep{{"HELO ABC", 250}}); err != nil {
t.Error(err)
}
// Valid EHLOs
if err := playSession(t, server, []scriptStep{{"EHLO mydomain", 250}}); err != nil {
@@ -70,6 +73,82 @@ func TestGreetState(t *testing.T) {
if err := playSession(t, server, []scriptStep{{"ehlo 127.0.0.1", 250}}); err != nil {
t.Error(err)
}
if err := playSession(t, server, []scriptStep{{"EHLO a", 250}}); err != nil {
t.Error(err)
}
if t.Failed() {
// Wait for handler to finish logging
time.Sleep(2 * time.Second)
// Dump buffered log data if there was a failure
_, _ = io.Copy(os.Stderr, logbuf)
}
}
// Test commands in READY state
func TestEmptyEnvelope(t *testing.T) {
ds := test.NewStore()
server, logbuf, teardown := setupSMTPServer(ds)
defer teardown()
// Test out some empty envelope without blanks
script := []scriptStep{
{"HELO localhost", 250},
{"MAIL FROM:<>", 250},
}
if err := playSession(t, server, script); err != nil {
// Dump buffered log data if there was a failure
_, _ = io.Copy(os.Stderr, logbuf)
t.Error(err)
}
// Test out some empty envelope with blanks
script = []scriptStep{
{"HELO localhost", 250},
{"MAIL FROM: <>", 250},
}
if err := playSession(t, server, script); err != nil {
// Dump buffered log data if there was a failure
_, _ = io.Copy(os.Stderr, logbuf)
t.Error(err)
}
}
// Test AUTH
func TestAuth(t *testing.T) {
ds := test.NewStore()
server, logbuf, teardown := setupSMTPServer(ds)
defer teardown()
// PLAIN AUTH
script := []scriptStep{
{"EHLO localhost", 250},
{"AUTH PLAIN aW5idWNrZXQ6cGFzc3dvcmQK", 235},
{"RSET", 250},
{"AUTH GSSAPI aW5idWNrZXQ6cGFzc3dvcmQK", 500},
{"RSET", 250},
{"AUTH PLAIN", 500},
{"RSET", 250},
{"AUTH PLAIN aW5idWNrZXQ6cG Fzc3dvcmQK", 500},
}
if err := playSession(t, server, script); err != nil {
t.Error(err)
}
// LOGIN AUTH
script = []scriptStep{
{"EHLO localhost", 250},
{"AUTH LOGIN", 334}, // Test with user/pass present.
{"username", 334},
{"password", 235},
{"RSET", 250},
{"AUTH LOGIN", 334}, // Test with empty user/pass.
{"", 334},
{"", 235},
}
if err := playSession(t, server, script); err != nil {
t.Error(err)
}
if t.Failed() {
// Wait for handler to finish logging
@@ -114,6 +193,12 @@ func TestReadyState(t *testing.T) {
{"RSET", 250},
{"MAIL FROM:<john@gmail.com> SIZE=1024", 250},
{"RSET", 250},
{"MAIL FROM:<john@gmail.com> SIZE=1024 BODY=8BITMIME", 250},
{"RSET", 250},
{"MAIL FROM:<bounces@onmicrosoft.com> SIZE=4096 AUTH=<>", 250},
{"RSET", 250},
{"MAIL FROM:<b@o.com> SIZE=4096 AUTH=<> BODY=7BIT", 250},
{"RSET", 250},
{"MAIL FROM:<host!host!user/data@foo.com>", 250},
{"RSET", 250},
{"MAIL FROM:<\"first last\"@space.com>", 250},
@@ -130,6 +215,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)

View File

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

View File

@@ -1,9 +1,11 @@
package web
import (
"html/template"
"net/http"
"os"
"github.com/inbucket/inbucket/pkg/config"
"github.com/rs/zerolog/log"
)
@@ -82,3 +84,23 @@ func requestLoggingWrapper(next http.Handler) http.Handler {
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

@@ -5,10 +5,12 @@ import (
"context"
"encoding/json"
"expvar"
"html/template"
"net"
"net/http"
"net/http/pprof"
"net/url"
"os"
"path/filepath"
"time"
@@ -16,6 +18,7 @@ import (
"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"
)
@@ -56,33 +59,59 @@ func Initialize(
msgHub = mh
manager = mm
// 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.Handle("/debug/vars", expvar.Handler())
Router.Handle(prefix("/debug/vars"), expvar.Handler())
if conf.Web.PProf {
Router.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
Router.HandleFunc("/debug/pprof/profile", pprof.Profile)
Router.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
Router.HandleFunc("/debug/pprof/trace", pprof.Trace)
Router.PathPrefix("/debug/pprof/").HandlerFunc(pprof.Index)
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 /debug/pprof")
Msg("Go pprof tools installed to " + prefix("/debug/pprof"))
}
// Static paths.
Router.PathPrefix("/static").Handler(
http.StripPrefix("/", http.FileServer(http.Dir(conf.Web.UIDir))))
Router.Path("/favicon.png").Handler(
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),
fileHandler(filepath.Join(conf.Web.UIDir, "index.html")))
Router.Path("/").Handler(spaHandler)
Router.Path("/monitor").Handler(spaHandler)
Router.Path("/status").Handler(spaHandler)
Router.PathPrefix("/m/").Handler(spaHandler)
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(
@@ -131,6 +160,7 @@ 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)

View File

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

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

View File

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

View File

@@ -3,12 +3,18 @@ stdenv.mkDerivation rec {
name = "env";
env = buildEnv { name = name; paths = buildInputs; };
buildInputs = [
act
dpkg
elmPackages.elm
elmPackages.elm-analyse
elmPackages.elm-format
go
elmPackages.elm-json
elmPackages.elm-language-server
elmPackages.elm-test
go_1_18
golint
nodejs-10_x
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"
}
}

View File

@@ -11,9 +11,8 @@ One time setup (assuming [Node.js] is already installed):
```
cd $INBUCKET/ui
npm i elm -g
npm i
npm run build
yarn install
yarn build
```
This will the create `node_modules`, `elm-stuff`, and `dist` directories.
@@ -30,15 +29,16 @@ 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: webpack development server
### Terminal 2: parcel development server
```
cd $INBUCKET/ui
npm run dev
yarn start
```
npm will start a development HTTP server listening on port 3000. You should use
this server for UI development, as it features hot reload and the Elm debugger.
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

View File

@@ -3,25 +3,25 @@
"source-directories": [
"src"
],
"elm-version": "0.19.0",
"elm-version": "0.19.1",
"dependencies": {
"direct": {
"NoRedInk/elm-json-decode-pipeline": "1.0.0",
"basti1302/elm-human-readable-filesize": "1.1.1",
"elm/browser": "1.0.1",
"elm/core": "1.0.2",
"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.2",
"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.1.0"
"ryannhg/date-format": "2.3.0"
},
"indirect": {
"elm/bytes": "1.0.3",
"elm/file": "1.0.1",
"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"
@@ -31,4 +31,4 @@
"direct": {},
"indirect": {}
}
}
}

7786
ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,28 +4,29 @@
"license": "MIT",
"private": true,
"scripts": {
"build": "webpack --mode production",
"watch": "webpack --mode development --watch",
"dev": "webpack-dev-server --mode development --host 0.0.0.0 --port 3000 --hot",
"errors": "webpack --mode development --display-error-details"
"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": {
"@babel/core": "^7.5.5",
"@babel/preset-env": "^7.5.5",
"@fortawesome/fontawesome-free": "^5.10.1",
"@webcomponents/webcomponentsjs": "^2.2.10",
"babel-loader": "^8.0.6",
"css-loader": "^1.0.1",
"elm": "^0.19.0-no-deps",
"elm-hot-webpack-loader": "^1.1.1",
"elm-webpack-loader": "^6.0.0",
"file-loader": "^3.0.1",
"html-webpack-plugin": "^3.2.0",
"node-elm-compiler": "^5.0.4",
"style-loader": "^0.23.1",
"webpack": "^4.39.1",
"webpack-cli": "^3.3.6",
"webpack-dev-server": "^3.8.0"
"@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"
}
}

View File

@@ -1,11 +1,14 @@
module Api exposing
( deleteMessage
( DataResult
, HttpResult
, deleteMessage
, getGreeting
, getHeaderList
, getMessage
, getServerConfig
, getServerMetrics
, markMessageSeen
, monitorUri
, purgeMailbox
, serveUrl
)
@@ -14,10 +17,12 @@ 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
@@ -29,31 +34,17 @@ type alias HttpResult msg =
Result HttpUtil.Error () -> msg
{-| Builds a public REST API URL (see wiki).
-}
apiV1Url : List String -> String
apiV1Url elements =
Url.Builder.absolute ([ "api", "v1" ] ++ elements) []
deleteMessage : Session -> HttpResult msg -> String -> String -> Cmd msg
deleteMessage session msg mailboxName id =
HttpUtil.delete msg (apiV1Url session [ "mailbox", mailboxName, id ])
{-| Builds an internal `serve` REST API URL; only used by this UI.
-}
serveUrl : List String -> String
serveUrl elements =
Url.Builder.absolute ([ "serve" ] ++ elements) []
deleteMessage : HttpResult msg -> String -> String -> Cmd msg
deleteMessage msg mailboxName id =
HttpUtil.delete msg (apiV1Url [ "mailbox", mailboxName, id ])
getHeaderList : DataResult msg (List MessageHeader) -> String -> Cmd msg
getHeaderList msg mailboxName =
getHeaderList : Session -> DataResult msg (List MessageHeader) -> String -> Cmd msg
getHeaderList session msg mailboxName =
let
context =
{ method = "GET"
, url = apiV1Url [ "mailbox", mailboxName ]
, url = apiV1Url session [ "mailbox", mailboxName ]
}
in
Http.get
@@ -62,12 +53,12 @@ getHeaderList msg mailboxName =
}
getGreeting : DataResult msg String -> Cmd msg
getGreeting msg =
getGreeting : Session -> DataResult msg String -> Cmd msg
getGreeting session msg =
let
context =
{ method = "GET"
, url = serveUrl [ "greeting" ]
, url = serveUrl session [ "greeting" ]
}
in
Http.get
@@ -76,12 +67,12 @@ getGreeting msg =
}
getMessage : DataResult msg Message -> String -> String -> Cmd msg
getMessage msg mailboxName id =
getMessage : Session -> DataResult msg Message -> String -> String -> Cmd msg
getMessage session msg mailboxName id =
let
context =
{ method = "GET"
, url = serveUrl [ "mailbox", mailboxName, id ]
, url = serveUrl session [ "mailbox", mailboxName, id ]
}
in
Http.get
@@ -90,12 +81,12 @@ getMessage msg mailboxName id =
}
getServerConfig : DataResult msg ServerConfig -> Cmd msg
getServerConfig msg =
getServerConfig : Session -> DataResult msg ServerConfig -> Cmd msg
getServerConfig session msg =
let
context =
{ method = "GET"
, url = serveUrl [ "status" ]
, url = serveUrl session [ "status" ]
}
in
Http.get
@@ -104,12 +95,19 @@ getServerConfig msg =
}
getServerMetrics : DataResult msg Metrics -> Cmd msg
getServerMetrics msg =
getServerMetrics : Session -> DataResult msg Metrics -> Cmd msg
getServerMetrics session msg =
let
context =
{ method = "GET"
, url = Url.Builder.absolute [ "debug", "vars" ] []
, url =
Url.Builder.absolute
(splitBasePath session.config.basePath
++ [ "debug"
, "vars"
]
)
[]
}
in
Http.get
@@ -118,15 +116,73 @@ getServerMetrics msg =
}
markMessageSeen : HttpResult msg -> String -> String -> Cmd msg
markMessageSeen msg mailboxName id =
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 [ "mailbox", mailboxName, id ])
|> HttpUtil.patch msg (apiV1Url session [ "mailbox", mailboxName, id ])
purgeMailbox : HttpResult msg -> String -> Cmd msg
purgeMailbox msg mailboxName =
HttpUtil.delete msg (apiV1Url [ "mailbox", mailboxName ])
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

View File

@@ -5,16 +5,18 @@ import Json.Decode.Pipeline as P
type alias AppConfig =
{ monitorVisible : Bool
{ 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
AppConfig "" True

View File

@@ -1,6 +1,6 @@
module Data.Date exposing (date)
import Json.Decode exposing (..)
import Json.Decode exposing (Decoder, int, map)
import Time exposing (Posix)

View File

@@ -1,8 +1,8 @@
module Data.Message exposing (Attachment, Message, attachmentDecoder, decoder)
import Data.Date exposing (date)
import Json.Decode exposing (..)
import Json.Decode.Pipeline exposing (..)
import Json.Decode exposing (Decoder, bool, int, list, string, succeed)
import Json.Decode.Pipeline exposing (optional, required)
import Time exposing (Posix)

View File

@@ -1,8 +1,8 @@
module Data.MessageHeader exposing (MessageHeader, decoder)
import Data.Date exposing (date)
import Json.Decode exposing (..)
import Json.Decode.Pipeline exposing (..)
import Json.Decode exposing (Decoder, bool, int, list, string, succeed)
import Json.Decode.Pipeline exposing (optional, required)
import Time exposing (Posix)

View File

@@ -1,8 +1,8 @@
module Data.Metrics exposing (Metrics, decodeIntList, decoder)
import Data.Date exposing (date)
import Json.Decode as Decode exposing (..)
import Json.Decode.Pipeline exposing (..)
import Json.Decode exposing (Decoder, int, map, string, succeed)
import Json.Decode.Pipeline exposing (requiredAt)
import Time exposing (Posix)

View File

@@ -15,11 +15,10 @@ module Data.Session exposing
import Browser.Navigation as Nav
import Data.AppConfig as AppConfig exposing (AppConfig)
import Html exposing (Html)
import Json.Decode as D
import Json.Decode.Pipeline exposing (..)
import Json.Decode.Pipeline exposing (optional)
import Json.Encode as E
import Ports
import Route exposing (Router)
import Time
import Url exposing (Url)
@@ -29,6 +28,7 @@ type alias Session =
, host : String
, flash : Maybe Flash
, routing : Bool
, router : Router
, zone : Time.Zone
, config : AppConfig
, persistent : Persistent
@@ -52,6 +52,7 @@ init key location config persistent =
, host = location.host
, flash = Nothing
, routing = True
, router = Route.newRouter config.basePath
, zone = Time.utc
, config = config
, persistent = persistent
@@ -64,6 +65,7 @@ initError key location error =
, host = location.host
, flash = Just (Flash "Initialization failed" [ ( "Error", error ) ])
, routing = True
, router = Route.newRouter ""
, zone = Time.utc
, config = AppConfig.default
, persistent = Persistent []

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)

View File

@@ -1,7 +1,6 @@
module HttpUtil exposing (Error, RequestContext, delete, errorFlash, expectJson, expectString, patch)
import Data.Session as Session
import Html exposing (Html, div, text)
import Http
import Json.Decode as Decode

View File

@@ -1,23 +1,46 @@
module Layout exposing (Model, Msg, Page(..), frame, init, reset, update)
import Data.Session as Session exposing (Session)
import Html exposing (..)
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
, id
, placeholder
, rel
, selected
, target
, type_
, value
)
import Html.Events as Events
import Route exposing (Route)
import Modal
import Route
import Timer exposing (Timer)
{-| Used to highlight current page in navbar.
@@ -31,8 +54,9 @@ type Page
type alias Model msg =
{ mapMsg : Msg -> msg
, menuVisible : Bool
, recentVisible : Bool
, mainMenuVisible : Bool
, recentMenuVisible : Bool
, recentMenuTimer : Timer
, mailboxName : String
}
@@ -40,8 +64,9 @@ type alias Model msg =
init : (Msg -> msg) -> Model msg
init mapMsg =
{ mapMsg = mapMsg
, menuVisible = False
, recentVisible = False
, mainMenuVisible = False
, recentMenuVisible = False
, recentMenuTimer = Timer.empty
, mailboxName = ""
}
@@ -51,55 +76,89 @@ init mapMsg =
reset : Model msg -> Model msg
reset model =
{ model
| menuVisible = False
, recentVisible = False
| mainMenuVisible = False
, recentMenuVisible = False
, recentMenuTimer = Timer.cancel model.recentMenuTimer
, mailboxName = ""
}
type Msg
= ClearFlash
| MainMenuToggled
| ModalFocused Modal.Msg
| ModalUnfocused
| OnMailboxNameInput String
| OpenMailbox
| ShowRecent Bool
| ToggleMenu
| RecentMenuMouseOver
| RecentMenuMouseOut
| RecentMenuTimeout Timer
| RecentMenuToggled
update : Msg -> Model msg -> Session -> ( Model msg, Session, Cmd msg )
update msg model session =
update : Msg -> Model msg -> ( Model msg, Effect msg )
update msg model =
case msg of
ClearFlash ->
( model
, Session.clearFlash session
, Cmd.none
)
( 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 }
, session
, Cmd.none
)
( { model | mailboxName = name }, Effect.none )
OpenMailbox ->
if model.mailboxName == "" then
( model, session, Cmd.none )
( model, Effect.none )
else
( model
, session
, Route.pushUrl session.key (Route.Mailbox model.mailboxName)
, Effect.navigateRoute True (Route.Mailbox model.mailboxName)
)
ShowRecent visible ->
( { model | recentVisible = visible }
, session
, Cmd.none
RecentMenuMouseOver ->
( { model
| recentMenuVisible = True
, recentMenuTimer = Timer.cancel model.recentMenuTimer
}
, Effect.none
)
ToggleMenu ->
( { model | menuVisible = not model.menuVisible }
, session
, Cmd.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
)
@@ -118,17 +177,17 @@ frame { model, session, activePage, activeMailbox, modal, content } =
div [ class "app" ]
[ header []
[ nav [ class "navbar" ]
[ button [ class "navbar-toggle", Events.onClick (ToggleMenu |> model.mapMsg) ]
[ button [ class "navbar-toggle", Events.onClick (MainMenuToggled |> model.mapMsg) ]
[ i [ class "fas fa-bars" ] [] ]
, span [ class "navbar-brand" ]
[ a [ Route.href Route.Home ] [ text "@ inbucket" ] ]
, ul [ class "main-nav", classList [ ( "active", model.menuVisible ) ] ]
[ a [ href <| session.router.toPath Route.Home ] [ text "@ inbucket" ] ]
, ul [ class "main-nav", classList [ ( "active", model.mainMenuVisible ) ] ]
[ if session.config.monitorVisible then
navbarLink Monitor Route.Monitor [ text "Monitor" ] activePage
navbarLink Monitor (session.router.toPath Route.Monitor) [ text "Monitor" ] activePage
else
text ""
, navbarLink Status Route.Status [ text "Status" ] activePage
, 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) ]
@@ -145,8 +204,8 @@ frame { model, session, activePage, activeMailbox, modal, content } =
]
]
, div [ class "navbar-bg" ] [ text "" ]
, frameModal modal
, div [ class "page" ] ([ errorFlash model session.flash ] ++ content)
, Modal.view (ModalUnfocused |> model.mapMsg) modal
, div [ class "page" ] (errorFlash model session.flash :: content)
, footer []
[ div [ class "footer" ]
[ externalLink "https://www.inbucket.org" "Inbucket"
@@ -158,18 +217,6 @@ frame { model, session, activePage, activeMailbox, modal, content } =
]
frameModal : Maybe (Html msg) -> Html msg
frameModal maybeModal =
case maybeModal of
Just modal ->
div [ class "modal-mask" ]
[ div [ class "modal well" ] [ modal ]
]
Nothing ->
text ""
errorFlash : Model msg -> Maybe Session.Flash -> Html msg
errorFlash model maybeFlash =
let
@@ -198,10 +245,10 @@ externalLink url title =
a [ href url, target "_blank", rel "noopener" ] [ text title ]
navbarLink : Page -> Route -> List (Html a) -> Page -> Html a
navbarLink page route linkContent activePage =
navbarLink : Page -> String -> List (Html a) -> Page -> Html a
navbarLink page url linkContent activePage =
li [ classList [ ( "navbar-active", page == activePage ) ] ]
[ a [ Route.href route ] linkContent ]
[ a [ href url ] linkContent ]
{-| Renders list of recent mailboxes, selecting the currently active mailbox.
@@ -229,29 +276,22 @@ navbarRecent page activeMailbox model session =
else
session.persistent.recentMailboxes
dropdownExpanded =
if model.recentVisible then
"true"
else
"false"
recentLink mailbox =
a [ Route.href (Route.Mailbox mailbox) ] [ text 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.recentVisible
, Events.onMouseOver (ShowRecent True |> model.mapMsg)
, Events.onMouseOut (ShowRecent False |> model.mapMsg)
, 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 (ShowRecent (not model.recentVisible) |> model.mapMsg)
, Events.onClick (RecentMenuToggled |> model.mapMsg)
]
[ i [ class "fas fa-chevron-down" ] [] ]
]

View File

@@ -4,7 +4,8 @@ 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 Html exposing (..)
import Effect exposing (Effect)
import Html exposing (Html)
import Json.Decode as D exposing (Value)
import Layout
import Page.Home as Home
@@ -58,6 +59,8 @@ init configValue location key =
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 =
@@ -66,12 +69,10 @@ init configValue location key =
}
route =
Route.fromUrl location
( model, cmd ) =
changeRouteTo route initModel
session.router.fromUrl location
in
( model, Cmd.batch [ cmd, Task.perform TimeZoneLoaded Time.here ] )
changeRouteTo route initModel
|> Tuple.mapSecond (\cmd -> Cmd.batch [ cmd, Task.perform TimeZoneLoaded Time.here ])
type Msg
@@ -167,7 +168,7 @@ updateMain msg model session =
UrlChanged url ->
-- Responds to new browser URL.
if session.routing then
changeRouteTo (Route.fromUrl url) model
changeRouteTo (session.router.fromUrl url) model
else
-- Skip once, but re-enable routing.
@@ -198,20 +199,18 @@ updateMain msg model session =
LayoutMsg subMsg ->
let
( layout, newSession, cmd ) =
Layout.update subMsg model.layout session
( layout, effect ) =
Layout.update subMsg model.layout
in
( updateSession { model | layout = layout } newSession
, cmd
)
( { model | layout = layout }, effect ) |> performEffects
_ ->
updatePage msg model
updatePage msg model |> performEffects
{-| Delegate incoming messages to their respective sub-pages.
-}
updatePage : Msg -> Model -> ( Model, Cmd Msg )
updatePage : Msg -> Model -> ( Model, Effect Msg )
updatePage msg model =
case ( msg, model.page ) of
( HomeMsg subMsg, Home subModel ) ->
@@ -232,61 +231,70 @@ updatePage msg model =
( _, _ ) ->
-- Disregard messages destined for the wrong page.
( model, Cmd.none )
( model, Effect.none )
changeRouteTo : Route -> Model -> ( Model, Cmd Msg )
changeRouteTo route model =
let
session =
getSession model |> Session.clearFlash
Session.clearFlash (getSession model)
newModel =
{ model | layout = Layout.reset model.layout }
in
case route of
Route.Home ->
Home.init session
|> updateWith Home HomeMsg newModel
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.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.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
Route.Monitor ->
if session.config.monitorVisible then
Monitor.init session
|> updateWith Monitor MonitorMsg newModel
else
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 = "Disabled route requested"
, table = [ ( "Error", "Monitor disabled by configuration." ) ]
{ title = "Unknown route requested"
, table = [ ( "Path", path ) ]
}
in
( applyToModelSession (Session.showFlash flash) newModel
, Cmd.none
)
Home.init (Session.showFlash flash session)
|> updateWith Home HomeMsg newModel
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
@@ -332,11 +340,11 @@ updateWith :
(subModel -> PageModel)
-> (subMsg -> Msg)
-> Model
-> ( subModel, Cmd subMsg )
-> ( Model, Cmd Msg )
updateWith toPage toMsg model ( subModel, subCmd ) =
-> ( subModel, Effect subMsg )
-> ( Model, Effect Msg )
updateWith toPage toMsg model ( subModel, subEffect ) =
( { model | page = toPage subModel }
, Cmd.map toMsg subCmd
, Effect.map toMsg subEffect
)

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

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

View File

@@ -1,13 +1,11 @@
module Page.Home exposing (Model, Msg, init, update, view)
import Api
import Data.Session as Session exposing (Session)
import Html exposing (..)
import Html.Attributes exposing (..)
import Http
import Data.Session exposing (Session)
import Effect exposing (Effect)
import Html exposing (Html)
import Html.Attributes exposing (class, property)
import HttpUtil
import Json.Encode as Encode
import Ports
@@ -20,9 +18,9 @@ type alias Model =
}
init : Session -> ( Model, Cmd Msg )
init : Session -> ( Model, Effect Msg )
init session =
( Model session "", Api.getGreeting GreetingLoaded )
( Model session "", Effect.getGreeting GreetingLoaded )
@@ -33,16 +31,14 @@ type Msg
= GreetingLoaded (Result HttpUtil.Error String)
update : Msg -> Model -> ( Model, Cmd Msg )
update : Msg -> Model -> ( Model, Effect Msg )
update msg model =
case msg of
GreetingLoaded (Ok greeting) ->
( { model | greeting = greeting }, Cmd.none )
( { model | greeting = greeting }, Effect.none )
GreetingLoaded (Err err) ->
( { model | session = Session.showFlash (HttpUtil.errorFlash err) model.session }
, Cmd.none
)
( model, Effect.showFlash (HttpUtil.errorFlash err) )

View File

@@ -1,12 +1,38 @@
module Page.Mailbox exposing (Model, Msg, init, load, subscriptions, update, view)
module Page.Mailbox exposing (Model, Msg, init, subscriptions, update, view)
import Api
import Data.Message as Message exposing (Message)
import Data.MessageHeader as MessageHeader exposing (MessageHeader)
import Data.Session as Session exposing (Session)
import Data.MessageHeader exposing (MessageHeader)
import Data.Session exposing (Session)
import DateFormat as DF
import DateFormat.Relative as Relative
import Html exposing (..)
import Effect exposing (Effect)
import Html
exposing
( Attribute
, Html
, a
, article
, aside
, button
, dd
, div
, dl
, dt
, h3
, i
, input
, li
, main_
, nav
, p
, span
, table
, td
, text
, tr
, ul
)
import Html.Attributes
exposing
( alt
@@ -15,7 +41,6 @@ import Html.Attributes
, disabled
, download
, href
, id
, placeholder
, property
, tabindex
@@ -24,14 +49,13 @@ import Html.Attributes
, value
)
import Html.Events as Events
import Http exposing (Error)
import HttpUtil
import Json.Decode as D
import Json.Encode as E
import Ports
import Modal
import Route
import Task
import Time exposing (Posix)
import Timer exposing (Timer)
@@ -51,8 +75,8 @@ type State
type MessageState
= NoMessage
| LoadingMessage
| ShowingMessage VisibleMessage
| Transitioning VisibleMessage
| ShowingMessage Message
| Transitioning Message
type alias MessageID =
@@ -66,43 +90,40 @@ type alias MessageList =
}
type alias VisibleMessage =
{ message : Message
, markSeenAt : Maybe Int
}
type alias Model =
{ session : Session
, mailboxName : String
, state : State
, socketConnected : Bool
, bodyMode : Body
, searchInput : String
, promptPurge : Bool
, markSeenTimer : Timer
, now : Posix
}
init : Session -> String -> Maybe MessageID -> ( Model, Cmd Msg )
type alias ServeUrl =
List String -> String
init : Session -> String -> Maybe MessageID -> ( Model, Effect Msg )
init session mailboxName selection =
( { session = session
, mailboxName = mailboxName
, state = LoadingList selection
, socketConnected = False
, bodyMode = SafeHtmlBody
, searchInput = ""
, promptPurge = False
, markSeenTimer = Timer.empty
, now = Time.millisToPosix 0
}
, load mailboxName
)
load : String -> Cmd Msg
load mailboxName =
Cmd.batch
[ Task.perform Tick Time.now
, Api.getHeaderList ListLoaded mailboxName
, Effect.batch
[ Effect.posixTime Tick
, Effect.getHeaderList ListLoaded mailboxName
]
)
@@ -110,24 +131,8 @@ load mailboxName =
subscriptions : Model -> Sub Msg
subscriptions model =
let
subSeen =
case model.state of
ShowingList _ (ShowingMessage { message }) ->
if message.seen then
Sub.none
else
Time.every 250 MarkSeenTick
_ ->
Sub.none
in
Sub.batch
[ Time.every (30 * 1000) Tick
, subSeen
]
subscriptions _ =
Time.every (30 * 1000) Tick
@@ -137,14 +142,13 @@ subscriptions model =
type Msg
= ListLoaded (Result HttpUtil.Error (List MessageHeader))
| ClickMessage MessageID
| ClickRefresh
| ListKeyPress String Int
| OpenMessage MessageID
| CloseMessage
| MessageLoaded (Result HttpUtil.Error Message)
| MessageBody Body
| OpenedTime Posix
| MarkSeenTick Posix
| MarkedSeen (Result HttpUtil.Error ())
| MarkSeenTriggered Timer
| MarkSeenLoaded (Result HttpUtil.Error ())
| DeleteMessage Message
| DeletedMessage (Result HttpUtil.Error ())
| PurgeMailboxPrompt
@@ -153,41 +157,52 @@ type Msg
| PurgedMailbox (Result HttpUtil.Error ())
| OnSearchInput String
| Tick Posix
| ModalFocused Modal.Msg
update : Msg -> Model -> ( Model, Cmd Msg )
update : Msg -> Model -> ( Model, Effect Msg )
update msg model =
case msg of
ClickMessage id ->
( updateSelected { model | session = Session.disableRouting model.session } id
, Cmd.batch
( updateSelected model id
, Effect.batch
[ -- Update browser location.
Route.replaceUrl model.session.key (Route.Message model.mailboxName id)
, Api.getMessage MessageLoaded model.mailboxName id
Effect.updateRoute (Route.Message model.mailboxName id)
, Effect.getMessage MessageLoaded model.mailboxName id
]
)
OpenMessage id ->
updateOpenMessage model id
ClickRefresh ->
let
selection =
case model.state of
ShowingList _ (ShowingMessage message) ->
Just message.id
_ ->
Nothing
in
-- Reset to loading state, preserving the current message selection.
( { model | state = LoadingList selection }
, Effect.getHeaderList ListLoaded model.mailboxName
)
CloseMessage ->
case model.state of
ShowingList list _ ->
( { model | state = ShowingList list NoMessage }, Cmd.none )
( { model | state = ShowingList list NoMessage }, Effect.none )
_ ->
( model, Cmd.none )
( model, Effect.none )
DeleteMessage message ->
updateDeleteMessage model message
DeletedMessage (Ok _) ->
( model, Cmd.none )
( model, Effect.none )
DeletedMessage (Err err) ->
( { model | session = Session.showFlash (HttpUtil.errorFlash err) model.session }
, Cmd.none
)
( model, Effect.showFlash (HttpUtil.errorFlash err) )
ListKeyPress id keyCode ->
case keyCode of
@@ -195,126 +210,92 @@ update msg model =
updateOpenMessage model id
_ ->
( model, Cmd.none )
( model, Effect.none )
ListLoaded (Ok headers) ->
case model.state of
LoadingList selection ->
let
newModel =
{ model
| state = ShowingList (MessageList headers Nothing "") NoMessage
}
in
case selection of
Just id ->
updateOpenMessage newModel id
Nothing ->
( { newModel
| session = Session.addRecent model.mailboxName model.session
}
, Cmd.none
)
_ ->
( model, Cmd.none )
updateListLoaded model headers
ListLoaded (Err err) ->
( { model | session = Session.showFlash (HttpUtil.errorFlash err) model.session }
, Cmd.none
)
( model, Effect.showFlash (HttpUtil.errorFlash err) )
MarkedSeen (Ok _) ->
( model, Cmd.none )
MarkSeenLoaded (Ok _) ->
( model, Effect.none )
MarkedSeen (Err err) ->
( { model | session = Session.showFlash (HttpUtil.errorFlash err) model.session }
, Cmd.none
)
MarkSeenLoaded (Err err) ->
( model, Effect.showFlash (HttpUtil.errorFlash err) )
MessageLoaded (Ok message) ->
updateMessageResult model message
MessageLoaded (Err err) ->
( { model | session = Session.showFlash (HttpUtil.errorFlash err) model.session }
, Cmd.none
)
( model, Effect.showFlash (HttpUtil.errorFlash err) )
MessageBody bodyMode ->
( { model | bodyMode = bodyMode }, Cmd.none )
( { model | bodyMode = bodyMode }, Effect.none )
ModalFocused message ->
( model, Effect.focusModalResult message )
OnSearchInput searchInput ->
updateSearchInput model searchInput
OpenedTime time ->
case model.state of
ShowingList list (ShowingMessage visible) ->
if visible.message.seen then
( model, Cmd.none )
else
-- Set 1500ms delay before reporting message as seen to backend.
let
markSeenAt =
Time.posixToMillis time + 1500
in
( { model
| state =
ShowingList list
(ShowingMessage
{ visible
| markSeenAt = Just markSeenAt
}
)
}
, Cmd.none
)
_ ->
( model, Cmd.none )
PurgeMailboxPrompt ->
( { model | promptPurge = True }, Cmd.none )
( { model | promptPurge = True }, Effect.focusModal ModalFocused )
PurgeMailboxCanceled ->
( { model | promptPurge = False }, Cmd.none )
( { model | promptPurge = False }, Effect.none )
PurgeMailboxConfirmed ->
updatePurge model
updateTriggerPurge model
PurgedMailbox (Ok _) ->
( model, Cmd.none )
( model, Effect.none )
PurgedMailbox (Err err) ->
( { model | session = Session.showFlash (HttpUtil.errorFlash err) model.session }
, Cmd.none
)
( model, Effect.showFlash (HttpUtil.errorFlash err) )
MarkSeenTick now ->
case model.state of
ShowingList _ (ShowingMessage { message, markSeenAt }) ->
case markSeenAt of
Just deadline ->
if Time.posixToMillis now >= deadline then
updateMarkMessageSeen model message
MarkSeenTriggered timer ->
if timer == model.markSeenTimer then
-- Matching timer means we have changed messages, mark this one seen.
updateMarkMessageSeen model
else
( model, Cmd.none )
Nothing ->
( model, Cmd.none )
_ ->
( model, Cmd.none )
else
( model, Effect.none )
Tick now ->
( { model | now = now }, Cmd.none )
( { model | now = now }, Effect.none )
updateListLoaded : Model -> List MessageHeader -> ( Model, Effect Msg )
updateListLoaded model headers =
case model.state of
LoadingList selection ->
let
newModel =
{ model
| state = ShowingList (MessageList headers Nothing "") NoMessage
}
in
Effect.append (Effect.addRecent newModel.mailboxName) <|
case selection of
Just id ->
-- Don't try to load selected message if not present in headers.
if List.any (\header -> Just header.id == selection) headers then
updateOpenMessage newModel id
else
( newModel, Effect.updateRoute (Route.Mailbox model.mailboxName) )
Nothing ->
( newModel, Effect.none )
_ ->
( model, Effect.none )
{-| Replace the currently displayed message.
-}
updateMessageResult : Model -> Message -> ( Model, Cmd Msg )
updateMessageResult : Model -> Message -> ( Model, Effect Msg )
updateMessageResult model message =
let
bodyMode =
@@ -326,44 +307,42 @@ updateMessageResult model message =
in
case model.state of
LoadingList _ ->
( model, Cmd.none )
( model, Effect.none )
ShowingList list _ ->
let
newTimer =
Timer.replace model.markSeenTimer
in
( { model
| state =
ShowingList
{ list | selected = Just message.id }
(ShowingMessage (VisibleMessage message Nothing))
(ShowingMessage message)
, bodyMode = bodyMode
, markSeenTimer = newTimer
}
, Task.perform OpenedTime Time.now
-- Set 1500ms delay before reporting message as seen to backend.
, Effect.schedule MarkSeenTriggered newTimer 1500
)
updatePurge : Model -> ( Model, Cmd Msg )
updatePurge model =
let
cmd =
Cmd.batch
[ Route.replaceUrl model.session.key (Route.Mailbox model.mailboxName)
, Api.purgeMailbox PurgedMailbox model.mailboxName
]
in
case model.state of
ShowingList list _ ->
( { model
| promptPurge = False
, session = Session.disableRouting model.session
, state = ShowingList (MessageList [] Nothing "") NoMessage
}
, cmd
)
_ ->
( model, cmd )
{-| Updates model and triggers commands to purge this mailbox.
-}
updateTriggerPurge : Model -> ( Model, Effect Msg )
updateTriggerPurge model =
( { model
| promptPurge = False
, state = ShowingList (MessageList [] Nothing "") NoMessage
}
, Effect.batch
[ Effect.updateRoute (Route.Mailbox model.mailboxName)
, Effect.purgeMailbox PurgedMailbox model.mailboxName
]
)
updateSearchInput : Model -> String -> ( Model, Cmd Msg )
updateSearchInput : Model -> String -> ( Model, Effect Msg )
updateSearchInput model searchInput =
let
searchFilter =
@@ -375,14 +354,14 @@ updateSearchInput model searchInput =
in
case model.state of
LoadingList _ ->
( model, Cmd.none )
( model, Effect.none )
ShowingList list messageState ->
( { model
| searchInput = searchInput
, state = ShowingList { list | searchFilter = searchFilter } messageState
}
, Cmd.none
, Effect.none
)
@@ -414,7 +393,7 @@ updateSelected model id =
{ model | state = ShowingList newList (Transitioning visible) }
updateDeleteMessage : Model -> Message -> ( Model, Cmd Msg )
updateDeleteMessage : Model -> Message -> ( Model, Effect Msg )
updateDeleteMessage model message =
let
filter f messageList =
@@ -422,61 +401,49 @@ updateDeleteMessage model message =
in
case model.state of
ShowingList list _ ->
( { model
| session = Session.disableRouting model.session
, state =
ShowingList (filter (\x -> x.id /= message.id) list) NoMessage
}
, Cmd.batch
[ Api.deleteMessage DeletedMessage message.mailbox message.id
, Route.replaceUrl model.session.key (Route.Mailbox model.mailboxName)
( { model | state = ShowingList (filter (\x -> x.id /= message.id) list) NoMessage }
, Effect.batch
[ Effect.deleteMessage DeletedMessage message.mailbox message.id
, Effect.updateRoute (Route.Mailbox model.mailboxName)
]
)
_ ->
( model, Cmd.none )
( model, Effect.none )
updateMarkMessageSeen : Model -> Message -> ( Model, Cmd Msg )
updateMarkMessageSeen model message =
{-| Updates both the active message, and the message list to mark the currently viewed message as seen.
-}
updateMarkMessageSeen : Model -> ( Model, Effect Msg )
updateMarkMessageSeen model =
case model.state of
ShowingList list (ShowingMessage visible) ->
ShowingList messages (ShowingMessage visibleMessage) ->
let
updateSeen header =
if header.id == message.id then
updateHeader header =
if header.id == visibleMessage.id then
{ header | seen = True }
else
header
map f messageList =
{ messageList | headers = List.map f messageList.headers }
newMessages =
{ messages | headers = List.map updateHeader messages.headers }
in
( { model
| state =
ShowingList (map updateSeen list)
(ShowingMessage
{ visible
| message = { message | seen = True }
, markSeenAt = Nothing
}
)
ShowingList newMessages (ShowingMessage { visibleMessage | seen = True })
}
, Api.markMessageSeen MarkedSeen message.mailbox message.id
, Effect.markMessageSeen MarkSeenLoaded visibleMessage.mailbox visibleMessage.id
)
_ ->
( model, Cmd.none )
( model, Effect.none )
updateOpenMessage : Model -> String -> ( Model, Cmd Msg )
updateOpenMessage : Model -> String -> ( Model, Effect Msg )
updateOpenMessage model id =
let
newModel =
{ model | session = Session.addRecent model.mailboxName model.session }
in
( updateSelected newModel id
, Api.getMessage MessageLoaded model.mailboxName id
( updateSelected model id
, Effect.getMessage MessageLoaded model.mailboxName id
)
@@ -487,6 +454,10 @@ updateOpenMessage model id =
view : Model -> { title : String, modal : Maybe (Html Msg), content : List (Html Msg) }
view model =
let
serveUrl : ServeUrl
serveUrl =
Api.serveUrl model.session
mode =
case model.state of
ShowingList _ (ShowingMessage _) ->
@@ -499,26 +470,7 @@ view model =
, modal = viewModal model.promptPurge
, content =
[ div [ class ("mailbox " ++ mode) ]
[ aside [ class "message-list-controls" ]
[ input
[ type_ "text"
, placeholder "search"
, Events.onInput OnSearchInput
, value model.searchInput
]
[]
, button
[ Events.onClick (OnSearchInput "")
, disabled (model.searchInput == "")
, alt "Clear Search"
]
[ i [ class "fas fa-times" ] [] ]
, button
[ Events.onClick PurgeMailboxPrompt
, alt "Purge Mailbox"
]
[ i [ class "fas fa-trash" ] [] ]
]
[ viewMessageListControls model
, viewMessageList model
, main_
[ class "message" ]
@@ -529,11 +481,11 @@ view model =
++ " or enter a different username into the box on upper right."
)
ShowingList _ (ShowingMessage { message }) ->
viewMessage model.session.zone message model.bodyMode
ShowingList _ (ShowingMessage message) ->
viewMessage serveUrl model.session.zone message model.bodyMode
ShowingList _ (Transitioning { message }) ->
viewMessage model.session.zone message model.bodyMode
ShowingList _ (Transitioning message) ->
viewMessage serveUrl model.session.zone message model.bodyMode
_ ->
text ""
@@ -559,6 +511,53 @@ viewModal promptPurge =
Nothing
viewMessageListControls : Model -> Html Msg
viewMessageListControls model =
let
clearButton =
Just <|
button
[ Events.onClick (OnSearchInput "")
, disabled (model.searchInput == "")
, alt "Clear Search"
]
[ i [ class "fas fa-times" ] [] ]
purgeButton =
Just <|
button
[ Events.onClick PurgeMailboxPrompt
, alt "Purge Mailbox"
]
[ i [ class "fas fa-trash" ] [] ]
refreshButton =
if model.socketConnected then
Nothing
else
Just <|
button
[ Events.onClick ClickRefresh
, alt "Refresh Mailbox"
]
[ i [ class "fas fa-sync" ] [] ]
searchInput =
Just <|
input
[ type_ "text"
, placeholder "search"
, Events.onInput OnSearchInput
, value model.searchInput
]
[]
in
[ searchInput, clearButton, refreshButton, purgeButton ]
|> List.filterMap identity
|> aside [ class "message-list-controls" ]
viewMessageList : Model -> Html Msg
viewMessageList model =
aside [ class "message-list" ] <|
@@ -591,14 +590,14 @@ messageChip model selected message =
]
viewMessage : Time.Zone -> Message -> Body -> Html Msg
viewMessage zone message bodyMode =
viewMessage : ServeUrl -> Time.Zone -> Message -> Body -> Html Msg
viewMessage serveUrl zone message bodyMode =
let
htmlUrl =
Api.serveUrl [ "mailbox", message.mailbox, message.id, "html" ]
serveUrl [ "mailbox", message.mailbox, message.id, "html" ]
sourceUrl =
Api.serveUrl [ "mailbox", message.mailbox, message.id, "source" ]
serveUrl [ "mailbox", message.mailbox, message.id, "source" ]
htmlButton =
if message.html == "" then
@@ -629,7 +628,7 @@ viewMessage zone message bodyMode =
]
, messageErrors message
, messageBody message bodyMode
, attachments message
, attachments serveUrl message
]
@@ -692,20 +691,22 @@ messageBody message bodyMode =
]
attachments : Message -> Html Msg
attachments message =
attachments : ServeUrl -> Message -> Html Msg
attachments serveUrl message =
if List.isEmpty message.attachments then
div [] []
text ""
else
table [ class "attachments well" ] (List.map (attachmentRow message) message.attachments)
message.attachments
|> List.map (attachmentRow serveUrl message)
|> table [ class "attachments well" ]
attachmentRow : Message -> Message.Attachment -> Html Msg
attachmentRow message attach =
attachmentRow : ServeUrl -> Message -> Message.Attachment -> Html Msg
attachmentRow serveUrl message attach =
let
url =
Api.serveUrl
serveUrl
[ "mailbox"
, message.mailbox
, message.id

View File

@@ -1,10 +1,29 @@
module Page.Monitor exposing (Model, Msg, init, update, view)
import Api
import Data.MessageHeader as MessageHeader exposing (MessageHeader)
import Data.Session as Session exposing (Session)
import Data.Session exposing (Session)
import DateFormat as DF
import Html exposing (..)
import Html.Attributes exposing (..)
import Effect exposing (Effect)
import Html
exposing
( Attribute
, Html
, button
, div
, em
, h1
, node
, span
, table
, tbody
, td
, text
, th
, thead
, tr
)
import Html.Attributes exposing (class, src, tabindex)
import Html.Events as Events
import Json.Decode as D
import Route
@@ -22,9 +41,9 @@ type alias Model =
}
init : Session -> ( Model, Cmd Msg )
init : Session -> ( Model, Effect Msg )
init session =
( Model session False [], Cmd.none )
( Model session False [], Effect.none )
@@ -39,20 +58,20 @@ type Msg
| MessageKeyPress MessageHeader Int
update : Msg -> Model -> ( Model, Cmd Msg )
update : Msg -> Model -> ( Model, Effect Msg )
update msg model =
case msg of
Connected True ->
( { model | connected = True, messages = [] }, Cmd.none )
( { model | connected = True, messages = [] }, Effect.none )
Connected False ->
( { model | connected = False }, Cmd.none )
( { model | connected = False }, Effect.none )
MessageReceived value ->
case D.decodeValue (MessageHeader.decoder |> D.at [ "detail" ]) value of
Ok header ->
( { model | messages = header :: List.take 500 model.messages }
, Cmd.none
, Effect.none
)
Err err ->
@@ -62,12 +81,10 @@ update msg model =
, table = [ ( "Error", D.errorToString err ) ]
}
in
( { model | session = Session.showFlash flash model.session }
, Cmd.none
)
( model, Effect.showFlash flash )
Clear ->
( { model | messages = [] }, Cmd.none )
( { model | messages = [] }, Effect.none )
OpenMessage header ->
openMessage header model
@@ -78,13 +95,13 @@ update msg model =
openMessage header model
_ ->
( model, Cmd.none )
( model, Effect.none )
openMessage : MessageHeader -> Model -> ( Model, Cmd Msg )
openMessage : MessageHeader -> Model -> ( Model, Effect Msg )
openMessage header model =
( model
, Route.pushUrl model.session.key (Route.Message header.mailbox header.id)
, Effect.navigateRoute True (Route.Message header.mailbox header.id)
)
@@ -115,8 +132,12 @@ view model =
[ button [ Events.onClick Clear ] [ text "Clear" ]
]
]
-- monitor-messages maintains a websocket connection to the Inbucket daemon at the path
-- specified by `src`.
, node "monitor-messages"
[ Events.on "connected" (D.map Connected <| D.at [ "detail" ] <| D.bool)
[ src (Api.monitorUri model.session)
, Events.on "connected" (D.map Connected <| D.at [ "detail" ] <| D.bool)
, Events.on "message" (D.map MessageReceived D.value)
]
[]

View File

@@ -1,18 +1,24 @@
module Page.Status exposing (Model, Msg, init, subscriptions, update, view)
import Api
import Data.Metrics as Metrics exposing (Metrics)
import Data.ServerConfig as ServerConfig exposing (ServerConfig)
import Data.Session as Session exposing (Session)
import Data.Metrics exposing (Metrics)
import Data.ServerConfig exposing (ServerConfig)
import Data.Session exposing (Session)
import DateFormat.Relative as Relative
import Effect exposing (Effect)
import Filesize
import Html exposing (..)
import Html.Attributes exposing (..)
import Http exposing (Error)
import Html
exposing
( Html
, div
, h1
, h2
, i
, text
)
import Html.Attributes exposing (class)
import HttpUtil
import Sparkline as Spark
import Svg.Attributes as SvgAttrib
import Task
import Time exposing (Posix)
@@ -53,7 +59,7 @@ type alias Metric =
}
init : Session -> ( Model, Cmd Msg )
init : Session -> ( Model, Effect Msg )
init session =
( { session = session
, now = Time.millisToPosix 0
@@ -75,9 +81,9 @@ init session =
, retainedCount = Metric "Stored Messages" 0 fmtInt graphZero initDataSet 60
, retainedSize = Metric "Store Size" 0 Filesize.format graphZero initDataSet 60
}
, Cmd.batch
[ Task.perform Tick Time.now
, Api.getServerConfig ServerConfigLoaded
, Effect.batch
[ Effect.posixTime Tick
, Effect.getServerConfig ServerConfigLoaded
]
)
@@ -93,7 +99,7 @@ initDataSet =
subscriptions : Model -> Sub Msg
subscriptions model =
subscriptions _ =
Time.every (10 * 1000) Tick
@@ -107,27 +113,25 @@ type Msg
| Tick Posix
update : Msg -> Model -> ( Model, Cmd Msg )
update : Msg -> Model -> ( Model, Effect Msg )
update msg model =
case msg of
MetricsReceived (Ok metrics) ->
( updateMetrics metrics model, Cmd.none )
( updateMetrics metrics model, Effect.none )
MetricsReceived (Err err) ->
( { model | session = Session.showFlash (HttpUtil.errorFlash err) model.session }
, Cmd.none
)
( model, Effect.showFlash (HttpUtil.errorFlash err) )
ServerConfigLoaded (Ok config) ->
( { model | config = Just config }, Cmd.none )
( { model | config = Just config }, Effect.none )
ServerConfigLoaded (Err err) ->
( { model | session = Session.showFlash (HttpUtil.errorFlash err) model.session }
, Cmd.none
)
( model, Effect.showFlash (HttpUtil.errorFlash err) )
Tick time ->
( { model | now = time }, Api.getServerMetrics MetricsReceived )
( { model | now = time }
, Effect.getServerMetrics MetricsReceived
)
{-| Update all metrics in Model; increment xCounter.
@@ -271,18 +275,19 @@ configPanel maybeConfig =
, textEntry "SMTP Listener" config.smtpConfig.addr
, textEntry "POP3 Listener" config.pop3Listener
, textEntry "HTTP Listener" config.webListener
, textEntry "Accept Policy" (acceptPolicy config.smtpConfig)
, textEntry "Store Policy" (storePolicy config.smtpConfig)
, textEntry "Accept Policy" (acceptPolicy config)
, textEntry "Store Policy" (storePolicy config)
, textEntry "Store Type" config.storageConfig.storeType
, textEntry "Message Cap" (mailboxCap config)
, textEntry "Retention Period" (retentionPeriod config)
]
acceptPolicy : ServerConfig -> String
acceptPolicy config =
if config.defaultAccept then
if config.smtpConfig.defaultAccept then
"All domains"
++ (case config.rejectDomains of
++ (case config.smtpConfig.rejectDomains of
Nothing ->
""
@@ -295,7 +300,7 @@ acceptPolicy config =
else
"No domains"
++ (case config.acceptDomains of
++ (case config.smtpConfig.acceptDomains of
Nothing ->
""
@@ -307,10 +312,11 @@ acceptPolicy config =
)
storePolicy : ServerConfig -> String
storePolicy config =
if config.defaultStore then
if config.smtpConfig.defaultStore then
"All domains"
++ (case config.discardDomains of
++ (case config.smtpConfig.discardDomains of
Nothing ->
""
@@ -323,7 +329,7 @@ storePolicy config =
else
"No domains"
++ (case config.storeDomains of
++ (case config.smtpConfig.storeDomains of
Nothing ->
""
@@ -407,28 +413,11 @@ viewMetric metric =
, div [ class "value" ] [ text (metric.formatter metric.value) ]
, div [ class "graph" ]
[ metric.graph metric.history
, text ("(" ++ String.fromInt metric.minutes ++ "min)")
, text (" (" ++ String.fromInt metric.minutes ++ "min)")
]
]
viewLiveMetric : String -> (Int -> String) -> Int -> Html a -> Html a
viewLiveMetric label formatter value graph =
div [ class "metric" ]
[ div [ class "label" ] [ text label ]
, div [ class "value" ] [ text (formatter value) ]
, div [ class "graph" ]
[ graph
, text "(10min)"
]
]
graphNull : Html a
graphNull =
div [] []
graphSize : Spark.Size
graphSize =
{ width = 180

View File

@@ -1,8 +1,5 @@
module Route exposing (Route(..), fromUrl, href, pushUrl, replaceUrl)
module Route exposing (Route(..), Router, newRouter)
import Browser.Navigation as Navigation exposing (Key)
import Html exposing (Attribute)
import Html.Attributes as Attr
import Url exposing (Url)
import Url.Builder as Builder
import Url.Parser as Parser exposing ((</>), Parser, map, oneOf, s, string, top)
@@ -17,6 +14,25 @@ type Route
| Status
type alias Router =
{ fromUrl : Url -> Route
, toPath : Route -> String
}
{-| Returns a configured Router.
-}
newRouter : String -> Router
newRouter basePath =
let
newPath =
prepareBasePath basePath
in
{ fromUrl = fromUrl newPath
, toPath = toPath newPath
}
{-| Routes our application handles.
-}
routes : List (Parser (Route -> a) a)
@@ -29,10 +45,26 @@ routes =
]
{-| Returns the Route for a given URL.
-}
fromUrl : String -> Url -> Route
fromUrl basePath url =
let
relative =
{ url | path = String.replace basePath "" url.path }
in
case Parser.parse (oneOf routes) relative of
Nothing ->
Unknown url.path
Just route ->
route
{-| Convert route to a URI.
-}
routeToPath : Route -> String
routeToPath page =
toPath : String -> Route -> String
toPath basePath page =
let
pieces =
case page of
@@ -54,35 +86,32 @@ routeToPath page =
Status ->
[ "status" ]
in
Builder.absolute pieces []
basePath ++ Builder.absolute pieces []
{-| Make sure basePath starts with a slash and does not have trailing slashes.
-- PUBLIC HELPERS
"inbucket/" becomes "/inbucket", "" remains ""
href : Route -> Attribute msg
href route =
Attr.href (routeToPath route)
replaceUrl : Key -> Route -> Cmd msg
replaceUrl key =
routeToPath >> Navigation.replaceUrl key
pushUrl : Key -> Route -> Cmd msg
pushUrl key =
routeToPath >> Navigation.pushUrl key
{-| Returns the Route for a given URL.
-}
fromUrl : Url -> Route
fromUrl location =
case Parser.parse (oneOf routes) location of
Nothing ->
Unknown location.path
prepareBasePath : String -> String
prepareBasePath path =
let
stripSlashes str =
if String.startsWith "/" str then
stripSlashes (String.dropLeft 1 str)
Just route ->
route
else if String.endsWith "/" str then
stripSlashes (String.dropRight 1 str)
else
str
newPath =
stripSlashes path
in
if newPath == "" then
""
else
"/" ++ newPath

60
ui/src/Timer.elm Normal file
View File

@@ -0,0 +1,60 @@
module Timer exposing (Timer, cancel, empty, replace, schedule)
import Process
import Task
{-| Implements an identity to track an asynchronous timer.
-}
type Timer
= Empty
| Idle Int
| Timer Int
empty : Timer
empty =
Empty
schedule : (Timer -> msg) -> Timer -> Float -> Cmd msg
schedule message timer millis =
Task.perform (always (message timer)) (Process.sleep millis)
{-| Replaces the provided timer with a newly created one.
-}
replace : Timer -> Timer
replace previous =
case previous of
Empty ->
Timer 0
Idle index ->
Timer (next index)
Timer index ->
Timer (next index)
{-| Cancels the provided timer without creating a replacement.
-}
cancel : Timer -> Timer
cancel previous =
case previous of
Timer index ->
Idle index
_ ->
previous
{-| Increments the timer identity, preventing integer overflow.
-}
next : Int -> Int
next index =
if index > 2 ^ 30 then
0
else
index + 1

View File

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

27
ui/src/index-dev.html Normal file
View File

@@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="en">
<head>
<!-- This index file will be served by the development server. -->
<base href="/">
<meta charset="utf-8">
<meta http-equiv="x-ua-compatible" content="ie=edge">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="theme-color" content="#000000">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<link rel="stylesheet" href="./main.css">
<link rel="stylesheet" href="./navbar.css">
<link rel="stylesheet" href="./mailbox.css">
<link rel="icon" type="image/png" href="./favicon.png">
<link rel="manifest" href="./manifest.json">
<title>Inbucket</title>
</head>
<body>
<noscript>
You need to enable JavaScript to run this app.
</noscript>
<div id="root"></div>
<script type="module" src="index.js"></script>
</body>
</html>

View File

@@ -1,13 +1,19 @@
<!DOCTYPE html>
<html lang="en">
<head>
<base href="{{ .BasePath }}">
<meta charset="utf-8">
<meta http-equiv="x-ua-compatible" content="ie=edge">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta name="theme-color" content="#000000">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
<meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" />
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<link rel="stylesheet" href="./main.css">
<link rel="stylesheet" href="./navbar.css">
<link rel="stylesheet" href="./mailbox.css">
<link rel="icon" type="image/png" href="./favicon.png">
<link rel="manifest" href="./manifest.json">
<title>Inbucket</title>
</head>
<body>
@@ -15,5 +21,6 @@
You need to enable JavaScript to run this app.
</noscript>
<div id="root"></div>
<script type="module" src="index.js"></script>
</body>
</html>

View File

@@ -1,8 +1,6 @@
import './main.css'
import './mailbox.css'
import './navbar.css'
import '@fortawesome/fontawesome-free/css/all.css'
import '@webcomponents/webcomponentsjs/webcomponents-bundle'
import 'opensans-npm-webfont'
import { Elm } from './Main.elm'
import './monitorMessages'
import './renderedHtml'

View File

@@ -69,13 +69,14 @@
grid-gap: 1px 20px;
grid:
"ctrl mesg" auto
"list mesg" 1fr / minmax(200px, 300px) minmax(650px, 1000px);
"list mesg" 1fr
/ minmax(200px, 300px) minmax(650px, auto);
height: 100%;
}
.message-list {
display: block;
overflow-y: scroll;
overflow-y: auto;
}
.message-list-controls {
@@ -97,9 +98,14 @@
border-width: 1px;
border-style: none solid solid solid;
cursor: pointer;
outline: none;
padding: 5px 8px;
}
.message-list-entry:focus {
background-color: var(--focused-bg-color) !important;
}
.message-list-entry.selected {
background-color: var(--selected-color);
}
@@ -108,6 +114,10 @@
border-style: solid;
}
.message-list-entry:focus .subject {
color: var(--focused-color);
}
.message-list-entry .subject {
color: var(--high-color);
}
@@ -116,6 +126,12 @@
font-weight: bold;
}
.message-list-entry:focus .from,
.message-list-entry:focus .date {
color: var(--focused-color);
opacity: 0.8;
}
.message-list-entry .from,
.message-list-entry .date {
color: var(--low-color);

View File

@@ -9,6 +9,70 @@
--border-color: #ddd;
--placeholder-color: #9f9f9f;
--selected-color: #eee;
--focused-color: #fff;
--focused-bg-color: #337ab7;
--input-bg: white;
--input-bg-active: white;
--btn-default-bg-color: #337ab7;
--btn-default-bg-image: linear-gradient(to bottom, #337ab7 0, #265a88 100%);
--btn-default-color: #ffffff;
--btn-danger-bg-color: #d9534f;
--btn-danger-bg-image: linear-gradient(to bottom, #d9534f 0, #c12e2a 100%);
--btn-light-bg-color: #eee;
--btn-light-bg-image: linear-gradient(to bottom, #f0f0f0 0, #e0e0e0 100%);
--monitor-header-bg: #e8e8e8;
--well-bg-color: #f5f5f5;
--well-bg-image: linear-gradient(to bottom, #e8e8e8 0, #f5f5f5 100%);
--well-warn-bg-color: #fff8cf;
--well-warn-bg-image: linear-gradient(to bottom, #fff899 0, #fff8cf 100%);
--well-warn-color: inherit;
--well-error-bg-color: #f58080;
--well-error-bg-image: linear-gradient(to bottom, #e86060 0, #f58080 100%);
--well-error-color: inherit;
--well-border: #e8e8e8;
}
@media (prefers-color-scheme: dark) {
:root {
--bg-color: #202124;
--primary-color: #bdc1c6;
--high-color: #8ab4f8;
--border-color: #5f6368;
--selected-color: #303134;
--input-bg: var(--bg-color);
--input-bg-active: rgb(48, 49, 52);
--btn-default-bg-color: #303134;
--btn-default-bg-image: none;
--btn-default-color: #e8eaed;
/*--btn-danger-bg-color: #d9534f;*/
--btn-danger-bg-image: none;
/*--btn-light-bg-color: #eee;*/
--btn-light-bg-image: none;
--monitor-header-bg: var(--selected-color);
--well-bg-color: var(--low-color);
--well-bg-image: none;
--well-warn-bg-color: #c3c099;
--well-warn-bg-image: none;
--well-warn-color: var(--bg-color);
--well-error-bg-color: #e86060;
--well-error-bg-image: none;
--well-error-color: var(--bg-color);
--well-border: var(--border-color);
}
}
html, body, div, span, applet, object, iframe,
@@ -37,7 +101,7 @@ time, mark, audio, video {
}
a {
color: #337ab7;
color: var(--high-color);
text-decoration: none;
}
@@ -46,8 +110,9 @@ body {
}
body, button, input, table {
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
font-family: "Open Sans", Helvetica, Arial, sans-serif;
font-size: 14px;
font-weight: 400;
line-height: 1.43;
color: var(--primary-color);
}
@@ -64,8 +129,8 @@ h1, h2, h3, h4, h5, h6, p {
/** SHARED */
a.button {
background-color: #337ab7;
background-image: linear-gradient(to bottom, #337ab7 0, #265a88 100%);
background-color: var(--btn-default-bg-color);
background-image: var(--btn-default-bg-image);
border: none;
border-radius: 4px;
color: #fff;
@@ -73,17 +138,15 @@ a.button {
font-size: 11px;
font-style: normal;
margin: 4px;
padding: 3px 8px;
padding: 3px 8px 4px;
text-decoration: none;
text-shadow: 0 -1px 0 rgba(0,0,0,0.2);
}
.well {
--light: #f5f5f5;
--dark: #e8e8e8;
background-color: var(--light);
background-image: linear-gradient(to bottom, var(--dark) 0, var(--light) 100%);
border: 1px solid var(--dark);
background-color: var(--well-bg-color);
background-image: var(--well-bg-image);
border: 1px solid var(--well-border);
border-radius: 4px;
box-shadow: 0 1px 2px rgba(0,0,0,.05);
padding: 6px 10px;
@@ -95,8 +158,9 @@ a.button {
}
.well-error {
--light: #f58080;
--dark: #e86060;
background-color: var(--well-error-bg-color);
background-image: var(--well-error-bg-image);
color: var(--well-error-color);
}
.well-error a {
@@ -105,8 +169,22 @@ a.button {
}
.well-warn {
--light: #fff8cf;
--dark: #fff899;
background-color: var(--well-warn-bg-color);
background-image: var(--well-warn-bg-image);
color: var(--well-warn-color);
}
input {
border: 1px solid var(--border-color);
background-color: var(--input-bg);
}
@media (prefers-color-scheme: dark) {
input:focus-visible, input:hover {
outline: none;
border: 1px solid var(--input-bg-active);
background-color: var(--input-bg-active);
}
}
/** APP */
@@ -116,23 +194,29 @@ a.button {
justify-content: center;
grid-gap: 20px;
grid-template:
"lpad head rpad" auto
"head head head" auto
"lpad page rpad" 1fr
"foot foot foot" auto / minmax(20px, auto) 1fr minmax(20px, auto);
height: 100vh;
"foot foot foot" auto / 1px 1fr 1px;
height: auto;
}
@media (max-width: 999px) {
.desktop {
display: none;
}
@media screen and (min-width: 1000px) {
.app {
grid-column-gap: 40px;
grid-template:
"head head head" auto
"lpad head rpad" auto
"lpad page rpad" 1fr
"foot foot foot" auto / 1px 1fr 1px;
height: auto;
"foot foot foot" auto
/ 1fr minmax(auto, 1300px) 1fr;
height: 100vh;
}
.desktop {
display: none;
td.desktop, th.desktop {
display: table-cell;
}
}
@@ -217,6 +301,10 @@ h3 {
padding: 10px !important;
}
.modal:focus {
outline: none;
}
/** BUTTONS */
.button-bar {
@@ -224,35 +312,39 @@ h3 {
}
.button-bar button {
background-color: #337ab7;
background-image: linear-gradient(to bottom, #337ab7 0, #265a88 100%);
background-color: var(--btn-default-bg-color);
background-image: var(--btn-default-bg-image);
border: none;
border-radius: 4px;
color: #fff;
color: var(--btn-default-color);
display: inline-block;
font-size: 12px;
font-style: normal;
font-weight: 400;
height: 30px;
margin: 0;
padding: 5px 10px;
padding: 5px 10px 6px;
text-align: center;
text-decoration: none;
text-shadow: 0 -1px 0 rgba(0,0,0,0.2);
}
.button-bar button:hover {
border: 1px solid var(--border-color);
}
.button-bar *:not(:last-child) {
margin-right: 4px;
}
.button-bar button.danger {
background-color: #d9534f;
background-image: linear-gradient(to bottom, #d9534f 0, #c12e2a 100%);
background-color: var(--btn-danger-bg-color);
background-image: var(--btn-danger-bg-image);
}
.button-bar button.light {
background-color: #eee;
background-image: linear-gradient(to bottom, #f0f0f0 0, #e0e0e0 100%);
background-color: var(--btn-light-bg-color);
background-image: var(--btn-light-bg-image);
color: #000;
}
@@ -272,7 +364,7 @@ h3 {
}
.metric-panel h2 {
background-image: linear-gradient(to bottom, #f5f5f5 0, #e8e8e8 100%);
background-color: var(--monitor-header-bg);
font-size: 16px;
font-weight: 500;
padding: 10px;
@@ -341,3 +433,9 @@ h3 {
background-color: var(--selected-color);
cursor: pointer;
}
.monitor tr:focus {
color: var(--focused-color);
background-color: var(--focused-bg-color);
outline: none;
}

View File

@@ -3,22 +3,55 @@
customElements.define(
'monitor-messages',
class MonitorMessages extends HTMLElement {
static get observedAttributes() {
return [ 'src' ]
}
constructor() {
const self = super()
// TODO make URI/URL configurable.
var uri = '/api/v1/monitor/messages'
self._url = ((window.location.protocol === 'https:') ? 'wss://' : 'ws://') + window.location.host + uri
self._socket = null
super()
this._url = null // Current websocket URL.
this._socket = null // Currently open WebSocket.
}
connectedCallback() {
if (this.hasAttribute('src')) {
this.wsOpen(this.getAttribute('src'))
}
}
attributeChangedCallback() {
// Checking _socket prevents connection attempts prior to connectedCallback().
if (this._socket && this.hasAttribute('src')) {
this.wsOpen(this.getAttribute('src'))
}
}
disconnectedCallback() {
this.wsClose()
}
// Connects to WebSocket and registers event listeners.
wsOpen(uri) {
const url =
((window.location.protocol === 'https:') ? 'wss://' : 'ws://') +
window.location.host + uri
if (this._socket && url === this._url) {
// Already connected to same URL.
return
}
this.wsClose()
this._url = url
console.info("Connecting to WebSocket", url)
const ws = new WebSocket(url)
this._socket = ws
// Register event listeners.
const self = this
self._socket = new WebSocket(self._url)
var ws = self._socket
ws.addEventListener('open', function (e) {
ws.addEventListener('open', function (_e) {
self.dispatchEvent(new CustomEvent('connected', { detail: true }))
})
ws.addEventListener('close', function (e) {
ws.addEventListener('close', function (_e) {
self.dispatchEvent(new CustomEvent('connected', { detail: false }))
})
ws.addEventListener('message', function (e) {
@@ -28,11 +61,20 @@ customElements.define(
})
}
disconnectedCallback() {
var ws = this._socket
// Closes WebSocket connection.
wsClose() {
const ws = this._socket
if (ws) {
ws.close()
}
}
get src() {
return this.getAttribute('src')
}
set src(value) {
this.setAttribute('src', value)
}
}
)

View File

@@ -2,16 +2,34 @@
:root {
--navbar-color: #9d9d9d;
--navbar-color-active: var(--navbar-color);
--navbar-bg: #222;
--navbar-bg-active: #080808;
--navbar-bg-border-active: none;
--navbar-image: linear-gradient(to bottom, #3c3c3c 0, #222 100%);
--navbar-height: 50px;
--navbar-border-bottom: none;
}
@media (prefers-color-scheme: dark) {
:root {
--navbar-color: #969ba1;
--navbar-color-active: #8ab4f8;
--navbar-bg: var(--bg-color);
--navbar-bg-active: none;
--navbar-bg-border-active: 3px solid var(--navbar-color-active);
--navbar-image: none;
--navbar-border-bottom: 1px solid var(--border-color);
}
}
.navbar {
background-color: var(--navbar-bg);
background-image: var(--navbar-image);
text-shadow: 0 -1px 0 rgba(0,0,0,0.2);
min-height: var(--navbar-height);
border-bottom: var(--navbar-border-bottom);
}
.main-nav {
@@ -25,6 +43,7 @@
.navbar-brand {
font-size: 18px;
font-weight: 600;
}
.navbar-toggle {
@@ -50,6 +69,12 @@
padding: 12px 15px;
}
.navbar-dropdown {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.navbar-dropdown-button {
display: none;
}
@@ -59,7 +84,9 @@
}
li.navbar-active > *:first-child {
background-color: #080808;
background-color: var(--navbar-bg-active);
color: var(--navbar-color-active);
border-bottom: var(--navbar-bg-border-active);
}
li.navbar-active a,
@@ -71,6 +98,9 @@ li.navbar-active span,
.navbar-dropdown-content a {
color: var(--navbar-color) !important;
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.navbar-dropdown-content a:hover {
@@ -82,7 +112,6 @@ li.navbar-active span,
}
.navbar-mailbox input {
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 5px 10px;
width: 250px;
@@ -139,6 +168,7 @@ li.navbar-active span,
.navbar-dropdown {
padding: 15px 19px 15px 25px;
max-width: 350px;
}
.navbar-dropdown-button {

View File

@@ -1,78 +0,0 @@
const HtmlWebpackPlugin = require('html-webpack-plugin')
const webpack = require('webpack')
module.exports = (env, argv) => {
const production = argv.mode === 'production'
const config = {
output: {
filename: 'static/[name].[hash:8].js',
publicPath: '/',
},
module: {
rules: [
{
test: /\.js$/,
exclude: [/elm-stuff/, /node_modules/],
loader: 'babel-loader',
query: {
presets: [
'@babel/preset-env',
],
},
},
{
test: /\.elm$/,
exclude: [/elm-stuff/, /node_modules/],
use: [
{ loader: 'elm-hot-webpack-loader' },
{
loader: 'elm-webpack-loader',
options: {
debug: !production,
optimize: production,
},
},
],
},
{
include: [/\/src/, /\/node_modules\/@fortawesome\/fontawesome-free\/css/],
test: /\.css$/,
loader: ['style-loader', 'css-loader'],
},
{
include: [/\/node_modules\/@fortawesome\/fontawesome-free\/webfonts/],
test: /\.(eot|svg|ttf|woff|woff2)$/,
loader: 'file-loader',
options: {
name: 'static/[name].[hash:8].[ext]',
},
},
]
},
plugins: [
new HtmlWebpackPlugin({
template: 'public/index.html',
favicon: 'public/favicon.png',
}),
],
devServer: {
inline: true,
historyApiFallback: true,
stats: { colors: true },
overlay: true,
open: true,
proxy: [{
context: ['/api', '/debug', '/serve'],
target: 'http://localhost:9000',
ws: true,
}],
watchOptions: {
ignored: /node_modules/,
},
},
}
if (argv.hot) {
config.plugins.push(new webpack.HotModuleReplacementPlugin())
}
return config
}

2266
ui/yarn.lock Normal file

File diff suppressed because it is too large Load Diff