mirror of
https://github.com/jhillyerd/inbucket.git
synced 2025-12-17 17:47:03 +00:00
Compare commits
129 Commits
v3.0.0-bet
...
v3.0.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0738791ba8 | ||
|
|
66831d10c7 | ||
|
|
344c3ffb21 | ||
|
|
87018ed42d | ||
|
|
1650a5b375 | ||
|
|
3f7adbfb22 | ||
|
|
03cc31fb70 | ||
|
|
a10a6244c9 | ||
|
|
9185423022 | ||
|
|
9aaca449f8 | ||
|
|
f39395bd7f | ||
|
|
2c68128d5d | ||
|
|
06d4120682 | ||
|
|
58bcd4f557 | ||
|
|
e91e8d5aee | ||
|
|
5322462899 | ||
|
|
5def9ed183 | ||
|
|
357589d90e | ||
|
|
b664bcfc4c | ||
|
|
ffd13e2ee7 | ||
|
|
747775b8f2 | ||
|
|
2c0d942c76 | ||
|
|
e7263439d5 | ||
|
|
cb6f99c487 | ||
|
|
04fb58e15e | ||
|
|
f11ad55474 | ||
|
|
26939f2bf6 | ||
|
|
05a3b1742a | ||
|
|
867d5f5d7f | ||
|
|
8e34a21dc6 | ||
|
|
8869acef0b | ||
|
|
752d5c9668 | ||
|
|
091e26c467 | ||
|
|
6593a36b48 | ||
|
|
68ef2d9873 | ||
|
|
ab988caf6b | ||
|
|
fa62220d98 | ||
|
|
1ecf424975 | ||
|
|
3342938dd4 | ||
|
|
6be1655723 | ||
|
|
1465e6fb49 | ||
|
|
21991cbfc7 | ||
|
|
7138a97935 | ||
|
|
beee68fc5d | ||
|
|
9e2af71743 | ||
|
|
a2c4292fc1 | ||
|
|
2016142747 | ||
|
|
4f9f961cac | ||
|
|
bf8536abb3 | ||
|
|
985f2702f2 | ||
|
|
11f3879442 | ||
|
|
8562c55c98 | ||
|
|
e3066bb535 | ||
|
|
35ab31efbc | ||
|
|
81edf40996 | ||
|
|
c64e7a6a6c | ||
|
|
4bd64563f2 | ||
|
|
66dec49a49 | ||
|
|
649e3743e0 | ||
|
|
c096f018d6 | ||
|
|
261bbef426 | ||
|
|
3c5960aba0 | ||
|
|
7f430f2bde | ||
|
|
c480fcb341 | ||
|
|
e74f5e5116 | ||
|
|
6ce045ddb7 | ||
|
|
9b03c311db | ||
|
|
ebd25a60e1 | ||
|
|
7c87649579 | ||
|
|
e56365b9a0 | ||
|
|
698b0406c8 | ||
|
|
361bbec293 | ||
|
|
407ae87a3b | ||
|
|
4648d8e593 | ||
|
|
5c5b0f819b | ||
|
|
8adfd82232 | ||
|
|
2162a4caaa | ||
|
|
cf4c5a29bb | ||
|
|
6598b09114 | ||
|
|
ce5bfddaa5 | ||
|
|
2934d799ef | ||
|
|
8a07a24828 | ||
|
|
2408ace6c2 | ||
|
|
1a5db5b5f8 | ||
|
|
f712f5b0f3 | ||
|
|
f0520b88c5 | ||
|
|
5a0c4778cb | ||
|
|
289b38f016 | ||
|
|
316a732e7f | ||
|
|
f0bc5741f3 | ||
|
|
046de42774 | ||
|
|
860045715c | ||
|
|
001e9fec58 | ||
|
|
2e0b7cc097 | ||
|
|
b0bbf2e9f5 | ||
|
|
3372ade61b | ||
|
|
62dd540be5 | ||
|
|
65a6ab2b4f | ||
|
|
9e1da20782 | ||
|
|
930801f6da | ||
|
|
4fc8d229eb | ||
|
|
e8e506f870 | ||
|
|
8a3d291ff3 | ||
|
|
107b649738 | ||
|
|
c91a3ecd41 | ||
|
|
2c74268014 | ||
|
|
da63e4d77a | ||
|
|
4a90b37815 | ||
|
|
cabbdacb89 | ||
|
|
baad19e838 | ||
|
|
c520af4983 | ||
|
|
c312909112 | ||
|
|
083b65c9bc | ||
|
|
59ae2112f7 | ||
|
|
1a45179e31 | ||
|
|
2b857245f7 | ||
|
|
9573504725 | ||
|
|
c21066752f | ||
|
|
66c95baf05 | ||
|
|
22a7789b7b | ||
|
|
d2da53cc0f | ||
|
|
bfac9a0cc2 | ||
|
|
a64429ae61 | ||
|
|
2436f2e3de | ||
|
|
fc76ce74cb | ||
|
|
eef4bbdb01 | ||
|
|
201987f6a8 | ||
|
|
45d9d2af39 | ||
|
|
12802e93cb |
@@ -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
36
.github/workflows/build-and-test.yml
vendored
Normal 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
54
.github/workflows/docker-build.yml
vendored
Normal 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
42
.github/workflows/release.yml
vendored
Normal 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
6
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
43
.travis.yml
43
.travis.yml
@@ -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
|
||||
157
CHANGELOG.md
157
CHANGELOG.md
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
32
Dockerfile
32
Dockerfile
@@ -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
|
||||
|
||||
|
||||
76
README.md
76
README.md
@@ -1,11 +1,12 @@
|
||||
Inbucket
|
||||
=============================================================================
|
||||
[][Build Status]
|
||||

|
||||

|
||||
|
||||
# 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]
|
||||
|
||||

|
||||
|
||||
|
||||
## 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/
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
28
go.mod
@@ -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
121
go.sum
@@ -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=
|
||||
|
||||
@@ -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?"`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package web
|
||||
|
||||
type jsonAppConfig struct {
|
||||
MonitorVisible bool `json:"monitor-visible"`
|
||||
BasePath string `json:"base-path"`
|
||||
MonitorVisible bool `json:"monitor-visible"`
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
10
shell.nix
10
shell.nix
@@ -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
4
ui/.parcelrc
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "@parcel/config-default",
|
||||
"namers": [ "parcel-namer-rewrite" ]
|
||||
}
|
||||
12
ui/.proxyrc.json
Normal file
12
ui/.proxyrc.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"/api": {
|
||||
"target": "http://localhost:9000",
|
||||
"ws": true
|
||||
},
|
||||
"/debug": {
|
||||
"target": "http://localhost:9000"
|
||||
},
|
||||
"/serve": {
|
||||
"target": "http://localhost:9000"
|
||||
}
|
||||
}
|
||||
14
ui/README.md
14
ui/README.md
@@ -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
|
||||
|
||||
18
ui/elm.json
18
ui/elm.json
@@ -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
7786
ui/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
134
ui/src/Api.elm
134
ui/src/Api.elm
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module Data.Date exposing (date)
|
||||
|
||||
import Json.Decode exposing (..)
|
||||
import Json.Decode exposing (Decoder, int, map)
|
||||
import Time exposing (Posix)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
370
ui/src/Effect.elm
Normal 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)
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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" ] [] ]
|
||||
]
|
||||
|
||||
114
ui/src/Main.elm
114
ui/src/Main.elm
@@ -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
58
ui/src/Modal.elm
Normal 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"
|
||||
@@ -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) )
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
]
|
||||
[]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
60
ui/src/Timer.elm
Normal 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
|
||||
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 3.0 KiB |
27
ui/src/index-dev.html
Normal file
27
ui/src/index-dev.html
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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'
|
||||
|
||||
@@ -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);
|
||||
|
||||
162
ui/src/main.css
162
ui/src/main.css
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
2266
ui/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user